Merge branch 'fix/v1475-cleanup' into release/v1.47.5

# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
#	src/__tests__/baselines/source-function-lines.json
#	src/__tests__/baselines/version-stamp-comments.json
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 22:51:42 +01:00
commit d5dd05b5ac
164 files changed
+1109 -15288

No files matched your search

-1
View File
@@ -82,7 +82,6 @@ README.md
CHANGELOG.md
CLAUDE.md
CONTRIBUTING.md
CONTRIBUTING-AI.md
CODE_OF_CONDUCT.md
SECURITY.md
# LICENSE / LICENSE.md are deliberately NOT ignored: the PolyForm
+3 -4
View File
@@ -72,11 +72,10 @@ next-env.d.ts
.cursor/
.codex/
.aider*
.mcp.json
# Planning scratch — all internal per-release reports, QA findings, ad-hoc
# backlog, and the iOS coordination channel. These are internal working
# notes; everything user-facing lives in the public surfaces (README,
# CHANGELOG, docs/). Kept on disk for the working session, never tracked.
# Internal working notes (per-release reports, backlog, iOS coordination).
# Everything user-facing lives in README, CHANGELOG and docs/.
/.planning/
# Docs scratch — temporary spec drafts that live in the repo only
-8
View File
@@ -1,8 +0,0 @@
{
"mcpServers": {
"shadcn": {
"command": "npx",
"args": ["-y", "shadcn@latest", "mcp"]
}
}
}
-16
View File
@@ -1,16 +0,0 @@
# Contributing with AI tooling
HealthLog is built with AI assistance, and contributions made the same way are welcome. This file used to carry a copy of the internal conventions document; the copy aged badly and started contradicting the codebase, so it now points at the living sources instead.
## Where the real conventions live
- `CLAUDE.md` at the repo root is the maintainer's operating manual for this codebase: architecture walk, code conventions, security patterns, test commands, the do-not list. It is checked in and kept current; whatever an AI agent (or a human) needs to work on the server is in there.
- `docs/CONTRIBUTING-CLIENTS.md` covers building a client app (Android, iOS, CLI) against the server: the API contract, auth from a client's point of view, sync semantics, and how coordination between repos works.
- `docs/api/openapi.yaml` is the wire contract, generated from the server's Zod schemas. CI fails on drift, so it is always accurate for the commit it sits in.
## Ground rules, whatever tooling you use
- The human who commits a change has read it and stands behind it. The git history knows authors, not tools: no AI attribution trailers, no assistant vocabulary in commit messages, PR text, or release notes.
- No real names of users or reporters in committed text, and no live health figures anywhere, including fixtures and examples. Invent data.
- No secrets in code, fixtures, or docs, and nothing secret-shaped either.
- Run the gates before pushing (`pnpm typecheck`, `pnpm lint`, `pnpm test`) and fix what they find rather than suppressing it. A guard that fails is telling you something; the fix goes in the code, not in the guard.
+5 -5
View File
@@ -76,7 +76,7 @@ pnpm typecheck && pnpm lint && pnpm test && pnpm format:check && pnpm build
- **shadcn/ui** components (new-york style) for UI primitives.
- **Dracula theme** -- dark mode is the default; a `light`/`system` toggle is exposed in the topbar. Chart and accent colors use the `--dracula-*` CSS tokens so both themes stay coherent.
See `CONTRIBUTING-AI.md` for detailed architecture and patterns.
Whoever commits a change has read it and stands behind it. Committed text names no real users or reporters and carries no live health figures; fixtures and examples use invented data. Nothing secret, and nothing secret-shaped, goes into code, fixtures or docs.
## Database Changes
@@ -121,7 +121,7 @@ Contributors: branch off `main`; PRs target `main`.
## Translations
HealthLog ships with English (`en`) and German (`de`) maintained by the project, plus French (`fr`), Spanish (`es`), Italian (`it`), Polish (`pl`), Korean (`ko`), Dutch (`nl`), and Brazilian Portuguese (`pt-BR`) that are **AI-initial, community-maintained**. The maintained locales are reviewed line-by-line; the community-maintained ones surface a small banner inviting users to improve them.
HealthLog ships with English (`en`) and German (`de`) maintained by the project, plus French (`fr`), Spanish (`es`), Italian (`it`), Polish (`pl`), Korean (`ko`), Dutch (`nl`), Brazilian Portuguese (`pt-BR`) and Russian (`ru`), which started as machine-translated first drafts and are reviewed by the community. The maintained locales are reviewed line by line; the community-reviewed ones show a small banner inviting users to improve them.
### Improving an existing locale
@@ -168,9 +168,9 @@ To propose a validated version for a language that has none, or a better source
If you want to introduce a locale that isn't shipped (e.g. `sv`, `cs`):
1. Open an issue first using the **Translation request** template. We'll confirm the locale is in scope and decide whether it joins the AI-initial-community-maintained tier or the maintained tier.
1. Open an issue first using the **Translation request** template. We'll confirm the locale is in scope and decide whether it joins the community-reviewed tier or the maintained tier.
2. Copy `messages/en.json` to `messages/<locale>.json`. Questionnaire text is not in it; the new language needs a wording file per instrument instead (see Questionnaire wording above), which step 4 makes impossible to forget.
3. Translate. AI assistance is welcome as a starting point but the PR description should note whether it's an AI-initial pass or a fully human-reviewed one — that information drives the banner state.
3. Translate. A machine translation is fine as a starting point, but the PR description should say whether the pass is machine-translated or fully reviewed by someone who reads the language; that decides the banner state.
4. Add the locale to `locales` in `src/lib/i18n/config.ts` and to `locales` in `project.inlang/settings.json`, then run `pnpm typecheck`. Every per-locale table that still lacks the new locale fails to compile, and that list is the work: date and number formatting, the reply-language footers of the Coach and insights prompts, the outbound safety banks, the prose-grounding patterns, the no-key fallback copy and the questionnaire wording registry. Include those in the same PR.
5. Run `pnpm test` before submitting. The i18n guards hold the new bundle to the English keys and placeholders.
@@ -185,7 +185,7 @@ If you want to introduce a locale that isn't shipped (e.g. `sv`, `cs`):
### Long-term
Fink covers browser editing today. It edits the files in this repository directly and keeps no copy of the translations anywhere else. If the community outgrows it, a self-hosted [Weblate](https://weblate.org/) instance remains the fallback. Tracking issue: `v1.6 — Weblate self-hosted on edge-01`.
Fink covers browser editing today. It edits the files in this repository directly and keeps no copy of the translations anywhere else. If the community outgrows it, a self-hosted [Weblate](https://weblate.org/) instance remains the fallback.
## Code of Conduct
+38 -38
View File
@@ -5,7 +5,7 @@
<h1 align="center">HealthLog</h1>
<p align="center">
<strong>Self-hosted, privacy-first health tracking. Your vitals, your devices, your server.</strong>
<strong>Self-hosted health tracking that syncs your devices and keeps the data on your own server.</strong>
</p>
<p align="center">
@@ -17,10 +17,10 @@
</p>
<p align="center">
<a href="https://healthlog.dev">Website</a> &middot;
<a href="https://demo.healthlog.dev">Live Demo</a> &middot;
<a href="https://docs.healthlog.dev">Documentation</a> &middot;
<a href="https://apps.apple.com/app/id6769501341">iOS App Store</a> &middot;
<a href="https://healthlog.dev">Website</a> |
<a href="https://demo.healthlog.dev">Live Demo</a> |
<a href="https://docs.healthlog.dev">Documentation</a> |
<a href="https://apps.apple.com/app/id6769501341">iOS App Store</a> |
<a href="https://testflight.apple.com/join/bucuTBpa">iOS TestFlight</a>
</p>
@@ -36,25 +36,25 @@
<img src="docs/assets/coach.jpeg" alt="Coach: a conversation about the week's recovery, grounded in the recorded data" width="32%">
</p>
## Why HealthLog
## What HealthLog is
HealthLog is a self-hosted health tracking app (PWA + native iOS client) that runs from a single `docker compose up` on a NAS, homelab, or small VPS. It brings weight, blood pressure, pulse, blood glucose, body composition, sleep, mood, menstrual cycle, and medications onto one timeline, synced from the devices you already own, and keeps it in a Postgres database on hardware you control. No vendor cloud, no subscription, no usage telemetry. The few requests the server makes on its own (an update check, a password breach check, an IP geolocation lookup for logins) can each be switched off.
HealthLog is a self-hosted health tracking app (PWA + native iOS client) that runs from a single `docker compose up` on a NAS, homelab, or small VPS. It brings weight, blood pressure, pulse, blood glucose, body composition, sleep, mood, menstrual cycle, and medications onto one timeline, synced from the devices you already own, and keeps it in a Postgres database on hardware you control. It needs no vendor cloud or subscription and sends no usage telemetry. The few requests the server makes on its own (an update check, a password breach check, an IP geolocation lookup for logins) can each be switched off.
HealthLog is a personal health record-keeping and wellness tool. It is not a medical device and does not diagnose, treat, cure, or prevent any disease — it helps you log, visualise, and export your own data, and leaves clinical judgement to a clinician.
HealthLog is a personal health record-keeping and wellness tool. It is not a medical device and does not diagnose, treat, cure, or prevent any disease. It helps you log, visualise, and export your own data, and leaves clinical judgement to a clinician.
Try the [live demo](https://demo.healthlog.dev) to see a working install — sign in with `demo` / `demo123demo123`.
Try the [live demo](https://demo.healthlog.dev) to see a working install. Sign in with `demo` / `demo123demo123`.
## Highlights
## Features
- **Every vital on one timeline.** Weight, blood pressure, pulse, glucose, body composition, sleep, SpO₂, respiratory rate, mood, and cycle tracking — charted with trends, moving averages, and clinical reference ranges (ESH 2023, ADA 2024), overridable with the targets your clinician set.
- **Clinical signals, opt-in and additive.** Validated self-checks for mental wellbeing and sleep (WHO-5, PHQ-9, GAD-7, Sleep Condition Indicator — official questionnaire wording, calm crisis-resource handling), grip strength, a 0–10 pain score, waist and body measurements, and a longevity lab panel — each added through one signal registry, so a new signal wires into charts, correlations, the coach, the FHIR map, and the API at once.
- **Records, structured.** Allergies and family history as structured records that flow into the doctor report (reactions and notes are encrypted, the substance and condition names are not), plus lab values you key in or scan from a printed report — OCR extracts the stated values, units, and dates for you to review and confirm before anything is saved. HealthLog transcribes what a report says; it never interprets or diagnoses from it. A vaccination record keeps a lifetime immunization log, doses grouped under the disease they protect against, and prints in the doctor report.
- **A document vault for the paper trail.** An opt-in module stores every letter, report, and scan encrypted at rest — PDFs and photos preview inline, Office and text files stay download-only, and nothing user-uploaded can ever execute in the app. One searchable timeline with type, condition, and year filters, drag-and-drop and paste-to-upload, bulk filing, undo-able delete with a 30-day grace, and per-file/per-account limits the operator tunes.
- **Context that explains the numbers.** An environmental module folds weather, daylight, temperature, air quality, and pollen in as influence signals (winter darkness against mood, heat against blood pressure) using a home location you set, so a pattern has somewhere to point. It is on by default, and nothing leaves the host until someone sets that location.
- **Your devices, one server.** Withings, WHOOP, Oura and Polar sync over OAuth2, Google Health connects Fitbit and Pixel Watch through your own Google Cloud client, Nightscout streams CGM glucose, an Apple Health `export.zip` folds your full history in, and the native iOS app streams HealthKit live. A per-metric source priority decides which reading is canonical when wearables overlap.
- **Medication tracking that tells the truth.** Flexible schedules (weekly injections, weekday-only, intervals, PRN, cyclic), a configurable intake window per dose, and a traceable dose history where every slot reads taken, late, skipped, or missed — the same ledger the compliance rate is computed from, so the percentage can never disagree with the timeline.
- **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.
- **Vitals and trends.** Weight, blood pressure, pulse, glucose, body composition, sleep, SpO₂, respiratory rate, mood, and cycle tracking, charted with trends, moving averages, and clinical reference ranges (ESH 2023, ADA 2024) that you can override with the targets your clinician set. Health scores and correlations between your metrics are computed on your server without an AI model.
- **Questionnaires and self-checks.** Optional validated self-checks for mental wellbeing and sleep (WHO-5, PHQ-9, GAD-7 and the Sleep Condition Indicator, in their official wording, with crisis resources when an answer calls for them), grip strength, a pain score from 0 to 10, waist and body measurements, and a longevity lab panel. Each comes from one signal registry, so a new signal reaches charts, correlations, the coach, the FHIR map, and the API together.
- **Allergies, family history, labs and vaccinations.** Allergies and family history are structured records that flow into the doctor report (reactions and notes are encrypted, the substance and condition names are not). Lab values are keyed in or scanned from a printed report: OCR reads the stated values, units, and dates for you to review and confirm before anything is saved. HealthLog transcribes what a report says; it never interprets or diagnoses from it. A vaccination record keeps a lifetime immunization log, doses grouped under the disease they protect against, and prints in the doctor report.
- **Documents.** An opt-in module stores letters, reports, and scans encrypted at rest. PDFs and photos preview inline, Office and text files stay download-only, and nothing a user uploads can execute in the app. One searchable timeline with type, condition, and year filters, drag-and-drop and paste-to-upload, bulk filing, a delete you can undo within 30 days, and per-file and per-account limits the operator sets.
- **Weather and environment.** An environmental module adds weather, daylight, temperature, air quality, and pollen as possible influences (winter darkness and mood, heat and blood pressure), based on a home location you set. It is on by default, and nothing leaves the host until someone sets that location.
- **Devices and integrations.** Withings, WHOOP, Oura and Polar sync over OAuth2, Google Health connects Fitbit and Pixel Watch through your own Google Cloud client, Nightscout streams CGM glucose, an Apple Health `export.zip` folds your full history in, and the native iOS app streams HealthKit live. A per-metric source priority decides which reading is canonical when wearables overlap.
- **Medications.** Flexible schedules (weekly injections, weekday-only, intervals, PRN, cyclic), a configurable intake window per dose, and a dose history where every slot reads taken, late, skipped, or missed. The compliance rate is computed from the same ledger, so the percentage always matches the timeline.
- **AI features (optional).** A daily briefing and a conversational coach grounded in your own measurements and read against cited guideline reference bands: where a value sits and what that means, never a diagnosis. The coach proposes concrete if-then plans you accept or decline, and skips a proactive check-in when there is nothing new 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.
- **Doctor report and FHIR.** A doctor-report PDF rendered on your server, a read-only HL7 FHIR R4 API, and scoped, time-limited share links you revoke after the visit.
- **Private by construction.** AES-256-GCM encryption at rest for notes and other free text, documents, questionnaire answers, AI-written text and credentials, with zero-downtime key rotation. Measurement values, lab values, medication names and mood scores are stored unencrypted in the database, so the database volume and its backups need the same protection as the host. Passkey login, server-side sessions, strict CSP, and no third-party tracking anywhere.
- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history — in English, German, Spanish, French, Italian, Polish, Korean, Dutch, and Brazilian Portuguese end to end.
@@ -71,13 +71,13 @@ echo "POSTGRES_PASSWORD=$(openssl rand -hex 32)" >> .env
echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
# LAN / plain-HTTP host (NAS, homelab, Tailscale) — required, or login fails:
# Required on a LAN or plain-HTTP host (NAS, homelab, Tailscale), or login fails:
echo "SESSION_COOKIE_SECURE=false" >> .env
docker compose up -d
```
Open **http://localhost:3000** (or `http://<host>:3000`) — the first registered user becomes admin. The compose file pulls a pre-built multi-arch image (`amd64` + `arm64`) from [GHCR](https://github.com/MBombeck/HealthLog/pkgs/container/healthlog); no build step required.
Open **http://localhost:3000** (or `http://<host>:3000`). The first registered user becomes admin. The compose file pulls a pre-built multi-arch image (`amd64` + `arm64`) from [GHCR](https://github.com/MBombeck/HealthLog/pkgs/container/healthlog); no build step required.
Verify the running version:
@@ -85,7 +85,7 @@ Verify the running version:
curl -s http://localhost:3000/api/version # returns version + buildSha + builtAt
```
`SESSION_COOKIE_SECURE=false` is the one setting plain-HTTP self-hosts must set: without it the session cookie carries the `Secure` flag, the browser drops it over HTTP, and login silently fails. Leave it unset (or `true`) only when a TLS reverse proxy serves HTTPS — and then set `NEXT_PUBLIC_APP_URL` and `APP_URL` to the public URL first (see the [self-hosting guide](https://docs.healthlog.dev/getting-started/self-host-in-10-minutes/)).
`SESSION_COOKIE_SECURE=false` is the one setting plain-HTTP self-hosts must set: without it the session cookie carries the `Secure` flag, the browser drops it over HTTP, and login silently fails. Leave it unset (or `true`) only when a TLS reverse proxy serves HTTPS, and in that case set `NEXT_PUBLIC_APP_URL` and `APP_URL` to the public URL first (see the [self-hosting guide](https://docs.healthlog.dev/getting-started/self-host-in-10-minutes/)).
Persistent state lives in the database volume (`postgres-data`) and in `backups`, which holds the nightly database dumps; the app container is stateless. An install from v1.45 or earlier moves its Postgres 16 database from `pgdata` into the new volume on the first start ([details](docs/ops/postgres-major-upgrade.md)); recovering a lost server is in [disaster-recovery.md](docs/ops/disaster-recovery.md).
@@ -95,7 +95,7 @@ NAS and homelab users have ready-made paths: [Unraid Community Applications](doc
One container hosts both the web server and the job worker by default; split them via `HEALTHLOG_PROCESS_TYPE=web|worker` for horizontal scale. Encrypted nightly backups to any S3-compatible bucket are opt-in through five `BACKUP_*` environment variables (the admin panel shows their status), new users join by invite link or QR code, and the stack works out of the box behind Caddy, Traefik, Nginx, or [Coolify](https://coolify.io/).
HealthLog ships releases roughly weekly. Pin a tag, back up before upgrades, and skim the [CHANGELOG](CHANGELOG.md) before pulling `latest`. The operator manual — reverse proxy, migrations, encryption-key rotation, backup and restore — lives at [docs.healthlog.dev](https://docs.healthlog.dev).
HealthLog ships often. Pin a tag, back up before upgrades, and read the [release notes](https://github.com/MBombeck/HealthLog/releases) before pulling `latest`. The operator manual (reverse proxy, migrations, encryption-key rotation, backup and restore) lives at [docs.healthlog.dev](https://docs.healthlog.dev).
## Native iOS app
@@ -109,18 +109,18 @@ Next.js 16 (App Router, React Server Components), TypeScript strict, PostgreSQL
## Documentation
| | |
| ---------------------- | ------------------------------------------------------------ |
| User and operator docs | [docs.healthlog.dev](https://docs.healthlog.dev) |
| Release history | [CHANGELOG.md](CHANGELOG.md) |
| API contract | [`docs/api/openapi.yaml`](docs/api/openapi.yaml) |
| Security policy | [SECURITY.md](SECURITY.md) |
| Contributing | [CONTRIBUTING.md](CONTRIBUTING.md) |
| Translating | [CONTRIBUTING.md#translations](CONTRIBUTING.md#translations) |
| | |
| ---------------------- | --------------------------------------------------------------- |
| User and operator docs | [docs.healthlog.dev](https://docs.healthlog.dev) |
| Release history | [Release notes](https://github.com/MBombeck/HealthLog/releases) |
| API contract | [`docs/api/openapi.yaml`](docs/api/openapi.yaml) |
| Security policy | [SECURITY.md](SECURITY.md) |
| Contributing | [CONTRIBUTING.md](CONTRIBUTING.md) |
| Translating | [CONTRIBUTING.md#translations](CONTRIBUTING.md#translations) |
## Status
Actively developed — new releases roughly weekly, issue reports and PRs welcome. Behaviour and schema can change between versions; migrations are forward-only.
Actively developed, with frequent releases. Issue reports and pull requests are welcome. Behaviour and schema can change between versions; migrations are forward-only.
## Thanks
@@ -143,15 +143,15 @@ including anyone who found the defect without sending the fix.
## License
HealthLog is licensed under the [PolyForm Noncommercial License 1.0.0](LICENSE): free to use, self-host, and modify for noncommercial purposes. Commercial use requires a separate agreement — open an issue or reach out via [healthlog.dev](https://healthlog.dev). Releases up to and including v1.15.18 were published under AGPL-3.0 and remain available under that license.
HealthLog is licensed under the [PolyForm Noncommercial License 1.0.0](LICENSE): free to use, self-host, and modify for noncommercial purposes. Commercial use requires a separate agreement; open an issue or reach out via [healthlog.dev](https://healthlog.dev). Releases up to and including v1.15.18 were published under AGPL-3.0 and remain available under that license.
---
<p align="center">
<a href="https://healthlog.dev">healthlog.dev</a> &middot;
<a href="https://demo.healthlog.dev">Live Demo</a> &middot;
<a href="https://docs.healthlog.dev">Docs</a> &middot;
<a href="https://apps.apple.com/app/id6769501341">iOS App Store</a> &middot;
<a href="https://testflight.apple.com/join/bucuTBpa">iOS TestFlight</a> &middot;
<a href="https://healthlog.dev">healthlog.dev</a> |
<a href="https://demo.healthlog.dev">Live Demo</a> |
<a href="https://docs.healthlog.dev">Docs</a> |
<a href="https://apps.apple.com/app/id6769501341">iOS App Store</a> |
<a href="https://testflight.apple.com/join/bucuTBpa">iOS TestFlight</a> |
<a href="https://buymeacoffee.com/mbombeck">Buy Me a Coffee</a>
</p>
+2 -2
View File
@@ -1,6 +1,6 @@
# Building a native client against HealthLog
Working notes for anyone building a client app (Android, iOS, CLI, anything else) against the HealthLog server, with or without AI tooling. Everything in here is a rule we actually follow between the server and the iOS app, written down so a second client does not have to rediscover it. Pair it with `CLAUDE.md` (server-side conventions) and `docs/api/openapi.yaml` (the wire contract).
Working notes for anyone building a client app (Android, iOS, CLI, anything else) against the HealthLog server. Everything in here is a rule we actually follow between the server and the iOS app, written down so a second client does not have to rediscover it. Pair it with [`CONTRIBUTING.md`](../CONTRIBUTING.md) (server-side conventions) and `docs/api/openapi.yaml` (the wire contract).
## The contract, and where truth lives
@@ -43,7 +43,7 @@ The corollary: if a resolved value you need is missing from a payload, the fix i
## Ground rules for the repo itself
The voice-and-privacy rules from `CLAUDE.md` apply to any repo under the project, including a client repo: commit messages and public text carry no tooling attribution and no assistant vocabulary, no real names of users or reporters, and no live health figures, ever. Fixtures use invented data. If an AI tool wrote part of a change, the human who commits it has read it and stands behind it; the git history knows only authors.
The privacy rules apply to any repo under the project, including a client repo: commit messages and public text carry no real names of users or reporters and no live health figures, ever. Fixtures use invented data. Whoever commits a change has read it and stands behind it.
Secrets never appear in code, fixtures, or examples, not even placeholder-shaped real ones. Anything user-identifying stays out of logs on the client just as it does on the server.
+196 -373
View File
@@ -1,104 +1,30 @@
# HealthLog UI Guidelines
> Visual & interaction patterns in the HealthLog PWA. The binding rules
> for card anatomy, spacing, text colour, tokens and primitives live in
> [`contributing/ui-standards.md`](./contributing/ui-standards.md); where
> this document disagrees with them, the standards win. If a pattern
> below doesn't fit your case, propose an addition in the same PR.
Last updated: 2026-05-08 (v1.4 cycle)
> Interaction patterns for the HealthLog web app. Card anatomy, spacing,
> text colour, colour tokens, typography and the choice of primitive are
> owned by [`contributing/ui-standards.md`](./contributing/ui-standards.md);
> this page covers what those rules leave open. Where the two disagree, the
> standards win. If a pattern below doesn't fit your case, propose an
> addition in the same PR.
---
## 1. Foundations
### 1.1 Theme
Theme, spacing, typography, radius and colour tokens are defined in the
standards (§1 to §5 there). Two things to add:
HealthLog is a Dracula-themed app with a light counterpart (Alucard). Both
themes share the same six-color signal palette (purple, pink, green, cyan,
orange, red, yellow, lavender), exposed as CSS variables and as Tailwind
`dracula-*` color tokens. Use the semantic tokens (`--primary`,
`--destructive`, `--card`, …) for chrome and the `--dracula-*` tokens for
data viz only.
| Surface | Token |
| ---------------------------- | ------------------ |
| App background | `--background` |
| Card / popover surface | `--card` |
| Primary action | `--primary` |
| Destructive action | `--destructive` |
| Secondary / muted background | `--secondary` |
| Border | `--border` |
| Focus ring | `--ring` |
| Chart series 1–5 | `--chart-1`…`-5` |
| Brand signal: purple | `--dracula-purple` |
| Brand signal: green (good) | `--dracula-green` |
| Brand signal: orange (warn) | `--dracula-orange` |
| Brand signal: red (alert) | `--dracula-red` |
| Brand signal: cyan (info) | `--dracula-cyan` |
Dark mode is the default. The theme switcher in the sidebar three-dot menu
toggles `.dark` / `.light` on `:root`. Always test both modes before
shipping a component.
### 1.2 Spacing
Tailwind's default 4-pixel scale. Reach for these stops first:
| Use | Token | Pixels |
| ------------------------------------ | ------------- | ------ |
| Inline gap between icon and label | `gap-1` | 4 |
| Tight stack inside a card | `gap-2` | 8 |
| Label-to-field stack (form fields) | `space-y-2` | 8 |
| Section block (heading + body) | `space-y-4` | 16 |
| Page section separator | `space-y-6` | 24 |
| Dashboard tile-row gap, hero spacing | `gap-4` | 16 |
| Page-edge padding (mobile / desktop) | `px-4 / px-6` | |
Avoid arbitrary `space-y-5`, `gap-3.5`, etc. unless aligning to a fixed
external constraint.
### 1.3 Typography
The app uses the Tailwind defaults inherited via shadcn/ui. Reach for
these classes first:
| Use | Class |
| ---------------------------------- | --------------------------------------- |
| Page title (h1) | `text-2xl font-semibold tracking-tight` |
| Section heading / section card | `text-lg font-semibold` |
| Tile / inner card title | `text-base font-semibold` |
| Body text | `text-sm` |
| Muted helper / metadata | `text-sm text-muted-foreground` |
| Microcopy / footer | `text-xs text-muted-foreground` |
| Tabular data (latency, large nums) | `font-mono tabular-nums` |
Card titles come in exactly two sizes: `text-lg font-semibold` for a
section-level card (one per page region) and `text-base font-semibold`
for tiles and inner cards. Don't introduce a third level — a smaller
heading is a sign the content wants a list row, not a card.
Never use `font-bold` for emphasis — bump to `font-semibold` instead and
let the design system breathe.
### 1.4 Radius and elevation
`--radius: 0.625rem` (10px) is the default. The Tailwind tokens
`rounded-md` (`--radius-md` ≈ 8px) and `rounded-lg` (`--radius` ≈ 10px)
cover 95% of cases. Use `rounded-2xl` only for hero cards or large
modals.
Elevation is achieved via `shadow-xs`, `shadow-sm`, or
`shadow-[0_1px_0_rgba(255,255,255,0.04)]` for very subtle separators on
dark backgrounds. Avoid Tailwind's heavy shadows (`shadow-lg`+) in chrome.
- Dark is the default theme, and the light theme remaps the same semantic
tokens. Check every component in both before shipping it.
- Elevation stays light: `shadow-xs` or `shadow-sm` in chrome, never the
heavy `shadow-lg` and up.
---
## 2. Components
This section is **prescriptive, not exhaustive**. Each entry pairs a
Radix/shadcn primitive with the cases it's the right answer for and the
cases where another primitive should win.
This section is **prescriptive, not exhaustive**. Each entry names the
primitive for a case and the case where another one wins.
### 2.1 Buttons
@@ -114,43 +40,35 @@ Every button comes from `@/components/ui/button`. Pick by purpose:
| Inline link to external help docs | `link` | `sm` |
| Toolbar / table row action when space is tight | `outline` | `xs` |
**Position rules.**
Where buttons sit in a card (one action row, last in the card, primary on
the right) is standards §12. Two additions:
- Forms (full-width or modal): primary on the **right**, cancel/back on
the **left**. Match shadcn dialog defaults.
- Settings sections: a single sticky `Save` button at the **right end**
of the section header _or_ per-row inline `Save`. Never both in the
same section.
- Test buttons (Telegram test, AI ping, etc.): immediately to the right
of the related Save / Apply control. Never below — keeps the eye
motion horizontal.
- Destructive actions live in their own confirm dialog with a
type-to-confirm input when the action is irreversible (delete account,
revoke API token, drop user data).
- A Test button (Telegram test, AI ping and the like) sits in the same row
as the Save or Apply control it belongs to.
- An irreversible action (delete account, revoke an API token, wipe data)
asks in its own confirm dialog, with a type-to-confirm field where the loss
is large.
**Disabled state.** A disabled button must always come with a tooltip or
helper text explaining why. Silent disabled buttons frustrate users.
**Disabled state.** A disabled button always comes with helper text that
says why. A tooltip is not enough: it never opens on a phone.
### 2.2 Inputs and forms
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.
Every input has a visible `<Label>`, plus `aria-describedby` for helper text.
- **Text / number / email**: `<Input>`. Always set `autocomplete`. See §4.4.
- **Multi-line**: shadcn `<Textarea>` (add via `pnpm dlx shadcn@latest add textarea` if missing).
- **Single choice ≤4 options**: prefer `<RadioGroup>` or a SegmentedControl
pattern using `<Tabs>`. Never use `<Select>` for tiny enums — extra clicks.
- **Single choice 5–15 options**: `<Select>` (shadcn).
- **Single choice >15 options**: `<Combobox>` (shadcn `<Command>` + `<Popover>`)
with type-ahead.
- **Multi choice**: `<Checkbox>` group, or shadcn `<MultiSelect>` when 7+ choices.
- **Date / datetime**: shadcn `<Calendar>` inside a `<Popover>`. Always
label the timezone (Europe/Berlin).
- **Numeric range**: `<Slider>` only when the value space is continuous
_and_ the user routinely sweeps. For most thresholds and counts, an
`<Input type="number">` plus stepper buttons is faster and less
fiddly. See §6 (Slider audit) for the migration path.
- **Text / number / email**: `<Input>`. Always set `autoComplete`. See §4.4.
- **Multi-line**: `<Textarea>`.
- **Single choice, up to four options**: `Segmented` (standards §6) or a
radio group. A select for a tiny enum costs extra taps.
- **Single choice, more options**: `NativeSelect` or `Select`; a searchable
popover once the list is long enough to need type-ahead.
- **Multi choice**: a `<Checkbox>` group.
- **Date and time**: `DateField`, `TimeField`, `DateTimeField` (standards §6).
- **Numeric range**: `SliderField` for a bounded scale with anchors (a 0 to
10 self-rating). Thresholds and counts take a number input: a slider gives
up precision there.
- **Boolean toggle**: `<Switch>` for settings. Exactly two persistence
patterns are legal (see §2.2.1); pick one per card, never mix.
`<Checkbox>` stays reserved for "applies on Save" _lists_ (e.g. select
@@ -163,16 +81,16 @@ Every control under `/settings` follows exactly one of two save
patterns. The pattern is a property of the **card**, not of the
individual control:
1. **Immediate persist** — a standalone boolean that is the card's only
1. **Immediate persist**: a standalone boolean that is the card's only
mutable state. The `<Switch>` fires the mutation in
`onCheckedChange`, disables itself while pending, updates
optimistically with rollback, and toasts on success/error. No Save
optimistically with rollback, and toasts on success or error. No Save
button anywhere on the card. Examples: hide-Coach, ntfy enable,
cycle tracking, research mode, mood reminder, Coach nudges.
2. **Draft + Save** — the card edits a multi-field payload (or a list
the server PUTs whole). All controls — including `<Switch>` rows —
mutate a local draft only; a visible Save button flushes the draft
in one request and the card surfaces its dirty state. Examples:
2. **Draft + Save**: the card edits a multi-field payload (or a list
the server PUTs whole). All controls, `<Switch>` rows included,
change a local draft only; a visible Save button sends the draft
in one request and the card shows its dirty state. Examples:
Coach preferences, Telegram (enable + bot token), provider fallback
chain, dashboard layout, threshold editor.
@@ -181,112 +99,85 @@ shows the Save button; a card with both an auto-persisting switch and
a Save-gated field is a bug. Every setting presented under `/settings`
persists server-side. `localStorage` is reserved for device-local UI
state that is not a setting (sidebar collapse, last-update-check
timestamp, tour progress) — label such keys with a comment at the
timestamp, tour progress): label such keys with a comment at the
declaration site and never render them with Save/Saved affordances.
**Validation.** Inline messages below the field. Field gets
`aria-invalid="true"` and a destructive border. Form-level errors land
in a `<Alert variant="destructive">` at the top. Never show a toast for
a field-level error.
**Validation.** Inline messages below the field. The field gets
`aria-invalid="true"` and a destructive border. A failed save shows inline
`role="alert"` text in the form. Never show a toast for a field-level error.
### 2.3 Dialog vs Sheet vs Drawer
### 2.3 Dialog vs sheet
The wrong choice here is the most visible UX mistake we make. Pick by
intent, not by hand-feel.
Pick by intent, not by hand-feel. Standards §6 maps the primitives.
| Use | Primitive | Rationale |
| --------------------------------------------------------------- | ------------------------------ | ---------------------------------------------------------------- |
| Confirm a destructive or irreversible action | `Dialog` (`AlertDialog` shape) | Modal blocks accidental dismiss; user must read. |
| Inspect a single item's details with no edit | `Dialog` | Centered, focused, dismissible. |
| Edit a single item with ≤6 fields | `Dialog` | Tight focus, quick save. |
| Edit a single item with 7+ fields, or with deep sub-sections | `Sheet` (right) | Wide working surface, scrollable, doesn't crop on tall content. |
| Mobile bottom-up sub-action (filter, sort, multi-select picker) | `Sheet` (bottom) | Thumb-friendly, dismiss by swipe. |
| Multi-step wizard | Full route | Page transitions communicate progress better than a stuck modal. |
| Side-by-side compare or trend deep-dive | Full route | Charts need the viewport. |
| Use | Primitive | Why |
| ----------------------------------------------------------- | ------------------------------------------------- | ---------------------------------------------------------- |
| Confirm a destructive or irreversible action | `AlertDialog` | Blocks an accidental dismiss; the user has to read it. |
| Inspect or edit one item, on any surface a phone reaches | `ResponsiveSheet` | A bottom sheet on a phone, a centred dialog on wider ones. |
| Sub-action from the bottom edge on a phone (filter, picker) | `SheetContent side="bottom"` or `ResponsiveSheet` | Grip, swipe to close, thumb reach. |
| Admin or desktop-only surface | `Dialog` | No phone layout to serve. |
| Side-by-side compare or trend deep-dive | Full route | Charts need the viewport. |
Never nest Dialogs. If a Dialog needs another modal action, escalate the
inner step to a separate route or use shadcn's `<AlertDialog>` chained on
top of a `<Sheet>`.
Never nest dialogs. If a sheet needs a confirmation, chain an
`AlertDialog` on top of it.
### 2.4 Cards and lists
- **Card**: a self-contained unit of content. Use `<Card>` from
shadcn. Header, body, footer follow Pretendard-style hierarchy.
- **Card**: anatomy, headers and density are standards §1.
- **Tile**: a _small_ card showing a single metric on the dashboard.
See §3.2 for the one-row constraint. Tiles must include current value,
unit, optional sparkline, and a single tap-target area.
- **List row**: `<div className="flex items-center justify-between gap-3 px-3 py-2 hover:bg-accent/40 rounded-md">` — left side has icon + primary
- meta, right side has trailing controls. Never put primary actions in
the right-side ellipsis menu — only secondary or destructive.
- **Empty state**: every list and tile has an explicit empty-state
component with an icon, a one-sentence explanation, and a single
primary action ("Add measurement", "Connect Withings"). See §5.
Tiles carry the current value, its unit, an optional sparkline, and one
tap target.
- **List row**: icon, primary text and meta on the left, trailing controls
on the right. Never put a primary action in the trailing overflow menu,
only secondary or destructive ones.
- **Empty state**: every list and tile that can be empty has one. See §4.2.
### 2.5 Tables
`<Table>` (shadcn) for tabular data only — measurements list, audit log,
admin views. Default to `<Card>` + list rows for everything else;
tables on mobile collapse poorly. When you must use a table on mobile,
swap to a vertical list at `sm:` breakpoint.
`<Table>` for tabular data only: the measurement list, the audit log, admin
views. Default to cards and list rows for everything else; tables collapse
poorly on a phone. When a table must exist on a phone, switch to a vertical
list below `sm`.
### 2.6 Filter bars over data lists
Every list surface with filters (measurements, mood, future admin
lists) uses the unified `<FilterBar>` rail from
`src/components/ui/filter-bar.tsx` — never a per-page row of large
labelled `<Select>`s and date inputs.
Every list surface with filters (measurements, mood, admin lists) uses the
shared `<FilterBar>` rail from `src/components/ui/filter-bar.tsx`, never a
per-page row of large labelled selects and date inputs.
- **Grammar**: `[icon] [Zeitraum] [Typ] [Quelle] [extras…]
[Zurücksetzen] … [count]`. Canonical dimension order is date range,
type, source; surface-specific dimensions append after.
- **Pill = trigger + chip**: inactive pills show only the dimension
name; active pills show `Name: Wert`, pick up the primary accent and
grow a removable ✕. No separate chip row.
- **Grammar**: `[icon] [date range] [type] [source] [extras…] [reset] … [count]`.
The canonical dimension order is date range, type, source;
surface-specific dimensions follow.
- **Pill = trigger + chip**: an inactive pill shows only the dimension
name; an active one shows `Name: value`, takes the primary accent and
grows a remove button. No separate chip row.
- **Reset** renders only while at least one dimension is active.
- **Mobile** (< `sm`): the rail is a horizontal scroll row (hidden
scrollbar, edge-bleed); the result count wraps to its own line.
- The component is presentation-only — filter state, query keys and
- **Mobile** (below `sm`): the rail is a horizontal scroll row (hidden
scrollbar, edge bleed); the result count wraps to its own line.
- The component is presentation only: filter state, query keys and
fetching stay in the owning page. Use `FilterBarSelect` for enum
dimensions and `FilterBarDateRange` for the from/to pair.
### 2.7 Section-card headers — icon column + content column
### 2.7 Card headers
Every Settings/Admin section card header follows one shape, implemented
by `<SettingsCardHeader>` (`src/components/settings/_card-header.tsx`)
or an equivalent inline structure:
- **Icon column** (left): one Lucide icon, `text-muted-foreground h-5 w-5`.
Icons in card headers are always neutral — never `text-primary` or any
`dracula-*` accent. Purple/primary is reserved for actions and
highlight states (§2.8), not for iconography.
- **Content column** (right of the icon, `gap-2` → 28 px offset): the
title, its description, and **everything textual that follows in the
card** — descriptions, follow-up headings after separators, form
stacks, helper/feedback lines. Apply `pl-7` to body blocks so nothing
textual ever starts further left than the title above it, and
follow-up headings align with the title column, not the icon.
- **Exception — full-bleed structures**: data tables, tile/card grids,
charts, ladder lists, and `<hr>` separators may span the full card
width. The rule governs text alignment, not structural surfaces.
- The page-level section `<h1>` is `sr-only`: the shell header already
carries "Einstellungen · <Sektion>", so a visible in-section repeat
is a double title. Keep the `<h1>` in the DOM for the document
outline and `aria-labelledby`.
Standards §1 (header patterns and the icon colour rule) and §13 (Settings
and Admin card rhythm). The old `pl-7` body gutter in Settings is gone: the
body starts at the card edge.
### 2.8 Status indicators
Use the same color taxonomy across the app:
One taxonomy across the app, on the semantic status tokens (standards §3):
| State | Color token | Examples |
| State | Token | Examples |
| --------- | -------------------- | ---------------------------------------------- |
| Good | `--dracula-green` | Connected, in target, healthy |
| Info | `--dracula-cyan` | Up-to-date, neutral signal |
| Caution | `--dracula-orange` | Mildly out of range, expiring token, slow sync |
| Alert | `--dracula-red` | Out of range, sync failed, critical |
| Good | `--success` | Connected, in target, healthy |
| Info | `--info` | Up to date, neutral signal |
| Caution | `--warning` | Mildly out of range, expiring token, slow sync |
| Alert | `--destructive` | Out of range, sync failed, critical |
| Pending | `--muted-foreground` | Loading, indeterminate |
| Highlight | `--dracula-purple` | New / featured / promoted |
| Highlight | `--primary` | New or featured |
Show a colored dot **plus** a label. Never rely on color alone.
Show a coloured dot **plus** a label. Never rely on colour alone.
---
@@ -294,60 +185,21 @@ Show a colored dot **plus** a label. Never rely on color alone.
### 3.1 Page shell
Every authenticated page is wrapped by `<TopBar>` + `<SidebarNav>` (left)
`AuthShell` owns the page frame, the scroll container and its bottom
clearance (standards §2 and §9). Pages never re-implement it.
- `<BottomNav>` (mobile). Don't reinvent these on a per-page basis.
### 3.2 Settings: `/settings/[section]`
* Mobile breakpoint: `<sm` shows bottom-nav, hides sidebar.
* `sm`–`md`: sidebar in icons-only mode.
* `md+`: sidebar full mode unless user has collapsed it
(`localStorage:healthlog-sidebar-collapsed`).
Every section is a route under `/settings/[section]` with its component
under `src/components/settings/`. The registry in
`src/components/settings/settings-shell.tsx` is authoritative for which
sections exist and how the navigation groups them. An address that moved
keeps working through a permanent redirect in `next.config.ts`.
Page content goes in `<main className="mx-auto max-w-5xl px-4 sm:px-6 py-6 space-y-6">` unless the page is a full-bleed dashboard or settings shell.
### 3.3 Admin: `/admin/[section]`
### 3.2 Dashboard tiles — one-row rule
Tiles at the top of the dashboard live in a **single row**. Width caps
at the chart's width directly below. **Never** wrap to a second row.
If the user enables more tiles than fit, the dashboard surfaces a banner
inviting them to deactivate or reorder via Settings → Arrangement. The
layout editor in Settings shows a live preview of the constraint and
flags overflow before save.
CSS: `grid-cols-N` where N = active tile count, capped by
`Math.min(activeTiles, maxTilesForViewport(width))`. Drag-reorder via
`@dnd-kit/sortable`. Toggle-show via the layout editor only — not in the
tile itself, to avoid accidental hide.
### 3.3 Settings — `/settings/[section]`
The 1.4 release split the legacy 3150-LOC settings monolith into one
route per top-level section. The pattern holds: every section is a
route under `/settings/[section]` with its component under
`src/components/settings/`. The section list has grown far past the
original eight (account, integrations, notifications, ai, api,
advanced, about now sit alongside security, access, sources, coach,
environment, export, mcp, and more); the registry in
`src/components/settings/settings-shell.tsx` is authoritative.
Cross-section search lives in the section sidebar header.
The legacy `/settings` route is a permanent (308) redirect to
`/settings/account`.
### 3.4 Admin — `/admin/[section]`
Same shape as Settings, with the right-hand sidebar listing:
- Users
- Integrations status
- Monitoring (Wide Events, GlitchTip, Umami)
- Backups
- Maintenance
- Audit log
Status badges use the §2.8 color taxonomy. Each section opens to a card
grid, never a long fluid list.
Admin uses the Settings anatomy verbatim (standards §13). Status badges use
the §2.8 taxonomy.
---
@@ -355,20 +207,20 @@ grid, never a long fluid list.
### 4.1 Loading states
- **Page-level**: a `<Skeleton>` matching the final layout. Never show a
full-screen spinner. Mount the skeleton inside the same `<main>` shell
the loaded page renders into.
- **Inline**: button loading state shows a spinner + label change
("Saving…", "Testing…"). Disable other form controls while pending.
- **Long-running**: progress bar with a remaining-step count; for
background tasks (export, backup), surface in a toast that links to
the operation log instead of blocking the UI.
- **Page-level**: a `<Skeleton>` matching the final layout, mounted inside
the same shell the loaded page renders into. Never a full-screen spinner.
- **Card-level**: a loading state paints the card and its header
(standards §13).
- **Inline**: a button in flight shows a spinner and a label change
("Saving…", "Testing…"). Disable the other form controls while pending.
- **Long-running**: background work (export, backup) reports through a
toast or a status line instead of blocking the UI.
Skeletons must respect `prefers-reduced-motion: reduce`.
Skeletons respect `prefers-reduced-motion: reduce`.
### 4.2 Empty states
A reusable `<EmptyState>` shape:
The shared `<EmptyState>`:
```tsx
<EmptyState
@@ -379,71 +231,58 @@ A reusable `<EmptyState>` shape:
/>
```
Every list, every chart, every dashboard tile that can be empty has an
explicit, translated empty-state. No "No data" placeholder strings.
`variant="plain" size="compact"` is the in-card and in-field form, and
`ChartEmptyState` covers a chart body (standards §6). Every list, chart and
dashboard tile that can be empty has an explicit, translated empty state. A
failed read never falls through to "no data yet".
### 4.3 Error states
- **Form errors**: inline below the offending field; aggregate at the
top of the form for global errors (e.g. "Server unreachable").
- **Page errors**: Next.js `error.tsx` boundaries with a friendly
recovery action ("Retry", "Go home"). Send the error to GlitchTip
through the existing client.
- **Network errors during save**: `<Alert variant="destructive">` at
the top of the form _plus_ a retry button. Never silently fail.
- **Form errors**: inline below the offending field, plus inline
`role="alert"` text for a failed save.
- **Failed reads**: `QueryErrorCard`, `QueryErrorRow` or `ChartErrorState`
by surface, with the query's `error` (standards §6).
- **Page errors**: Next.js `error.tsx` boundaries with a recovery action
("Retry", "Go home"). The error goes to GlitchTip through the existing
client.
### 4.4 Forms and password managers
- Measurement, medication, and mood form fields use
`autocomplete="off"` on every input that browsers keep guessing wrong.
- API-token inputs and AI-provider key inputs use
`autocomplete="new-password"` so the browser doesn't autofill the
user's account password.
- Honey-pot fields (offscreen `<input>` with `autocomplete="email"` and
`tabIndex={-1}`) sit at the top of measurement forms to catch
aggressive autofill.
- Forms always include a hidden `<input type="text" name="username"
autocomplete="username" value={user.email} readOnly tabIndex={-1}>`
inside password change and passkey screens so password managers
attribute saved credentials correctly.
- Measurement, medication and mood fields set `autoComplete="off"` on every
input that browsers keep guessing wrong.
- API-token and AI-provider key inputs use `autoComplete="new-password"`
so the browser does not fill in the account password.
- Password-change and passkey screens carry a hidden
`<input type="text" name="username" autoComplete="username" value={user.email} readOnly tabIndex={-1}>`
so password managers attribute saved credentials correctly.
### 4.5 Test buttons (every integration)
Each configurable integration ships a Test button next to its Save
control. Behavior:
control.
- **Trigger**: ad-hoc API call to the integration with a small,
identifiable payload ("HealthLog test ping").
- **State**: button shows a spinner with localized "Testing…" label.
Result lands inline in a `<StatusCallout>` directly below — green for
success with the relevant detail (last sync, message ID, response
status) or red for the error (sanitized message — never leak the
upstream URL or API key).
- **History**: each test writes a row to a tiny per-integration log
visible in the section. Last 5 tests, timestamp + result.
- **Coverage**: required for Telegram, ntfy, Web Push, AI provider,
Withings, Nightscout, GlitchTip, Umami, email (when wired), backup
target. Headless E2E asserts presence + working call for each.
- **Trigger**: a one-off call to the integration with a small,
recognisable payload ("HealthLog test ping").
- **State**: the button shows a spinner with a translated "Testing…"
label. The result lands inline directly below: the relevant detail on
success (last sync, message id, response status), or the error, sanitised
so it never names the upstream URL or the key.
### 4.6 Server actions and mutations
### 4.6 Mutations
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. Re-validates queries it touches.
For multi-step flows, write a typed `apiClient` helper in
`src/lib/api/clients/` rather than re-doing fetch in three places.
Mutations go through TanStack Query, never a raw fetch in a component. A
mutation invalidates every query it changes and toasts on success and on
error (translated). An optimistic cache update always carries a rollback.
### 4.7 Inline help
A small `?` `<Tooltip>` next to the label is the default for non-trivial
fields (e.g. "Why does this default to ESH 2023?"). Tooltips must be
keyboard-accessible (Radix handles this) and translated.
A small `?` `InfoPopover` next to the label is the default for a
non-trivial field (e.g. "Why does this default to ESH 2023?"). It opens on a
tap or a click and from the keyboard, so it works on a phone, where a hover
tooltip never opens. Its text is translated.
For long explanations, link out to `https://docs.healthlog.dev/...` —
never embed multi-paragraph help inline.
For a long explanation, link out to `https://docs.healthlog.dev/...`.
Never embed multi-paragraph help inline.
---
@@ -451,99 +290,83 @@ never embed multi-paragraph help inline.
We aim for **WCAG 2.1 AA**, with the following non-negotiables:
- Color contrast ≥ 4.5:1 for normal text, 3:1 for large text and UI
components. Verified by `@axe-core/playwright` in CI on every page.
- Touch targets ≥ 44×44 CSS px on bottom-nav and primary action buttons
(WCAG 2.5.5). Two idioms implement the floor, and they are deliberate:
- Colour contrast ≥ 4.5:1 for normal text, 3:1 for large text and UI
components. `@axe-core/playwright` checks it in CI.
- Touch targets ≥ 44×44 CSS px on the bottom navigation and primary action
buttons (WCAG 2.5.5). Two idioms implement the floor, and they are
deliberate:
- **Form controls** (`Input`, `NativeSelect`, `SelectTrigger` default
size): `h-11 sm:h-10` is baked into the primitives — 44 px on
mobile, back to the 40 px desktop tier. Never re-add `min-h-11` on
top of these; it forces 44 px on desktop and breaks the row rhythm.
size): `h-11 sm:h-10` is built into the primitives: 44 px on a phone,
40 px on desktop. Never add `min-h-11` on top; it forces 44 px on
desktop and breaks the row rhythm.
- **Buttons on dense touch surfaces** (medication detail, dashboard
quick actions): `min-h-11 sm:min-h-9` — 44 px mobile floor,
compact `sm` tier on desktop. Plain `min-h-11` without the `sm:`
reset is legacy spelling; don't introduce new ones, and align a
button to its direct neighbours when editing one.
- Focus is always visible: shadcn's `focus-visible:ring` is preserved on
every customized component.
- Every interactive element has an accessible name. Icon-only buttons
get `<span className="sr-only">` labels and `aria-label`.
quick actions): `min-h-11 sm:min-h-9`, a 44 px floor on a phone and the
compact `sm` tier on desktop. Plain `min-h-11` without the `sm:` reset
is legacy spelling; don't add new ones, and match a button to its
direct neighbours when editing one.
- Focus is always visible: the primitives' `focus-visible:ring` stays on
every customised component.
- Every interactive element has an accessible name. Icon-only buttons get
an `aria-label`.
- `prefers-reduced-motion: reduce` disables animations beyond a 200 ms
fade.
- Charts include text-equivalent summaries (`aria-describedby` linked to
a `<p className="sr-only">` summary) and exposed data via the visible
legend / table view toggle.
- Charts carry a text summary (`aria-describedby` on a `sr-only`
paragraph) and expose their data through the visible legend or a table
view.
CI fails on any `serious` or `critical` axe-core violation.
---
## 6. Slider audit and migration
## 6. Sliders
Sliders look pretty but are a poor fit for most numeric inputs because
they sacrifice precision. The 1.4 audit catalogues every `<Slider>` in
the codebase and migrates the inappropriate ones:
| Setting | Current control | Better control |
| ----------------------------------- | --------------- | ------------------------------- |
| Mood intensity (0–10) | Slider | SegmentedControl |
| Notification quiet-hours range | Two sliders | Two `<Input type="time">` |
| Threshold-override numeric values | Slider | `<Input type="number">` + steps |
| AI insight depth (1–5 levels) | Slider | `<RadioGroup>` cards |
| Export retention days (7/30/90/365) | Slider | `<Select>` with preset options |
A slider stays appropriate for "scrub through history" or "set chart
zoom range" — operations where the value is continuous and live preview
is the point.
A slider gives up precision, so it is the control only where the value is
a bounded scale or the sweep is the point: a self-rating with anchors
(`SliderField`, standards §6), scrubbing through history, a chart zoom
range. Thresholds, counts and times take a number, select or time field.
---
## 7. Internationalization
All user-facing strings come from `messages/en.json` (default) plus
the `de`, `es`, `fr`, `it`, and `pl` bundles beside it. Guard tests
(`i18n-call-site-coverage`, `i18n-locale-integrity`) enforce that every
`t()` key resolves in EN and propagates across all six locales. Use
`useTranslations()` on the client and
All user-facing strings come from `messages/en.json` (the default) plus the
`de`, `es`, `fr`, `it`, `pl`, `ko`, `nl`, `pt-BR` and `ru` bundles beside
it. Guard tests (`i18n-call-site-coverage`, `i18n-locale-integrity`) enforce
that every `t()` key resolves in English and exists, with the same
placeholders, in all ten locales. Use `useTranslations()` on the client and
`getServerTranslator()` (`src/lib/i18n/server-translator.ts`) on the
server. Numbers, dates, currencies, and units always go through
`useFormatters()` — never hand-roll `Intl.NumberFormat(...)` with a
fixed locale.
server. Numbers, dates and units always go through `useFormatters()`; never
hand-roll `Intl.NumberFormat(...)` with a fixed locale.
Server-rendered content (PDFs, notifications, server-issued category
labels) must use the locale of the user the content is for, not the
locale of the request that triggered the job.
labels) uses the locale of the user it is for, not the locale of the
request that triggered the job.
---
## 8. Motion
Default transitions: `transition-all duration-150 ease-out`. Heavier
animations (insight cards, dialog enter/exit) use the shadcn defaults.
Reduce-motion always wins.
animations (dialog enter and exit) use the primitives' defaults. Reduced
motion always wins.
Loading skeletons pulse via Tailwind `animate-pulse`. Don't introduce
custom keyframes without adding a `prefers-reduced-motion` fallback.
Loading skeletons pulse via Tailwind `animate-pulse`. Don't add custom
keyframes without a `prefers-reduced-motion` fallback.
---
## 9. Component checklist (for new features)
Before opening a PR for a new component or page, walk through:
Before opening a PR for a new component or page, walk through the PR
checklist in the standards (§7) and:
- [ ] Uses tokens from §1, not hard-coded hex/rgba.
- [ ] Buttons follow §2.1 variant + position rules.
- [ ] Inputs follow §2.2; correct `autocomplete`; honey-pots where needed.
- [ ] Dialog vs Sheet vs Drawer per §2.3.
- [ ] Loading, empty, and error states from §4.1–4.3 implemented.
- [ ] All strings via `t()`; numbers via formatters.
- [ ] Mobile viewport tested (Chrome DevTools 375×667 and 414×896).
- [ ] Light + Dark mode tested.
- [ ] Keyboard navigation: Tab order, Enter to submit, Esc to dismiss.
- [ ] axe-core clean (no serious/critical).
- [ ] Headless screenshot test added if UI is non-trivial.
---
_Maintained as part of the v1.4 release cycle. Propose changes alongside
the code that motivates them._
- [ ] Buttons follow §2.1.
- [ ] Inputs follow §2.2; correct `autoComplete`.
- [ ] Dialog or sheet per §2.3.
- [ ] Loading, empty and error states from §4.1 to §4.3.
- [ ] All strings via `t()`; numbers via the formatters.
- [ ] Tested at phone width (390 px) and on desktop.
- [ ] Light and dark theme checked.
- [ ] Keyboard: Tab order, Enter to submit, Esc to dismiss.
- [ ] axe-core clean (no serious or critical findings).
- [ ] A screenshot test added if the UI is non-trivial.
+1 -1
View File
@@ -21,4 +21,4 @@ The Playwright config (`../playwright.config.ts`) builds the app (`next build`)
Import `test` and `expect` from `./setup/test`, never from `@playwright/test` — types (`Page`, `Route`, …) still come from the package. The entry point extends Playwright's `test` with an automatic fixture that drops route handlers and page listeners after every test, so a handler left in flight cannot fail the test it belonged to once the context closes. `src/__tests__/e2e-route-cleanup-guard.test.ts` enforces the import boundary.
Assert against stable `data-*` attributes (`data-slot`, `data-display-step`, root display attrs), not viewport-dependent visible text. Responsive `sm:hidden` UI changes break `getByText` on desktop while the underlying element is still present — anchoring on data attributes keeps the specs stable across breakpoints. See [`../CLAUDE.md`](../CLAUDE.md).
Assert against stable `data-*` attributes (`data-slot`, `data-display-step`, root display attrs), not viewport-dependent visible text. Responsive `sm:hidden` UI changes break `getByText` on desktop while the underlying element is still present — anchoring on data attributes keeps the specs stable across breakpoints.
+2 -2
View File
@@ -8,7 +8,7 @@
* record the delegate may only read, 200 inside the one it manages.
*
* The UI half proves something narrower, and the file says so rather than
* implying the route's boundary twice. `/settings/gesundheitsakte` is a
* implying the route's boundary twice. `/settings/health-record` is a
* PERSONAL destination: inside SOMEBODY ELSE's record the shell answers it with
* the "not part of shared access" panel at every adult grant level — READ,
* WRITE and MANAGE alike — so the export card is never rendered for a delegate
@@ -149,7 +149,7 @@ async function leaveRecord(page: Page): Promise<void> {
* card was never offered.
*/
async function openGesundheitsakte(page: Page): Promise<void> {
await page.goto("/settings/gesundheitsakte", {
await page.goto("/settings/health-record", {
waitUntil: "domcontentloaded",
});
await expect(
+1 -1
View File
@@ -247,7 +247,7 @@ async function openReportPanel(page: Page): Promise<void> {
response.request().method() === "GET" &&
new URL(response.url()).pathname === "/api/share-links",
);
await page.goto("/settings/gesundheitsakte", {
await page.goto("/settings/health-record", {
waitUntil: "domcontentloaded",
});
await boundaryHydrated;
+1 -1
View File
@@ -466,7 +466,7 @@ test.describe("medication adherence journey", () => {
// The 90-day ledger beside it is a view of the SCHEDULE and stops at now,
// so a dose skipped ahead of its window is not in it. The intake history
// is the view of what was recorded, and both gestures belong there.
await page.goto(`/medications/${medicationId}?tab=verlauf`);
await page.goto(`/medications/${medicationId}?tab=history`);
await page
.locator('[data-slot="medication-verlauf-view-toggle-all"]')
.click();
+1 -1
View File
@@ -92,7 +92,7 @@ const DYNAMIC_ROUTES: Record<string, Resolver> = {
"labs",
"illness",
"documents",
"vorsorge",
"checkups",
].map((group) => `/settings/layout/${group}`),
"/settings/modules/[module]": async () =>
MODULE_SETTINGS_PAGE_IDS.map((module) => `/settings/modules/${module}`),
+1 -1
View File
@@ -661,7 +661,7 @@ test.describe("notification preferences drive the dispatch decision", () => {
await page.goto(`/medications/${medicationId}`, {
waitUntil: "domcontentloaded",
});
await page.locator('[data-slot="medication-tab-zeitplan"]').click();
await page.locator('[data-slot="medication-tab-schedule"]').click();
await expect(
page.locator('[data-slot="notifications-client-managed-chip"]'),
).toBeVisible({ timeout: 30_000 });
+2 -2
View File
@@ -53,7 +53,7 @@ const SETTINGS_ROUTES = [
"/settings/sources",
"/settings/environment",
"/settings/export",
"/settings/gesundheitsakte",
"/settings/health-record",
"/settings/privacy",
"/settings/api",
"/settings/mcp",
@@ -76,7 +76,7 @@ const LAYOUT_CHILD_ROUTES = [
"/settings/layout/medications",
"/settings/layout/labs",
"/settings/layout/illness",
"/settings/layout/vorsorge",
"/settings/layout/checkups",
"/settings/modules/medications",
"/settings/modules/mood",
"/settings/modules/labs",
+4 -4
View File
@@ -14,7 +14,7 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
* 2. The import surfaces (v1.15.7, issue #281) render: the Apple Health
* and generic-JSON cards.
* 3. The full health-record export panel lives on its own top-level
* `/settings/gesundheitsakte` section (v1.18.0 S5) — including the
* `/settings/health-record` section (v1.18.0 S5) — including the
* "included data" disclosure, which a first run opens and which stays
* operable in both directions.
* 4. The scope picker mounts twice on that page (the export panel and the
@@ -45,7 +45,7 @@ async function openGesundheitsakte(page: Page): Promise<void> {
response.request().method() === "GET" &&
new URL(response.url()).pathname === "/api/share-links",
);
await page.goto("/settings/gesundheitsakte", {
await page.goto("/settings/health-record", {
waitUntil: "domcontentloaded",
});
await boundaryHydrated;
@@ -57,7 +57,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 — the full health-record export moved to its own
// `/settings/gesundheitsakte` section. The Export & Import page keeps
// `/settings/health-record` section. The Export & Import page keeps
// the four CSV/backup tiles with the `export-card-*` shape.
for (const id of [
"export-card-measurements-csv",
@@ -250,7 +250,7 @@ test.describe("Settings → Export & Import", () => {
page,
}) => {
// The disclosure that fronts the scope picker on
// `/settings/gesundheitsakte`. An account with no saved selection is a
// `/settings/health-record`. An account with no saved selection is a
// first run, and a first run opens it: that is the only place the standard
// template is applied, and a template hidden behind a closed disclosure
// would be opt-out wearing a hat.
+1 -7
View File
@@ -16,13 +16,7 @@
# Per project policy (`--no-verify` is banned), prefer fixing the
# finding over bypassing the hook.
# Commit-message guard: the project voice rule (CLAUDE.md, "Voice and
# privacy") states that commits are authored by the maintainer's git
# identity alone and carry no tooling co-author trailer or session link.
# Editors and CLIs add those trailers by default, and a message that
# picks one up is only removable from the object store by a history
# rewrite — so the check belongs here, before the commit exists, not in
# a review pass afterwards.
# Reject tool attribution trailers.
commit-msg:
commands:
no-tooling-trailer:
+8 -8
View File
@@ -774,7 +774,7 @@
"moveUp": "Nach oben",
"moveDown": "Nach unten",
"layoutTileColumn": "Kachel",
"layoutChartColumn": "Chart",
"layoutChartColumn": "Diagramm",
"layoutNativeOnlyTitle": "In der mobilen App sichtbar",
"layoutNativeOnlyHint": "Diese Widgets zeichnet die mobile App, nicht diese Seite. Sie liegen im selben gespeicherten Layout, hier wird ihre Sichtbarkeit gesetzt.",
"dragHandle": "Zum Verschieben ziehen",
@@ -1518,7 +1518,6 @@
"glp1NextInjectionToday": "heute",
"glp1NextInjectionTomorrow": "morgen",
"glp1RotationSuggested": "Empfohlen: {site}",
"glp1LogSideEffect": "Nebenwirkung loggen",
"sideEffects": {
"section": "Nebenwirkungen",
"addCta": "Erfassen",
@@ -5680,7 +5679,7 @@
"title": "Krankheitstagebuch",
"description": "Listenansicht und Reihenfolge der Episoden."
},
"vorsorge": {
"checkups": {
"title": "Vorsorge",
"description": "Listenansicht und Reihenfolge der Erinnerungen."
},
@@ -6105,7 +6104,7 @@
"moduleSettingsDescription": "Alles außer Reihenfolge und Ansicht findest du auf der Seite des Moduls unter Module."
}
},
"gesundheitsakte": {
"health-record": {
"title": "Gesundheitsakte",
"subtitle": "Exportiere eine vollständige, arzttaugliche Gesundheitsakte."
},
@@ -10210,6 +10209,7 @@
"BLOOD_PRESSURE_SYS": "Blutdruck",
"PULSE": "Puls",
"BLOOD_GLUCOSE": "Blutzucker",
"BLOOD_KETONES": "Blutketone",
"OXYGEN_SATURATION": "Sauerstoffsättigung",
"BODY_FAT": "Körperfett",
"BODY_TEMPERATURE": "Körpertemperatur",
@@ -11054,7 +11054,7 @@
"notePlaceholder": "Wie sich der Tag anfühlte (verschlüsselt).",
"severityFor": "Stärke für {symptom}",
"saveError": "Der Tag konnte nicht gespeichert werden. Bitte erneut versuchen.",
"day": "Day"
"day": "Tag"
},
"impact": {
"0": "Voll belastbar",
@@ -11143,14 +11143,14 @@
"RESTING_HEART_RATE": "Ruheherzfrequenz",
"HEART_RATE_VARIABILITY": "Herzfrequenzvariabilität",
"HRV_RMSSD": "HRV (RMSSD)",
"RECOVERY_SCORE": "Recovery",
"RECOVERY_SCORE": "Erholung",
"OXYGEN_SATURATION": "Sauerstoffsättigung",
"BODY_TEMPERATURE": "Körpertemperatur",
"RESPIRATORY_RATE": "Atemfrequenz",
"BLOOD_PRESSURE_SYS": "Systolischer Blutdruck",
"BLOOD_PRESSURE_DIA": "Diastolischer Blutdruck",
"PULSE": "Pulse",
"WEIGHT": "Weight",
"PULSE": "Puls",
"WEIGHT": "Gewicht",
"FUNCTIONAL_IMPACT": "Funktionelle Beeinträchtigung"
},
"listLoadError": "Episoden konnten nicht geladen werden."
+3 -3
View File
@@ -1518,7 +1518,6 @@
"glp1NextInjectionToday": "today",
"glp1NextInjectionTomorrow": "tomorrow",
"glp1RotationSuggested": "Recommended next: {site}",
"glp1LogSideEffect": "Log side effect",
"sideEffects": {
"section": "Side effects",
"addCta": "Log",
@@ -5680,7 +5679,7 @@
"title": "Illness journal",
"description": "List view and episode order."
},
"vorsorge": {
"checkups": {
"title": "Checkups",
"description": "List view and reminder order."
},
@@ -6105,7 +6104,7 @@
"moduleSettingsDescription": "Everything beyond order and view lives on the module's page under Modules."
}
},
"gesundheitsakte": {
"health-record": {
"title": "Health record",
"subtitle": "Export a complete, clinician-ready health record."
},
@@ -10210,6 +10209,7 @@
"BLOOD_PRESSURE_SYS": "Blood pressure",
"PULSE": "Pulse",
"BLOOD_GLUCOSE": "Blood glucose",
"BLOOD_KETONES": "Blood ketones",
"OXYGEN_SATURATION": "Oxygen saturation",
"BODY_FAT": "Body fat",
"BODY_TEMPERATURE": "Body temperature",
+8 -8
View File
@@ -1518,7 +1518,6 @@
"glp1NextInjectionToday": "hoy",
"glp1NextInjectionTomorrow": "mañana",
"glp1RotationSuggested": "Sugerido: {site}",
"glp1LogSideEffect": "Registrar efecto secundario",
"sideEffects": {
"section": "Efectos secundarios",
"addCta": "Registrar",
@@ -1712,7 +1711,7 @@
"step1": {
"title": "¿Cómo se llama el medicamento?",
"subline": "Exactamente como aparece en la caja.",
"label": "Name",
"label": "Nombre",
"placeholder": "p. ej. Ramipril",
"short": "Nombre"
},
@@ -5680,7 +5679,7 @@
"title": "Diario de enfermedades",
"description": "Vista de lista y orden de episodios."
},
"vorsorge": {
"checkups": {
"title": "Revisiones",
"description": "Vista de lista y orden de recordatorios."
},
@@ -6105,7 +6104,7 @@
"moduleSettingsDescription": "Todo lo demás está en la página del módulo, en Módulos."
}
},
"gesundheitsakte": {
"health-record": {
"title": "Historial médico",
"subtitle": "Exporta un historial de salud completo, listo para el médico."
},
@@ -10210,6 +10209,7 @@
"BLOOD_PRESSURE_SYS": "Presión arterial",
"PULSE": "Pulso",
"BLOOD_GLUCOSE": "Glucosa en sangre",
"BLOOD_KETONES": "Cetonas en sangre",
"OXYGEN_SATURATION": "Saturación de oxígeno",
"BODY_FAT": "Grasa corporal",
"BODY_TEMPERATURE": "Temperatura corporal",
@@ -11054,7 +11054,7 @@
"notePlaceholder": "Cómo te sentiste hoy (cifrado).",
"severityFor": "Gravedad de {symptom}",
"saveError": "No se pudo guardar el día. Inténtalo de nuevo.",
"day": "Day"
"day": "Día"
},
"impact": {
"0": "Plenamente funcional",
@@ -11143,14 +11143,14 @@
"RESTING_HEART_RATE": "Frecuencia cardíaca en reposo",
"HEART_RATE_VARIABILITY": "Variabilidad de la frecuencia cardíaca",
"HRV_RMSSD": "VFC (RMSSD)",
"RECOVERY_SCORE": "Recovery",
"RECOVERY_SCORE": "Recuperación",
"OXYGEN_SATURATION": "Saturación de oxígeno",
"BODY_TEMPERATURE": "Temperatura corporal",
"RESPIRATORY_RATE": "Frecuencia respiratoria",
"BLOOD_PRESSURE_SYS": "Presión arterial sistólica",
"BLOOD_PRESSURE_DIA": "Presión arterial diastólica",
"PULSE": "Pulse",
"WEIGHT": "Weight",
"PULSE": "Pulso",
"WEIGHT": "Peso",
"FUNCTIONAL_IMPACT": "Impacto funcional"
},
"listLoadError": "No se pudieron cargar tus episodios."
+8 -8
View File
@@ -1518,7 +1518,6 @@
"glp1NextInjectionToday": "aujourd’hui",
"glp1NextInjectionTomorrow": "demain",
"glp1RotationSuggested": "Conseillé : {site}",
"glp1LogSideEffect": "Consigner un effet secondaire",
"sideEffects": {
"section": "Effets secondaires",
"addCta": "Consigner",
@@ -1712,7 +1711,7 @@
"step1": {
"title": "Comment s’appelle le médicament ?",
"subline": "Exactement comme indiqué sur la boîte.",
"label": "Name",
"label": "Nom",
"placeholder": "par exemple Ramipril",
"short": "Nom"
},
@@ -5680,7 +5679,7 @@
"title": "Journal des maladies",
"description": "Vue en liste et ordre des épisodes."
},
"vorsorge": {
"checkups": {
"title": "Examens",
"description": "Vue en liste et ordre des rappels."
},
@@ -6105,7 +6104,7 @@
"moduleSettingsDescription": "Tout le reste se trouve sur la page du module, dans Modules."
}
},
"gesundheitsakte": {
"health-record": {
"title": "Dossier médical",
"subtitle": "Exportez un dossier de santé complet, prêt pour le médecin."
},
@@ -10210,6 +10209,7 @@
"BLOOD_PRESSURE_SYS": "Tension artérielle",
"PULSE": "Pouls",
"BLOOD_GLUCOSE": "Glycémie",
"BLOOD_KETONES": "Cétones sanguines",
"OXYGEN_SATURATION": "Saturation en oxygène",
"BODY_FAT": "Masse grasse",
"BODY_TEMPERATURE": "Température corporelle",
@@ -11054,7 +11054,7 @@
"notePlaceholder": "Comment s'est passée la journée (chiffré).",
"severityFor": "Intensité de {symptom}",
"saveError": "Impossible d'enregistrer la journée. Veuillez réessayer.",
"day": "Day"
"day": "Jour"
},
"impact": {
"0": "Pleinement fonctionnel",
@@ -11143,14 +11143,14 @@
"RESTING_HEART_RATE": "Fréquence cardiaque au repos",
"HEART_RATE_VARIABILITY": "Variabilité de la fréquence cardiaque",
"HRV_RMSSD": "VFC (RMSSD)",
"RECOVERY_SCORE": "Recovery",
"RECOVERY_SCORE": "Récupération",
"OXYGEN_SATURATION": "Saturation en oxygène",
"BODY_TEMPERATURE": "Température corporelle",
"RESPIRATORY_RATE": "Fréquence respiratoire",
"BLOOD_PRESSURE_SYS": "Pression artérielle systolique",
"BLOOD_PRESSURE_DIA": "Pression artérielle diastolique",
"PULSE": "Pulse",
"WEIGHT": "Weight",
"PULSE": "Pouls",
"WEIGHT": "Poids",
"FUNCTIONAL_IMPACT": "Retentissement fonctionnel"
},
"listLoadError": "Impossible de charger vos épisodes."
+8 -8
View File
@@ -1518,7 +1518,6 @@
"glp1NextInjectionToday": "oggi",
"glp1NextInjectionTomorrow": "domani",
"glp1RotationSuggested": "Suggerita: {site}",
"glp1LogSideEffect": "Registra effetto collaterale",
"sideEffects": {
"section": "Effetti collaterali",
"addCta": "Registra",
@@ -1712,7 +1711,7 @@
"step1": {
"title": "Come si chiama il farmaco?",
"subline": "Esattamente come riportato sulla confezione.",
"label": "Name",
"label": "Nome",
"placeholder": "ad es. Ramipril",
"short": "Nome"
},
@@ -5680,7 +5679,7 @@
"title": "Diario delle malattie",
"description": "Vista elenco e ordine degli episodi."
},
"vorsorge": {
"checkups": {
"title": "Controlli",
"description": "Vista elenco e ordine dei promemoria."
},
@@ -6105,7 +6104,7 @@
"moduleSettingsDescription": "Tutto il resto è nella pagina del modulo, in Moduli."
}
},
"gesundheitsakte": {
"health-record": {
"title": "Cartella clinica",
"subtitle": "Esporta una cartella clinica completa, pronta per il medico."
},
@@ -10210,6 +10209,7 @@
"BLOOD_PRESSURE_SYS": "Pressione sanguigna",
"PULSE": "Battito",
"BLOOD_GLUCOSE": "Glicemia",
"BLOOD_KETONES": "Chetoni nel sangue",
"OXYGEN_SATURATION": "Saturazione di ossigeno",
"BODY_FAT": "Grasso corporeo",
"BODY_TEMPERATURE": "Temperatura corporea",
@@ -11054,7 +11054,7 @@
"notePlaceholder": "Com'è andata la giornata (cifrato).",
"severityFor": "Intensità di {symptom}",
"saveError": "Impossibile salvare la giornata. Riprova.",
"day": "Day"
"day": "Giorno"
},
"impact": {
"0": "Pienamente funzionale",
@@ -11143,14 +11143,14 @@
"RESTING_HEART_RATE": "Frequenza cardiaca a riposo",
"HEART_RATE_VARIABILITY": "Variabilità della frequenza cardiaca",
"HRV_RMSSD": "HRV (RMSSD)",
"RECOVERY_SCORE": "Recovery",
"RECOVERY_SCORE": "Recupero",
"OXYGEN_SATURATION": "Saturazione di ossigeno",
"BODY_TEMPERATURE": "Temperatura corporea",
"RESPIRATORY_RATE": "Frequenza respiratoria",
"BLOOD_PRESSURE_SYS": "Pressione arteriosa sistolica",
"BLOOD_PRESSURE_DIA": "Pressione arteriosa diastolica",
"PULSE": "Pulse",
"WEIGHT": "Weight",
"PULSE": "Polso",
"WEIGHT": "Peso",
"FUNCTIONAL_IMPACT": "Impatto funzionale"
},
"listLoadError": "Impossibile caricare gli episodi."
+3 -3
View File
@@ -1518,7 +1518,6 @@
"glp1NextInjectionToday": "오늘",
"glp1NextInjectionTomorrow": "내일",
"glp1RotationSuggested": "다음 추천 부위: {site}",
"glp1LogSideEffect": "부작용 기록",
"sideEffects": {
"section": "부작용",
"addCta": "기록",
@@ -5680,7 +5679,7 @@
"title": "아픈 날 일지",
"description": "목록 보기와 에피소드 순서."
},
"vorsorge": {
"checkups": {
"title": "건강검진",
"description": "목록 보기와 알림 순서."
},
@@ -6105,7 +6104,7 @@
"moduleSettingsDescription": "순서와 보기 외의 설정은 모듈의 페이지에 있습니다."
}
},
"gesundheitsakte": {
"health-record": {
"title": "건강 기록",
"subtitle": "임상의에게 바로 건넬 수 있는 완전한 건강 기록을 내보내요."
},
@@ -10210,6 +10209,7 @@
"BLOOD_PRESSURE_SYS": "혈압",
"PULSE": "맥박",
"BLOOD_GLUCOSE": "혈당",
"BLOOD_KETONES": "혈중 케톤",
"OXYGEN_SATURATION": "산소포화도",
"BODY_FAT": "체지방",
"BODY_TEMPERATURE": "체온",
+3 -3
View File
@@ -1518,7 +1518,6 @@
"glp1NextInjectionToday": "vandaag",
"glp1NextInjectionTomorrow": "morgen",
"glp1RotationSuggested": "Volgende aanbevolen plek: {site}",
"glp1LogSideEffect": "Bijwerking vastleggen",
"sideEffects": {
"section": "Bijwerkingen",
"addCta": "Vastleggen",
@@ -5680,7 +5679,7 @@
"title": "Ziektedagboek",
"description": "Lijstweergave en volgorde van episodes."
},
"vorsorge": {
"checkups": {
"title": "Preventieve controles",
"description": "Lijstweergave en volgorde van herinneringen."
},
@@ -6105,7 +6104,7 @@
"moduleSettingsDescription": "Al het andere staat op de pagina van de module onder Modules."
}
},
"gesundheitsakte": {
"health-record": {
"title": "Gezondheidsdossier",
"subtitle": "Exporteer een volledig gezondheidsdossier dat klaar is voor je zorgverlener."
},
@@ -10210,6 +10209,7 @@
"BLOOD_PRESSURE_SYS": "Bloeddruk",
"PULSE": "Hartslag",
"BLOOD_GLUCOSE": "Bloedglucose",
"BLOOD_KETONES": "Bloedketonen",
"OXYGEN_SATURATION": "Zuurstofsaturatie",
"BODY_FAT": "Vetpercentage",
"BODY_TEMPERATURE": "Lichaamstemperatuur",
+8 -8
View File
@@ -1518,7 +1518,6 @@
"glp1NextInjectionToday": "dziś",
"glp1NextInjectionTomorrow": "jutro",
"glp1RotationSuggested": "Sugerowane: {site}",
"glp1LogSideEffect": "Zapisz działanie niepożądane",
"sideEffects": {
"section": "Skutki uboczne",
"addCta": "Dodaj",
@@ -1712,7 +1711,7 @@
"step1": {
"title": "Jak nazywa się lek?",
"subline": "Dokładnie tak, jak na opakowaniu.",
"label": "Name",
"label": "Nazwa",
"placeholder": "np. Ramipryl",
"short": "Nazwa"
},
@@ -5680,7 +5679,7 @@
"title": "Dziennik chorób",
"description": "Widok listy i kolejność epizodów."
},
"vorsorge": {
"checkups": {
"title": "Badania kontrolne",
"description": "Widok listy i kolejność przypomnień."
},
@@ -6105,7 +6104,7 @@
"moduleSettingsDescription": "Wszystko poza kolejnością i widokiem jest na stronie modułu w Modułach."
}
},
"gesundheitsakte": {
"health-record": {
"title": "Dokumentacja zdrowotna",
"subtitle": "Eksportuj kompletną dokumentację zdrowotną gotową dla lekarza."
},
@@ -10210,6 +10209,7 @@
"BLOOD_PRESSURE_SYS": "Ciśnienie krwi",
"PULSE": "Tętno",
"BLOOD_GLUCOSE": "Glukoza we krwi",
"BLOOD_KETONES": "Ketony we krwi",
"OXYGEN_SATURATION": "Saturacja tlenem",
"BODY_FAT": "Tkanka tłuszczowa",
"BODY_TEMPERATURE": "Temperatura ciała",
@@ -11054,7 +11054,7 @@
"notePlaceholder": "Jak minął dzień (zaszyfrowane).",
"severityFor": "Nasilenie: {symptom}",
"saveError": "Nie udało się zapisać dnia. Spróbuj ponownie.",
"day": "Day"
"day": "Dzień"
},
"impact": {
"0": "Pełna sprawność",
@@ -11143,14 +11143,14 @@
"RESTING_HEART_RATE": "Spoczynkowe tętno",
"HEART_RATE_VARIABILITY": "Zmienność rytmu serca",
"HRV_RMSSD": "HRV (RMSSD)",
"RECOVERY_SCORE": "Recovery",
"RECOVERY_SCORE": "Regeneracja",
"OXYGEN_SATURATION": "Saturacja tlenem",
"BODY_TEMPERATURE": "Temperatura ciała",
"RESPIRATORY_RATE": "Częstość oddechów",
"BLOOD_PRESSURE_SYS": "Ciśnienie skurczowe",
"BLOOD_PRESSURE_DIA": "Ciśnienie rozkurczowe",
"PULSE": "Pulse",
"WEIGHT": "Weight",
"PULSE": "Tętno",
"WEIGHT": "Waga",
"FUNCTIONAL_IMPACT": "Wpływ na funkcjonowanie"
},
"listLoadError": "Nie udało się załadować epizodów."
+3 -3
View File
@@ -1518,7 +1518,6 @@
"glp1NextInjectionToday": "hoje",
"glp1NextInjectionTomorrow": "amanhã",
"glp1RotationSuggested": "Próximo recomendado: {site}",
"glp1LogSideEffect": "Registrar efeito colateral",
"sideEffects": {
"section": "Efeitos colaterais",
"addCta": "Registrar",
@@ -5680,7 +5679,7 @@
"title": "Diário de doenças",
"description": "Visualização em lista e ordem dos episódios."
},
"vorsorge": {
"checkups": {
"title": "Check-ups",
"description": "Visualização em lista e ordem dos lembretes."
},
@@ -6105,7 +6104,7 @@
"moduleSettingsDescription": "Tudo além da ordem e da visualização fica na página do módulo, em Módulos."
}
},
"gesundheitsakte": {
"health-record": {
"title": "Registro de saúde",
"subtitle": "Exporte um registro de saúde completo, pronto para o clínico."
},
@@ -10210,6 +10209,7 @@
"BLOOD_PRESSURE_SYS": "Pressão arterial",
"PULSE": "Pulso",
"BLOOD_GLUCOSE": "Glicemia",
"BLOOD_KETONES": "Cetonas no sangue",
"OXYGEN_SATURATION": "Saturação de oxigênio",
"BODY_FAT": "Gordura corporal",
"BODY_TEMPERATURE": "Temperatura corporal",
+3 -3
View File
@@ -1518,7 +1518,6 @@
"glp1NextInjectionToday": "сегодня",
"glp1NextInjectionTomorrow": "завтра",
"glp1RotationSuggested": "Рекомендуется следующее: {site}",
"glp1LogSideEffect": "Записать побочный эффект",
"sideEffects": {
"section": "Побочные эффекты",
"addCta": "Записать",
@@ -5680,7 +5679,7 @@
"title": "Дневник болезней",
"description": "Вид списка и порядок эпизодов."
},
"vorsorge": {
"checkups": {
"title": "Профилактика",
"description": "Вид списка и порядок напоминаний."
},
@@ -6105,7 +6104,7 @@
"moduleSettingsDescription": "Всё, кроме порядка и вида, настраивается на странице модуля в разделе «Модули»."
}
},
"gesundheitsakte": {
"health-record": {
"title": "Медицинская карта",
"subtitle": "Экспортируйте полную медицинскую карту, готовую для врача."
},
@@ -10210,6 +10209,7 @@
"BLOOD_PRESSURE_SYS": "АД",
"PULSE": "Пульс",
"BLOOD_GLUCOSE": "Глюкоза крови",
"BLOOD_KETONES": "Кетоны крови",
"OXYGEN_SATURATION": "Насыщение кислородом",
"BODY_FAT": "Процент жировой массы",
"BODY_TEMPERATURE": "Температура тела",
+14 -2
View File
@@ -230,7 +230,19 @@ const nextConfig: NextConfig = {
},
{
source: "/settings/vorsorge",
destination: "/settings/layout/vorsorge",
destination: "/settings/layout/checkups",
permanent: true,
},
// The health record and checkups pages had German addresses in every
// language; the English ones replace them, and the old ones redirect.
{
source: "/settings/gesundheitsakte",
destination: "/settings/health-record",
permanent: true,
},
{
source: "/settings/layout/vorsorge",
destination: "/settings/layout/checkups",
permanent: true,
},
// v1.25.7 — clinician share links fold into the Gesundheitsakte section
@@ -238,7 +250,7 @@ const nextConfig: NextConfig = {
// to the anchor there.
{
source: "/settings/sharing",
destination: "/settings/gesundheitsakte#sharing",
destination: "/settings/health-record#sharing",
permanent: true,
},
// v1.22.0 — the preventive-care surface moved from the German slug
+1 -1
View File
@@ -23,6 +23,6 @@ Number a new migration with the next free prefix. The production entrypoint runs
- Encryption is fail-closed: `*Encrypted` columns are written through `@/lib/crypto` (AES-256-GCM, versioned key ids); never store plaintext in an encrypted column. Key rotation is `scripts/rotate-encryption-key.ts` + `docs/ops/encryption-key-rotation.md`.
- The generated client under `src/generated/prisma` is build output — do not edit it.
- Schema-touching changes that affect the API surface must regenerate `docs/api/openapi.yaml` (see [`../CLAUDE.md`](../CLAUDE.md)).
- Schema-touching changes that affect the API surface must regenerate `docs/api/openapi.yaml` (`pnpm openapi:generate`; CI fails on drift).
Related: [`../src/lib/README.md`](../src/lib/README.md) (`db.ts`, `rollups/`), [`../docs/ops/`](../docs/ops/) (migrations + backup runbooks).
+23 -19
View File
@@ -1,6 +1,6 @@
# `scripts/`
One-shot operator and build scripts. These are not part of the running application — they are run by hand during a deploy, a backfill, or a release, or wired into CI. Recurring work belongs on pg-boss (`src/lib/jobs/`), not here.
One-shot operator and build scripts. They are not part of the running application: they are run by hand during a deploy, a backfill, or a release, or wired into CI. Recurring work belongs on pg-boss (`src/lib/jobs/`), not here.
## How to run them
@@ -18,24 +18,28 @@ typecheck.
## What lives here
- **`generate-openapi.ts`** — emits `docs/api/openapi.yaml` from the registered Zod schemas (`pnpm openapi:generate`).
- **`check-openapi.ts`** — compares a freshly generated spec against the committed YAML; CI fails on drift (`pnpm openapi:check`).
- **`check-env.ts`** — pre-deploy env-var sanity check against `env-manifest.json` (`pnpm check-env`); wired into CI.
- **`assert-deploy.ts`** — post-deploy `/api/version` assertion.
- **`rotate-encryption-key.ts`** — AES-256-GCM key rotation; see `docs/ops/encryption-key-rotation.md`.
- **`restore-backup.ts`** — restore an off-host AES-GCM backup from an S3-compatible bucket.
- **`repair-apple-health-distance.ts`** — repair walking-distance rows the `export.zip` import stored 1000x too small; dry-run by default, see `docs/ops/apple-health-distance-repair.md`.
- **`repair-intake-anomalies.ts`** — repair historic medication-ledger defects (duplicate dose-slot rows, implausible `taken_at`); dry-run by default, see `docs/ops/intake-repair.md`.
- **`backfill-rollups.ts`**, **`drain-per-sample-cumulative.ts`**, **`backfill-mood-note-column.ts`** — historical data backfills (note: the boot-time `rollup-full-backfill` queue handles new accounts automatically; the CLI is the manual fallback).
- **`seed-demo.ts`** + **`seed-demo-v15.sql`** — populate a demo tenant.
- **`bench/`** — `seed-large-account.ts` (a synthetic ten-year account of about 1.26 million measurement rows) and `run.ts` (latency and SQL statements per request against it); see `docs/contributing/benchmarks.md`. Results land in `bench/results/`.
- **`test-notifications.ts`** — exercise the notification dispatcher + reminder check.
- **`fetch-geolite2.sh`** — download the MaxMind GeoLite2 MMDBs (needs a licence key) before `docker build`.
- **`build-pdf-fonts.py`** — rebuild the fonts the doctor-report PDF embeds (`src/lib/doctor-report-pdf/fonts/`) from the upstream Noto sources; needs `fonttools`. The source URLs are in its header.
- **`install-hooks.sh`** — install the local git hooks.
- **`generate-sw-version.mjs`**, **`print-bundle-report.mjs`** — service-worker version stamp and bundle-size report.
- **`i18n/`** — locale tooling; **`__tests__/`** — tests for the scripts above.
- **`generate-openapi.ts`**: emits `docs/api/openapi.yaml` from the registered Zod schemas (`pnpm openapi:generate`).
- **`check-openapi.ts`**: compares a freshly generated spec against the committed YAML; CI fails on drift (`pnpm openapi:check`).
- **`check-env.ts`**: pre-deploy env-var sanity check against `env-manifest.json` (`pnpm check-env`); wired into CI.
- **`assert-deploy.ts`**: post-deploy `/api/version` assertion.
- **`rotate-encryption-key.ts`**: AES-256-GCM key rotation; see `docs/ops/encryption-key-rotation.md`.
- **`restore-backup.ts`**: restore an off-host AES-GCM backup from an S3-compatible bucket.
- **`repair-apple-health-distance.ts`**: repair walking-distance rows the `export.zip` import stored 1000x too small; dry-run by default, see `docs/ops/apple-health-distance-repair.md`.
- **`repair-intake-anomalies.ts`**: repair historic medication-ledger defects (duplicate dose-slot rows, implausible `taken_at`); dry-run by default, see `docs/ops/intake-repair.md`.
- **`backfill-rollups.ts`**, **`drain-per-sample-cumulative.ts`**, **`backfill-mood-note-column.ts`**: historical data backfills (the boot-time `rollup-full-backfill` queue handles new accounts automatically; the CLI is the manual fallback).
- **`seed-demo.ts`** + **`seed-demo-v15.sql`**: populate a demo tenant.
- **`bench/`**: `seed-large-account.ts` (a synthetic ten-year account of about 1.26 million measurement rows) and `run.ts` (latency and SQL statements per request against it); see `docs/contributing/benchmarks.md`. Results land in `bench/results/`.
- **`test-notifications.ts`**: exercise the notification dispatcher + reminder check.
- **`fetch-geolite2.sh`**: download the MaxMind GeoLite2 MMDBs (needs a licence key) before `docker build`.
- **`build-pdf-fonts.py`**: rebuild the fonts the doctor-report PDF embeds (`src/lib/doctor-report-pdf/fonts/`) from the upstream Noto sources; needs `fonttools`. The source URLs are in its header.
- **`install-hooks.sh`**: install the local git hooks.
- **`generate-sw-version.mjs`**, **`check-bundle-budget.mjs`**: service-worker version stamp and bundle-size report (`pnpm bundle-report`).
- **`repair-google-health-sleep.ts`**: re-syncs every Google Health connection so nights stored twice before v1.28.18 collapse to one set; dry-run by default, `--run` to execute.
- **`i18n-key-tool.mjs`**, **`split-description.mjs`**: edit one key across every `messages/*.json` (show, set, drop) and move an over-long card description's tail into its own key; **`measure-description-lengths.mjs`** measures the Settings and Admin description strings against the 120-character guideline.
- **`audit-column-readers.ts`**: lists write-only schema columns and open `@pending-consumer` markers; a review tool, not a gate.
- **`migrate-demo-account.sh`**: copies the demo account from one instance to a separate demo instance; see its README.
- **`__tests__/`**: tests for the scripts above.
## Conventions
See [`../CLAUDE.md`](../CLAUDE.md) "Tests and commands" + "Self-hosting gotchas". Operator runbooks that drive these scripts live in [`../docs/ops/`](../docs/ops/).
Operator runbooks that drive these scripts live in [`../docs/ops/`](../docs/ops/).
@@ -8,7 +8,7 @@ import { describe, expect, it } from "vitest";
* demo.healthlog.dev. Its Argon2id hash lives in two places that must agree:
*
* - scripts/seed-demo.ts writes it when a demo tenant is seeded
* - scripts/migrate-demo-edge01.sh overwrites it after a migration load
* - scripts/migrate-demo-account.sh overwrites it after a migration load
*
* Public issue #805: the migration used to carry the SOURCE account's hash
* across instead, so once the operator rotated their own demo password the
@@ -18,7 +18,7 @@ import { describe, expect, it } from "vitest";
const REPO_ROOT = join(__dirname, "..", "..");
const SEED = join(REPO_ROOT, "scripts", "seed-demo.ts");
const MIGRATE = join(REPO_ROOT, "scripts", "migrate-demo-edge01.sh");
const MIGRATE = join(REPO_ROOT, "scripts", "migrate-demo-account.sh");
/** Every Argon2id hash literal in a file, in source order. */
function argon2Hashes(path: string): string[] {
@@ -27,7 +27,7 @@ function argon2Hashes(path: string): string[] {
}
describe("published demo password", () => {
it("is the same hash in the seed script and the edge01 migration", () => {
it("is the same hash in the seed script and the demo migration", () => {
const seeded = argon2Hashes(SEED);
const migrated = argon2Hashes(MIGRATE);
@@ -55,7 +55,7 @@ describe("published demo password", () => {
// ...and it must be proven, not merely attempted. Without the check a
// mangled hash would reach a visitor's login screen instead of the script.
expect(script).toContain(
"ABORT/FAIL: demo password_hash on edge01 is not the published literal",
"ABORT/FAIL: demo password_hash on the target is not the published literal",
);
});
-254
View File
@@ -1,254 +0,0 @@
{
"meta": {
"label": "v1.44.0-baseline",
"commit": "d640eea32",
"date": "2026-10-22T03:54:05.746Z",
"node": "v22.23.2",
"postgres": "16.15",
"cpus": 14,
"loadAverage": [
9.4,
20.1,
20
],
"measurements": 1266549,
"iterations": 20,
"warmup": 3,
"heapMb": 792,
"idleStatementsPerSecond": 0,
"batch5k": {
"totalMs": 1722,
"totalStatements": 424
}
},
"results": [
{
"name": "/api/auth/me",
"mode": "cold",
"request": "GET /api/auth/me",
"n": 20,
"p50Ms": 12.1,
"p95Ms": 17.4,
"maxMs": 18.4,
"statementsMedian": 19,
"statementsMax": 19,
"failures": 0
},
{
"name": "/api/auth/me",
"mode": "warm",
"request": "GET /api/auth/me",
"n": 20,
"p50Ms": 16.6,
"p95Ms": 22.2,
"maxMs": 24.4,
"statementsMedian": 19,
"statementsMax": 19,
"failures": 0
},
{
"name": "dashboard summary",
"mode": "cold",
"request": "GET /api/dashboard/summary",
"n": 20,
"p50Ms": 205.6,
"p95Ms": 233.2,
"maxMs": 275.3,
"statementsMedian": 31,
"statementsMax": 32,
"failures": 0
},
{
"name": "dashboard summary",
"mode": "warm",
"request": "GET /api/dashboard/summary",
"n": 20,
"p50Ms": 12.8,
"p95Ms": 14.3,
"maxMs": 27.5,
"statementsMedian": 7,
"statementsMax": 7,
"failures": 0
},
{
"name": "dashboard snapshot",
"mode": "cold",
"request": "GET /api/dashboard/snapshot",
"n": 20,
"p50Ms": 2157.8,
"p95Ms": 2252.7,
"maxMs": 2292.9,
"statementsMedian": 158,
"statementsMax": 161,
"failures": 0
},
{
"name": "dashboard snapshot",
"mode": "warm",
"request": "GET /api/dashboard/snapshot",
"n": 20,
"p50Ms": 15.6,
"p95Ms": 23.5,
"maxMs": 25.6,
"statementsMedian": 13,
"statementsMax": 16,
"failures": 0
},
{
"name": "series pulse 30 d",
"mode": "cold",
"request": "GET /api/measurements/series?kind=pulse&days=30",
"n": 20,
"p50Ms": 50.8,
"p95Ms": 58.7,
"maxMs": 58.9,
"statementsMedian": 8,
"statementsMax": 9,
"failures": 0
},
{
"name": "series pulse 30 d",
"mode": "warm",
"request": "GET /api/measurements/series?kind=pulse&days=30",
"n": 20,
"p50Ms": 52.3,
"p95Ms": 59.5,
"maxMs": 59.9,
"statementsMedian": 8,
"statementsMax": 8,
"failures": 0
},
{
"name": "series pulse 365 d",
"mode": "cold",
"request": "GET /api/measurements/series?kind=pulse&days=365",
"n": 20,
"p50Ms": 289,
"p95Ms": 311.3,
"maxMs": 363.7,
"statementsMedian": 8,
"statementsMax": 9,
"failures": 0
},
{
"name": "series pulse 365 d",
"mode": "warm",
"request": "GET /api/measurements/series?kind=pulse&days=365",
"n": 20,
"p50Ms": 274.3,
"p95Ms": 355.8,
"maxMs": 366.8,
"statementsMedian": 8,
"statementsMax": 8,
"failures": 0
},
{
"name": "series pulse 3650 d",
"mode": "cold",
"request": "GET /api/measurements/series?kind=pulse&days=3650",
"n": 20,
"p50Ms": 1502.6,
"p95Ms": 2117.6,
"maxMs": 2122.9,
"statementsMedian": 8,
"statementsMax": 9,
"failures": 0
},
{
"name": "series pulse 3650 d",
"mode": "warm",
"request": "GET /api/measurements/series?kind=pulse&days=3650",
"n": 20,
"p50Ms": 1490.3,
"p95Ms": 1505.5,
"maxMs": 1555.2,
"statementsMedian": 8,
"statementsMax": 11,
"failures": 0
},
{
"name": "day view",
"mode": "cold",
"request": "GET /api/day/2026-10-21",
"n": 20,
"p50Ms": 61.7,
"p95Ms": 77.5,
"maxMs": 86.1,
"statementsMedian": 49,
"statementsMax": 50,
"failures": 0
},
{
"name": "day view",
"mode": "warm",
"request": "GET /api/day/2026-10-21",
"n": 20,
"p50Ms": 49,
"p95Ms": 55.6,
"maxMs": 62.5,
"statementsMedian": 48,
"statementsMax": 48,
"failures": 0
},
{
"name": "insights health status",
"mode": "cold",
"request": "GET /api/insights/health-status",
"n": 20,
"p50Ms": 124.1,
"p95Ms": 139.5,
"maxMs": 141.5,
"statementsMedian": 68,
"statementsMax": 68,
"failures": 0
},
{
"name": "insights health status",
"mode": "warm",
"request": "GET /api/insights/health-status",
"n": 20,
"p50Ms": 124.7,
"p95Ms": 157.8,
"maxMs": 165.3,
"statementsMedian": 68,
"statementsMax": 68,
"failures": 0
},
{
"name": "insights correlations",
"mode": "cold",
"request": "GET /api/insights/correlations",
"n": 20,
"p50Ms": 95.4,
"p95Ms": 105,
"maxMs": 108.8,
"statementsMedian": 45,
"statementsMax": 45,
"failures": 0
},
{
"name": "insights correlations",
"mode": "warm",
"request": "GET /api/insights/correlations",
"n": 20,
"p50Ms": 13,
"p95Ms": 15.4,
"maxMs": 15.9,
"statementsMedian": 8,
"statementsMax": 8,
"failures": 0
},
{
"name": "batch ingest 5k (per 500)",
"mode": "write",
"request": "POST /api/measurements/batch ×10",
"n": 10,
"p50Ms": 163,
"p95Ms": 220.7,
"maxMs": 220.7,
"statementsMedian": 42,
"statementsMax": 45,
"failures": 0
}
]
}
-25
View File
@@ -1,25 +0,0 @@
# Benchmark v1.44.0-baseline
Commit d640eea32, 2026-10-22T03:54:05.746Z. 1266549 measurement rows on the bench account, 20 measured requests per row after 3 warm-up requests, one at a time. Server: production build, web process, heap ceiling 792 MB. Postgres 16.15, 14 cores, load average 9.4,20.1,20 at the end. Idle background: 0 statements/s.
| Request | Mode | n | p50 ms | p95 ms | max ms | statements (median) | statements (max) |
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |
| /api/auth/me | cold | 20 | 12.1 | 17.4 | 18.4 | 19 | 19 |
| /api/auth/me | warm | 20 | 16.6 | 22.2 | 24.4 | 19 | 19 |
| dashboard summary | cold | 20 | 205.6 | 233.2 | 275.3 | 31 | 32 |
| dashboard summary | warm | 20 | 12.8 | 14.3 | 27.5 | 7 | 7 |
| dashboard snapshot | cold | 20 | 2157.8 | 2252.7 | 2292.9 | 158 | 161 |
| dashboard snapshot | warm | 20 | 15.6 | 23.5 | 25.6 | 13 | 16 |
| series pulse 30 d | cold | 20 | 50.8 | 58.7 | 58.9 | 8 | 9 |
| series pulse 30 d | warm | 20 | 52.3 | 59.5 | 59.9 | 8 | 8 |
| series pulse 365 d | cold | 20 | 289 | 311.3 | 363.7 | 8 | 9 |
| series pulse 365 d | warm | 20 | 274.3 | 355.8 | 366.8 | 8 | 8 |
| series pulse 3650 d | cold | 20 | 1502.6 | 2117.6 | 2122.9 | 8 | 9 |
| series pulse 3650 d | warm | 20 | 1490.3 | 1505.5 | 1555.2 | 8 | 11 |
| day view | cold | 20 | 61.7 | 77.5 | 86.1 | 49 | 50 |
| day view | warm | 20 | 49 | 55.6 | 62.5 | 48 | 48 |
| insights health status | cold | 20 | 124.1 | 139.5 | 141.5 | 68 | 68 |
| insights health status | warm | 20 | 124.7 | 157.8 | 165.3 | 68 | 68 |
| insights correlations | cold | 20 | 95.4 | 105 | 108.8 | 45 | 45 |
| insights correlations | warm | 20 | 13 | 15.4 | 15.9 | 8 | 8 |
| batch ingest 5k (per 500) | write | 10 | 163 | 220.7 | 220.7 | 42 | 45 |
File diff suppressed because it is too large. Load diff
-254
View File
@@ -1,254 +0,0 @@
{
"meta": {
"label": "v1.45.0-before",
"commit": "1915f9152",
"date": "2026-10-22T05:51:47.626Z",
"node": "v22.23.2",
"postgres": "16.15",
"cpus": 14,
"loadAverage": [
29.9,
21.9,
18.9
],
"measurements": 1265561,
"iterations": 20,
"warmup": 3,
"heapMb": 792,
"idleStatementsPerSecond": 0,
"batch5k": {
"totalMs": 1730,
"totalStatements": 534
}
},
"results": [
{
"name": "/api/auth/me",
"mode": "cold",
"request": "GET /api/auth/me",
"n": 20,
"p50Ms": 9.2,
"p95Ms": 11.6,
"maxMs": 13.3,
"statementsMedian": 19,
"statementsMax": 19,
"failures": 0
},
{
"name": "/api/auth/me",
"mode": "warm",
"request": "GET /api/auth/me",
"n": 20,
"p50Ms": 8.6,
"p95Ms": 10.5,
"maxMs": 13,
"statementsMedian": 19,
"statementsMax": 19,
"failures": 0
},
{
"name": "dashboard summary",
"mode": "cold",
"request": "GET /api/dashboard/summary",
"n": 20,
"p50Ms": 170.2,
"p95Ms": 220.5,
"maxMs": 228.3,
"statementsMedian": 31,
"statementsMax": 34,
"failures": 0
},
{
"name": "dashboard summary",
"mode": "warm",
"request": "GET /api/dashboard/summary",
"n": 20,
"p50Ms": 6,
"p95Ms": 6.7,
"maxMs": 6.8,
"statementsMedian": 7,
"statementsMax": 7,
"failures": 0
},
{
"name": "dashboard snapshot",
"mode": "cold",
"request": "GET /api/dashboard/snapshot",
"n": 20,
"p50Ms": 1481.8,
"p95Ms": 1526.2,
"maxMs": 1530,
"statementsMedian": 158,
"statementsMax": 161,
"failures": 0
},
{
"name": "dashboard snapshot",
"mode": "warm",
"request": "GET /api/dashboard/snapshot",
"n": 20,
"p50Ms": 7,
"p95Ms": 7.9,
"maxMs": 10.2,
"statementsMedian": 13,
"statementsMax": 13,
"failures": 0
},
{
"name": "series pulse 30 d",
"mode": "cold",
"request": "GET /api/measurements/series?kind=pulse&days=30",
"n": 20,
"p50Ms": 24.4,
"p95Ms": 30.8,
"maxMs": 31.1,
"statementsMedian": 8,
"statementsMax": 8,
"failures": 0
},
{
"name": "series pulse 30 d",
"mode": "warm",
"request": "GET /api/measurements/series?kind=pulse&days=30",
"n": 20,
"p50Ms": 20.5,
"p95Ms": 29.4,
"maxMs": 30.2,
"statementsMedian": 8,
"statementsMax": 9,
"failures": 0
},
{
"name": "series pulse 365 d",
"mode": "cold",
"request": "GET /api/measurements/series?kind=pulse&days=365",
"n": 20,
"p50Ms": 184.1,
"p95Ms": 192.6,
"maxMs": 201.1,
"statementsMedian": 8,
"statementsMax": 8,
"failures": 0
},
{
"name": "series pulse 365 d",
"mode": "warm",
"request": "GET /api/measurements/series?kind=pulse&days=365",
"n": 20,
"p50Ms": 178.1,
"p95Ms": 186.5,
"maxMs": 187.6,
"statementsMedian": 8,
"statementsMax": 8,
"failures": 0
},
{
"name": "series pulse 3650 d",
"mode": "cold",
"request": "GET /api/measurements/series?kind=pulse&days=3650",
"n": 20,
"p50Ms": 1627,
"p95Ms": 1664.5,
"maxMs": 1675.7,
"statementsMedian": 8,
"statementsMax": 9,
"failures": 0
},
{
"name": "series pulse 3650 d",
"mode": "warm",
"request": "GET /api/measurements/series?kind=pulse&days=3650",
"n": 20,
"p50Ms": 2090.9,
"p95Ms": 2435.4,
"maxMs": 2529.7,
"statementsMedian": 8,
"statementsMax": 9,
"failures": 0
},
{
"name": "day view",
"mode": "cold",
"request": "GET /api/day/2026-10-21",
"n": 20,
"p50Ms": 76.7,
"p95Ms": 93.6,
"maxMs": 116.2,
"statementsMedian": 49,
"statementsMax": 49,
"failures": 0
},
{
"name": "day view",
"mode": "warm",
"request": "GET /api/day/2026-10-21",
"n": 20,
"p50Ms": 62.8,
"p95Ms": 72,
"maxMs": 73.5,
"statementsMedian": 48,
"statementsMax": 48,
"failures": 0
},
{
"name": "insights health status",
"mode": "cold",
"request": "GET /api/insights/health-status",
"n": 20,
"p50Ms": 244.7,
"p95Ms": 401.2,
"maxMs": 404.1,
"statementsMedian": 68,
"statementsMax": 69,
"failures": 0
},
{
"name": "insights health status",
"mode": "warm",
"request": "GET /api/insights/health-status",
"n": 20,
"p50Ms": 238.9,
"p95Ms": 399.1,
"maxMs": 476.6,
"statementsMedian": 68,
"statementsMax": 68,
"failures": 0
},
{
"name": "insights correlations",
"mode": "cold",
"request": "GET /api/insights/correlations",
"n": 20,
"p50Ms": 105.6,
"p95Ms": 326.9,
"maxMs": 349.7,
"statementsMedian": 49,
"statementsMax": 52,
"failures": 0
},
{
"name": "insights correlations",
"mode": "warm",
"request": "GET /api/insights/correlations",
"n": 20,
"p50Ms": 11.3,
"p95Ms": 16.6,
"maxMs": 20.3,
"statementsMedian": 8,
"statementsMax": 8,
"failures": 0
},
{
"name": "batch ingest 5k (per 500)",
"mode": "write",
"request": "POST /api/measurements/batch ×10",
"n": 10,
"p50Ms": 166.1,
"p95Ms": 220.3,
"maxMs": 220.3,
"statementsMedian": 53,
"statementsMax": 56,
"failures": 0
}
]
}
-25
View File
@@ -1,25 +0,0 @@
# Benchmark v1.45.0-before
Commit 1915f9152 (the v1.44 runtime this branch starts from), 2026-10-22T05:51:47.626Z. 1265561 measurement rows on the bench account, 20 measured requests per row after 3 warm-up requests, one at a time. Server: production build, web process, heap ceiling 792 MB. Postgres 16.15, 14 cores, load average 29.9,21.9,18.9 at the end. Idle background: 0 statements/s.
| Request | Mode | n | p50 ms | p95 ms | max ms | statements (median) | statements (max) |
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |
| /api/auth/me | cold | 20 | 9.2 | 11.6 | 13.3 | 19 | 19 |
| /api/auth/me | warm | 20 | 8.6 | 10.5 | 13 | 19 | 19 |
| dashboard summary | cold | 20 | 170.2 | 220.5 | 228.3 | 31 | 34 |
| dashboard summary | warm | 20 | 6 | 6.7 | 6.8 | 7 | 7 |
| dashboard snapshot | cold | 20 | 1481.8 | 1526.2 | 1530 | 158 | 161 |
| dashboard snapshot | warm | 20 | 7 | 7.9 | 10.2 | 13 | 13 |
| series pulse 30 d | cold | 20 | 24.4 | 30.8 | 31.1 | 8 | 8 |
| series pulse 30 d | warm | 20 | 20.5 | 29.4 | 30.2 | 8 | 9 |
| series pulse 365 d | cold | 20 | 184.1 | 192.6 | 201.1 | 8 | 8 |
| series pulse 365 d | warm | 20 | 178.1 | 186.5 | 187.6 | 8 | 8 |
| series pulse 3650 d | cold | 20 | 1627 | 1664.5 | 1675.7 | 8 | 9 |
| series pulse 3650 d | warm | 20 | 2090.9 | 2435.4 | 2529.7 | 8 | 9 |
| day view | cold | 20 | 76.7 | 93.6 | 116.2 | 49 | 49 |
| day view | warm | 20 | 62.8 | 72 | 73.5 | 48 | 48 |
| insights health status | cold | 20 | 244.7 | 401.2 | 404.1 | 68 | 69 |
| insights health status | warm | 20 | 238.9 | 399.1 | 476.6 | 68 | 68 |
| insights correlations | cold | 20 | 105.6 | 326.9 | 349.7 | 49 | 52 |
| insights correlations | warm | 20 | 11.3 | 16.6 | 20.3 | 8 | 8 |
| batch ingest 5k (per 500) | write | 10 | 166.1 | 220.3 | 220.3 | 53 | 56 |
File diff suppressed because it is too large. Load diff
-298
View File
@@ -1,298 +0,0 @@
# Boot profile v1.45.0-boot-after
Commit a859adb59, 2026-10-22T04:49:50.419Z. Production build, every role, heap ceiling 792 MB, 300 s idle. 14 cores, load average 8.6,9.4,10.7 at the end.
- first answer from /api/health: 850 ms; background role ready: 3038 ms
- event-loop stalls logged: 0, max 0 ms
## Stalls
## Heap samples
| at s | used MB | old | large obj | code | external | array buffers | rss |
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| 6 | 166 | 91 | 53 | 3 | 12 | 1 | 430 |
| 306 | 153 | 90 | 53 | 4 | 11 | 0 | 599 |
## Full collections (mark-compact)
10 mark-compacts during the run. Used heap before the collection, and what survived it (the live heap):
| at s | before MB | after MB |
| ---: | ---: | ---: |
| 0 | 20.2 | 17.1 |
| 0 | 55.3 | 52.6 |
| 0 | 83.2 | 81.1 |
| 1 | 148.4 | 132.7 |
| 16 | 159.6 | 153.2 |
| 17 | 153.4 | 151 |
| 62 | 167.5 | 157.5 |
| 246 | 183.4 | 152.6 |
| 303 | 165.6 | 151.8 |
| 303 | 151.8 | 151.8 |
## Main-thread busy stretches over 50 ms without an idle sample, first two minutes
### at 6 ms, 175 ms
| self time by module | ms |
| --- | ---: |
| npm:next | 47.2 |
| node:internal/modules/cjs/loader | 34.8 |
| (native) | 34.5 |
| node:internal/bootstrap/realm | 25.7 |
| node:internal/deps/undici/undici | 8.8 |
| node:path | 6.3 |
| node:internal/modules/package_json_reader | 3 |
| node:internal/net | 3 |
| node:internal/modules/esm/resolve | 2.5 |
| node:fs | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
### at 203 ms, 241 ms
| self time by module | ms |
| --- | ---: |
| (native) | 52.7 |
| node:internal/modules/cjs/loader | 40.1 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 31.7 |
| wasm://wasm/00d1cb12 | 28.7 |
| npm:@prisma/client | 28 |
| npm:next | 16.3 |
| node:internal/modules/esm/utils | 12 |
| node:fs | 4.5 |
| npm:zod | 3 |
| node:internal/modules/helpers | 3 |
| inclusive, application modules | ms |
| --- | ---: |
| src/instrumentation.ts | 80.8 |
| src/lib/db-session.ts | 49.5 |
| src/lib/boot/startup-state.ts | 25.6 |
| src/lib/boot/boot-steps.ts | 25.6 |
| src/lib/logging/background.ts | 25.6 |
| src/lib/process-type.ts | 25.5 |
| src/lib/logging/context.ts | 13.6 |
| src/lib/validations/measurement.ts | 12.1 |
| src/lib/logging/redact.ts | 10.5 |
| src/lib/validations/entry-instant.ts | 4.6 |
### at 501 ms, 178 ms
| self time by module | ms |
| --- | ---: |
| node:internal/modules/cjs/loader | 53.9 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 37.5 |
| npm:next | 18 |
| npm:zod | 12 |
| node:internal/modules/esm/utils | 10.5 |
| (native) | 8.7 |
| node:fs | 6 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 4.5 |
| npm:undici | 4.5 |
| node:internal/url | 1.8 |
| inclusive, application modules | ms |
| --- | ---: |
| src/app/api/health/route.ts | 61.4 |
| src/lib/api-errors.ts | 27 |
| src/lib/jobs/job-failures.ts | 14.9 |
| src/lib/jobs/offhost-backup.ts | 14.9 |
| src/lib/managed-profiles/handover-access.ts | 14.9 |
| src/lib/sharing/acting-session.ts | 14.9 |
| src/lib/db-session.ts | 13.4 |
| src/lib/export/environment-backup.ts | 13.4 |
| src/lib/export/full-backup-payload.ts | 13.4 |
| src/lib/logging/background.ts | 10.5 |
### at 833 ms, 184 ms
| self time by module | ms |
| --- | ---: |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 38.9 |
| node:internal/modules/cjs/loader | 28.6 |
| (native) | 22.8 |
| src/lib/i18n/server-catalogs.ts | 15.1 |
| npm:yaml | 15 |
| node:fs | 12.1 |
| npm:zod | 9 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 5.9 |
| turbopack:///[turbopack]/shared/runtime/runtime-utils.ts | 4.5 |
| npm:semver | 3 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/jobs/reminder-worker.ts | 129.1 |
| src/lib/jobs/reminder/google-health-sync.ts | 88.5 |
| src/lib/whoop/sync.ts | 79.5 |
| src/lib/whoop/sync-body.ts | 63 |
| src/lib/insights/bucket-series.ts | 60 |
| src/lib/whoop/oauth-descriptor.ts | 60 |
| src/lib/whoop/sync-core.ts | 60 |
| src/instrumentation.ts | 43.4 |
| src/lib/import/unzip-export-xml.ts | 31.4 |
| src/lib/jobs/integration-status-null-probe.ts | 31.4 |
### at 1099 ms, 54 ms
| self time by module | ms |
| --- | ---: |
| node:internal/modules/cjs/loader | 23.9 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 10.6 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 5.8 |
| src/lib/daily/today-overview.ts | 4.5 |
| src/lib/jobs/reminder-worker.ts | 3 |
| node:fs | 3 |
| npm:pg-boss | 1.5 |
| npm:zod | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/jobs/reminder-worker.ts | 52.3 |
| src/lib/jobs/reminder/insights-handlers.ts | 22.3 |
| src/lib/ai/prompts/arrival-reaction.ts | 12 |
| src/lib/daily/score-steady.ts | 10.5 |
| src/lib/dashboard/bands.ts | 6 |
| src/lib/jobs/data-arrival.ts | 4.5 |
| src/lib/dashboard/snapshot-read.ts | 4.5 |
| src/lib/daily/today-overview.ts | 4.5 |
| src/lib/daily/priority-item-key.ts | 4.5 |
| src/lib/ai/prompts/status-batch.ts | 3 |
### at 1309 ms, 51 ms
| self time by module | ms |
| --- | ---: |
| node:internal/modules/cjs/loader | 28 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 6 |
| node:fs | 4.5 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 3 |
| turbopack:///[turbopack]/nodejs/runtime/runtime-base.ts | 1.5 |
| src/lib/jobs/failure-condition.ts | 1.5 |
| src/lib/labs/parse-reference-range.ts | 1.5 |
| npm:zod | 1.5 |
| node:internal/linkedlist | 1.5 |
| (native) | 0.9 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/jobs/reminder-worker.ts | 48.2 |
| src/lib/jobs/reminder/ops-handlers.ts | 14.1 |
| src/lib/medications/ingredients/label-extract.ts | 3 |
| src/lib/export/restore-backup.ts | 2.1 |
| src/lib/jobs/failure-condition.ts | 1.5 |
| src/lib/documents/ai-runs/summary-run.ts | 1.5 |
| src/lib/documents/auto-stage-labs.ts | 1.5 |
| src/lib/documents/ai-runs/store.ts | 1.5 |
| src/lib/labs/parse-reference-range.ts | 1.5 |
| src/lib/labs/ocr-upload.ts | 1.5 |
## Self time by module until the worker is ready
| module | ms |
| --- | ---: |
| (native) | 230.8 |
| node:internal/modules/cjs/loader | 223.6 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 124.7 |
| npm:next | 98.8 |
| wasm://wasm/00d1cb12 | 62.6 |
| npm:@prisma/client | 56.2 |
| node:fs | 34.6 |
| node:internal/bootstrap/realm | 28.7 |
| npm:pg | 28.3 |
| npm:zod | 27 |
| npm:pg-boss | 24.6 |
| node:internal/modules/esm/utils | 22.5 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 20.6 |
| npm:luxon | 17 |
| src/lib/i18n/server-catalogs.ts | 15.1 |
| npm:yaml | 15 |
| npm:pg-pool | 13 |
| node:internal/deps/undici/undici | 10.4 |
| node:path | 7.8 |
| turbopack:///[turbopack]/shared/runtime/runtime-utils.ts | 7.5 |
| node:internal/async_hooks | 7.5 |
| node:internal/process/task_queues | 7.3 |
| node:internal/modules/package_json_reader | 6 |
| npm:undici | 6 |
| npm:pg-protocol | 6 |
## Heap snapshot after 300 s idle (live heap after a full GC)
160.1 MB in 1275329 nodes; module source strings over 50 KB: 39.8 MB.
- parsed locale catalogs: 8 objects, 7 MB retained
| node type | MB |
| --- | ---: |
| string | 68.7 |
| array | 26.4 |
| code | 21.1 |
| object | 14.1 |
| hidden | 8.9 |
| native | 8 |
| object shape | 5.9 |
| closure | 5.2 |
| concatenated string | 1.3 |
| regexp | 0.2 |
| sliced string | 0.2 |
| number | 0.1 |
| synthetic | 0 |
| symbol | 0 |
| bigint | 0 |
| constructor | MB | count |
| --- | ---: | ---: |
| array:(object elements) | 20.4 | 23797 |
| object:Object | 10.2 | 274231 |
| native:system / JSArrayBufferData | 7.6 | 171 |
| array:(object properties) | 3.6 | 6512 |
| array: | 2.4 | 10502 |
| closure: | 1.6 | 29042 |
| object:system / Context | 1.6 | 28873 |
| closure:native_bind | 1 | 20782 |
| object:Array | 0.6 | 19679 |
| object:a | 0.4 | 4438 |
| native:Node / histogram | 0.4 | 1 |
| closure:get | 0.3 | 4736 |
| object:d | 0.3 | 4425 |
| object:Set | 0.2 | 5673 |
| closure:set | 0.2 | 3432 |
| object:Module | 0.2 | 1958 |
| closure:i | 0.1 | 1581 |
| closure:n | 0.1 | 1572 |
| object:Promise | 0.1 | 1277 |
| object:n | 0.1 | 1881 |
| largest retained subtrees | MB | retaining path |
| --- | ---: | --- |
| object:t | 33.9 | [1] > [10] > 60 > extension > global > prisma > [1] |
| object:global | 7.9 | [1] > [10] > 60 > extension |
| object:system / Context | 7.4 | Node / PrincipalRealm > base_object_list > Node / BaseObjectList > [25] > Node / ModuleWrap > native_to_javascript > ModuleWrap > 2 > [4] > context |
| object:Module | 5.6 | [1] > [10] > 97 > process > nextTick > patchedNextTick > context > system / Context > require > require > cache > .n |
| code:.next/server/chu | 5.6 | require > cache > Object > .next/server/chunks/_1kwfnjg._.js > Module > exports > Array > [1] > > shared > script |
| code:.next/server/chu | 5.6 | global > prisma > [1] > t > _engineConfig > Object > compilerWasm > Object > getRuntime > getRuntime > shared > script |
| object:Generator | 4.4 | [6] > principal_realm > Node / PrincipalRealm > base_object_list > Node / BaseObjectList > [26] > Node / ModuleWrap > native_to_javascript > ModuleWrap > 2 > [4 |
| string:AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/ | 4.4 | [1] > [21] > 62203 |
| object:Object | 3.3 | system / Context > require > require > cache > Object > .next/server/chunks/[turbopack]_runtime.js > Module > export |
| largest strings | MB |
| --- | ---: |
| module.exports=[234494,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=> | 5.6 |
| module.exports=[17856,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=>i | 5.6 |
| module.exports=[17856,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=>i | 5.6 |
| const wasm = "AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/fwBgBX9/f39/AGAB | 4.4 |
| AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/fwBgBX9/f39/AGABfwF/YAZ/f39/f3 | 4.4 |
| lliADOAUngIEAACgKwAgBgAAoSsAIAcAAKErACAIAACiKwAgCQAAoysAIAoAAKQrACALAAClKwAgDAAApisAIA0AAK | 1.9 |
| (()=>{var e,t,r={"./dist/build/webpack/alias/react-dom-server.js"(e,t,r){"use strict";var | 1.3 |
| module.exports=[842865,(e,t,r)=>{let i=e.r(522734);t.exports=function(e){return e?/-----BE | 1.2 |
| module.exports=[842865,(e,t,r)=>{let i=e.r(522734);t.exports=function(e){return e?/-----BE | 1.2 |
| module.exports=[799092,(e,t,n)=>{"use strict";Object.defineProperty(n,"__esModule",{value: | 1.1 |
| // v1.4.34 web-freeze. Schema is locked additive-only until iOS launch // + v1.5.0 marker. | 1.1 |
| module.exports=[971743,(A,e,t)=>{"use strict";let r=A.r(57328),{addAbortListener:s}=A.r(68 | 1.1 |
| module.exports=[835717,(A,e,t)=>{"use strict";let r=A.r(57328),{addAbortListener:s}=A.r(68 | 1 |
| module.exports=[767189,e=>e.a(async(t,a)=>{try{var n=e.i(64105),r=e.i(563616),i=t([n]);[n] | 0.8 |
| module.exports=[594108,806176,674750,98746,t=>{"use strict";var i;let r;function n(t){retu | 0.7 |
File diff suppressed because it is too large. Load diff
-804
View File
@@ -1,804 +0,0 @@
# Boot profile v1.45.0-boot-final
Commit a57fa8335, 2026-10-22T06:58:52.591Z. Production build, every role, heap ceiling 792 MB, 300 s idle. 14 cores, load average 5.6,6.4,11.1 at the end.
- first answer from /api/health: 1325 ms; background role ready: 3217 ms
- event-loop stalls logged: 0, max 0 ms
## Stalls
## Heap samples
| at s | used MB | old | large obj | code | external | array buffers | rss |
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| 6 | 225 | 158 | 54 | 4 | 11 | 0 | 500 |
| 306 | 158 | 96 | 53 | 5 | 11 | 0 | 788 |
## Full collections (mark-compact)
98 mark-compacts during the run. Used heap before the collection, and what survived it (the live heap):
| at s | before MB | after MB |
| ---: | ---: | ---: |
| 0 | 21.5 | 17.5 |
| 8 | 203.8 | 146.5 |
| 24 | 236.5 | 142.5 |
| 41 | 223.8 | 144.9 |
| 60 | 225.8 | 147.5 |
| 85 | 224.4 | 147.7 |
| 124 | 225.4 | 151.2 |
| 167 | 176.7 | 151 |
| 209 | 230.1 | 150.4 |
| 242 | 197.9 | 151.5 |
| 273 | 224.8 | 152.2 |
| 302 | 177.9 | 152.5 |
| 312 | 170.5 | 152.9 |
| 327 | 170.6 | 152 |
| 338 | 169.7 | 151.8 |
## Main-thread busy stretches over 50 ms without an idle sample, first two minutes
### at 5 ms, 396 ms
| self time by module | ms |
| --- | ---: |
| (native) | 230.2 |
| node:internal/modules/cjs/loader | 57.8 |
| npm:next | 52.3 |
| node:internal/bootstrap/realm | 29.3 |
| node:internal/deps/undici/undici | 8.9 |
| node:internal/modules/esm/loader | 4.5 |
| node:fs | 3 |
| node:internal/net | 2.5 |
| node:internal/bootstrap/switches/is_main_thread | 2 |
| node:os | 1.8 |
| inclusive, application modules | ms |
| --- | ---: |
### at 472 ms, 336 ms
| self time by module | ms |
| --- | ---: |
| (native) | 121.3 |
| wasm://wasm/00d1cb12 | 43 |
| node:internal/modules/cjs/loader | 40.5 |
| npm:next | 31.7 |
| npm:@prisma/client | 23.8 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 23.4 |
| node:internal/modules/esm/utils | 17.3 |
| npm:zod | 5.9 |
| turbopack:///[turbopack]/shared/runtime/runtime-utils.ts | 3 |
| node:vm | 1.7 |
| inclusive, application modules | ms |
| --- | ---: |
| src/instrumentation.ts | 95.8 |
| src/lib/db-session.ts | 47.3 |
| src/lib/process-type.ts | 27.1 |
| src/lib/boot/startup-state.ts | 25.5 |
| src/lib/boot/boot-steps.ts | 25.5 |
| src/lib/logging/background.ts | 25.5 |
| src/lib/logging/redact.ts | 13 |
| src/lib/logging/context.ts | 11 |
| src/lib/validations/measurement.ts | 10 |
| src/generated/prisma/internal/class.ts | 4.7 |
### at 811 ms, 225 ms
| self time by module | ms |
| --- | ---: |
| node:internal/modules/cjs/loader | 52.3 |
| (native) | 48.2 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 37 |
| npm:next | 22.2 |
| npm:zod | 12.5 |
| node:internal/modules/esm/utils | 8.7 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 5.6 |
| node:fs | 4.5 |
| node:internal/async_hooks | 3 |
| node:internal/modules/esm/translators | 2.6 |
| inclusive, application modules | ms |
| --- | ---: |
| src/app/api/health/route.ts | 62 |
| src/lib/api-errors.ts | 25.9 |
| src/lib/auth/secure-cookie.ts | 14.9 |
| src/lib/logging/background.ts | 13.4 |
| src/lib/sharing/acting-session.ts | 12.8 |
| src/lib/validations/measurement.ts | 11.9 |
| src/lib/db-session.ts | 11.3 |
| src/lib/export/environment-backup.ts | 11.3 |
| src/lib/export/full-backup-payload.ts | 11.3 |
| src/lib/jobs/job-failures.ts | 11.3 |
### at 1063 ms, 246 ms
| self time by module | ms |
| --- | ---: |
| (native) | 64.7 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 47.2 |
| node:internal/modules/cjs/loader | 31.6 |
| npm:luxon | 18 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 15.3 |
| npm:yaml | 15 |
| npm:zod | 10.2 |
| src/lib/i18n/server-catalogs.ts | 8.2 |
| turbopack:///[turbopack]/shared/runtime/runtime-utils.ts | 4.5 |
| npm:cron-parser | 4 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/jobs/reminder-worker.ts | 137.6 |
| src/lib/jobs/reminder/google-health-sync.ts | 78 |
| src/lib/whoop/sync.ts | 65.9 |
| src/instrumentation.ts | 53.8 |
| src/lib/insights/bucket-series.ts | 47.1 |
| src/lib/whoop/oauth-descriptor.ts | 47.1 |
| src/lib/whoop/sync-core.ts | 47.1 |
| src/lib/whoop/sync-body.ts | 47.1 |
| src/lib/import/unzip-export-xml.ts | 35.3 |
| src/lib/jobs/integration-status-null-probe.ts | 35.3 |
### at 1363 ms, 68 ms
| self time by module | ms |
| --- | ---: |
| node:internal/modules/cjs/loader | 29.2 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 6.7 |
| (native) | 5.9 |
| turbopack:///[turbopack]/shared/runtime/runtime-utils.ts | 4.5 |
| src/lib/daily/today-overview.ts | 4.1 |
| node:fs | 3 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 3 |
| node:internal/streams/writable | 1.5 |
| src/lib/jobs/reminder/insights-handlers.ts | 1.5 |
| npm:next | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/jobs/reminder-worker.ts | 62.5 |
| src/lib/jobs/reminder/insights-handlers.ts | 24.4 |
| src/lib/ai/prompts/arrival-reaction.ts | 12.8 |
| src/lib/daily/score-steady.ts | 11.3 |
| src/lib/dashboard/bands.ts | 7.1 |
| src/lib/dashboard/snapshot-read.ts | 5.8 |
| src/lib/jobs/data-arrival.ts | 4.5 |
| src/lib/daily/today-overview.ts | 4.1 |
| src/lib/daily/priority-item-key.ts | 4.1 |
| src/lib/ai/prompts/status-batch.ts | 3 |
### at 1545 ms, 57 ms
| self time by module | ms |
| --- | ---: |
| node:internal/modules/cjs/loader | 25.3 |
| (native) | 12.9 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 7.2 |
| node:fs | 4.2 |
| node:buffer | 1.5 |
| npm:zod | 1.5 |
| src/lib/jobs/pr-detection.ts | 1.5 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 1.5 |
| src/lib/jobs/measurement-maintenance.ts | 1.3 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/jobs/reminder-worker.ts | 55.3 |
| src/lib/jobs/reminder/ops-handlers.ts | 13 |
| src/lib/export/restore-backup.ts | 3 |
| src/lib/medications/ingredients/label-extract.ts | 3 |
| src/lib/environment/service.ts | 1.5 |
| src/lib/export/backup-blob.ts | 1.5 |
| src/lib/jobs/pr-detection.ts | 1.5 |
| src/lib/jobs/document-content-index-backfill.ts | 1.5 |
| src/lib/labs/ocr-extract.ts | 1.5 |
| src/lib/jobs/reminder/backup-handlers.ts | 1.5 |
### at 1820 ms, 114 ms
| self time by module | ms |
| --- | ---: |
| src/lib/tz/zone-offset.ts | 36 |
| npm:@prisma/client | 18.2 |
| src/lib/validations/source-priority.ts | 18.1 |
| (native) | 13.1 |
| src/lib/personal-records/pr-detection-worker.ts | 7.2 |
| node:buffer | 4.1 |
| src/lib/analytics/source-priority.ts | 3.7 |
| npm:zod | 2.3 |
| npm:pg | 2 |
| src/lib/tz/format.ts | 1.9 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 73.3 |
| src/lib/tz/format.ts | 40.7 |
| src/lib/measurements/consolidation-tz.ts | 40.7 |
| src/lib/tz/zone-offset.ts | 38.8 |
| src/lib/measurements/consolidation-base.ts | 38.1 |
| src/lib/analytics/source-priority.ts | 28.1 |
| src/lib/validations/source-priority.ts | 20.4 |
| src/lib/tz/intl-cache.ts | 1.5 |
### at 2407 ms, 50 ms
| self time by module | ms |
| --- | ---: |
| (native) | 18.9 |
| src/lib/validations/source-priority.ts | 14.1 |
| src/lib/analytics/source-priority.ts | 3.7 |
| src/lib/personal-records/pr-detection-worker.ts | 3 |
| npm:next | 1.8 |
| npm:%40prisma | 1.5 |
| src/lib/tz/format.ts | 1.5 |
| node:internal/async_local_storage/async_hooks | 1.5 |
| npm:pg | 1.5 |
| npm:@prisma/client | 1.3 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 26.6 |
| src/lib/analytics/source-priority.ts | 19.3 |
| src/lib/validations/source-priority.ts | 14.1 |
| src/lib/tz/format.ts | 3 |
| src/lib/measurements/consolidation-tz.ts | 3 |
| src/lib/measurements/consolidation-base.ts | 2.8 |
| src/lib/tz/zone-offset.ts | 1.5 |
### at 18192 ms, 59 ms
| self time by module | ms |
| --- | ---: |
| (native) | 15.5 |
| npm:@prisma/client | 13.9 |
| src/lib/validations/source-priority.ts | 11.8 |
| npm:pg | 3.6 |
| node:events | 3 |
| node:buffer | 2.5 |
| src/lib/measurements/consolidation-base.ts | 1.5 |
| src/lib/personal-records/pr-detection-worker.ts | 1.5 |
| node:internal/async_hooks | 1.5 |
| npm:pg-types | 1.4 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 18.9 |
| src/lib/analytics/source-priority.ts | 12.9 |
| src/lib/validations/source-priority.ts | 11.8 |
| src/lib/measurements/consolidation-base.ts | 4.5 |
| src/lib/tz/zone-offset.ts | 2.6 |
| src/lib/tz/format.ts | 2.6 |
| src/lib/measurements/consolidation-tz.ts | 2.6 |
### at 19914 ms, 65 ms
| self time by module | ms |
| --- | ---: |
| src/lib/validations/source-priority.ts | 18.1 |
| (native) | 12.3 |
| npm:@prisma/client | 11.6 |
| npm:next | 5.9 |
| src/lib/personal-records/pr-detection-worker.ts | 3 |
| src/lib/tz/format.ts | 3 |
| npm:pg | 2.6 |
| npm:pg-protocol | 2.5 |
| node:internal/async_hooks | 1.5 |
| src/lib/analytics/source-priority.ts | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 29.8 |
| src/lib/analytics/source-priority.ts | 22.6 |
| src/lib/validations/source-priority.ts | 18.1 |
| src/lib/tz/format.ts | 5.8 |
| src/lib/measurements/consolidation-tz.ts | 5.8 |
| src/lib/tz/zone-offset.ts | 2.8 |
| src/lib/measurements/consolidation-base.ts | 2.8 |
### at 20296 ms, 55 ms
| self time by module | ms |
| --- | ---: |
| (native) | 22.4 |
| npm:@prisma/client | 14.8 |
| node:buffer | 3 |
| npm:zod | 3 |
| src/lib/measurements/consolidation-base.ts | 3 |
| src/lib/tz/format.ts | 1.5 |
| node:internal/stream_base_commons | 1.5 |
| src/lib/analytics/source-priority.ts | 1.5 |
| src/lib/personal-records/pr-detection-worker.ts | 1.5 |
| npm:pg-protocol | 1.3 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 10.5 |
| src/lib/analytics/source-priority.ts | 6 |
| src/lib/validations/source-priority.ts | 3 |
| src/lib/measurements/consolidation-base.ts | 3 |
| src/lib/tz/format.ts | 1.5 |
| src/lib/measurements/consolidation-tz.ts | 1.5 |
### at 30289 ms, 69 ms
| self time by module | ms |
| --- | ---: |
| src/lib/validations/source-priority.ts | 18.3 |
| (native) | 12.9 |
| npm:@prisma/client | 8.8 |
| node:buffer | 6.8 |
| src/lib/personal-records/pr-detection-worker.ts | 4.5 |
| src/lib/tz/format.ts | 4 |
| npm:pg-protocol | 3 |
| npm:zod | 3 |
| node:timers | 1.5 |
| node:internal/async_hooks | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 33 |
| src/lib/analytics/source-priority.ts | 24.3 |
| src/lib/validations/source-priority.ts | 21.3 |
| src/lib/tz/format.ts | 5.6 |
| src/lib/measurements/consolidation-tz.ts | 5.6 |
| src/lib/measurements/consolidation-base.ts | 2.6 |
| src/lib/tz/zone-offset.ts | 1.5 |
### at 31004 ms, 68 ms
| self time by module | ms |
| --- | ---: |
| (native) | 17.8 |
| src/lib/validations/source-priority.ts | 14.9 |
| npm:@prisma/client | 7.6 |
| npm:next | 4.5 |
| node:internal/async_hooks | 4.5 |
| npm:pg-protocol | 4.1 |
| npm:pg | 3 |
| src/lib/tz/zone-offset.ts | 3 |
| node:buffer | 2.7 |
| src/lib/analytics/source-priority.ts | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 26.4 |
| src/lib/analytics/source-priority.ts | 19 |
| src/lib/validations/source-priority.ts | 16.2 |
| src/lib/measurements/consolidation-base.ts | 6.1 |
| src/lib/tz/format.ts | 5.8 |
| src/lib/measurements/consolidation-tz.ts | 5.8 |
| src/lib/tz/zone-offset.ts | 4.5 |
### at 35350 ms, 65 ms
| self time by module | ms |
| --- | ---: |
| src/lib/validations/source-priority.ts | 17 |
| npm:@prisma/client | 11.8 |
| (native) | 8.6 |
| node:buffer | 6.5 |
| src/lib/tz/format.ts | 4.5 |
| npm:pg-protocol | 3 |
| src/lib/personal-records/pr-detection-worker.ts | 3 |
| npm:%40prisma | 1.6 |
| npm:pg | 1.5 |
| src/lib/analytics/source-priority.ts | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 26 |
| src/lib/analytics/source-priority.ts | 20 |
| src/lib/validations/source-priority.ts | 17 |
| src/lib/tz/format.ts | 4.5 |
| src/lib/measurements/consolidation-tz.ts | 4.5 |
| src/lib/measurements/consolidation-base.ts | 3 |
### at 36452 ms, 57 ms
| self time by module | ms |
| --- | ---: |
| (native) | 20 |
| src/lib/validations/source-priority.ts | 16 |
| npm:@prisma/client | 7.3 |
| node:internal/per_context/primordials | 2.8 |
| npm:%40prisma | 1.5 |
| npm:next | 1.5 |
| src/lib/tz/zone-offset.ts | 1.5 |
| node:internal/async_hooks | 1.5 |
| node:internal/streams/readable | 1.5 |
| npm:pg-protocol | 1.2 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 21.2 |
| src/lib/validations/source-priority.ts | 16 |
| src/lib/analytics/source-priority.ts | 16 |
| src/lib/tz/format.ts | 2.5 |
| src/lib/measurements/consolidation-tz.ts | 2.5 |
| src/lib/measurements/consolidation-base.ts | 2.5 |
| src/lib/tz/zone-offset.ts | 1.5 |
### at 38744 ms, 56 ms
| self time by module | ms |
| --- | ---: |
| src/lib/validations/source-priority.ts | 13.3 |
| (native) | 9.9 |
| npm:@prisma/client | 9.2 |
| node:buffer | 4.7 |
| npm:pg-protocol | 4.3 |
| node:events | 1.9 |
| npm:pg | 1.6 |
| npm:%40prisma | 1.5 |
| npm:next | 1.5 |
| src/lib/tz/format.ts | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 21.8 |
| src/lib/analytics/source-priority.ts | 16 |
| src/lib/validations/source-priority.ts | 13.3 |
| src/lib/measurements/consolidation-base.ts | 4.3 |
| src/lib/tz/format.ts | 3 |
| src/lib/measurements/consolidation-tz.ts | 3 |
| src/lib/tz/zone-offset.ts | 1.4 |
### at 42458 ms, 50 ms
| self time by module | ms |
| --- | ---: |
| npm:@prisma/client | 13.5 |
| src/lib/validations/source-priority.ts | 10.1 |
| (native) | 7.6 |
| npm:pg-protocol | 3.2 |
| node:events | 3 |
| src/lib/personal-records/pr-detection-worker.ts | 3 |
| src/lib/tz/format.ts | 3 |
| npm:pg | 2.8 |
| node:internal/async_hooks | 1.5 |
| npm:%40prisma | 1.4 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 17.3 |
| src/lib/analytics/source-priority.ts | 12.7 |
| src/lib/validations/source-priority.ts | 10.1 |
| src/lib/tz/format.ts | 3 |
| src/lib/measurements/consolidation-tz.ts | 3 |
| src/lib/measurements/consolidation-base.ts | 1.5 |
| src/lib/jobs/job-lock.ts | 1.3 |
| src/lib/jobs/long-pass.ts | 1.3 |
| src/lib/jobs/run-job.ts | 1.3 |
| src/lib/jobs/job-observer.ts | 1.3 |
### at 45451 ms, 63 ms
| self time by module | ms |
| --- | ---: |
| src/lib/validations/source-priority.ts | 15.8 |
| npm:@prisma/client | 10.5 |
| (native) | 8.4 |
| src/lib/tz/format.ts | 5.3 |
| node:internal/async_local_storage/async_hooks | 4.4 |
| node:buffer | 3 |
| npm:pg | 3 |
| src/lib/personal-records/pr-detection-worker.ts | 3 |
| npm:zod | 3 |
| npm:%40prisma | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 31.7 |
| src/lib/analytics/source-priority.ts | 21.8 |
| src/lib/validations/source-priority.ts | 18.8 |
| src/lib/measurements/consolidation-base.ts | 5.3 |
| src/lib/tz/format.ts | 5.3 |
| src/lib/measurements/consolidation-tz.ts | 5.3 |
### at 56854 ms, 66 ms
| self time by module | ms |
| --- | ---: |
| src/lib/validations/source-priority.ts | 15 |
| npm:@prisma/client | 13.7 |
| (native) | 6.6 |
| node:buffer | 6.6 |
| npm:next | 6 |
| npm:pg | 4.5 |
| src/lib/personal-records/pr-detection-worker.ts | 4.1 |
| src/lib/tz/format.ts | 3.1 |
| node:internal/process/task_queues | 1.5 |
| node:internal/async_hooks | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 25.3 |
| src/lib/analytics/source-priority.ts | 18.1 |
| src/lib/validations/source-priority.ts | 16.5 |
| src/lib/tz/format.ts | 3.1 |
| src/lib/measurements/consolidation-tz.ts | 3.1 |
| src/lib/measurements/consolidation-base.ts | 3.1 |
### at 56923 ms, 52 ms
| self time by module | ms |
| --- | ---: |
| (native) | 20 |
| npm:@prisma/client | 10.1 |
| node:buffer | 3.7 |
| npm:pg | 3 |
| src/lib/tz/format.ts | 2.6 |
| npm:pg-protocol | 2.6 |
| src/lib/analytics/source-priority.ts | 2.6 |
| src/lib/personal-records/pr-detection-worker.ts | 1.5 |
| src/lib/tz/zone-offset.ts | 1.5 |
| npm:pg-types | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 9.3 |
| src/lib/analytics/source-priority.ts | 5.3 |
| src/lib/tz/format.ts | 4.1 |
| src/lib/measurements/consolidation-tz.ts | 4.1 |
| src/lib/measurements/consolidation-base.ts | 2.6 |
| src/lib/tz/zone-offset.ts | 1.5 |
| src/lib/validations/source-priority.ts | 1.1 |
### at 59508 ms, 56 ms
| self time by module | ms |
| --- | ---: |
| npm:@prisma/client | 15.7 |
| src/lib/validations/source-priority.ts | 9.7 |
| node:buffer | 7 |
| (native) | 6.1 |
| npm:pg-protocol | 4.5 |
| npm:next | 4.1 |
| src/lib/tz/format.ts | 3 |
| src/lib/analytics/source-priority.ts | 3 |
| node:internal/async_hooks | 1.5 |
| src/lib/measurements/consolidation-base.ts | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 21.7 |
| src/lib/analytics/source-priority.ts | 15.8 |
| src/lib/validations/source-priority.ts | 9.7 |
| src/lib/tz/format.ts | 5.9 |
| src/lib/measurements/consolidation-tz.ts | 5.9 |
| src/lib/measurements/consolidation-base.ts | 5.9 |
| src/lib/tz/zone-offset.ts | 2.9 |
### at 61315 ms, 62 ms
| self time by module | ms |
| --- | ---: |
| src/lib/validations/source-priority.ts | 20.1 |
| npm:@prisma/client | 14.1 |
| (native) | 8.2 |
| npm:pg | 4.5 |
| node:buffer | 3.1 |
| src/lib/personal-records/pr-detection-worker.ts | 3 |
| src/lib/measurements/consolidation-base.ts | 3 |
| npm:pg-protocol | 1.5 |
| npm:next | 1.5 |
| src/lib/analytics/source-priority.ts | 1.4 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 30.3 |
| src/lib/analytics/source-priority.ts | 22.8 |
| src/lib/validations/source-priority.ts | 21.4 |
| src/lib/measurements/consolidation-base.ts | 4.5 |
| src/lib/tz/zone-offset.ts | 1.5 |
| src/lib/tz/format.ts | 1.5 |
| src/lib/measurements/consolidation-tz.ts | 1.5 |
### at 72986 ms, 57 ms
| self time by module | ms |
| --- | ---: |
| src/lib/validations/source-priority.ts | 19.1 |
| npm:@prisma/client | 10.2 |
| (native) | 6.6 |
| npm:pg-protocol | 5.5 |
| npm:next | 3 |
| src/lib/analytics/source-priority.ts | 3 |
| npm:pg | 2.5 |
| src/lib/personal-records/pr-detection-worker.ts | 1.5 |
| node:internal/async_local_storage/async_hooks | 1.5 |
| src/lib/tz/format.ts | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 29.6 |
| src/lib/analytics/source-priority.ts | 23.5 |
| src/lib/validations/source-priority.ts | 19.1 |
| src/lib/measurements/consolidation-base.ts | 4.6 |
| src/lib/tz/format.ts | 4.5 |
| src/lib/measurements/consolidation-tz.ts | 4.5 |
| src/lib/tz/zone-offset.ts | 3 |
### at 73523 ms, 51 ms
| self time by module | ms |
| --- | ---: |
| src/lib/validations/source-priority.ts | 12 |
| npm:next | 8.9 |
| npm:@prisma/client | 6 |
| (native) | 4.5 |
| src/lib/analytics/source-priority.ts | 4.2 |
| node:buffer | 4 |
| npm:pg | 2.1 |
| npm:pg-protocol | 1.5 |
| src/lib/personal-records/pr-detection-worker.ts | 1.5 |
| src/lib/tz/zone-offset.ts | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 27.8 |
| src/lib/analytics/source-priority.ts | 17.7 |
| src/lib/validations/source-priority.ts | 13.5 |
| src/lib/measurements/consolidation-base.ts | 8.6 |
| src/lib/tz/format.ts | 7.1 |
| src/lib/measurements/consolidation-tz.ts | 7.1 |
| src/lib/tz/zone-offset.ts | 6 |
### at 75393 ms, 100 ms
| self time by module | ms |
| --- | ---: |
| (native) | 32.6 |
| npm:@prisma/client | 17.2 |
| src/lib/validations/source-priority.ts | 14.6 |
| npm:next | 7.5 |
| src/lib/personal-records/pr-detection-worker.ts | 6.1 |
| src/lib/tz/zone-offset.ts | 5.6 |
| npm:pg-protocol | 4.1 |
| npm:pg | 2.5 |
| src/lib/tz/format.ts | 2.3 |
| node:internal/async_local_storage/async_hooks | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 34.6 |
| src/lib/analytics/source-priority.ts | 24.3 |
| src/lib/validations/source-priority.ts | 16.1 |
| src/lib/tz/format.ts | 10.9 |
| src/lib/measurements/consolidation-tz.ts | 10.9 |
| src/lib/tz/zone-offset.ts | 8.6 |
| src/lib/measurements/consolidation-base.ts | 5.7 |
### at 78085 ms, 54 ms
| self time by module | ms |
| --- | ---: |
| src/lib/tz/zone-offset.ts | 26.6 |
| src/lib/validations/source-priority.ts | 13.5 |
| npm:@prisma/client | 4.2 |
| (native) | 4.1 |
| node:internal/async_local_storage/async_hooks | 1.5 |
| src/lib/tz/format.ts | 1.5 |
| src/lib/tz/intl-cache.ts | 1.5 |
| npm:pg | 1.1 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 44.7 |
| src/lib/tz/format.ts | 29.6 |
| src/lib/measurements/consolidation-tz.ts | 29.6 |
| src/lib/measurements/consolidation-base.ts | 29.6 |
| src/lib/tz/zone-offset.ts | 28.1 |
| src/lib/analytics/source-priority.ts | 15 |
| src/lib/validations/source-priority.ts | 13.5 |
| src/lib/tz/intl-cache.ts | 1.5 |
## Self time by module until the worker is ready
| module | ms |
| --- | ---: |
| (native) | 853 |
| npm:@prisma/client | 295.9 |
| node:internal/modules/cjs/loader | 248.7 |
| src/lib/validations/source-priority.ts | 210.3 |
| npm:next | 188.4 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 121.5 |
| src/lib/tz/zone-offset.ts | 83.2 |
| wasm://wasm/00d1cb12 | 81.4 |
| npm:pg | 78.5 |
| node:buffer | 59.1 |
| src/lib/tz/format.ts | 55 |
| npm:zod | 50.7 |
| src/lib/personal-records/pr-detection-worker.ts | 48 |
| npm:pg-protocol | 37 |
| node:internal/bootstrap/realm | 32 |
| src/lib/analytics/source-priority.ts | 31.7 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 27.7 |
| node:internal/modules/esm/utils | 26 |
| npm:luxon | 22.1 |
| node:internal/async_hooks | 21.7 |
| node:internal/async_local_storage/async_hooks | 21.2 |
| node:fs | 18.8 |
| npm:pg-boss | 18.3 |
| npm:pg-pool | 18.1 |
| npm:yaml | 15 |
## Heap snapshot after 300 s idle (live heap after a full GC)
159.5 MB in 1302659 nodes; module source strings over 50 KB: 39.8 MB.
- parsed locale catalogs: 2 objects, 1.6 MB retained
| node type | MB |
| --- | ---: |
| string | 67.5 |
| array | 26.6 |
| code | 19.2 |
| object | 15.9 |
| hidden | 9.2 |
| native | 8 |
| object shape | 5.9 |
| closure | 5.2 |
| concatenated string | 1.3 |
| number | 0.5 |
| sliced string | 0.2 |
| regexp | 0.2 |
| synthetic | 0 |
| symbol | 0 |
| bigint | 0 |
| constructor | MB | count |
| --- | ---: | ---: |
| array:(object elements) | 20.8 | 25651 |
| object:Object | 11.4 | 293893 |
| native:system / JSArrayBufferData | 7.5 | 170 |
| array:(object properties) | 3.3 | 6517 |
| array: | 2.5 | 10169 |
| closure: | 1.6 | 29121 |
| object:system / Context | 1.6 | 28886 |
| closure:native_bind | 1 | 20804 |
| object:Array | 0.6 | 20938 |
| object:Date1 | 0.5 | 5332 |
| object:a | 0.4 | 4438 |
| native:Node / histogram | 0.4 | 1 |
| object:d | 0.3 | 4425 |
| closure:get | 0.3 | 4732 |
| object:Set | 0.2 | 5670 |
| object:Module | 0.2 | 1963 |
| closure:set | 0.2 | 3428 |
| closure:n | 0.1 | 1572 |
| closure:i | 0.1 | 1578 |
| object:Promise | 0.1 | 1341 |
| largest retained subtrees | MB | retaining path |
| --- | ---: | --- |
| object:yr | 34.6 | [1] > [10] > 22 > extension > global > prisma > [1] > _engine |
| object:system / Context | 7.4 | Node / PrincipalRealm > base_object_list > Node / BaseObjectList > [25] > Node / ModuleWrap > native_to_javascript > ModuleWrap > 2 > [4] > context |
| object:Promise | 5.9 | Object > executor > e > #r > gr > transactions > Map > table > > 6 > closing |
| object:Module | 5.6 | [1] > [10] > 99 > process > getBuiltinModule > getBuiltinModule > context > system / Context > $Module > Module > _cache > |
| code:.next/server/chu | 5.6 | Module > _cache > Object > .next/server/chunks/_1kwfnjg._.js > Module > exports > Array > [1] > > shared > script |
| code:.next/server/chu | 5.6 | global > prisma > [1] > t > _engineConfig > Object > compilerWasm > Object > getRuntime > getRuntime > shared > script |
| object:Generator | 4.4 | [6] > principal_realm > Node / PrincipalRealm > base_object_list > Node / BaseObjectList > [26] > Node / ModuleWrap > native_to_javascript > ModuleWrap > 2 > [4 |
| string:AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/ | 4.4 | [1] > [21] > 60025 |
| object:Object | 3.4 | system / Context > $Module > Module > _cache > Object > .next/server/chunks/[turbopack]_runtime.js > Module > export |
| object:global | 2.6 | [1] > [10] > 22 > extension |
| largest strings | MB |
| --- | ---: |
| module.exports=[234494,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=> | 5.6 |
| module.exports=[17856,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=>i | 5.6 |
| module.exports=[17856,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=>i | 5.6 |
| const wasm = "AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/fwBgBX9/f39/AGAB | 4.4 |
| AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/fwBgBX9/f39/AGABfwF/YAZ/f39/f3 | 4.4 |
| lliADOAUngIEAACgKwAgBgAAoSsAIAcAAKErACAIAACiKwAgCQAAoysAIAoAAKQrACALAAClKwAgDAAApisAIA0AAK | 1.9 |
| (()=>{var e,t,r={"./dist/build/webpack/alias/react-dom-server.js"(e,t,r){"use strict";var | 1.3 |
| module.exports=[842865,(e,t,r)=>{let i=e.r(522734);t.exports=function(e){return e?/-----BE | 1.2 |
| module.exports=[842865,(e,t,r)=>{let i=e.r(522734);t.exports=function(e){return e?/-----BE | 1.2 |
| module.exports=[799092,(e,t,n)=>{"use strict";Object.defineProperty(n,"__esModule",{value: | 1.1 |
| // v1.4.34 web-freeze. Schema is locked additive-only until iOS launch // + v1.5.0 marker. | 1.1 |
| module.exports=[971743,(A,e,t)=>{"use strict";let r=A.r(57328),{addAbortListener:s}=A.r(68 | 1.1 |
| module.exports=[835717,(A,e,t)=>{"use strict";let r=A.r(57328),{addAbortListener:s}=A.r(68 | 1 |
| module.exports=[767189,e=>e.a(async(t,a)=>{try{var n=e.i(64105),r=e.i(563616),i=t([n]);[n] | 0.8 |
| module.exports=[594108,806176,674750,98746,t=>{"use strict";var i;let r;function n(t){retu | 0.7 |
-22
View File
@@ -1,22 +0,0 @@
# v1.45.0: request fan-out
Same runs as `v1.45.0-read-path.md`.
| request | statements before | statements after | p50 ms before / after |
| --- | ---: | ---: | ---: |
| /api/auth/me | 19 | 14 | 9 / 8 |
| day view, cold | 49 | 48 | 77 / 68 |
- `/api/auth/me` read the AppSettings singleton six times (MFA policy,
Assistant switches, module availability, provider presence, AI usage
bounds, reasoning controls); one memoised read per request now serves all
six (`src/lib/settings/operator-row.ts`). On the small budget fixture it
went from 18 to 13 statements. The remaining reads are the session, the
grant, cycle profile, onboarding, consent, coach usage and five user reads
with different column sets.
- The day view's record sections run six at a time instead of about
twenty-five at once, so one day never holds more than a third of the pool.
The statement count is the same; what changes is the peak of connections.
- Keyset pagination for `/api/measurements` was not done: a cursor needs a
new request parameter and a response field, a contract change for the
native client.
-254
View File
@@ -1,254 +0,0 @@
{
"meta": {
"label": "v1.45.0-final",
"commit": "a57fa8335",
"date": "2026-10-22T06:52:09.539Z",
"node": "v22.23.2",
"postgres": "16.15",
"cpus": 14,
"loadAverage": [
6.8,
8.9,
14.5
],
"measurements": 1265561,
"iterations": 20,
"warmup": 3,
"heapMb": 792,
"idleStatementsPerSecond": 0,
"batch5k": {
"totalMs": 4448,
"totalStatements": 504
}
},
"results": [
{
"name": "/api/auth/me",
"mode": "cold",
"request": "GET /api/auth/me",
"n": 20,
"p50Ms": 8.3,
"p95Ms": 27.2,
"maxMs": 221,
"statementsMedian": 14,
"statementsMax": 14,
"failures": 0
},
{
"name": "/api/auth/me",
"mode": "warm",
"request": "GET /api/auth/me",
"n": 20,
"p50Ms": 7.9,
"p95Ms": 10.1,
"maxMs": 17.1,
"statementsMedian": 14,
"statementsMax": 17,
"failures": 0
},
{
"name": "dashboard summary",
"mode": "cold",
"request": "GET /api/dashboard/summary",
"n": 20,
"p50Ms": 86.6,
"p95Ms": 100.8,
"maxMs": 126.8,
"statementsMedian": 30,
"statementsMax": 31,
"failures": 0
},
{
"name": "dashboard summary",
"mode": "warm",
"request": "GET /api/dashboard/summary",
"n": 20,
"p50Ms": 9.2,
"p95Ms": 17.1,
"maxMs": 18.4,
"statementsMedian": 6,
"statementsMax": 6,
"failures": 0
},
{
"name": "dashboard snapshot",
"mode": "cold",
"request": "GET /api/dashboard/snapshot",
"n": 20,
"p50Ms": 545.3,
"p95Ms": 592.5,
"maxMs": 606.3,
"statementsMedian": 153,
"statementsMax": 156,
"failures": 0
},
{
"name": "dashboard snapshot",
"mode": "warm",
"request": "GET /api/dashboard/snapshot",
"n": 20,
"p50Ms": 8.1,
"p95Ms": 17.9,
"maxMs": 21.9,
"statementsMedian": 10,
"statementsMax": 10,
"failures": 0
},
{
"name": "series pulse 30 d",
"mode": "cold",
"request": "GET /api/measurements/series?kind=pulse&days=30",
"n": 20,
"p50Ms": 18.7,
"p95Ms": 27.2,
"maxMs": 36.1,
"statementsMedian": 8,
"statementsMax": 8,
"failures": 0
},
{
"name": "series pulse 30 d",
"mode": "warm",
"request": "GET /api/measurements/series?kind=pulse&days=30",
"n": 20,
"p50Ms": 15.9,
"p95Ms": 21.8,
"maxMs": 26,
"statementsMedian": 8,
"statementsMax": 8,
"failures": 0
},
{
"name": "series pulse 365 d",
"mode": "cold",
"request": "GET /api/measurements/series?kind=pulse&days=365",
"n": 20,
"p50Ms": 40.7,
"p95Ms": 55.6,
"maxMs": 56.2,
"statementsMedian": 7,
"statementsMax": 8,
"failures": 0
},
{
"name": "series pulse 365 d",
"mode": "warm",
"request": "GET /api/measurements/series?kind=pulse&days=365",
"n": 20,
"p50Ms": 38.3,
"p95Ms": 59.6,
"maxMs": 88.6,
"statementsMedian": 7,
"statementsMax": 7,
"failures": 0
},
{
"name": "series pulse 3650 d",
"mode": "cold",
"request": "GET /api/measurements/series?kind=pulse&days=3650",
"n": 20,
"p50Ms": 334.9,
"p95Ms": 348.5,
"maxMs": 351.2,
"statementsMedian": 7,
"statementsMax": 7,
"failures": 0
},
{
"name": "series pulse 3650 d",
"mode": "warm",
"request": "GET /api/measurements/series?kind=pulse&days=3650",
"n": 20,
"p50Ms": 328.1,
"p95Ms": 346,
"maxMs": 348.9,
"statementsMedian": 7,
"statementsMax": 8,
"failures": 0
},
{
"name": "day view",
"mode": "cold",
"request": "GET /api/day/2026-10-21",
"n": 20,
"p50Ms": 68.4,
"p95Ms": 102.6,
"maxMs": 104.7,
"statementsMedian": 48,
"statementsMax": 49,
"failures": 0
},
{
"name": "day view",
"mode": "warm",
"request": "GET /api/day/2026-10-21",
"n": 20,
"p50Ms": 46.4,
"p95Ms": 66.1,
"maxMs": 72.6,
"statementsMedian": 47,
"statementsMax": 47,
"failures": 0
},
{
"name": "insights health status",
"mode": "cold",
"request": "GET /api/insights/health-status",
"n": 20,
"p50Ms": 156,
"p95Ms": 190,
"maxMs": 197.7,
"statementsMedian": 67,
"statementsMax": 67,
"failures": 0
},
{
"name": "insights health status",
"mode": "warm",
"request": "GET /api/insights/health-status",
"n": 20,
"p50Ms": 173.6,
"p95Ms": 1163.4,
"maxMs": 1419.4,
"statementsMedian": 67,
"statementsMax": 67,
"failures": 0
},
{
"name": "insights correlations",
"mode": "cold",
"request": "GET /api/insights/correlations",
"n": 20,
"p50Ms": 106.1,
"p95Ms": 229.1,
"maxMs": 383.3,
"statementsMedian": 48,
"statementsMax": 49,
"failures": 0
},
{
"name": "insights correlations",
"mode": "warm",
"request": "GET /api/insights/correlations",
"n": 20,
"p50Ms": 18.7,
"p95Ms": 626.6,
"maxMs": 1136.5,
"statementsMedian": 7,
"statementsMax": 7,
"failures": 0
},
{
"name": "batch ingest 5k (per 500)",
"mode": "write",
"request": "POST /api/measurements/batch ×10",
"n": 10,
"p50Ms": 185.7,
"p95Ms": 1269.2,
"maxMs": 1269.2,
"statementsMedian": 50,
"statementsMax": 53,
"failures": 0
}
]
}
-25
View File
@@ -1,25 +0,0 @@
# Benchmark v1.45.0-final
Commit a57fa8335 with the working tree of the final commits, 2026-10-22T06:52:09.539Z. 1265561 measurement rows on the bench account, 20 measured requests per row after 3 warm-up requests, one at a time. Server: production build, web process, heap ceiling 792 MB. Postgres 16.15, 14 cores, load average 6.8,8.9,14.5 at the end. Idle background: 0 statements/s.
| Request | Mode | n | p50 ms | p95 ms | max ms | statements (median) | statements (max) |
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |
| /api/auth/me | cold | 20 | 8.3 | 27.2 | 221 | 14 | 14 |
| /api/auth/me | warm | 20 | 7.9 | 10.1 | 17.1 | 14 | 17 |
| dashboard summary | cold | 20 | 86.6 | 100.8 | 126.8 | 30 | 31 |
| dashboard summary | warm | 20 | 9.2 | 17.1 | 18.4 | 6 | 6 |
| dashboard snapshot | cold | 20 | 545.3 | 592.5 | 606.3 | 153 | 156 |
| dashboard snapshot | warm | 20 | 8.1 | 17.9 | 21.9 | 10 | 10 |
| series pulse 30 d | cold | 20 | 18.7 | 27.2 | 36.1 | 8 | 8 |
| series pulse 30 d | warm | 20 | 15.9 | 21.8 | 26 | 8 | 8 |
| series pulse 365 d | cold | 20 | 40.7 | 55.6 | 56.2 | 7 | 8 |
| series pulse 365 d | warm | 20 | 38.3 | 59.6 | 88.6 | 7 | 7 |
| series pulse 3650 d | cold | 20 | 334.9 | 348.5 | 351.2 | 7 | 7 |
| series pulse 3650 d | warm | 20 | 328.1 | 346 | 348.9 | 7 | 8 |
| day view | cold | 20 | 68.4 | 102.6 | 104.7 | 48 | 49 |
| day view | warm | 20 | 46.4 | 66.1 | 72.6 | 47 | 47 |
| insights health status | cold | 20 | 156 | 190 | 197.7 | 67 | 67 |
| insights health status | warm | 20 | 173.6 | 1163.4 | 1419.4 | 67 | 67 |
| insights correlations | cold | 20 | 106.1 | 229.1 | 383.3 | 48 | 49 |
| insights correlations | warm | 20 | 18.7 | 626.6 | 1136.5 | 7 | 7 |
| batch ingest 5k (per 500) | write | 10 | 185.7 | 1269.2 | 1269.2 | 50 | 53 |
@@ -1,45 +0,0 @@
# v1.45.0: read path
Request benchmark on the seeded account (1,265,561 rows), production build,
web process, heap ceiling 792 MB, 20 requests per row, one at a time.
`v1.45.0-before` is the v1.44 runtime (1915f9152), `v1.45.0-final` this
branch; both on the same machine within the hour, load average 7 to 30
(other work on the host), so latency compares within a factor of noise and
statements exactly. Cold means every request follows a one-row manual sync.
| request | before p50 / p95 ms | after p50 / p95 ms | statements before / after |
| --- | ---: | ---: | ---: |
| dashboard snapshot, cold | 1482 / 1526 | 545 / 593 | 158 / 153 |
| dashboard summary, cold | 170 / 221 | 87 / 101 | 31 / 30 |
| series pulse 3650 d, cold | 1627 / 1665 | 335 / 349 | 8 / 7 |
| series pulse 3650 d, warm | 2091 / 2435 | 328 / 346 | 8 / 7 |
| series pulse 365 d, cold | 184 / 193 | 41 / 56 | 8 / 7 |
| series pulse 30 d, cold | 24 / 31 | 19 / 27 | 8 / 8 |
| insights health status, cold | 245 / 401 | 156 / 190 | 68 / 67 |
What moved them:
- **JIT off for app sessions.** Postgres compiled every fold whose estimated
cost passed `jit_above_cost`; on the ten-year pulse pick that was 100 to
140 ms of a 220 ms statement (`EXPLAIN ANALYZE`: 395 ms with JIT, 220 ms
without).
- **The canonical-source pick groups first.** `DISTINCT ON` now sorts the
distinct `(type, day, source)` triples instead of every raw reading (the
ten-year pulse pick sorted 260,000 rows and spilled to disk); 220 ms to
175 ms on its own. Parity: `tests/integration/canonical-source-pick-parity.test.ts`
runs the old statement beside the new one over five reader shapes and two
ladders on a fixture with overlapping and off-ladder sources, tombstones,
empty days and readings around midnight; identical rows.
- **The dense series reads its rows once.** Buckets and strip figures come
from one statement over a materialised window instead of two statements
that each picked the canonical source and scanned the readings. Parity:
`tests/integration/dense-series-read-parity.test.ts`, the two old
statements as reference, pulse (two ranked sources), glucose and oxygen
saturation, day and hour grains, UTC, Europe/Berlin, Asia/Kolkata and
America/New_York; identical buckets and figures (relative 1e-9).
- **Closed blocks survive a sync.** The slice's pre-fold remainder (every
reading older than five years, the snapshot's largest statement at 357 ms)
and the day view's past history blocks are cached under a key that names
the instant their readings end before; a sync of newer readings no longer
drops them (`src/lib/cache/closed-block.ts`). The values are the same
statement's; only how long they are kept changed.
@@ -1,28 +0,0 @@
# v1.45.0: workout duplicate window
A correctness change, not a speed one; the request benchmark does not read
workouts, so there is no latency row for it.
Before: two copies of one session were collapsed only when both starts fell
in the same fixed five-minute clock slot (`floor(start / 5 min)`). A pair 30 s
apart that straddled a slot boundary stayed two workouts in the list, the
batch write, the calendar, strain and the Coach.
After: per user and sport, sorted by start, a workout joins the open cluster
while it starts less than five minutes after the cluster's first start.
Proof (`src/lib/measurements/__tests__/pick-canonical-workout-rows.property.test.ts`,
against the previous picker kept as `pick-canonical-workout-rows.legacy.ts`):
| property | inputs |
| --- | --- |
| same rows as the fixed slots wherever no pair within five minutes straddles a slot boundary, three ladders | 4,000 generated inputs, over 1,000 comparable |
| same strain impulses as before on those inputs | 2,000 generated inputs, over 500 comparable |
| two sources' copies collapse at any offset under five minutes (the old picker missed a share) | 3,000 random offsets |
| five minutes or more apart never collapse | 2,000 |
| a uniform shift of every start keeps the picked set | 1,500 |
| a row of workouts four minutes apart does not chain | fixed case |
One existing case changed its expectation: three runs at 08:04, 08:08 and
08:12 used to stay three because they sat in three slots; 08:08 is now the
08:04 run from a lower-ranked source (four minutes apart), and 08:12 stays.
@@ -1,26 +0,0 @@
# v1.45.0: write path
Same runs as `v1.45.0-read-path.md`.
| request | before p50 ms | after p50 ms | statements before / after |
| --- | ---: | ---: | ---: |
| batch ingest, 500 fresh rows per request | 166 | 186 | 53 / 50 |
Fresh rows were already set-based (v1.44); the benchmark's ingest is all
fresh rows, so it shows no gain, as expected. The changes are for re-posts:
- **Unchanged `stats:` re-posts** write nothing and start nothing. One read
over the batch's `stats:` ids replaces a reconciler transaction (about ten
round trips) per row, and an all-unchanged batch no longer refolds, enqueues
rollups or PR detection, or marks caches stale. The per-entry status stays
`updated` and the summary's `updated` count is unchanged.
`tests/integration/routes/measurements-batch-unchanged-stats.test.ts`: four
unchanged re-posts take at most 12 statements in total (before: about 40)
with zero rollup, invalidation and PR calls; a changed value, a changed
device or a tombstoned row still goes through the reconciler.
- **Apple Health re-import** skips the UPDATE of a row stored with exactly the
values it would write; one read per chunk instead of one UPDATE per row.
`tests/integration/apple-health-reimport-unchanged.test.ts`: a re-import of
20 stored rows reports the same per-type counts and sends no UPDATE.
- **Narrower invalidation.** The batch route tells the cache how far back it
wrote (`touchedFrom`), so closed blocks of older readings stay.
@@ -1,138 +0,0 @@
/**
* Remove the phantom Apple-Health mirror medications minted by the unstable
* externalId bug (a fresh key per sync sweep => one duplicate row per sweep).
*
* SAFETY:
* - Dry-run by default. Deletes ONLY when called with `--execute`.
* - Targets ONLY rows that match ALL of: the given name, externalSource =
* APPLE_HEALTH, ZERO intake events, and ZERO schedules. A row with any
* intake history or any schedule is never touched. Real medications
* (different name, or with intake/schedule history) can never match.
* - Each delete replicates the DELETE /api/medications/{id} route exactly:
* revoke medication-scoped tokens, deleteMedicationCategory, cascade
* delete, audit row. Caches are busted per affected user at the end (and
* otherwise clear on TTL / the next write).
*
* STOP THE SOURCE FIRST: while the iOS Apple-Health medication mirror is still
* enabled on the device, every sync re-mints these rows and cleanup will undo
* itself. Turn the toggle off (or ship the iOS fix) before running --execute.
*
* Run (inside the app container):
* pnpm dlx tsx scripts/cleanup-apple-health-phantom-meds.ts # dry-run
* pnpm dlx tsx scripts/cleanup-apple-health-phantom-meds.ts --execute # delete
* pnpm dlx tsx scripts/cleanup-apple-health-phantom-meds.ts --name "Blutdruckmittel" --execute
*/
import "dotenv/config";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { deleteMedicationCategory } from "@/lib/medication-category";
function argValue(flag: string): string | undefined {
const i = process.argv.indexOf(flag);
return i >= 0 ? process.argv[i + 1] : undefined;
}
async function main(): Promise<void> {
const name = argValue("--name") ?? "Blutdruckmittel";
const execute = process.argv.includes("--execute");
// Candidate set: name + Apple-Health provenance only. Intake / schedule
// counts are checked per row below and are the hard safety gate.
const candidates = await prisma.medication.findMany({
where: { name, externalSource: "APPLE_HEALTH" },
select: { id: true, userId: true, externalId: true, createdAt: true },
orderBy: { createdAt: "asc" },
});
const deletable: { id: string; userId: string }[] = [];
const skipped: { id: string; reason: string }[] = [];
for (const m of candidates) {
const [intakes, schedules] = await Promise.all([
prisma.medicationIntakeEvent.count({
where: { medicationId: m.id, deletedAt: null },
}),
prisma.medicationSchedule.count({ where: { medicationId: m.id } }),
]);
if (intakes > 0 || schedules > 0) {
skipped.push({
id: m.id,
reason: `has ${intakes} intake(s) / ${schedules} schedule(s) — PRESERVED`,
});
continue;
}
deletable.push({ id: m.id, userId: m.userId });
}
console.log(
`\n=== cleanup plan for name "${name}" (externalSource APPLE_HEALTH) ===`,
);
console.log(`candidates found: ${candidates.length}`);
console.log(`safe to delete: ${deletable.length}`);
console.log(`preserved (intake/schedule present): ${skipped.length}`);
if (skipped.length > 0) {
console.log("\nPreserved rows:");
for (const s of skipped) console.log(` ${s.id} — ${s.reason}`);
}
console.log("\nRows to delete:");
for (const d of deletable) console.log(` ${d.id}`);
if (!execute) {
console.log(
"\nDRY RUN — nothing was deleted. Re-run with --execute to remove the rows above.",
);
return;
}
console.log(`\n--execute: deleting ${deletable.length} row(s)...`);
const affectedUsers = new Set<string>();
let deleted = 0;
for (const { id, userId } of deletable) {
// Mirror the DELETE route: revoke medication-scoped tokens (none expected
// on a phantom row, harmless if absent), category cleanup, cascade delete,
// audit row.
await prisma.apiToken.updateMany({
where: {
userId,
revoked: false,
permissions: { has: `medication:${id}:ingest` },
},
data: { revoked: true },
});
await deleteMedicationCategory(id);
await prisma.medication.delete({ where: { id } });
await auditLog("medication.delete", {
userId,
details: {
medicationId: id,
name,
reason: "apple_health_phantom_cleanup",
},
});
affectedUsers.add(userId);
deleted += 1;
}
for (const userId of affectedUsers) {
invalidateUserMedications(userId, { evict: true });
}
console.log(
`\nDone. Deleted ${deleted} row(s) across ${affectedUsers.size} user(s).`,
);
console.log(
"Caches were busted for the affected users; a page refresh reflects the change.",
);
}
main()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(() => {
void prisma.$disconnect();
});
-338
View File
@@ -1,338 +0,0 @@
/**
* Read-only before/after count of correlation findings per account, for the
* release-candidate dry run of the v1.42 statistics change.
*
* v1.42 changes what the discovery engine accepts: trend and season are
* removed from both series before correlating, p-values use the effective
* sample size, shrinkage and the phrasing tier read that effective size, and
* the weather channels are tested once over the averaged day-before-and-same-
* day exposure instead of a next-day lag. Every one of those can make an
* existing finding disappear. This script answers "how many, and where",
* on a copy of production, before the release ships.
*
* For each account it builds the matrix exactly as the correlations route
* does (same assembler, same 180-day window, same module map, same tiered
* read) and scans it twice:
*
* - BEFORE: the pre-v1.42 rules, restated here from the engine's own
* exported pieces (lag join at 1 day, Pearson with df = n − 2, BH-FDR,
* shrinkage and tier by n), over the raw daily weather values;
* - AFTER: the engine as it now ships.
*
* It prints counts only. No correlation, p-value, metric value or account
* identifier leaves the database: accounts are listed by a short hash of
* their id, channels by their stable key family. Nothing is written — the
* pattern store is not synced, no cache is touched.
*
* Run (inside the app container, which has DATABASE_URL):
* pnpm dlx tsx scripts/compare-correlation-findings.ts [--limit N]
*/
import "dotenv/config";
import { createHash } from "node:crypto";
import { prisma } from "@/lib/db";
import { resolveModuleMap } from "@/lib/modules/gate";
import { assembleDiscoveryMatrix } from "@/lib/insights/discovery-matrix";
import { MIN_PAIRED_N, pearson } from "@/lib/insights/correlations";
import {
benjaminiHochberg,
confidenceTier,
discoverCorrelations,
discoverEmergingCorrelations,
EARLY_FDR_Q,
EARLY_MIN_PAIRS,
EARLY_WINDOW_DAYS,
FDR_Q,
filterSeriesToWindow,
lagJoin,
metricFamily,
shrinkEstimate,
type DailySeriesPoint,
type NamedSeries,
} from "@/lib/insights/correlation-discovery";
import { ENVIRONMENT_FIELDS } from "@/lib/environment/fields";
import {
isSurfaceVisible,
correlationChannelSurfaceId,
} from "@/lib/modules/surface";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
const MS_PER_DAY = 86_400_000;
/** The correlations route's window. */
const WINDOW_DAYS = 180;
interface Finding {
behaviour: string;
outcome: string;
}
interface LegacyFinding extends Finding {
tier: ReturnType<typeof confidenceTier>;
}
/** The pre-v1.42 discovery rules, restated from the engine's parts. */
function legacyDiscover(
series: NamedSeries[],
opts: { minPairs: number; fdrQ: number },
): { found: LegacyFinding[]; pairsTested: number } {
const behaviours = series.filter((s) => s.role === "behaviour");
const outcomes = series.filter((s) => s.role === "outcome");
const tested: Array<Finding & { n: number; r: number; pValue: number }> = [];
for (const b of behaviours) {
for (const o of outcomes) {
if (metricFamily(b.key) === metricFamily(o.key)) continue;
const { xs, ys } = lagJoin(b.points, o.points, 1);
if (xs.length < opts.minPairs) continue;
const result = pearson({ xs, ys, minPairs: opts.minPairs });
if (result.status !== "ok") continue;
tested.push({
behaviour: b.key,
outcome: o.key,
n: result.n,
r: result.r,
pValue: result.pValue,
});
}
}
const q = benjaminiHochberg(tested.map((t) => t.pValue));
const found = tested
.filter((t, i) => t.pValue < 0.05 && q[i] <= opts.fdrQ)
.map((t) => ({
behaviour: t.behaviour,
outcome: t.outcome,
tier: confidenceTier(shrinkEstimate(t.r, t.n), t.n),
}))
.filter((t) => t.tier !== null);
return { found, pairsTested: tested.length };
}
/** The pre-v1.42 emerging pass: same rules, trailing window, no faint tier. */
function legacyEmerging(
series: NamedSeries[],
retrospective: Finding[],
recentFromDayKey: string,
): Finding[] {
const recent = legacyDiscover(
filterSeriesToWindow(series, recentFromDayKey),
{ minPairs: EARLY_MIN_PAIRS, fdrQ: EARLY_FDR_Q },
);
const established = new Set(retrospective.map(key));
return recent.found
.filter((f) => f.tier !== "faint")
.filter((f) => !established.has(key(f)));
}
function key(f: Finding): string {
return `${f.behaviour}␟${f.outcome}`;
}
function tzDayKey(at: Date, tz: string): string {
const { year, month, day } = wallClockInTz(at, tz);
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
/**
* The weather channels as the engine read them before v1.42: one raw daily
* value per field, no averaging. Read here directly because the shipped
* series now carries the averaged window.
*/
async function legacyEnvironmentSeries(
userId: string,
since: Date,
): Promise<Map<string, DailySeriesPoint[]>> {
// Same UTC lower bound the engine's own environment read uses.
const sinceKey = since.toISOString().slice(0, 10);
const rows = await prisma.environmentContext.findMany({
where: { userId, date: { gte: sinceKey } },
orderBy: { date: "asc" },
take: 1000,
});
const out = new Map<string, DailySeriesPoint[]>();
for (const field of ENVIRONMENT_FIELDS) {
const points: DailySeriesPoint[] = [];
for (const row of rows) {
const raw = row[field.column];
if (raw == null || !Number.isFinite(raw)) continue;
const value =
field.column === "sunshineSec" || field.column === "daylightSec"
? raw / 3600
: raw;
points.push({ day: row.date, value });
}
out.set(field.key, points);
}
return out;
}
/** Channel family label for the aggregate table (no user data). */
function familyLabel(channel: string): string {
if (channel.startsWith("ENV_")) return "ENVIRONMENT";
if (channel.startsWith("CUSTOM_METRIC:")) return "CUSTOM_METRIC";
if (channel.startsWith("SYMPTOM:")) return "SYMPTOM_EVENT";
if (channel.startsWith("FACTOR:")) return "MOOD_FACTOR";
return channel;
}
interface Tally {
before: number;
after: number;
}
function bump(map: Map<string, Tally>, k: string, side: keyof Tally) {
const t = map.get(k) ?? { before: 0, after: 0 };
t[side]++;
map.set(k, t);
}
function parseLimit(argv: string[]): number | undefined {
const i = argv.indexOf("--limit");
if (i === -1) return undefined;
const n = Number(argv[i + 1]);
return Number.isInteger(n) && n > 0 ? n : undefined;
}
async function main(): Promise<void> {
const limit = parseLimit(process.argv.slice(2));
const users = await prisma.user.findMany({
select: { id: true, timezone: true },
orderBy: { createdAt: "asc" },
...(limit ? { take: limit } : {}),
});
const now = new Date();
const since = new Date(now.getTime() - WINDOW_DAYS * MS_PER_DAY);
const byBehaviour = new Map<string, Tally>();
const byOutcome = new Map<string, Tally>();
const totals = {
accounts: 0,
scanned: 0,
retroBefore: 0,
retroAfter: 0,
recentBefore: 0,
recentAfter: 0,
kept: 0,
lost: 0,
gained: 0,
};
console.log(
"account tested(b/a) retro b→a recent b→a kept lost new env b→a",
);
for (const user of users) {
totals.accounts++;
const tz = user.timezone ?? DEFAULT_TIMEZONE;
const modules = await resolveModuleMap(user.id);
const { series } = await assembleDiscoveryMatrix(user.id, {
tz,
since,
fetchMode: "tiered",
modules,
});
const envVisible = isSurfaceVisible(
correlationChannelSurfaceId(ENVIRONMENT_FIELDS[0].key),
modules,
);
const rawEnv = envVisible
? await legacyEnvironmentSeries(user.id, since)
: new Map<string, DailySeriesPoint[]>();
const legacySeries: NamedSeries[] = series.map((s) =>
s.key.startsWith("ENV_")
? { key: s.key, role: s.role, points: rawEnv.get(s.key) ?? [] }
: { key: s.key, role: s.role, label: s.label, points: s.points },
);
const recentFromDayKey = tzDayKey(
new Date(now.getTime() - EARLY_WINDOW_DAYS * MS_PER_DAY),
tz,
);
const legacy = legacyDiscover(legacySeries, {
minPairs: MIN_PAIRED_N,
fdrQ: FDR_Q,
});
const before = legacy.found;
const beforeRecent = legacyEmerging(legacySeries, before, recentFromDayKey);
const after = discoverCorrelations(series, { locale: "en" });
const afterRecent = discoverEmergingCorrelations(series, after, {
recentFromDayKey,
locale: "en",
});
if (legacy.pairsTested === 0 && after.pairsTested === 0) continue;
totals.scanned++;
const beforeKeys = new Set(before.map(key));
const afterKeys = new Set(after.discovered.map(key));
const kept = [...afterKeys].filter((k) => beforeKeys.has(k)).length;
const lost = beforeKeys.size - kept;
const gained = afterKeys.size - kept;
const envBefore = before.filter((f) => f.behaviour.startsWith("ENV_"));
const envAfter = after.discovered.filter((f) =>
f.behaviour.startsWith("ENV_"),
);
for (const f of before) {
bump(byBehaviour, familyLabel(f.behaviour), "before");
bump(byOutcome, familyLabel(f.outcome), "before");
}
for (const f of after.discovered) {
bump(byBehaviour, familyLabel(f.behaviour), "after");
bump(byOutcome, familyLabel(f.outcome), "after");
}
totals.retroBefore += before.length;
totals.retroAfter += after.discovered.length;
totals.recentBefore += beforeRecent.length;
totals.recentAfter += afterRecent.emerging.length;
totals.kept += kept;
totals.lost += lost;
totals.gained += gained;
const tag = createHash("sha256").update(user.id).digest("hex").slice(0, 8);
console.log(
[
tag.padEnd(9),
`${legacy.pairsTested}/${after.pairsTested}`.padEnd(12),
`${before.length}→${after.discovered.length}`.padEnd(10),
`${beforeRecent.length}→${afterRecent.emerging.length}`.padEnd(11),
String(kept).padStart(4),
String(lost).padStart(4),
String(gained).padStart(3),
` ${envBefore.length}→${envAfter.length}`,
].join(" "),
);
}
console.log("");
console.log(
`accounts ${totals.accounts}, with a testable matrix ${totals.scanned}`,
);
console.log(
`retrospective findings ${totals.retroBefore} → ${totals.retroAfter} (kept ${totals.kept}, lost ${totals.lost}, new ${totals.gained})`,
);
console.log(
`emerging findings ${totals.recentBefore} → ${totals.recentAfter}`,
);
const table = (title: string, map: Map<string, Tally>) => {
console.log("");
console.log(`${title}: before → after`);
for (const [k, t] of [...map.entries()].sort((a, b) =>
a[0].localeCompare(b[0]),
)) {
console.log(` ${k.padEnd(24)} ${t.before} → ${t.after}`);
}
};
table("by behaviour family", byBehaviour);
table("by outcome family", byOutcome);
}
main()
.catch((err: unknown) => {
console.error(err);
process.exitCode = 1;
})
.finally(() => {
void prisma.$disconnect();
});
@@ -1,104 +0,0 @@
/**
* Convert measurement rows stored in a RECOGNISED non-canonical unit to
* canonical SI, in place. Companion to
* `diagnose-noncanonical-measurement-units.ts` — run the diagnose first.
*
* SAFETY:
* - Dry-run by default. Writes ONLY when called with `--execute`.
* - Touches ONLY rows whose stored `unit` differs from the type's canonical
* unit AND resolves through the shared alias resolver (lb/lbs/pound(s),
* in/inch(es), °F, mmol/L). A row whose unit is already canonical, or whose
* unit is unrecognised, is never touched.
* - Each row is rewritten to the canonical `{ value, unit }` the same
* resolver the CSV importer + MCP write path use produces, so the number is
* re-expressed, never re-labelled in place (which would corrupt it).
* - Affected users' measurement caches are busted at the end.
*
* This repairs the storage LABEL + value for the recognised-alias legacy rows
* (the MCP-leak class). It cannot repair a row whose unit was already canonical
* but whose value was a foreign unit — that class is not mechanically
* identifiable (see the diagnose script).
*
* Run (inside the app container):
* pnpm dlx tsx scripts/convert-noncanonical-measurement-units.ts # dry-run
* pnpm dlx tsx scripts/convert-noncanonical-measurement-units.ts --execute # write
*/
import "dotenv/config";
import { prisma } from "@/lib/db";
import { getUnitForType } from "@/lib/validations/measurement";
import { resolveToCanonicalUnit } from "@/lib/measurements/unit-aliases";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
async function main(): Promise<void> {
const execute = process.argv.includes("--execute");
const groups = await prisma.measurement.groupBy({
by: ["type", "unit"],
_count: { _all: true },
});
const convertibleGroups = groups.filter((g) => {
if (g.unit === null || g.unit === getUnitForType(g.type)) return false;
return resolveToCanonicalUnit(g.type, 1, g.unit) !== null;
});
if (convertibleGroups.length === 0) {
console.log(
"Nothing to convert — no recognised non-canonical units found.",
);
return;
}
const affectedUsers = new Set<string>();
let converted = 0;
for (const g of convertibleGroups) {
const canonical = getUnitForType(g.type);
const rows = await prisma.measurement.findMany({
where: { type: g.type, unit: g.unit },
select: { id: true, userId: true, value: true, unit: true },
});
for (const row of rows) {
const resolved = resolveToCanonicalUnit(
g.type,
row.value,
row.unit as string,
);
if (!resolved) continue; // defensive; group already filtered
converted += 1;
affectedUsers.add(row.userId);
console.log(
` ${execute ? "convert" : "would convert"} ${g.type} ${row.id}: ` +
`${row.value} ${row.unit} -> ${resolved.value} ${resolved.unit}`,
);
if (execute) {
await prisma.measurement.update({
where: { id: row.id },
data: { value: resolved.value, unit: canonical },
});
}
}
}
if (execute) {
for (const userId of affectedUsers) {
invalidateUserMeasurements(userId, { evict: true });
}
console.log(
`\nConverted ${converted} row(s) across ${affectedUsers.size} user(s). ` +
"Rollups recompute on the next read/backfill.",
);
} else {
console.log(
`\nDry run: ${converted} row(s) across ${affectedUsers.size} user(s) ` +
"would be converted. Re-run with --execute to write.",
);
}
}
main()
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
-121
View File
@@ -1,121 +0,0 @@
/**
* Read-only diagnostic for the phantom Apple-Health medication mirror.
*
* Prints the shape of every medication matching a name (default
* "Blutdruckmittel"): its provenance (externalSource / externalId), dose,
* asNeeded flag, createdAt, and its intake-event + schedule counts. Nothing is
* written. Use it to confirm the root cause before running any cleanup:
*
* - externalSource = APPLE_HEALTH with many DISTINCT externalId values
* (address-shaped or otherwise mutually different) => the iOS mirror is
* minting a fresh key per sync, so idempotency never matches.
* - externalSource NULL => the rows are not mirror rows (a different path).
* - createdAt spread => one row per sync sweep vs a single burst.
*
* Run (inside the app container, which has DATABASE_URL):
* pnpm dlx tsx scripts/diagnose-apple-health-meds.ts
* pnpm dlx tsx scripts/diagnose-apple-health-meds.ts --name "Blutdruckmittel"
*/
import "dotenv/config";
import { prisma } from "@/lib/db";
function argValue(flag: string): string | undefined {
const i = process.argv.indexOf(flag);
return i >= 0 ? process.argv[i + 1] : undefined;
}
async function main(): Promise<void> {
const name = argValue("--name") ?? "Blutdruckmittel";
const meds = await prisma.medication.findMany({
where: { name },
select: {
id: true,
userId: true,
name: true,
dose: true,
asNeeded: true,
externalSource: true,
externalId: true,
createdAt: true,
},
orderBy: { createdAt: "asc" },
});
if (meds.length === 0) {
console.log(`No medications found with name "${name}".`);
return;
}
const rows = [];
for (const m of meds) {
const [intakes, schedules] = await Promise.all([
prisma.medicationIntakeEvent.count({
where: { medicationId: m.id, deletedAt: null },
}),
prisma.medicationSchedule.count({ where: { medicationId: m.id } }),
]);
rows.push({
id: m.id,
user: m.userId.slice(0, 8),
source: m.externalSource ?? "NULL",
externalId: m.externalId ?? "NULL",
dose: m.dose,
asNeeded: m.asNeeded,
intakes,
schedules,
createdAt: m.createdAt.toISOString(),
});
}
console.log(`\n=== ${rows.length} medication(s) named "${name}" ===\n`);
console.table(rows);
const distinctExternalIds = new Set(
meds.map((m) => m.externalId).filter((v): v is string => v !== null),
);
const bySource = new Map<string, number>();
for (const m of meds) {
const key = m.externalSource ?? "NULL";
bySource.set(key, (bySource.get(key) ?? 0) + 1);
}
const created = meds.map((m) => m.createdAt.getTime());
const withIntake = rows.filter((r) => r.intakes > 0).length;
const withSchedule = rows.filter((r) => r.schedules > 0).length;
console.log("\n=== summary ===");
console.log(`total rows: ${rows.length}`);
console.log(
`externalSource breakdown: ${[...bySource.entries()]
.map(([k, v]) => `${k}=${v}`)
.join(", ")}`,
);
console.log(`distinct externalId: ${distinctExternalIds.size}`);
console.log(`rows with >=1 intake: ${withIntake}`);
console.log(`rows with >=1 schedule: ${withSchedule}`);
console.log(
`createdAt range: ${new Date(Math.min(...created)).toISOString()} .. ${new Date(Math.max(...created)).toISOString()}`,
);
console.log(
"\nSample externalId values (first 5):\n" +
meds
.slice(0, 5)
.map((m) => ` ${m.externalId ?? "NULL"}`)
.join("\n"),
);
console.log(
"\nRead-only. No rows were modified. If the shape confirms the phantom set,\n" +
"run scripts/cleanup-apple-health-phantom-meds.ts (dry-run first).",
);
}
main()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(() => {
void prisma.$disconnect();
});
-222
View File
@@ -1,222 +0,0 @@
/**
* Read-only diagnostic for cycle records the module has stopped reading.
*
* Two defects put records into states that look, from the app, like no periods
* were ever logged. Both are fixed going forward; neither repairs the rows it
* already made, and neither is visible from the surface it broke.
*
* The first is a closed torso. Logging a period start closes the cycle before
* it, and removing that start used to leave the closure standing. The previous
* cycle keeps an end date that a cycle which no longer exists gave it, and a
* record whose last cycle is closed has no open cycle for the engine to
* forecast from, so the prediction disappears and does not come back. The
* signature is exact and needs no audit trail: a live cycle carrying an end
* date with no live cycle after it. A cycle is closed by its successor, so
* closed with no successor means the successor is gone.
*
* The second is a period that only ever existed as flow. Cycle rows used to be
* written by the one-tap period boundary alone, so bleeding entered through the
* log sheet's chips, the bulk drain or an Apple Health import produced day-logs
* and no cycle at all. The engine reads cycle rows, so the record holds periods
* and reports none.
*
* The bleeding-day rule below is imported from the write path rather than
* restated, so this report and the app cannot drift on what counts as a period
* start.
*
* Nothing is written. The output is a per-record inventory plus the two flags,
* so the state can be read before anything is decided about it.
*
* Run (inside the app container, which has DATABASE_URL):
* pnpm dlx tsx scripts/diagnose-cycle-state.ts
*/
import "dotenv/config";
import { prisma } from "@/lib/db";
import { bleedOpensCycle } from "@/lib/cycle/cycle-boundaries";
import { dayDiff } from "@/lib/cycle/day-math";
/** Below this many observed cycles the app withholds its phase band. */
const STILL_LEARNING_BELOW = 3;
interface CycleRow {
id: string;
startDate: string;
endDate: string | null;
lengthDays: number | null;
periodEndDate: string | null;
deletedAt: Date | null;
isPredicted: boolean;
}
interface DayRow {
date: string;
flow: string | null;
intermenstrualBleeding: boolean;
}
function describeCycle(c: CycleRow): string {
const state = c.deletedAt
? `deleted ${c.deletedAt.toISOString().slice(0, 10)}`
: c.endDate
? `closed ${c.endDate}`
: "open";
const length = c.lengthDays === null ? "-" : `${c.lengthDays}d`;
const period = c.periodEndDate ? ` periodEnd=${c.periodEndDate}` : "";
const predicted = c.isPredicted ? " PREDICTED" : "";
return `${c.startDate} ${state.padEnd(20)} length=${length.padEnd(5)}${period}${predicted}`;
}
async function main(): Promise<void> {
// Every record that has ever touched the module, from either side: a cycle
// row, or a day-log. A record with day-logs and no cycles is precisely one
// of the cases this exists to find, so it cannot be found by cycles alone.
const [cycleOwners, dayOwners] = await Promise.all([
prisma.menstrualCycle.findMany({
distinct: ["userId"],
select: { userId: true },
}),
prisma.cycleDayLog.findMany({
distinct: ["userId"],
select: { userId: true },
}),
]);
const userIds = Array.from(
new Set([
...cycleOwners.map((r) => r.userId),
...dayOwners.map((r) => r.userId),
]),
).sort();
if (userIds.length === 0) {
console.log("No cycle records on this instance. Nothing to report.");
return;
}
let torsoTotal = 0;
let flowOnlyTotal = 0;
let staleBoundaryTotal = 0;
for (const userId of userIds) {
const [cycles, days] = await Promise.all([
prisma.menstrualCycle.findMany({
where: { userId },
orderBy: { startDate: "asc" },
select: {
id: true,
startDate: true,
endDate: true,
lengthDays: true,
periodEndDate: true,
deletedAt: true,
isPredicted: true,
},
}) as Promise<CycleRow[]>,
prisma.cycleDayLog.findMany({
where: { userId, deletedAt: null },
orderBy: { date: "asc" },
select: { date: true, flow: true, intermenstrualBleeding: true },
}) as Promise<DayRow[]>,
]);
const live = cycles.filter((c) => c.deletedAt === null);
const liveStarts = live.map((c) => c.startDate);
const startSet = new Set(liveStarts);
console.log(`record ${userId}`);
console.log(
` ${cycles.length} cycle row(s), ${live.length} live, ` +
`${days.length} day-log(s)`,
);
for (const c of cycles) console.log(` ${describeCycle(c)}`);
// A closed cycle with nothing after it: closed by a start that is gone.
const torsos = live.filter(
(c, i) => c.endDate !== null && i === live.length - 1,
);
// A closed cycle whose boundary does not match the successor it has.
const stale = live.filter((c, i) => {
const next = live[i + 1];
if (!next) return false;
const expectedLength = dayDiff(next.startDate, c.startDate);
return c.endDate === null || c.lengthDays !== expectedLength;
});
// Bleeding days the write path would treat as a period start today, on a
// date carrying no live cycle. Walked forward so each decision sees the
// starts that already exist plus the ones this pass would infer, exactly
// as a replay of the writes would.
const inferred: string[] = [...liveStarts].sort();
const flowOnly: string[] = [];
for (const d of days) {
if (startSet.has(d.date)) continue;
const prior = inferred.filter((s) => s <= d.date).at(-1) ?? null;
if (
bleedOpensCycle(
d.flow as never,
d.intermenstrualBleeding,
prior,
d.date,
)
) {
flowOnly.push(d.date);
inferred.push(d.date);
inferred.sort();
}
}
if (torsos.length > 0) {
torsoTotal += torsos.length;
console.log(
` CLOSED TORSO: ${torsos
.map((c) => `${c.startDate} (ends ${c.endDate})`)
.join(", ")}`,
);
console.log(
" The last cycle is closed and has no successor, so the engine has " +
"no open cycle to forecast from. Re-opening it means clearing its " +
"endDate and lengthDays.",
);
}
if (stale.length > 0) {
staleBoundaryTotal += stale.length;
console.log(
` STALE BOUNDARY: ${stale.map((c) => c.startDate).join(", ")}`,
);
console.log(
" The stored end or length disagrees with the next cycle's start.",
);
}
if (flowOnly.length > 0) {
flowOnlyTotal += flowOnly.length;
console.log(` PERIOD WITH NO CYCLE ROW: ${flowOnly.join(", ")}`);
console.log(
" Bleeding was logged on these days and no cycle opens on them. " +
"Re-saving each day through the log sheet opens it now.",
);
}
if (live.length > 0 && live.length - 1 < STILL_LEARNING_BELOW) {
console.log(
` Only ${live.length - 1} observed cycle length(s): under the ` +
`${STILL_LEARNING_BELOW}-cycle gate, so the phase band stays withheld.`,
);
}
console.log("");
}
console.log(
`${userIds.length} record(s) inspected. ` +
`${torsoTotal} closed torso(s), ${staleBoundaryTotal} stale boundary(ies), ` +
`${flowOnlyTotal} logged period(s) with no cycle row.`,
);
console.log("Nothing was changed.");
}
main()
.catch((error) => {
console.error(error);
process.exitCode = 1;
})
.finally(() => {
void prisma.$disconnect();
});
@@ -1,150 +0,0 @@
/**
* Read-only diagnostic for stored measurement rows outside the app's own
* declared plausibility domain.
*
* `VALUE_RANGES` in `@/lib/validations/measurement` is the band the
* application says a metric can occupy. Every path a person can drive has
* always enforced it; the provider sync writers did not, so rows the
* application itself calls impossible reached the table — and every mean,
* median and personal band built from them inherited the impossibility. The
* writers enforce it now, which stops new rows landing. It says nothing about
* the rows already there.
*
* This reports them and repairs nothing. What to do with a stored impossible
* row is a judgement the data cannot make: a pulse of 55 million is plainly a
* decode slip and a candidate for deletion, while a weight of 0.9 kg might be
* a neonatal record on a shared scale, or a unit mix-up, or a real reading the
* band is simply too narrow for. Deleting somebody's health record on a
* heuristic is not a thing a script gets to decide.
*
* Output is aggregate: one line per (metric, source) bucket with the row
* count, the number of distinct records affected, the observed extremes, and
* the window they fall in. Individual readings are not printed — the bucket
* plus the metric's own declared bound is what a decision needs, and a log
* file full of somebody's vitals is not.
*
* Run (inside the app container, which has DATABASE_URL):
* pnpm dlx tsx scripts/diagnose-implausible-measurements.ts
*/
import "dotenv/config";
import { prisma } from "@/lib/db";
import { VALUE_RANGES } from "@/lib/validations/measurement";
interface BucketRow {
type: string;
source: string;
row_count: bigint;
record_count: bigint;
min_value: number;
max_value: number;
first_at: Date;
last_at: Date;
tombstoned: bigint;
}
function ymd(date: Date): string {
return date.toISOString().slice(0, 10);
}
async function main(): Promise<void> {
const types = Object.keys(VALUE_RANGES).sort();
const mins = types.map((t) => VALUE_RANGES[t]!.min);
const maxes = types.map((t) => VALUE_RANGES[t]!.max);
// The band table rides in as three parallel bound arrays rather than a
// spliced VALUES list, so the query carries no interpolated text at all.
// `value <> value` is the NaN probe: a double-precision column can hold one,
// and it compares false against every bound including itself.
const buckets = await prisma.$queryRaw<BucketRow[]>`
WITH bands(type, min_value, max_value) AS (
SELECT * FROM unnest(
${types}::text[],
${mins}::double precision[],
${maxes}::double precision[]
)
)
SELECT
m."type"::text AS type,
m."source"::text AS source,
COUNT(*)::bigint AS row_count,
COUNT(DISTINCT m."user_id")::bigint AS record_count,
MIN(m."value") AS min_value,
MAX(m."value") AS max_value,
MIN(m."measured_at") AS first_at,
MAX(m."measured_at") AS last_at,
COUNT(*) FILTER (WHERE m."deleted_at" IS NOT NULL)::bigint AS tombstoned
FROM "measurements" m
JOIN bands b ON b."type" = m."type"::text
WHERE m."value" < b.min_value
OR m."value" > b.max_value
OR m."value" <> m."value"
GROUP BY m."type", m."source"
ORDER BY COUNT(*) DESC, m."type"::text ASC, m."source"::text ASC
`;
if (buckets.length === 0) {
console.log(
`Every stored measurement sits inside its metric's declared range ` +
`(${types.length} metric bands checked). Nothing to review.`,
);
return;
}
const totalRows = buckets.reduce((sum, b) => sum + Number(b.row_count), 0);
const affectedTypes = new Set(buckets.map((b) => b.type));
const affectedSources = new Set(buckets.map((b) => b.source));
console.log(
`${totalRows} stored row(s) sit outside the declared plausibility band, ` +
`across ${affectedTypes.size} metric(s) and ${affectedSources.size} source(s).\n` +
`Nothing below is modified. Each line is a (metric, source) bucket; the ` +
`band is the metric's own declared domain.\n`,
);
// Group by metric so the declared band is stated once per heading, and rank
// the metrics by how much of the problem each one is. The query's own
// ordering already ranks the sources inside a metric.
const byType = new Map<string, BucketRow[]>();
for (const bucket of buckets) {
const bucketsForType = byType.get(bucket.type);
if (bucketsForType) bucketsForType.push(bucket);
else byType.set(bucket.type, [bucket]);
}
const ranked = [...byType.entries()].sort(
(a, b) =>
b[1].reduce((sum, x) => sum + Number(x.row_count), 0) -
a[1].reduce((sum, x) => sum + Number(x.row_count), 0),
);
for (const [type, rows] of ranked) {
const band = VALUE_RANGES[type]!;
console.log(`${type} — declared band ${band.min}…${band.max}`);
for (const bucket of rows) {
const live = Number(bucket.row_count) - Number(bucket.tombstoned);
console.log(
` ${bucket.source.padEnd(14)} ` +
`${String(bucket.row_count).padStart(7)} row(s) ` +
`(${live} live, ${bucket.tombstoned} tombstoned) ` +
`across ${bucket.record_count} record(s) — ` +
`observed ${bucket.min_value}…${bucket.max_value}, ` +
`${ymd(bucket.first_at)} → ${ymd(bucket.last_at)}`,
);
}
}
console.log(
`\nThe sync writers refuse these values now, so the population above is ` +
`closed unless a writer without the gate is still in play. Removing a ` +
`row is a maintainer decision, per bucket.`,
);
}
main()
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(() => {
void prisma.$disconnect();
});
-103
View File
@@ -1,103 +0,0 @@
/**
* Read-only diagnostic for hand-entered sleep rows stored in the wrong unit.
*
* `SLEEP_DURATION` is stored in minutes. The manual entry field asked for
* hours and sent the typed number through unconverted, so a night entered as
* 7.5 was filed as seven and a half MINUTES. Nothing objected: a single sleep
* STAGE really can be that short, so the value sits inside the column's
* plausibility band and looks like an ordinary row.
*
* That ambiguity is why this reports and does not repair. A stage segment of
* eight minutes and a night typed as eight hours are the same number in the
* same column, and only the person who entered it knows which it was. What CAN
* be narrowed is the population: a MANUAL, whole-night row (no sleep stage) is
* one somebody typed into the field, and a typed night shorter than the
* longest plausible hours entry is almost certainly hours that were read as
* minutes.
*
* The output lists the candidates with their date, stored value and the night
* they would have been if the number was hours, so the maintainer can decide
* per row. Nothing is written.
*
* Run (inside the app container, which has DATABASE_URL):
* pnpm dlx tsx scripts/diagnose-manual-sleep-hours.ts
*/
import "dotenv/config";
import { prisma } from "@/lib/db";
import { MINUTES_PER_HOUR } from "@/lib/measurements/entry-units";
/**
* Longest night anybody types into an hours field. A stored value at or below
* this reads as hours; above it, the number can only ever have been minutes.
*/
const MAX_PLAUSIBLE_HOURS = 24;
async function main(): Promise<void> {
const suspects = await prisma.measurement.findMany({
where: {
type: "SLEEP_DURATION",
source: "MANUAL",
deletedAt: null,
// Whole-night rows only — a staged row comes from a sync source, never
// from the entry field.
sleepStage: null,
value: { gt: 0, lte: MAX_PLAUSIBLE_HOURS },
},
orderBy: [{ userId: "asc" }, { measuredAt: "asc" }],
select: {
id: true,
userId: true,
value: true,
measuredAt: true,
createdAt: true,
},
});
if (suspects.length === 0) {
console.log(
"No hand-entered sleep rows in the hours-shaped band. Nothing to review.",
);
return;
}
const byUser = new Map<string, typeof suspects>();
for (const row of suspects) {
const bucket = byUser.get(row.userId);
if (bucket) bucket.push(row);
else byUser.set(row.userId, [row]);
}
console.log(
`${suspects.length} hand-entered sleep row(s) across ${byUser.size} record(s) ` +
`store a value of ${MAX_PLAUSIBLE_HOURS} or less.\n` +
`Each is either a very short recorded sleep, or hours that were read as minutes.\n`,
);
for (const [userId, rows] of byUser) {
console.log(`record ${userId} — ${rows.length} row(s)`);
for (const row of rows) {
const asHours = row.value * MINUTES_PER_HOUR;
console.log(
` ${row.measuredAt.toISOString().slice(0, 10)} id=${row.id} ` +
`stored=${row.value} min if it was hours=${asHours} min ` +
`(${(asHours / MINUTES_PER_HOUR).toFixed(2)} h) ` +
`entered=${row.createdAt.toISOString().slice(0, 10)}`,
);
}
console.log("");
}
console.log(
"Nothing was changed. Correct a row from the record list, or delete and re-enter it.",
);
}
main()
.catch((error) => {
console.error(error);
process.exitCode = 1;
})
.finally(() => {
void prisma.$disconnect();
});
@@ -1,76 +0,0 @@
/**
* Read-only diagnostic for measurement rows stored in a NON-canonical unit.
*
* Canonical storage is SI: every write path stamps `getUnitForType(type)`. The
* one historical leak was the MCP `log_measurement` tool, which persisted a
* caller-supplied `unit` verbatim (closed in v1.32.26). A row whose `unit`
* column differs from its type's canonical unit is such a legacy row, and it
* mislabels every display surface that trusts the column.
*
* This groups measurements by `(type, unit)` and prints every group whose
* `unit` is not the canonical unit for its type, flagging whether the stored
* unit is a RECOGNISED alias (mechanically convertible by the companion
* convert script) or UNKNOWN (needs a manual look). Nothing is written.
*
* NOT auto-fixable and deliberately out of scope: a row whose stored unit IS
* already canonical but whose VALUE was a foreign unit (e.g. a lb reading typed
* into the old kg-labelled entry form). Those are indistinguishable from real
* canonical readings; the reporter edits them via the record list.
*
* Run (inside the app container, which has DATABASE_URL):
* pnpm dlx tsx scripts/diagnose-noncanonical-measurement-units.ts
*/
import "dotenv/config";
import { prisma } from "@/lib/db";
import { getUnitForType } from "@/lib/validations/measurement";
import { resolveToCanonicalUnit } from "@/lib/measurements/unit-aliases";
async function main(): Promise<void> {
const groups = await prisma.measurement.groupBy({
by: ["type", "unit"],
_count: { _all: true },
});
const mismatches = groups.filter((g) => {
if (g.unit === null) return false;
return g.unit !== getUnitForType(g.type);
});
if (mismatches.length === 0) {
console.log("No non-canonical measurement units found. Storage is clean.");
return;
}
let convertible = 0;
let unknown = 0;
console.log(
`Found ${mismatches.length} non-canonical (type, unit) group(s):\n`,
);
for (const g of mismatches.sort((a, b) => b._count._all - a._count._all)) {
const canonical = getUnitForType(g.type);
// Probe convertibility with a neutral value — only the recognition matters.
const recognised = resolveToCanonicalUnit(g.type, 1, g.unit as string);
const status = recognised ? "CONVERTIBLE" : "UNKNOWN — manual review";
if (recognised) convertible += g._count._all;
else unknown += g._count._all;
console.log(
` ${g.type}: stored="${g.unit}" canonical="${canonical}" ` +
`rows=${g._count._all} [${status}]`,
);
}
console.log(
`\nTotals: ${convertible} row(s) convertible, ${unknown} row(s) unknown.`,
);
console.log(
"Convert the recognised aliases with:\n" +
" pnpm dlx tsx scripts/convert-noncanonical-measurement-units.ts --execute",
);
}
main()
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* One-shot helper for a text sweep across the six locale bundles.
* One-shot helper for a text sweep across every locale bundle.
*
* node scripts/i18n-key-tool.mjs drop <dotted.key> [...]
* node scripts/i18n-key-tool.mjs set <locale> <dotted.key> <value>
-404
View File
@@ -1,404 +0,0 @@
#!/usr/bin/env python3
"""
v1.4.25 W9e — build messages/{fr,es,it,pl}.json from messages/en.json.
These are AI-initial translations. A MaintainershipBanner runs at the
top of /auth-shell for non-maintained locales, advertising the AI
provenance and linking to the GitHub translation-feedback template.
We translate via a layered lookup:
1. Full-path overrides (e.g. "auth.email" -> precise FR)
2. Last-segment lookups (covers Save/Cancel/Delete/etc.)
3. Whole-string lookups (covers common short phrases)
4. Token-by-token translation for sentences
5. Fallback: keep the English value verbatim (the banner says so)
Placeholders ({foo}, {bar}, <tag>) are preserved verbatim — the
translator never touches text inside curly braces or angle brackets.
Run: python3 scripts/i18n/build-locale.py
"""
import json
import os
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
MESSAGES = ROOT / "messages"
EN = json.loads((MESSAGES / "en.json").read_text(encoding="utf-8"))
# ---------------------------------------------------------------------------
# Core UI vocabulary — high-confidence translations for the most-used
# short strings. Each entry is keyed on the lowercase English form (with
# trailing punctuation/ellipsis stripped) so a "Save" / "save" / "Save..."
# all hit the same row.
# ---------------------------------------------------------------------------
# Format: english_lower -> { fr, es, it, pl }
COMMON_VOCAB: dict[str, dict[str, str]] = {
# Buttons / actions
"save": {"fr": "Enregistrer", "es": "Guardar", "it": "Salva", "pl": "Zapisz"},
"saved": {"fr": "Enregistré", "es": "Guardado", "it": "Salvato", "pl": "Zapisano"},
"cancel": {"fr": "Annuler", "es": "Cancelar", "it": "Annulla", "pl": "Anuluj"},
"delete": {"fr": "Supprimer", "es": "Eliminar", "it": "Elimina", "pl": "Usuń"},
"edit": {"fr": "Modifier", "es": "Editar", "it": "Modifica", "pl": "Edytuj"},
"create": {"fr": "Créer", "es": "Crear", "it": "Crea", "pl": "Utwórz"},
"close": {"fr": "Fermer", "es": "Cerrar", "it": "Chiudi", "pl": "Zamknij"},
"confirm": {"fr": "Confirmer", "es": "Confirmar", "it": "Conferma", "pl": "Potwierdź"},
"back": {"fr": "Retour", "es": "Atrás", "it": "Indietro", "pl": "Wstecz"},
"next": {"fr": "Suivant", "es": "Siguiente", "it": "Avanti", "pl": "Dalej"},
"yes": {"fr": "Oui", "es": "Sí", "it": "Sì", "pl": "Tak"},
"no": {"fr": "Non", "es": "No", "it": "No", "pl": "Nie"},
"search": {"fr": "Rechercher", "es": "Buscar", "it": "Cerca", "pl": "Szukaj"},
"filter": {"fr": "Filtrer", "es": "Filtrar", "it": "Filtra", "pl": "Filtruj"},
"add": {"fr": "Ajouter", "es": "Añadir", "it": "Aggiungi", "pl": "Dodaj"},
"import": {"fr": "Importer", "es": "Importar", "it": "Importa", "pl": "Importuj"},
"export": {"fr": "Exporter", "es": "Exportar", "it": "Esporta", "pl": "Eksportuj"},
"reset": {"fr": "Réinitialiser", "es": "Restablecer", "it": "Reimposta", "pl": "Resetuj"},
"test": {"fr": "Tester", "es": "Probar", "it": "Verifica", "pl": "Testuj"},
"copy": {"fr": "Copier", "es": "Copiar", "it": "Copia", "pl": "Kopiuj"},
"retry": {"fr": "Réessayer", "es": "Reintentar", "it": "Riprova", "pl": "Spróbuj ponownie"},
"replace": {"fr": "Remplacer", "es": "Reemplazar", "it": "Sostituisci", "pl": "Zastąp"},
"loading": {"fr": "Chargement…", "es": "Cargando…", "it": "Caricamento…", "pl": "Ładowanie…"},
"loading...": {"fr": "Chargement…", "es": "Cargando…", "it": "Caricamento…", "pl": "Ładowanie…"},
"error": {"fr": "Erreur", "es": "Error", "it": "Errore", "pl": "Błąd"},
"success": {"fr": "Succès", "es": "Éxito", "it": "Successo", "pl": "Sukces"},
"unknown": {"fr": "Inconnu", "es": "Desconocido", "it": "Sconosciuto", "pl": "Nieznane"},
"unknown error": {"fr": "Erreur inconnue", "es": "Error desconocido", "it": "Errore sconosciuto", "pl": "Nieznany błąd"},
"network error": {"fr": "Erreur réseau", "es": "Error de red", "it": "Errore di rete", "pl": "Błąd sieci"},
"no data available": {"fr": "Aucune donnée disponible", "es": "Sin datos disponibles", "it": "Nessun dato disponibile", "pl": "Brak danych"},
"all": {"fr": "Tout", "es": "Todo", "it": "Tutto", "pl": "Wszystko"},
"active": {"fr": "Actif", "es": "Activo", "it": "Attivo", "pl": "Aktywne"},
"inactive": {"fr": "Inactif", "es": "Inactivo", "it": "Inattivo", "pl": "Nieaktywne"},
"enabled": {"fr": "Activé", "es": "Activado", "it": "Abilitato", "pl": "Włączone"},
"disabled": {"fr": "Désactivé", "es": "Desactivado", "it": "Disabilitato", "pl": "Wyłączone"},
"optional": {"fr": "Facultatif", "es": "Opcional", "it": "Opzionale", "pl": "Opcjonalne"},
"required": {"fr": "Requis", "es": "Obligatorio", "it": "Obbligatorio", "pl": "Wymagane"},
"or": {"fr": "ou", "es": "o", "it": "o", "pl": "lub"},
"not logged in": {"fr": "Non connecté", "es": "No conectado", "it": "Non connesso", "pl": "Niezalogowany"},
"access denied": {"fr": "Accès refusé", "es": "Acceso denegado", "it": "Accesso negato", "pl": "Brak dostępu"},
"copied!": {"fr": "Copié !", "es": "¡Copiado!", "it": "Copiato!", "pl": "Skopiowano!"},
"show password": {"fr": "Afficher le mot de passe", "es": "Mostrar contraseña", "it": "Mostra password", "pl": "Pokaż hasło"},
"hide password": {"fr": "Masquer le mot de passe", "es": "Ocultar contraseña", "it": "Nascondi password", "pl": "Ukryj hasło"},
"more options": {"fr": "Plus d'options", "es": "Más opciones", "it": "Altre opzioni", "pl": "Więcej opcji"},
"copy details": {"fr": "Copier les détails", "es": "Copiar detalles", "it": "Copia dettagli", "pl": "Kopiuj szczegóły"},
"report issue": {"fr": "Signaler un problème", "es": "Reportar problema", "it": "Segnala un problema", "pl": "Zgłoś problem"},
# Auth
"log in": {"fr": "Se connecter", "es": "Iniciar sesión", "it": "Accedi", "pl": "Zaloguj się"},
"login": {"fr": "Connexion", "es": "Iniciar sesión", "it": "Accedi", "pl": "Zaloguj"},
"log out": {"fr": "Se déconnecter", "es": "Cerrar sesión", "it": "Disconnetti", "pl": "Wyloguj się"},
"logout": {"fr": "Déconnexion", "es": "Cerrar sesión", "it": "Disconnetti", "pl": "Wyloguj"},
"register": {"fr": "S'inscrire", "es": "Registrarse", "it": "Registrati", "pl": "Zarejestruj się"},
"sign in": {"fr": "Se connecter", "es": "Iniciar sesión", "it": "Accedi", "pl": "Zaloguj się"},
"sign up": {"fr": "S'inscrire", "es": "Registrarse", "it": "Registrati", "pl": "Zarejestruj się"},
"email": {"fr": "E-mail", "es": "Correo electrónico", "it": "E-mail", "pl": "E-mail"},
"password": {"fr": "Mot de passe", "es": "Contraseña", "it": "Password", "pl": "Hasło"},
"username": {"fr": "Nom d'utilisateur", "es": "Nombre de usuario", "it": "Nome utente", "pl": "Nazwa użytkownika"},
# Health terms (Apple Health / Withings vocabulary)
"blood pressure": {"fr": "Tension artérielle", "es": "Presión arterial", "it": "Pressione arteriosa", "pl": "Ciśnienie krwi"},
"pulse": {"fr": "Pouls", "es": "Pulso", "it": "Polso", "pl": "Tętno"},
"weight": {"fr": "Poids", "es": "Peso", "it": "Peso", "pl": "Waga"},
"mood": {"fr": "Humeur", "es": "Estado de ánimo", "it": "Umore", "pl": "Nastrój"},
"sleep": {"fr": "Sommeil", "es": "Sueño", "it": "Sonno", "pl": "Sen"},
"steps": {"fr": "Pas", "es": "Pasos", "it": "Passi", "pl": "Kroki"},
"medications": {"fr": "Médicaments", "es": "Medicamentos", "it": "Farmaci", "pl": "Leki"},
"medication": {"fr": "Médicament", "es": "Medicamento", "it": "Farmaco", "pl": "Lek"},
"height": {"fr": "Taille", "es": "Altura", "it": "Altezza", "pl": "Wzrost"},
"bmi": {"fr": "IMC", "es": "IMC", "it": "IMC", "pl": "BMI"},
"heart rate": {"fr": "Fréquence cardiaque", "es": "Frecuencia cardíaca", "it": "Frequenza cardiaca", "pl": "Tętno"},
"resting heart rate": {"fr": "Fréquence cardiaque au repos", "es": "Frecuencia cardíaca en reposo", "it": "Frequenza cardiaca a riposo", "pl": "Tętno spoczynkowe"},
"body fat": {"fr": "Masse grasse", "es": "Grasa corporal", "it": "Massa grassa", "pl": "Tkanka tłuszczowa"},
# Nav
"dashboard": {"fr": "Tableau de bord", "es": "Panel", "it": "Cruscotto", "pl": "Pulpit"},
"insights": {"fr": "Analyses", "es": "Análisis", "it": "Analisi", "pl": "Statystyki"},
"settings": {"fr": "Paramètres", "es": "Ajustes", "it": "Impostazioni", "pl": "Ustawienia"},
"profile": {"fr": "Profil", "es": "Perfil", "it": "Profilo", "pl": "Profil"},
"trends": {"fr": "Tendances", "es": "Tendencias", "it": "Tendenze", "pl": "Trendy"},
"history": {"fr": "Historique", "es": "Historial", "it": "Cronologia", "pl": "Historia"},
"overview": {"fr": "Vue d'ensemble", "es": "Resumen", "it": "Panoramica", "pl": "Przegląd"},
"language": {"fr": "Langue", "es": "Idioma", "it": "Lingua", "pl": "Język"},
"timezone": {"fr": "Fuseau horaire", "es": "Zona horaria", "it": "Fuso orario", "pl": "Strefa czasowa"},
# Time periods
"today": {"fr": "Aujourd'hui", "es": "Hoy", "it": "Oggi", "pl": "Dzisiaj"},
"yesterday": {"fr": "Hier", "es": "Ayer", "it": "Ieri", "pl": "Wczoraj"},
"tomorrow": {"fr": "Demain", "es": "Mañana", "it": "Domani", "pl": "Jutro"},
"this week": {"fr": "Cette semaine", "es": "Esta semana", "it": "Questa settimana", "pl": "Ten tydzień"},
"this month": {"fr": "Ce mois-ci", "es": "Este mes", "it": "Questo mese", "pl": "Ten miesiąc"},
"last 7 days": {"fr": "7 derniers jours", "es": "Últimos 7 días", "it": "Ultimi 7 giorni", "pl": "Ostatnie 7 dni"},
"last 30 days": {"fr": "30 derniers jours", "es": "Últimos 30 días", "it": "Ultimi 30 giorni", "pl": "Ostatnie 30 dni"},
"last 90 days": {"fr": "90 derniers jours", "es": "Últimos 90 días", "it": "Ultimi 90 giorni", "pl": "Ostatnie 90 dni"},
"all time": {"fr": "Toute la période", "es": "Todo el tiempo", "it": "Tutto il tempo", "pl": "Cały okres"},
# Mood scale
"great": {"fr": "Excellent", "es": "Excelente", "it": "Ottimo", "pl": "Świetnie"},
"good": {"fr": "Bien", "es": "Bien", "it": "Bene", "pl": "Dobrze"},
"neutral": {"fr": "Neutre", "es": "Neutral", "it": "Neutro", "pl": "Neutralnie"},
"bad": {"fr": "Mauvais", "es": "Mal", "it": "Male", "pl": "Źle"},
"awful": {"fr": "Terrible", "es": "Terrible", "it": "Pessimo", "pl": "Okropnie"},
# Misc
"date": {"fr": "Date", "es": "Fecha", "it": "Data", "pl": "Data"},
"time": {"fr": "Heure", "es": "Hora", "it": "Ora", "pl": "Czas"},
"value": {"fr": "Valeur", "es": "Valor", "it": "Valore", "pl": "Wartość"},
"type": {"fr": "Type", "es": "Tipo", "it": "Tipo", "pl": "Typ"},
"note": {"fr": "Note", "es": "Nota", "it": "Nota", "pl": "Notatka"},
"notes": {"fr": "Notes", "es": "Notas", "it": "Note", "pl": "Notatki"},
"name": {"fr": "Nom", "es": "Nombre", "it": "Nome", "pl": "Nazwa"},
"description": {"fr": "Description", "es": "Descripción", "it": "Descrizione", "pl": "Opis"},
"actions": {"fr": "Actions", "es": "Acciones", "it": "Azioni", "pl": "Akcje"},
"status": {"fr": "Statut", "es": "Estado", "it": "Stato", "pl": "Status"},
"total": {"fr": "Total", "es": "Total", "it": "Totale", "pl": "Łącznie"},
"average": {"fr": "Moyenne", "es": "Promedio", "it": "Media", "pl": "Średnia"},
"min": {"fr": "Min", "es": "Mín", "it": "Min", "pl": "Min"},
"max": {"fr": "Max", "es": "Máx", "it": "Max", "pl": "Maks"},
"from": {"fr": "De", "es": "Desde", "it": "Da", "pl": "Od"},
"to": {"fr": "À", "es": "Hasta", "it": "A", "pl": "Do"},
"of": {"fr": "de", "es": "de", "it": "di", "pl": "z"},
# Charts
"chart": {"fr": "Graphique", "es": "Gráfico", "it": "Grafico", "pl": "Wykres"},
"trend": {"fr": "Tendance", "es": "Tendencia", "it": "Tendenza", "pl": "Trend"},
"compare": {"fr": "Comparer", "es": "Comparar", "it": "Confronta", "pl": "Porównaj"},
"comparison": {"fr": "Comparaison", "es": "Comparación", "it": "Confronto", "pl": "Porównanie"},
# Doctor report
"patient": {"fr": "Patient", "es": "Paciente", "it": "Paziente", "pl": "Pacjent"},
"gender": {"fr": "Sexe", "es": "Sexo", "it": "Sesso", "pl": "Płeć"},
"male": {"fr": "Homme", "es": "Hombre", "it": "Uomo", "pl": "Mężczyzna"},
"female": {"fr": "Femme", "es": "Mujer", "it": "Donna", "pl": "Kobieta"},
"non-binary": {"fr": "Non-binaire", "es": "No binario", "it": "Non binario", "pl": "Niebinarna"},
"date of birth": {"fr": "Date de naissance", "es": "Fecha de nacimiento", "it": "Data di nascita", "pl": "Data urodzenia"},
# GLP-1
"glp-1 therapy": {"fr": "Traitement GLP-1", "es": "Terapia GLP-1", "it": "Terapia GLP-1", "pl": "Terapia GLP-1"},
"current dose": {"fr": "Dose actuelle", "es": "Dosis actual", "it": "Dose attuale", "pl": "Aktualna dawka"},
"compliance": {"fr": "Observance", "es": "Cumplimiento", "it": "Aderenza", "pl": "Przestrzeganie zaleceń"},
"compliance rate": {"fr": "Taux d'observance", "es": "Tasa de cumplimiento", "it": "Tasso di aderenza", "pl": "Wskaźnik przestrzegania"},
"taken": {"fr": "Pris", "es": "Tomada", "it": "Assunta", "pl": "Przyjęte"},
"skipped": {"fr": "Sauté", "es": "Omitida", "it": "Saltata", "pl": "Pominięte"},
"missed": {"fr": "Manqué", "es": "Perdida", "it": "Persa", "pl": "Pominięte"},
"side effect": {"fr": "Effet secondaire", "es": "Efecto secundario", "it": "Effetto collaterale", "pl": "Skutek uboczny"},
"dose": {"fr": "Dose", "es": "Dosis", "it": "Dose", "pl": "Dawka"},
# Brand/identity
"healthlog": {"fr": "HealthLog", "es": "HealthLog", "it": "HealthLog", "pl": "HealthLog"},
# Settings sections
"account": {"fr": "Compte", "es": "Cuenta", "it": "Account", "pl": "Konto"},
"appearance": {"fr": "Apparence", "es": "Apariencia", "it": "Aspetto", "pl": "Wygląd"},
"notifications": {"fr": "Notifications", "es": "Notificaciones", "it": "Notifiche", "pl": "Powiadomienia"},
"integrations": {"fr": "Intégrations", "es": "Integraciones", "it": "Integrazioni", "pl": "Integracje"},
"data": {"fr": "Données", "es": "Datos", "it": "Dati", "pl": "Dane"},
"privacy": {"fr": "Confidentialité", "es": "Privacidad", "it": "Privacy", "pl": "Prywatność"},
"security": {"fr": "Sécurité", "es": "Seguridad", "it": "Sicurezza", "pl": "Bezpieczeństwo"},
"theme": {"fr": "Thème", "es": "Tema", "it": "Tema", "pl": "Motyw"},
"light": {"fr": "Clair", "es": "Claro", "it": "Chiaro", "pl": "Jasny"},
"dark": {"fr": "Sombre", "es": "Oscuro", "it": "Scuro", "pl": "Ciemny"},
"system": {"fr": "Système", "es": "Sistema", "it": "Sistema", "pl": "Systemowy"},
# Onboarding
"welcome": {"fr": "Bienvenue", "es": "Bienvenido", "it": "Benvenuto", "pl": "Witamy"},
"get started": {"fr": "Commencer", "es": "Empezar", "it": "Inizia", "pl": "Rozpocznij"},
"continue": {"fr": "Continuer", "es": "Continuar", "it": "Continua", "pl": "Kontynuuj"},
"skip": {"fr": "Ignorer", "es": "Omitir", "it": "Salta", "pl": "Pomiń"},
"finish": {"fr": "Terminer", "es": "Finalizar", "it": "Termina", "pl": "Zakończ"},
"done": {"fr": "Terminé", "es": "Hecho", "it": "Fatto", "pl": "Gotowe"},
# Errors
"page not found": {"fr": "Page introuvable", "es": "Página no encontrada", "it": "Pagina non trovata", "pl": "Strona nie znaleziona"},
"something went wrong": {"fr": "Une erreur est survenue", "es": "Algo salió mal", "it": "Si è verificato un errore", "pl": "Coś poszło nie tak"},
}
# Full-path overrides for highly contextual strings
PATH_OVERRIDES: dict[str, dict[str, str]] = {
# doctorReport core
"doctorReport.title": {
"fr": "Rapport de santé",
"es": "Informe de salud",
"it": "Rapporto sanitario",
"pl": "Raport zdrowotny",
},
"doctorReport.subtitle": {
"fr": "HealthLog — Rapport personnel de santé",
"es": "HealthLog — Informe personal de salud",
"it": "HealthLog — Rapporto personale di salute",
"pl": "HealthLog — Osobisty raport zdrowotny",
},
"doctorReport.period": {
"fr": "Période du rapport",
"es": "Período del informe",
"it": "Periodo del rapporto",
"pl": "Okres raportu",
},
"doctorReport.createdOn": {
"fr": "Créé le",
"es": "Creado el",
"it": "Creato il",
"pl": "Utworzono",
},
"doctorReport.vitalsTitle": {
"fr": "Signes vitaux — Résumé",
"es": "Signos vitales — Resumen",
"it": "Parametri vitali — Riepilogo",
"pl": "Parametry życiowe — Podsumowanie",
},
"doctorReport.footerDisclaimer1": {
"fr": "Ce rapport a été généré automatiquement à partir de données auto-déclarées et vise à soutenir",
"es": "Este informe se generó automáticamente a partir de datos autorregistrados y está destinado a apoyar",
"it": "Questo rapporto è stato generato automaticamente da dati auto-registrati ed è destinato a supportare",
"pl": "Ten raport został wygenerowany automatycznie z danych samodzielnie rejestrowanych i ma na celu wspierać",
},
"doctorReport.footerDisclaimer2": {
"fr": "la conversation médecin-patient. Il ne remplace pas un diagnostic médical. Les corrélations n'impliquent pas la causalité.",
"es": "la conversación entre médico y paciente. No sustituye un diagnóstico médico. Las correlaciones no implican causalidad.",
"it": "il dialogo medico-paziente. Non sostituisce una diagnosi medica. Le correlazioni non implicano causalità.",
"pl": "rozmowę lekarza z pacjentem. Nie zastępuje diagnozy medycznej. Korelacje nie oznaczają związku przyczynowego.",
},
"doctorReport.footerSource": {
"fr": "Source : HealthLog | Fuseau horaire : Europe/Berlin | Créé : {timestamp}",
"es": "Fuente: HealthLog | Zona horaria: Europe/Berlin | Creado: {timestamp}",
"it": "Fonte: HealthLog | Fuso orario: Europe/Berlin | Creato: {timestamp}",
"pl": "Źródło: HealthLog | Strefa czasowa: Europe/Berlin | Utworzono: {timestamp}",
},
# Maintainership banner — referenced by the React component
"i18n.maintainershipBanner.notice": {
"fr": "Cette traduction est maintenue par IA et peut contenir des imprécisions.",
"es": "Esta traducción está mantenida por IA y puede contener imprecisiones.",
"it": "Questa traduzione è gestita tramite IA e potrebbe contenere imprecisioni.",
"pl": "To tłumaczenie jest utrzymywane przez AI i może zawierać nieścisłości.",
},
"i18n.maintainershipBanner.cta": {
"fr": "Aidez-nous sur GitHub",
"es": "Ayúdanos en GitHub",
"it": "Aiutaci su GitHub",
"pl": "Pomóż na GitHubie",
},
"i18n.maintainershipBanner.dismiss": {
"fr": "Ignorer",
"es": "Cerrar",
"it": "Chiudi",
"pl": "Zamknij",
},
}
# Last-segment fallback: many keys named after the EN word ("save",
# "cancel", "close", "delete"). When the value is just the capitalised
# / titlecased English version, we can translate by the EN value lookup.
PLACEHOLDER_RE = re.compile(r"(\{[^}]+\}|<[^>]+>|`[^`]+`)")
def split_protected(s: str) -> list[tuple[str, bool]]:
"""Split a string into (chunk, is_protected) parts. Protected chunks
are placeholders/tags that must NEVER be translated."""
parts: list[tuple[str, bool]] = []
last = 0
for m in PLACEHOLDER_RE.finditer(s):
if m.start() > last:
parts.append((s[last : m.start()], False))
parts.append((m.group(0), True))
last = m.end()
if last < len(s):
parts.append((s[last:], False))
return parts
def translate_chunk(text: str, locale: str) -> str:
"""Apply COMMON_VOCAB to a free-text chunk. Lowercase lookup; preserves
the original capitalisation of the FIRST translated word."""
if not text.strip():
return text
# Whole-string lookup first
key = text.strip().lower().rstrip(".!?:,;")
if key in COMMON_VOCAB and locale in COMMON_VOCAB[key]:
translated = COMMON_VOCAB[key][locale]
# Preserve leading whitespace + trailing punctuation
leading = text[: len(text) - len(text.lstrip())]
trailing_punct = ""
stripped = text.strip()
while stripped and stripped[-1] in ".!?:,;":
trailing_punct = stripped[-1] + trailing_punct
stripped = stripped[:-1]
return f"{leading}{translated}{trailing_punct}"
# No translation — return verbatim
return text
def translate_value(value: str, locale: str, full_path: str) -> str:
"""Translate one leaf value to the target locale.
Priority:
1. PATH_OVERRIDES exact match
2. Whole-value lookup in COMMON_VOCAB
3. Verbatim English (banner advertises this)
"""
# Empty / whitespace
if not value.strip():
return value
# 1. Path override
if full_path in PATH_OVERRIDES and locale in PATH_OVERRIDES[full_path]:
return PATH_OVERRIDES[full_path][locale]
# 2. Whole-value lookup with placeholder protection
parts = split_protected(value)
out: list[str] = []
for chunk, protected in parts:
if protected:
out.append(chunk)
else:
out.append(translate_chunk(chunk, locale))
return "".join(out)
def walk_and_translate(node, locale: str, prefix: str = "") -> object:
if isinstance(node, dict):
return {k: walk_and_translate(v, locale, f"{prefix}.{k}" if prefix else k) for k, v in node.items()}
if isinstance(node, str):
return translate_value(node, locale, prefix)
return node
def main() -> int:
if len(sys.argv) > 1:
targets = sys.argv[1:]
else:
targets = ["fr", "es", "it", "pl"]
for locale in targets:
out = walk_and_translate(EN, locale)
out_path = MESSAGES / f"{locale}.json"
# Preserve EN's pretty-print style (2-space indent, sorted? — EN is not sorted)
# We mirror EN's literal indent + key order so diffs stay reviewable.
out_path.write_text(
json.dumps(out, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
# Verify key parity
new = json.loads(out_path.read_text(encoding="utf-8"))
en_keys, new_keys = set(), set()
def flat(o, p, s):
if isinstance(o, dict):
for k, v in o.items():
flat(v, f"{p}.{k}" if p else k, s)
elif isinstance(o, str):
s.add(p)
flat(EN, "", en_keys)
flat(new, "", new_keys)
only_en = en_keys - new_keys
only_new = new_keys - en_keys
print(f" {locale}.json: {len(new_keys)} leaf keys; only_en={len(only_en)}; only_new={len(only_new)}")
if only_en or only_new:
print(" PARITY MISMATCH!", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -1,28 +1,30 @@
# migrate-demo-edge01.sh
# migrate-demo-account.sh
Copies **exactly one** user — the demo account
`usr_demo_cf31025295714ece8d91f5af13afd76d` — from the production apps01
Postgres to the **separate** edge01 demo Postgres. No other user's rows are
transferred under any circumstance.
Copies **exactly one** user, the demo account named by `DEMO_USER_ID`, from a
source instance's Postgres to the **separate** Postgres of a demo instance. No
other user's rows are transferred under any circumstance.
> Review the script first, then run it deliberately. It performs destructive,
> demo-scoped deletes on the edge01 demo DB, then loads the apps01 demo slice.
> It never writes to apps01 (source is read-only).
> demo-scoped deletes on the target demo DB, then loads the demo slice from the
> source. It never writes to the source (read-only).
## How it connects
- **Source (apps01):** SSH alias `apps-01`, container `db-pg8wggwogo8c4gc4ks0kk4ss`
(stable), psql `healthlog`/`healthlog`.
- **Target (edge01):** SSH alias `edge-01`. The demo Postgres is a separate
Coolify resource whose container name is **not hardcoded** — the script
resolves it at runtime via `docker ps | grep -iE 'postgres|^db-'` and aborts
if it cannot pick a single match (override with `DST_DB_CONTAINER=db-xxxx`).
The demo **app** uuid is `ck8cs4osswg8w440gskw08w8`; match its linked
Postgres resource, not the docs/landing DB.
- **Edge demo user id is resolved at runtime** by `username='demo'` (it may
differ from the apps01 id). The wipe phase is scoped to that resolved id; the
load then brings the apps01 row in, so afterwards the edge01 demo user
carries the apps01 canonical id.
Everything instance-specific comes from the environment:
- `DEMO_USER_ID`: the user id to copy. `PROTECT_USER_ID`: an id that must
never appear on the target (the operator's own account).
- `SRC_SSH` and `SRC_DB_CONTAINER_PATTERN`: the source host's ssh alias and a
`grep -E` pattern for its database container; psql `healthlog`/`healthlog`.
- `DST_SSH`: the target host's ssh alias. The demo Postgres container is
**not hardcoded**: the script resolves it at runtime via
`docker ps | grep -iE 'postgres|^db-'` and aborts if it cannot pick a single
match (override with `DST_DB_CONTAINER=db-xxxx`). Match the demo app's own
Postgres, not a docs or landing-page database on the same host.
- **The target demo user id is resolved at runtime** by `username='demo'` (it
may differ from the source id). The wipe phase is scoped to that resolved id;
the load then brings the source row in, so afterwards the target demo user
carries the source's canonical id.
Each table is transferred with a scoped, server-side `\copy ... TO STDOUT`
piped host-to-host into `\copy ... FROM STDIN`, using **explicit column lists
@@ -97,9 +99,9 @@ acknowledgement).
`*_oauth_states` / `*_connect_tickets`, telegram scheduled-deletions +
reminder messages, clinician_share_links, provider_health) — tied to live
credentials.
- **Encrypted-at-rest payloads we can't decrypt on edge01:**
- **Encrypted-at-rest payloads the target cannot decrypt:**
`insight_narratives` and all `coach_*` (coach_conversations, coach_messages,
coach_facts, coach_usage). The edge01 instance does not hold the prod
coach_facts, coach_usage). The target does not hold the source's
encryption key, so the AES-256-GCM ciphertext would be unreadable. The same
reason drives NULLing `cycle_day_logs.sensitive_encrypted` /
`notes_encrypted` while still carrying that table's plaintext cycle fields.
@@ -109,16 +111,16 @@ acknowledgement).
import_jobs, host_metrics, feedback, recommendation_feedback, and all rollup
tables (measurement_rollups, mood_entry_rollups, strain_trimp_cache,
cycle_predictions) — these
rebuild on edge01.
rebuild on the target.
- **No rows:** workouts / workout_routes / workout_samples (demo has none).
- **Instance / global:** app_settings; global catalogues (mood_tags,
cycle_symptoms) — these pre-exist on edge01 and the carried link rows
cycle_symptoms) pre-exist on the target and the carried link rows
reference the global ids only.
## Encryption note
The edge01 demo instance is assumed **not** to share the apps01 encryption
key. Every column whose value is AES-256-GCM ciphertext under the prod key is
The demo instance is assumed **not** to share the source's encryption key.
Every column whose value is AES-256-GCM ciphertext under the source key is
either NULLed (secrets on `users`, `cycle_day_logs.*_encrypted`) or skipped
entirely (`insight_narratives`, `coach_messages.encryptedContent`). Carrying
that ciphertext would produce undecryptable rows that the app's fail-closed
@@ -128,38 +130,37 @@ loader would reject.
The demo user is loaded with `role='ADMIN'` so the admin surfaces are
viewable in the demo. This is **only safe because DEMO_MODE must stay ON** on
edge01 — the proxy layer (`src/proxy.ts`) blocks every mutating request in
the target: the proxy layer (`src/proxy.ts`) blocks every mutating request in
demo mode, so an admin demo session can browse admin pages but cannot change
anything. If DEMO_MODE were ever off on edge01, this admin demo account would
anything. If DEMO_MODE were ever off on the target, this admin demo account would
be a write-capable admin. Keep DEMO_MODE enabled.
## Safety mechanisms built into the script
- `set -euo pipefail` and `ON_ERROR_STOP=1` on every psql call.
- **Pre-flight guard (apps01):** for each carried set, a query that must
- **Pre-flight guard (source):** for each carried set, a query that must
return `0` foreign-owned rows — aborts on any non-zero. Includes a check
that the demo owns **0 custom mood_tags** (so every carried
`mood_entry_tag_link` points at a global tag that pre-exists on edge01) plus
`mood_entry_tag_link` points at a global tag that pre-exists on the target) plus
a defensive "non-global tag" link count.
- **Scoped wipe-first (edge01):** FK-child-first deletes, every one scoped to
the runtime-resolved edge01 demo id. No unscoped delete anywhere. Refuses to
proceed if `username='demo'` on edge01 resolves to the operator id.
- **Post-load verification (edge01):** per-table demo row counts; a hard
assertion that the operator id `cmlupy4tn000001rpzx1pxvz7` appears in zero
rows; a check that user_id-bearing carried tables hold only the demo id; and
- **Scoped wipe-first (target):** FK-child-first deletes, every one scoped to
the runtime-resolved target demo id. No unscoped delete anywhere. Refuses to
proceed if `username='demo'` on the target resolves to the operator id.
- **Post-load verification (target):** per-table demo row counts; a hard
assertion that `PROTECT_USER_ID` appears in zero rows; a check that user_id-bearing carried tables hold only the demo id; and
a confirmation the demo user exists with `role=ADMIN`.
## Caveats / things to confirm before running
- **`FOREIGN_HITS` assertion** counts _all_ non-demo rows in the carried
tables on edge01, not just what this run inserted. On a clean
single-demo-user DB it is `0`. If edge01 legitimately hosts other demo-only
tables on the target, not just what this run inserted. On a clean
single-demo-user DB it is `0`. If the target legitimately hosts other demo-only
accounts, treat a non-zero value as a warning to inspect, not an automatic
failure (the script prints it as a WARNING, not an abort).
- Confirm edge01 psql credentials are `healthlog`/`healthlog` (the script
- Confirm the target's psql credentials are `healthlog`/`healthlog` (the script
verifies connectivity before any write; adjust `PG_USER` / `PG_DB` if the
demo Postgres resource uses different ones).
- The script assumes edge01's schema is at the same (or a compatible) Prisma
migration level as apps01. A column present on apps01 but missing on edge01
fails the `\copy` loudly rather than silently corrupting data — run
migrations on edge01 first if the two have drifted.
- The script assumes the target's schema is at the same (or a compatible)
Prisma migration level as the source. A column present on the source but
missing on the target fails the `\copy` loudly rather than silently
corrupting data; run migrations on the target first if the two have drifted.
@@ -1,14 +1,14 @@
#!/usr/bin/env bash
#
# migrate-demo-edge01.sh — copy EXACTLY ONE user (the demo account) from the
# production apps01 Postgres to the SEPARATE edge01 demo Postgres.
# migrate-demo-account.sh: copy EXACTLY ONE user (the demo account) from the
# source Postgres to the SEPARATE target demo Postgres.
#
# ============================================================================
# WHAT THIS SCRIPT DOES (and the isolation guarantees it makes)
# ============================================================================
#
# It transfers a single, demo-scoped slice of health data so the public demo
# instance on edge01 shows a realistic-looking account WITHOUT carrying any
# instance on the target shows a realistic-looking account WITHOUT carrying any
# real-operator data. The ONLY user copied is:
#
# $DEMO_USER_ID (the single demo account, supplied via env)
@@ -26,7 +26,7 @@
# 3. No secret / credential column is transferred. The users SELECT NULLs
# every encrypted / token / integration-secret column explicitly. The
# cycle-day-log SELECT NULLs the two app-encrypted columns (they are
# keyed to the prod encryption key, which is NOT present on edge01).
# keyed to the prod encryption key, which is NOT present on the target).
#
# The transfer mechanism per table is a scoped server-side COPY piped
# host-to-host:
@@ -42,12 +42,12 @@
# - an added/dropped column on either side fails loudly rather than
# silently shifting data into the wrong column.
#
# Tables load in FK-parent-first order. The edge01 demo rows are wiped first
# (FK-child-first), scoped to the edge01 demo user id resolved at runtime.
# Tables load in FK-parent-first order. The target demo rows are wiped first
# (FK-child-first), scoped to the target demo user id resolved at runtime.
#
# THIS SCRIPT IS FOR REVIEW. Read it, then run it deliberately. It performs
# DESTRUCTIVE deletes on the edge01 demo DB (scoped to the demo user) followed
# by inserts. It NEVER writes to apps01 (source is read-only).
# DESTRUCTIVE deletes on the target demo DB (scoped to the demo user) followed
# by inserts. It NEVER writes to the source (source is read-only).
#
# ----------------------------------------------------------------------------
# EXCLUDED ENTIRELY (intentionally NOT copied)
@@ -59,16 +59,16 @@
# fitbit_connections, all *_oauth_states / *_connect_tickets,
# telegram_scheduled_deletions, telegram_reminder_messages,
# clinician_share_links, provider_health.
# - Encrypted-at-rest payloads we cannot decrypt on edge01:
# - Encrypted-at-rest payloads we cannot decrypt on the target:
# insight_narratives, coach_messages (coach_* entirely).
# - Coach: coach_conversations, coach_messages, coach_facts, coach_usage.
# - Ops / transient / regenerable: idempotency_keys, rate_limits,
# data_backups, import_jobs, host_metrics, feedback,
# recommendation_feedback, all rollup tables (measurement_rollups,
# mood_entry_rollups, strain_trimp_cache, cycle_predictions) — these REGENERATE on edge01.
# mood_entry_rollups, strain_trimp_cache, cycle_predictions) — these REGENERATE on the target.
# - workouts / workout_routes / workout_samples — demo has none.
# - app_settings — instance-level, not user data.
# - Global catalogues (mood_tags, cycle_symptoms) — pre-exist on edge01;
# - Global catalogues (mood_tags, cycle_symptoms) — pre-exist on the target;
# carried link rows reference these GLOBAL ids only (see guards below).
#
set -euo pipefail
@@ -129,21 +129,21 @@ psql_src() {
# so psql_dst is defined after resolution.
echo "=============================================================="
echo " HealthLog demo-user migration apps01 -> edge01"
echo " HealthLog demo-user migration source -> target"
echo " demo user : $DEMO_USER_ID"
echo " src host : $SRC_SSH (container $SRC_DB_CONTAINER)"
echo " dst host : $DST_SSH (container resolved below)"
echo "=============================================================="
# ============================================================================
# RESOLVE the edge01 demo DB container (NOT hardcoded — it is a separate
# RESOLVE the target demo DB container (NOT hardcoded — it is a separate
# Coolify Postgres resource whose container name can change on redeploy).
# ============================================================================
echo
if [[ -n "${DST_DB_CONTAINER:-}" ]]; then
echo "## Using DST_DB_CONTAINER from env: $DST_DB_CONTAINER"
else
echo "## Resolving edge01 demo Postgres container ..."
echo "## Resolving target demo Postgres container ..."
DST_DB_CONTAINER="$(ssh "$DST_SSH" "docker ps --format '{{.Names}}' | grep -iE 'postgres|^db-' || true")"
echo "candidate containers on $DST_SSH:"
echo "$DST_DB_CONTAINER" | sed 's/^/ /'
@@ -159,7 +159,7 @@ else
DST_DB_CONTAINER="$(printf '%s\n' "$DST_DB_CONTAINER" | head -n1)"
fi
readonly DST_DB_CONTAINER
echo "resolved edge01 demo DB container: $DST_DB_CONTAINER"
echo "resolved target demo DB container: $DST_DB_CONTAINER"
# Confirm the psql user/db actually work on the resolved container before we
# touch anything (fail fast on a wrong container guess).
@@ -167,31 +167,31 @@ psql_dst() {
# Usage: psql_dst "<sql>"
ssh "$DST_SSH" "docker exec -i $DST_DB_CONTAINER psql -X -v ON_ERROR_STOP=1 -U $PG_USER -d $PG_DB $*"
}
echo "## Verifying edge01 DB connectivity ..."
echo "## Verifying target DB connectivity ..."
psql_dst "-c 'SELECT current_database(), current_user;'"
# ============================================================================
# RESOLVE the edge01 demo user id (it MAY differ from the apps01 id!).
# We resolve by username='demo'. After this migration the edge01 demo user
# will carry the apps01 canonical id (we delete the old row + load the apps01
# row). Scope EVERY wipe to whatever the edge01 id is right now.
# RESOLVE the target demo user id (it MAY differ from the source id!).
# We resolve by username='demo'. After this migration the target demo user
# will carry the source canonical id (we delete the old row + load the source
# row). Scope EVERY wipe to whatever the target id is right now.
# ============================================================================
echo
echo "## Resolving edge01 demo user id (by username='demo') ..."
EDGE_DEMO_ID="$(psql_dst "-tA -c \"SELECT id FROM users WHERE username = 'demo';\"" | tr -d '[:space:]')"
if [[ -z "$EDGE_DEMO_ID" ]]; then
echo "note: no existing demo user on edge01 (username='demo') — wipe phase is a no-op."
echo "## Resolving target demo user id (by username='demo') ..."
TARGET_DEMO_ID="$(psql_dst "-tA -c \"SELECT id FROM users WHERE username = 'demo';\"" | tr -d '[:space:]')"
if [[ -z "$TARGET_DEMO_ID" ]]; then
echo "note: no existing demo user on the target (username='demo') — wipe phase is a no-op."
else
echo "edge01 existing demo user id: $EDGE_DEMO_ID"
if [[ "$EDGE_DEMO_ID" == "$REAL_USER_ID" ]]; then
echo "ABORT: edge01 username='demo' resolves to the REAL operator id. Refusing."
echo "existing target demo user id: $TARGET_DEMO_ID"
if [[ "$TARGET_DEMO_ID" == "$REAL_USER_ID" ]]; then
echo "ABORT: target username='demo' resolves to the REAL operator id. Refusing."
exit 1
fi
fi
readonly EDGE_DEMO_ID
readonly TARGET_DEMO_ID
# ============================================================================
# PRE-FLIGHT GUARD (on apps01 source): assert every carried set is demo-only.
# PRE-FLIGHT GUARD (on the source): assert every carried set is demo-only.
# Each query MUST return 0. A non-zero result means a scope predicate is wrong
# and we would leak another user's rows — ABORT immediately.
# ============================================================================
@@ -243,13 +243,13 @@ preflight "mood_entry_tag_links(join)" \
# GUARD: mood_entry_tag_links must reference ONLY global mood_tags (user_id IS
# NULL on the tag). The demo is asserted to have 0 custom mood_tags; if a link
# referenced a per-user tag id, the FK would break on edge01 (that tag row is
# referenced a per-user tag id, the FK would break on the target (that tag row is
# not carried). Custom-tag count MUST be 0.
DEMO_CUSTOM_MOODTAGS="$(psql_src "-tA -c \"SELECT count(*) FROM mood_tags WHERE user_id = '$DEMO_USER_ID';\"" | tr -d '[:space:]')"
echo " demo custom mood_tags = $DEMO_CUSTOM_MOODTAGS (must be 0 — all links point to GLOBAL tags)"
if [[ "$DEMO_CUSTOM_MOODTAGS" != "0" ]]; then
echo "ABORT: demo user owns $DEMO_CUSTOM_MOODTAGS custom mood_tags. mood_entry_tag_links"
echo " would reference non-global tag ids that are NOT carried -> FK break on edge01."
echo " would reference non-global tag ids that are NOT carried -> FK break on the target."
echo " Carry mood_tags (user-scoped) before mood_entry_tag_links, or skip the links."
exit 1
fi
@@ -260,55 +260,55 @@ preflight "mood_entry_tag_links(non-global tag)" \
echo "## PRE-FLIGHT guard passed — every carried set is demo-only."
# ============================================================================
# WIPE-FIRST on edge01 (idempotent, demo-scoped ONLY). FK-child-first order.
# Every DELETE is scoped to the resolved edge01 demo user id ($EDGE_DEMO_ID)
# WIPE-FIRST on the target (idempotent, demo-scoped ONLY). FK-child-first order.
# Every DELETE is scoped to the resolved target demo user id ($TARGET_DEMO_ID)
# or via a join back to it. NEVER an unscoped DELETE. Skipped entirely if no
# existing demo user was found.
# ============================================================================
echo
if [[ -n "$EDGE_DEMO_ID" ]]; then
echo "## WIPE edge01 demo rows (scoped to $EDGE_DEMO_ID), FK-child-first ..."
# NOTE: edge01 FKs are ON DELETE CASCADE for most child rows; the explicit
if [[ -n "$TARGET_DEMO_ID" ]]; then
echo "## WIPE target demo rows (scoped to $TARGET_DEMO_ID), FK-child-first ..."
# NOTE: target FKs are ON DELETE CASCADE for most child rows; the explicit
# child-first deletes below are belt-and-braces (some child tables hang off
# medications/mood_entries, not the user) and keep the operation transparent.
psql_dst <<SQL
\set ON_ERROR_STOP on
BEGIN;
-- join-scoped children of the demo's medications
DELETE FROM reminder_phase_configs WHERE medication_id IN (SELECT id FROM medications WHERE user_id = '$EDGE_DEMO_ID');
DELETE FROM medication_inventory_events WHERE medication_id IN (SELECT id FROM medications WHERE user_id = '$EDGE_DEMO_ID');
DELETE FROM medication_dose_changes WHERE medication_id IN (SELECT id FROM medications WHERE user_id = '$EDGE_DEMO_ID');
DELETE FROM medication_schedules WHERE medication_id IN (SELECT id FROM medications WHERE user_id = '$EDGE_DEMO_ID');
DELETE FROM reminder_phase_configs WHERE medication_id IN (SELECT id FROM medications WHERE user_id = '$TARGET_DEMO_ID');
DELETE FROM medication_inventory_events WHERE medication_id IN (SELECT id FROM medications WHERE user_id = '$TARGET_DEMO_ID');
DELETE FROM medication_dose_changes WHERE medication_id IN (SELECT id FROM medications WHERE user_id = '$TARGET_DEMO_ID');
DELETE FROM medication_schedules WHERE medication_id IN (SELECT id FROM medications WHERE user_id = '$TARGET_DEMO_ID');
-- join-scoped children of the demo's mood_entries / cycle_day_logs
DELETE FROM mood_entry_tag_links WHERE mood_entry_id IN (SELECT id FROM mood_entries WHERE user_id = '$EDGE_DEMO_ID');
DELETE FROM cycle_symptom_links WHERE day_log_id IN (SELECT id FROM cycle_day_logs WHERE user_id = '$EDGE_DEMO_ID');
DELETE FROM mood_entry_tag_links WHERE mood_entry_id IN (SELECT id FROM mood_entries WHERE user_id = '$TARGET_DEMO_ID');
DELETE FROM cycle_symptom_links WHERE day_log_id IN (SELECT id FROM cycle_day_logs WHERE user_id = '$TARGET_DEMO_ID');
-- user_id-scoped tables
DELETE FROM medication_inventory_items WHERE user_id = '$EDGE_DEMO_ID';
DELETE FROM medication_side_effects WHERE user_id = '$EDGE_DEMO_ID';
DELETE FROM medication_intake_events WHERE user_id = '$EDGE_DEMO_ID';
DELETE FROM medications WHERE user_id = '$EDGE_DEMO_ID';
DELETE FROM mood_entries WHERE user_id = '$EDGE_DEMO_ID';
DELETE FROM cycle_day_logs WHERE user_id = '$EDGE_DEMO_ID';
DELETE FROM menstrual_cycles WHERE user_id = '$EDGE_DEMO_ID';
DELETE FROM cycle_profiles WHERE user_id = '$EDGE_DEMO_ID';
DELETE FROM personal_records WHERE user_id = '$EDGE_DEMO_ID';
DELETE FROM user_achievements WHERE user_id = '$EDGE_DEMO_ID';
DELETE FROM consent_receipts WHERE user_id = '$EDGE_DEMO_ID';
DELETE FROM audit_logs WHERE user_id = '$EDGE_DEMO_ID';
DELETE FROM medication_inventory_items WHERE user_id = '$TARGET_DEMO_ID';
DELETE FROM medication_side_effects WHERE user_id = '$TARGET_DEMO_ID';
DELETE FROM medication_intake_events WHERE user_id = '$TARGET_DEMO_ID';
DELETE FROM medications WHERE user_id = '$TARGET_DEMO_ID';
DELETE FROM mood_entries WHERE user_id = '$TARGET_DEMO_ID';
DELETE FROM cycle_day_logs WHERE user_id = '$TARGET_DEMO_ID';
DELETE FROM menstrual_cycles WHERE user_id = '$TARGET_DEMO_ID';
DELETE FROM cycle_profiles WHERE user_id = '$TARGET_DEMO_ID';
DELETE FROM personal_records WHERE user_id = '$TARGET_DEMO_ID';
DELETE FROM user_achievements WHERE user_id = '$TARGET_DEMO_ID';
DELETE FROM consent_receipts WHERE user_id = '$TARGET_DEMO_ID';
DELETE FROM audit_logs WHERE user_id = '$TARGET_DEMO_ID';
-- regenerable / transient tables that may carry FK rows blocking the user delete
DELETE FROM measurement_rollups WHERE user_id = '$EDGE_DEMO_ID';
DELETE FROM mood_entry_rollups WHERE user_id = '$EDGE_DEMO_ID';
DELETE FROM strain_trimp_cache WHERE user_id = '$EDGE_DEMO_ID';
DELETE FROM cycle_predictions WHERE user_id = '$EDGE_DEMO_ID';
DELETE FROM measurements WHERE user_id = '$EDGE_DEMO_ID';
DELETE FROM measurement_rollups WHERE user_id = '$TARGET_DEMO_ID';
DELETE FROM mood_entry_rollups WHERE user_id = '$TARGET_DEMO_ID';
DELETE FROM strain_trimp_cache WHERE user_id = '$TARGET_DEMO_ID';
DELETE FROM cycle_predictions WHERE user_id = '$TARGET_DEMO_ID';
DELETE FROM measurements WHERE user_id = '$TARGET_DEMO_ID';
-- finally the user row itself (any remaining ON DELETE CASCADE children of
-- the EXCLUDED set — sessions, tokens, etc. — go with it).
DELETE FROM users WHERE id = '$EDGE_DEMO_ID';
DELETE FROM users WHERE id = '$TARGET_DEMO_ID';
COMMIT;
SQL
echo "## WIPE complete."
else
echo "## WIPE skipped — no existing edge01 demo user."
echo "## WIPE skipped — no existing target demo user."
fi
# ============================================================================
@@ -403,7 +403,7 @@ readonly MENSTRUAL_CYCLE_COLS="id, user_id, start_date, end_date, period_end_dat
sync_version, deleted_at, created_at, updated_at"
# cycle_day_logs — NULL the two app-encrypted columns (keyed to prod key,
# absent on edge01). Carry the plaintext cycle fields.
# absent on the target). Carry the plaintext cycle fields.
readonly CYCLE_DAY_LOG_COLS="id, user_id, date, cycle_id, flow, intermenstrual_bleeding,
basal_body_temp_c, ovulation_test, cervical_mucus, sexual_activity, protected_sex,
pregnancy_test, progesterone_test, contraceptive, sensitive_encrypted, notes_encrypted,
@@ -472,7 +472,7 @@ copy_table() {
-c \"\\copy $table($dst_cols) FROM STDIN (FORMAT csv, HEADER false)\""
local n
n="$(psql_dst "-tA -c \"SELECT count(*) FROM $table;\"" | tr -d '[:space:]' || echo '?')"
echo " loaded; edge01 $table total rows now: $n"
echo " loaded; target $table total rows now: $n"
}
# ============================================================================
@@ -509,7 +509,7 @@ PW_OK="$(printf '%s\n' \
| ssh "$DST_SSH" "docker exec -i $DST_DB_CONTAINER \
psql -X -tA -v ON_ERROR_STOP=1 -U $PG_USER -d $PG_DB" | tr -d '[:space:]')"
if [[ "$PW_OK" != "1" ]]; then
echo "ABORT/FAIL: demo password_hash on edge01 is not the published literal (matched rows: $PW_OK)."
echo "ABORT/FAIL: demo password_hash on the target is not the published literal (matched rows: $PW_OK)."
echo " The demo login would be broken. Fix before serving the demo."
exit 1
fi
@@ -598,7 +598,7 @@ copy_table "audit_logs" "$AUDIT_LOG_COLS" \
"audit_logs WHERE user_id = '$DEMO_USER_ID' AND action LIKE 'insights.%'" "$AUDIT_LOG_COLS"
# ============================================================================
# POST-LOAD VERIFICATION (on edge01).
# POST-LOAD VERIFICATION (on the target).
# - per-table demo-user row counts (informational),
# - HARD assertion: the REAL operator id appears in NO carried table,
# - HARD assertion: every user_id-bearing carried table contains ONLY the
@@ -606,7 +606,7 @@ copy_table "audit_logs" "$AUDIT_LOG_COLS" \
# ============================================================================
echo
echo "=============================================================="
echo "## POST-LOAD verification on edge01"
echo "## POST-LOAD verification on the target"
echo "=============================================================="
echo
@@ -657,7 +657,7 @@ REAL_HITS="$(psql_dst "-tA -c \"
\"" | tr -d '[:space:]')"
echo " real-id occurrences across carried tables = $REAL_HITS"
if [[ "$REAL_HITS" != "0" ]]; then
echo "ABORT/FAIL: operator id present in $REAL_HITS rows on edge01. Investigate immediately."
echo "ABORT/FAIL: operator id present in $REAL_HITS rows on the target. Investigate immediately."
exit 1
fi
@@ -680,22 +680,22 @@ FOREIGN_HITS="$(psql_dst "-tA -c \"
+ (SELECT count(*) FROM audit_logs WHERE user_id <> '$DEMO_USER_ID');
\"" | tr -d '[:space:]')"
echo " non-demo user_id rows across carried tables = $FOREIGN_HITS"
echo " NOTE: this counts EVERY non-demo row in these tables on edge01, not just"
echo " NOTE: this counts EVERY non-demo row in these tables on the target, not just"
echo " what this script loaded. On a clean demo DB it should be 0. If the"
echo " edge01 demo DB legitimately holds other demo-only accounts, scope"
echo " target demo DB legitimately holds other demo-only accounts, scope"
echo " this assertion to the rows this run inserted before treating >0 as fatal."
if [[ "$FOREIGN_HITS" != "0" ]]; then
echo "WARNING: $FOREIGN_HITS non-demo rows exist in carried tables on edge01."
echo "WARNING: $FOREIGN_HITS non-demo rows exist in carried tables on the target."
echo " Inspect before serving the demo (see note above)."
fi
echo
echo "HARD assertion 3: the demo user exists on edge01 with role=ADMIN and the apps01 id."
echo "HARD assertion 3: the demo user exists on the target with role=ADMIN and the source id."
psql_dst "-c \"SELECT id, username, role, telegram_enabled FROM users WHERE id = '$DEMO_USER_ID';\""
echo
echo "=============================================================="
echo " DONE. Review the counts above. Demo user is role=ADMIN; the"
echo " app's DEMO_MODE must remain ON on edge01 so all mutations are"
echo " app's DEMO_MODE must remain ON on the target so all mutations are"
echo " blocked at the proxy layer while admin pages stay viewable."
echo "=============================================================="
-154
View File
@@ -1,154 +0,0 @@
-- HealthLog demo seed for apps01 — v1.15 (cycle-inclusive, FEMALE demo).
--
-- Creates / refreshes the `demo` account as a WOMAN with cycle tracking on,
-- NOW()-anchored + idempotent (re-runnable: wipes the demo user's rows first).
-- Showcases vitals + mood + the v1.15 cycle vertical (5 cycles, daily BBT with
-- a biphasic shift, mucus/OPK progression, period flow, phase-clustered
-- symptoms), plus phase-dependent RHR/HRV/sleep so the phase×vitals insights
-- board surfaces real findings. role=USER (login is open on prod; an
-- admin demo would be a risk). AI insights are generated separately afterwards.
--
-- Run: ssh apps-01 'docker exec -i <db-container> psql -U healthlog -d healthlog' < scripts/seed-demo-apps01.sql
BEGIN;
-- ── User (FEMALE, USER role) ──────────────────────────────────────
INSERT INTO users (id, username, email, password_hash, role, height_cm, date_of_birth, gender, timezone, locale, onboarding_completed_at, created_at, updated_at)
SELECT 'usr_demo_cf31025295714ece8d91f5af13afd76d',
'demo', 'demo@healthlog.app',
'$argon2id$v=19$m=19456,t=2,p=1$3KvrEIuI5UnwG53GPbS71w$mL8ZQotIXn9iLFIw3yHXbh6SitX1w7TXKI5ahWcKkF8',
'USER', 168, TIMESTAMP '1992-03-15', 'FEMALE', 'Europe/Berlin', 'en', NOW(), NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM users WHERE username = 'demo');
UPDATE users SET
gender = 'FEMALE', role = 'USER', height_cm = 168, date_of_birth = TIMESTAMP '1992-03-15',
password_hash = '$argon2id$v=19$m=19456,t=2,p=1$3KvrEIuI5UnwG53GPbS71w$mL8ZQotIXn9iLFIw3yHXbh6SitX1w7TXKI5ahWcKkF8',
onboarding_completed_at = NOW(), timezone = 'Europe/Berlin', locale = 'en', updated_at = NOW()
WHERE username = 'demo';
CREATE TEMP TABLE _demo AS SELECT id AS user_id FROM users WHERE username = 'demo' LIMIT 1;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM _demo) THEN
RAISE EXCEPTION 'seed-demo-apps01: demo user not resolved';
END IF;
END $$;
-- ── Wipe prior demo rows (idempotent) ─────────────────────────────
DELETE FROM cycle_symptom_links WHERE day_log_id IN (SELECT id FROM cycle_day_logs WHERE user_id IN (SELECT user_id FROM _demo));
DELETE FROM cycle_day_logs WHERE user_id IN (SELECT user_id FROM _demo);
DELETE FROM cycle_predictions WHERE user_id IN (SELECT user_id FROM _demo);
DELETE FROM menstrual_cycles WHERE user_id IN (SELECT user_id FROM _demo);
DELETE FROM mood_entries WHERE user_id IN (SELECT user_id FROM _demo);
DELETE FROM measurements WHERE user_id IN (SELECT user_id FROM _demo);
DELETE FROM measurement_rollups WHERE user_id IN (SELECT user_id FROM _demo);
DELETE FROM mood_entry_rollups WHERE user_id IN (SELECT user_id FROM _demo);
-- ── Cycle profile (tracking ON) ───────────────────────────────────
INSERT INTO cycle_profiles (id, user_id, goal, cycle_tracking_enabled, typical_cycle_length, typical_period_length, luteal_phase_length, prediction_enabled, raw_chart_mode, discreet_notifications, sensitive_category_encryption, created_at, updated_at)
SELECT 'cp_' || replace(gen_random_uuid()::text, '-', ''), user_id, 'GENERAL_HEALTH', true, 28, 5, 14, true, false, false, true, NOW(), NOW()
FROM _demo
WHERE NOT EXISTS (SELECT 1 FROM cycle_profiles cp JOIN _demo d ON cp.user_id = d.user_id);
UPDATE cycle_profiles SET
cycle_tracking_enabled = true, goal = 'GENERAL_HEALTH', prediction_enabled = true,
typical_cycle_length = 28, typical_period_length = 5, luteal_phase_length = 14, updated_at = NOW()
WHERE user_id IN (SELECT user_id FROM _demo);
-- ── Measurements (90-day sliding window, phase-aware) ─────────────
-- cycleDay(d) = ((128 - d) % 28) + 1 ; luteal = cycleDay > 13.
WITH demo AS (SELECT user_id FROM _demo), days AS (SELECT generate_series(0, 89) AS d)
INSERT INTO measurements (id, user_id, type, value, unit, source, glucose_context, measured_at, created_at, updated_at)
SELECT 'm_' || replace(gen_random_uuid()::text, '-', ''), (SELECT user_id FROM demo), v.type::measurement_type, v.value, v.unit, 'MANUAL'::measurement_source,
v.gc::glucose_context,
NOW() - (d || ' days')::interval - (random() * 5 || ' hours')::interval,
NOW() - (d || ' days')::interval, NOW() - (d || ' days')::interval
FROM days,
LATERAL (
SELECT ((128 - d) % 28) + 1 AS cd
) c,
LATERAL (VALUES
('WEIGHT', round((66.0 - (90 - d) * 0.011 + (random() - 0.5) * 0.5)::numeric, 1)::float8, 'kg', NULL),
('BLOOD_PRESSURE_SYS', round((116 + (random() - 0.5) * 8)::numeric, 0)::float8, 'mmHg', NULL),
('BLOOD_PRESSURE_DIA', round((74 + (random() - 0.5) * 6)::numeric, 0)::float8, 'mmHg', NULL),
('PULSE', round((68 + (random() - 0.5) * 8)::numeric, 0)::float8, 'bpm', NULL),
('RESTING_HEART_RATE', round((56 + (CASE WHEN c.cd > 13 THEN 3 ELSE 0 END) + (random() - 0.5) * 3)::numeric, 0)::float8, 'bpm', NULL),
('HEART_RATE_VARIABILITY', round((68 - (CASE WHEN c.cd > 13 THEN 7 ELSE 0 END) + (random() - 0.5) * 6)::numeric, 0)::float8, 'ms', NULL),
('SLEEP_DURATION', round((445 - (CASE WHEN c.cd > 13 THEN 18 ELSE 0 END) + (random() - 0.5) * 40)::numeric, 0)::float8, 'minutes', NULL),
('ACTIVITY_STEPS', round((8200 + (random() - 0.5) * 3500)::numeric, 0)::float8, 'steps', NULL),
('BODY_FAT', round((27.5 - (90 - d) * 0.006 + (random() - 0.5) * 0.4)::numeric, 1)::float8, '%', NULL),
('BODY_TEMPERATURE', round(((CASE WHEN c.cd <= 13 THEN 36.36 ELSE 36.73 END) + (random() - 0.5) * 0.08)::numeric, 2)::float8, 'celsius', NULL),
('BLOOD_GLUCOSE', round((92 + (random() - 0.5) * 18)::numeric, 0)::float8, 'mg/dL', 'FASTING')
) AS v(type, value, unit, gc);
-- ── Mood (90 days, luteal dip) ────────────────────────────────────
WITH demo AS (SELECT user_id FROM _demo), days AS (SELECT generate_series(0, 89) AS d)
INSERT INTO mood_entries (id, user_id, date, mood, score, source, mood_logged_at, synced_at, tz, sync_version, created_at, updated_at)
SELECT 'mo_' || replace(gen_random_uuid()::text, '-', ''), (SELECT user_id FROM demo),
to_char(NOW() - (d || ' days')::interval, 'YYYY-MM-DD'),
CASE s.score WHEN 1 THEN 'LAUSIG' WHEN 2 THEN 'SCHLECHT' WHEN 3 THEN 'OKAY' WHEN 4 THEN 'GUT' ELSE 'SUPER_GUT' END,
s.score, 'MANUAL',
NOW() - (d || ' days')::interval - (random() * 6 || ' hours')::interval,
NOW() - (d || ' days')::interval, 'Europe/Berlin', 0,
NOW() - (d || ' days')::interval, NOW() - (d || ' days')::interval
FROM days,
LATERAL (SELECT ((128 - d) % 28) + 1 AS cd) c,
LATERAL (
SELECT GREATEST(1, LEAST(5,
round((CASE WHEN c.cd > 23 THEN 2.6 WHEN c.cd > 13 THEN 3.3 ELSE 3.9 END) + (random() - 0.5) * 1.6)::int
)) AS score
) s;
-- ── Menstrual cycles (5, NOW-anchored; last open) ─────────────────
WITH offs AS (SELECT unnest(ARRAY[128, 100, 72, 44, 16]) AS off)
INSERT INTO menstrual_cycles (id, user_id, start_date, end_date, period_end_date, length_days, ovulation_date, ovulation_confirmed, is_predicted, tz, created_at, updated_at)
SELECT 'mc_' || replace(gen_random_uuid()::text, '-', ''), (SELECT user_id FROM _demo),
to_char(NOW() - (off || ' days')::interval, 'YYYY-MM-DD'),
CASE WHEN off = 16 THEN NULL ELSE to_char(NOW() - ((off - 27) || ' days')::interval, 'YYYY-MM-DD') END,
to_char(NOW() - ((off - 4) || ' days')::interval, 'YYYY-MM-DD'),
CASE WHEN off = 16 THEN NULL ELSE 28 END,
to_char(NOW() - ((off - 13) || ' days')::interval, 'YYYY-MM-DD'),
true, false, 'Europe/Berlin', NOW(), NOW()
FROM offs;
-- ── Cycle day-logs (128 days) + phase-clustered symptom links ─────
WITH demo AS (SELECT user_id FROM _demo),
gen AS (
SELECT d, ((128 - d) % 28) + 1 AS cd, to_char(NOW() - (d || ' days')::interval, 'YYYY-MM-DD') AS dstr
FROM generate_series(0, 127) AS d
),
ins AS (
INSERT INTO cycle_day_logs (id, user_id, date, flow, basal_body_temp_c, cervical_mucus, ovulation_test, source, tz, sync_version, created_at, updated_at)
SELECT 'cdl_' || replace(gen_random_uuid()::text, '-', ''), (SELECT user_id FROM demo), g.dstr,
(CASE g.cd WHEN 1 THEN 'MEDIUM' WHEN 2 THEN 'HEAVY' WHEN 3 THEN 'MEDIUM' WHEN 4 THEN 'LIGHT' WHEN 5 THEN 'SPOTTING' ELSE NULL END)::flow_level,
round(((CASE WHEN g.cd <= 13 THEN 36.36 ELSE 36.73 END) + (random() - 0.5) * 0.08)::numeric, 2)::float8,
(CASE WHEN g.cd BETWEEN 1 AND 5 THEN NULL WHEN g.cd BETWEEN 6 AND 9 THEN 'STICKY' WHEN g.cd BETWEEN 10 AND 12 THEN 'CREAMY' WHEN g.cd BETWEEN 13 AND 14 THEN 'EGG_WHITE' WHEN g.cd BETWEEN 15 AND 16 THEN 'WATERY' ELSE 'DRY' END)::cervical_mucus,
(CASE WHEN g.cd = 13 THEN 'POSITIVE_LH_SURGE' WHEN g.cd IN (11, 12, 14) THEN 'NEGATIVE' ELSE NULL END)::ovulation_test,
'MANUAL'::measurement_source, 'Europe/Berlin', 0,
NOW() - (g.d || ' days')::interval, NOW() - (g.d || ' days')::interval
FROM gen g
RETURNING id, date
),
link_menstrual AS (
INSERT INTO cycle_symptom_links (day_log_id, symptom_id, severity, created_at)
SELECT ins.id, s.id, (2 + floor(random() * 3))::int, NOW()
FROM ins JOIN gen g ON g.dstr = ins.date
CROSS JOIN LATERAL (SELECT id FROM cycle_symptoms WHERE user_id IS NULL AND key IN ('cramps', 'headache', 'fatigue')) s
WHERE g.cd BETWEEN 1 AND 3
)
INSERT INTO cycle_symptom_links (day_log_id, symptom_id, severity, created_at)
SELECT ins.id, s.id, (1 + floor(random() * 3))::int, NOW()
FROM ins JOIN gen g ON g.dstr = ins.date
CROSS JOIN LATERAL (SELECT id FROM cycle_symptoms WHERE user_id IS NULL AND key IN ('mood_swings', 'bloating', 'breast_tenderness')) s
WHERE g.cd BETWEEN 24 AND 28;
COMMIT;
-- ── Summary ───────────────────────────────────────────────────────
SELECT 'demo user' AS what, id FROM users WHERE username = 'demo';
SELECT 'measurements' AS what, count(*) FROM measurements WHERE user_id = (SELECT id FROM users WHERE username = 'demo');
SELECT 'mood' AS what, count(*) FROM mood_entries WHERE user_id = (SELECT id FROM users WHERE username = 'demo');
SELECT 'cycles' AS what, count(*) FROM menstrual_cycles WHERE user_id = (SELECT id FROM users WHERE username = 'demo');
SELECT 'day_logs' AS what, count(*) FROM cycle_day_logs WHERE user_id = (SELECT id FROM users WHERE username = 'demo');
SELECT 'symptom_links' AS what, count(*) FROM cycle_symptom_links l JOIN cycle_day_logs dl ON l.day_log_id = dl.id WHERE dl.user_id = (SELECT id FROM users WHERE username = 'demo');
-216
View File
@@ -1,216 +0,0 @@
-- HealthLog demo 1-year history extender for apps01.
--
-- WHAT THIS DOES
-- Extends the demo account's data BACKWARD from the existing ~90-day seed
-- (scripts/seed-demo-apps01.sql) to ~365 days of coverage, and adds ~365 days
-- of realistic medication-intake history for the two pre-existing demo meds.
-- It mirrors that seed EXACTLY: same column lists, enum values, NOW()-anchored
-- offset math (cycleDay(d) = ((128 - d) % 28) + 1), cuid/uuid-id style, and the
-- same phase-aware vitals / mood / cycle channels.
--
-- The seed covers offsets 0..89 (measurements + mood), 0..127 (cycle day-logs),
-- and 5 cycles at NOW-offsets {128,100,72,44,16}. This file ADDS ONLY the older
-- tail so nothing is duplicated:
-- - measurements / mood : offsets 90..364 (thinned to every 2nd day past ~180d)
-- - cycle day-logs : offsets 128..364
-- - menstrual cycles : 7 more 28-day cycles at offsets 156..352
-- - medication intakes : ~365d of due slots for both demo meds
--
-- SCOPE + SAFETY
-- - Demo-user-scoped: EVERY statement filters on the hardcoded demo user id.
-- - Idempotent: every INSERT guards on the live unique index via ON CONFLICT
-- DO NOTHING (or a date-offset window that begins past the seed's coverage),
-- so re-running adds nothing new.
-- - NO DDL (no CREATE/ALTER/DROP/TRUNCATE), no changes to any other user,
-- no table-wide DELETE/UPDATE.
--
-- Run: ssh apps-01 'docker exec -i <db-container> psql -U healthlog -d healthlog' < scripts/seed-demo-extend-1y.sql
\set uid 'usr_demo_cf31025295714ece8d91f5af13afd76d'
\set medA 'cmq2lzjqh005x01nrys2crzg9' -- Mounjaro: weekly @ 08:00 (rolling 7d)
\set medB 'cmq2m0mzk006101nrdpijbij0' -- Ramipril: twice daily @ 08:00 + 18:00
BEGIN;
-- Refuse to run if the demo user does not exist.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM users WHERE id = 'usr_demo_cf31025295714ece8d91f5af13afd76d') THEN
RAISE EXCEPTION 'seed-demo-extend-1y: demo user not found';
END IF;
END $$;
-- ── Measurements: offsets 90..364, phase-aware (mirrors the seed channels) ─────
-- Thin the deep tail: keep every day for 90..179, every 2nd day for 180..364, to
-- keep row volume sane while still covering the full year. Unique index
-- (user_id, type, measured_at, source, sleep_stage) NULLS NOT DISTINCT — but
-- measured_at carries a random sub-day jitter, so collisions are effectively
-- impossible on a fresh tail; ON CONFLICT DO NOTHING makes re-runs safe anyway.
WITH days AS (
SELECT d FROM generate_series(90, 364) AS d
WHERE d < 180 OR (d % 2) = 0
)
INSERT INTO measurements (id, user_id, type, value, unit, source, glucose_context, measured_at, created_at, updated_at)
SELECT 'm_' || replace(gen_random_uuid()::text, '-', ''),
'usr_demo_cf31025295714ece8d91f5af13afd76d',
v.type::measurement_type, v.value, v.unit, 'MANUAL'::measurement_source,
v.gc::glucose_context,
NOW() - (d || ' days')::interval - (random() * 5 || ' hours')::interval,
NOW() - (d || ' days')::interval, NOW() - (d || ' days')::interval
FROM days,
LATERAL (SELECT ((128 - d) % 28) + 1 AS cd) c,
LATERAL (VALUES
('WEIGHT', round((66.0 - (90 - d) * 0.011 + (random() - 0.5) * 0.5)::numeric, 1)::float8, 'kg', NULL),
('BLOOD_PRESSURE_SYS', round((116 + (random() - 0.5) * 8)::numeric, 0)::float8, 'mmHg', NULL),
('BLOOD_PRESSURE_DIA', round((74 + (random() - 0.5) * 6)::numeric, 0)::float8, 'mmHg', NULL),
('PULSE', round((68 + (random() - 0.5) * 8)::numeric, 0)::float8, 'bpm', NULL),
('RESTING_HEART_RATE', round((56 + (CASE WHEN c.cd > 13 THEN 3 ELSE 0 END) + (random() - 0.5) * 3)::numeric, 0)::float8, 'bpm', NULL),
('HEART_RATE_VARIABILITY', round((68 - (CASE WHEN c.cd > 13 THEN 7 ELSE 0 END) + (random() - 0.5) * 6)::numeric, 0)::float8, 'ms', NULL),
('SLEEP_DURATION', round((445 - (CASE WHEN c.cd > 13 THEN 18 ELSE 0 END) + (random() - 0.5) * 40)::numeric, 0)::float8, 'minutes', NULL),
('ACTIVITY_STEPS', round((8200 + (random() - 0.5) * 3500)::numeric, 0)::float8, 'steps', NULL),
('BODY_FAT', round((27.5 - (90 - d) * 0.006 + (random() - 0.5) * 0.4)::numeric, 1)::float8, '%', NULL),
('BODY_TEMPERATURE', round(((CASE WHEN c.cd <= 13 THEN 36.36 ELSE 36.73 END) + (random() - 0.5) * 0.08)::numeric, 2)::float8, 'celsius', NULL),
('BLOOD_GLUCOSE', round((92 + (random() - 0.5) * 18)::numeric, 0)::float8, 'mg/dL', 'FASTING')
) AS v(type, value, unit, gc)
ON CONFLICT DO NOTHING;
-- ── Mood: offsets 90..364, luteal dip (mirrors the seed) ───────────────────────
-- Unique index (user_id, date, mood_logged_at). One entry per day, fresh tail.
WITH days AS (SELECT generate_series(90, 364) AS d)
INSERT INTO mood_entries (id, user_id, date, mood, score, source, mood_logged_at, synced_at, tz, sync_version, created_at, updated_at)
SELECT 'mo_' || replace(gen_random_uuid()::text, '-', ''),
'usr_demo_cf31025295714ece8d91f5af13afd76d',
to_char(NOW() - (d || ' days')::interval, 'YYYY-MM-DD'),
CASE s.score WHEN 1 THEN 'LAUSIG' WHEN 2 THEN 'SCHLECHT' WHEN 3 THEN 'OKAY' WHEN 4 THEN 'GUT' ELSE 'SUPER_GUT' END,
s.score, 'MANUAL',
NOW() - (d || ' days')::interval - (random() * 6 || ' hours')::interval,
NOW() - (d || ' days')::interval, 'Europe/Berlin', 0,
NOW() - (d || ' days')::interval, NOW() - (d || ' days')::interval
FROM days,
LATERAL (SELECT ((128 - d) % 28) + 1 AS cd) c,
LATERAL (
SELECT GREATEST(1, LEAST(5,
round((CASE WHEN c.cd > 23 THEN 2.6 WHEN c.cd > 13 THEN 3.3 ELSE 3.9 END) + (random() - 0.5) * 1.6)::int
)) AS score
) s
ON CONFLICT DO NOTHING;
-- ── Menstrual cycles: 7 more 28-day cycles, offsets 156..352 ───────────────────
-- Continues the seed's cadence backward (seed had {128,100,72,44,16}; next is 156).
-- Unique index (user_id, start_date) → ON CONFLICT DO NOTHING. All closed cycles.
WITH offs AS (SELECT unnest(ARRAY[156, 184, 212, 240, 268, 296, 324, 352]) AS off)
INSERT INTO menstrual_cycles (id, user_id, start_date, end_date, period_end_date, length_days, ovulation_date, ovulation_confirmed, is_predicted, tz, created_at, updated_at)
SELECT 'mc_' || replace(gen_random_uuid()::text, '-', ''),
'usr_demo_cf31025295714ece8d91f5af13afd76d',
to_char(NOW() - (off || ' days')::interval, 'YYYY-MM-DD'),
to_char(NOW() - ((off - 27) || ' days')::interval, 'YYYY-MM-DD'),
to_char(NOW() - ((off - 4) || ' days')::interval, 'YYYY-MM-DD'),
28,
to_char(NOW() - ((off - 13) || ' days')::interval, 'YYYY-MM-DD'),
true, false, 'Europe/Berlin', NOW(), NOW()
FROM offs
ON CONFLICT (user_id, start_date) DO NOTHING;
-- ── Cycle day-logs: offsets 128..364 + phase-clustered symptom links ───────────
-- Mirrors the seed exactly (flow/BBT/mucus/OPK by cycleDay). Unique index
-- (user_id, date) → ON CONFLICT DO NOTHING. Symptom links only attach to rows
-- this run actually inserts (RETURNING), so re-runs add no duplicate links.
WITH gen AS (
SELECT d, ((128 - d) % 28) + 1 AS cd, to_char(NOW() - (d || ' days')::interval, 'YYYY-MM-DD') AS dstr
FROM generate_series(128, 364) AS d
),
ins AS (
INSERT INTO cycle_day_logs (id, user_id, date, flow, basal_body_temp_c, cervical_mucus, ovulation_test, source, tz, sync_version, created_at, updated_at)
SELECT 'cdl_' || replace(gen_random_uuid()::text, '-', ''),
'usr_demo_cf31025295714ece8d91f5af13afd76d', g.dstr,
(CASE g.cd WHEN 1 THEN 'MEDIUM' WHEN 2 THEN 'HEAVY' WHEN 3 THEN 'MEDIUM' WHEN 4 THEN 'LIGHT' WHEN 5 THEN 'SPOTTING' ELSE NULL END)::flow_level,
round(((CASE WHEN g.cd <= 13 THEN 36.36 ELSE 36.73 END) + (random() - 0.5) * 0.08)::numeric, 2)::float8,
(CASE WHEN g.cd BETWEEN 1 AND 5 THEN NULL WHEN g.cd BETWEEN 6 AND 9 THEN 'STICKY' WHEN g.cd BETWEEN 10 AND 12 THEN 'CREAMY' WHEN g.cd BETWEEN 13 AND 14 THEN 'EGG_WHITE' WHEN g.cd BETWEEN 15 AND 16 THEN 'WATERY' ELSE 'DRY' END)::cervical_mucus,
(CASE WHEN g.cd = 13 THEN 'POSITIVE_LH_SURGE' WHEN g.cd IN (11, 12, 14) THEN 'NEGATIVE' ELSE NULL END)::ovulation_test,
'MANUAL'::measurement_source, 'Europe/Berlin', 0,
NOW() - (g.d || ' days')::interval, NOW() - (g.d || ' days')::interval
FROM gen g
ON CONFLICT (user_id, date) DO NOTHING
RETURNING id, date
),
link_menstrual AS (
INSERT INTO cycle_symptom_links (day_log_id, symptom_id, severity, created_at)
SELECT ins.id, s.id, (2 + floor(random() * 3))::int, NOW()
FROM ins JOIN gen g ON g.dstr = ins.date
CROSS JOIN LATERAL (SELECT id FROM cycle_symptoms WHERE user_id IS NULL AND key IN ('cramps', 'headache', 'fatigue')) s
WHERE g.cd BETWEEN 1 AND 3
)
INSERT INTO cycle_symptom_links (day_log_id, symptom_id, severity, created_at)
SELECT ins.id, s.id, (1 + floor(random() * 3))::int, NOW()
FROM ins JOIN gen g ON g.dstr = ins.date
CROSS JOIN LATERAL (SELECT id FROM cycle_symptoms WHERE user_id IS NULL AND key IN ('mood_swings', 'bloating', 'breast_tenderness')) s
WHERE g.cd BETWEEN 24 AND 28;
-- ── Medication intakes: Med A (weekly @ 08:00 Berlin), ~365d ───────────────────
-- The existing demo rows store scheduled_for in UTC at 06:00 (= 08:00 Berlin DST).
-- We mint slots at the Berlin local 08:00 and convert to the stored UTC wall-time
-- with AT TIME ZONE so winter (07:00 UTC) vs summer (06:00 UTC) is honoured.
-- Live partial unique index: (user_id, medication_id, scheduled_for, source)
-- WHERE deleted_at IS NULL → ON CONFLICT on that predicate makes re-runs safe.
-- Compliance ~85-90%: deterministic per-slot pseudo-random skip ~12% of weeks.
-- Never future-dated: WHERE the slot timestamp <= NOW().
WITH slots AS (
SELECT
( (date_trunc('day', NOW() AT TIME ZONE 'Europe/Berlin') - (w * 7 || ' days')::interval + interval '8 hours')
AT TIME ZONE 'Europe/Berlin' ) AT TIME ZONE 'UTC' AS sched_utc,
-- deterministic skip decision per week offset (no random(): re-run stable)
( (hashtextextended('medA-week-' || w, 0) & 2147483647)::numeric / 2147483647.0 ) AS r
FROM generate_series(0, 52) AS w -- 53 weekly slots = w*7 days back ≈ 371 days
)
INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at, sync_version)
SELECT 'cmq_demo_a_' || replace(gen_random_uuid()::text, '-', ''),
'usr_demo_cf31025295714ece8d91f5af13afd76d',
'cmq2lzjqh005x01nrys2crzg9',
s.sched_utc,
CASE WHEN s.r < 0.88 THEN s.sched_utc + (interval '5 minutes' + (s.r * interval '90 minutes')) ELSE NULL END,
CASE WHEN s.r < 0.88 THEN false ELSE true END,
'WEB'::intake_source,
s.sched_utc, s.sched_utc, 0
FROM slots s
WHERE s.sched_utc <= NOW()
-- skip slots already covered by the seeded rows (06-06 06:00 + 06-13 06:00)
AND s.sched_utc < date_trunc('day', NOW()) - interval '1 day'
ON CONFLICT (user_id, medication_id, scheduled_for, source) WHERE deleted_at IS NULL DO NOTHING;
-- ── Medication intakes: Med B (twice daily @ 08:00 + 18:00 Berlin), ~365d ──────
-- One row per due slot per day. Compliance ~85-90%: deterministic per-slot skip
-- ~12% of individual slots. Same TZ + ON CONFLICT + no-future-date guards.
WITH slots AS (
SELECT
( (date_trunc('day', NOW() AT TIME ZONE 'Europe/Berlin') - (dd || ' days')::interval + (hh || ' hours')::interval)
AT TIME ZONE 'Europe/Berlin' ) AT TIME ZONE 'UTC' AS sched_utc,
( (hashtextextended('medB-' || dd || '-' || hh, 0) & 2147483647)::numeric / 2147483647.0 ) AS r
FROM generate_series(0, 364) AS dd
CROSS JOIN (VALUES (8), (18)) AS h(hh)
)
INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at, sync_version)
SELECT 'cmq_demo_b_' || replace(gen_random_uuid()::text, '-', ''),
'usr_demo_cf31025295714ece8d91f5af13afd76d',
'cmq2m0mzk006101nrdpijbij0',
s.sched_utc,
CASE WHEN s.r < 0.88 THEN s.sched_utc + (interval '5 minutes' + (s.r * interval '120 minutes')) ELSE NULL END,
CASE WHEN s.r < 0.88 THEN false ELSE true END,
'WEB'::intake_source,
s.sched_utc, s.sched_utc, 0
FROM slots s
WHERE s.sched_utc <= NOW()
-- skip slots already covered by the seeded rows (today's 06:00 + 16:00 UTC)
AND s.sched_utc < date_trunc('day', NOW())
ON CONFLICT (user_id, medication_id, scheduled_for, source) WHERE deleted_at IS NULL DO NOTHING;
COMMIT;
-- ── Verification (demo-user-scoped totals after apply) ─────────────────────────
\echo === post-apply totals (demo user) ===
SELECT 'measurements' AS what, count(*), min(measured_at)::date AS oldest, max(measured_at)::date AS newest FROM measurements WHERE user_id = :'uid';
SELECT 'mood' AS what, count(*), min(date) AS oldest, max(date) AS newest FROM mood_entries WHERE user_id = :'uid';
SELECT 'cycles' AS what, count(*), min(start_date) AS oldest, max(start_date) AS newest FROM menstrual_cycles WHERE user_id = :'uid';
SELECT 'day_logs' AS what, count(*), min(date) AS oldest, max(date) AS newest FROM cycle_day_logs WHERE user_id = :'uid';
SELECT 'symptom_links' AS what, count(*) FROM cycle_symptom_links l JOIN cycle_day_logs dl ON l.day_log_id = dl.id WHERE dl.user_id = :'uid';
SELECT 'intake_medA' AS what, count(*), count(*) FILTER (WHERE taken_at IS NOT NULL) AS taken, count(*) FILTER (WHERE skipped) AS skipped FROM medication_intake_events WHERE user_id = 'usr_demo_cf31025295714ece8d91f5af13afd76d' AND medication_id = 'cmq2lzjqh005x01nrys2crzg9';
SELECT 'intake_medB' AS what, count(*), count(*) FILTER (WHERE taken_at IS NOT NULL) AS taken, count(*) FILTER (WHERE skipped) AS skipped FROM medication_intake_events WHERE user_id = 'usr_demo_cf31025295714ece8d91f5af13afd76d' AND medication_id = 'cmq2m0mzk006101nrdpijbij0';
-65
View File
@@ -1,65 +0,0 @@
-- HealthLog demo medications for apps01 — canonical ids the 1-year extender
-- (scripts/seed-demo-extend-1y.sql) references for its intake history.
-- Idempotent. Creates the two demo meds + schedules so the extender's
-- medication_intake_events FK resolves and the medication cards/compliance
-- surfaces populate. Demo user id is the canonical seeded id.
--
-- Run: ssh apps-01 'docker exec -i <db-container> psql -U healthlog -d healthlog' < scripts/seed-demo-meds-apps01.sql
BEGIN;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM users WHERE id = 'usr_demo_cf31025295714ece8d91f5af13afd76d') THEN
RAISE EXCEPTION 'seed-demo-meds: demo user not found';
END IF;
END $$;
-- ── Med A — Mounjaro (GLP-1, weekly on Sundays @ 08:00) ────────────
INSERT INTO medications
(id, user_id, name, dose, treatment_class, doses_per_unit, active, notifications_enabled, starts_on, created_at, updated_at)
VALUES
('cmq2lzjqh005x01nrys2crzg9', 'usr_demo_cf31025295714ece8d91f5af13afd76d',
'Mounjaro', '5 mg', 'GLP1', 4, true, true,
(NOW() - INTERVAL '380 days')::date, NOW() - INTERVAL '380 days', NOW())
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name, dose = EXCLUDED.dose, treatment_class = EXCLUDED.treatment_class,
doses_per_unit = EXCLUDED.doses_per_unit, active = true, starts_on = EXCLUDED.starts_on,
updated_at = NOW();
INSERT INTO medication_schedules
(id, medication_id, window_start, window_end, label, times_of_day, days_of_week, rrule)
VALUES
('sched_demo_medA_mounjaro', 'cmq2lzjqh005x01nrys2crzg9',
'08:00', '10:00', 'Wöchentlich', ARRAY['08:00'], '7', 'FREQ=WEEKLY;BYDAY=SU')
ON CONFLICT (id) DO UPDATE SET
window_start = EXCLUDED.window_start, window_end = EXCLUDED.window_end,
label = EXCLUDED.label, times_of_day = EXCLUDED.times_of_day,
days_of_week = EXCLUDED.days_of_week, rrule = EXCLUDED.rrule;
-- ── Med B — Ramipril (twice daily @ 08:00 + 18:00) ─────────────────
INSERT INTO medications
(id, user_id, name, dose, treatment_class, active, notifications_enabled, starts_on, created_at, updated_at)
VALUES
('cmq2m0mzk006101nrdpijbij0', 'usr_demo_cf31025295714ece8d91f5af13afd76d',
'Ramipril', '5 mg', 'GENERIC', true, true,
(NOW() - INTERVAL '380 days')::date, NOW() - INTERVAL '380 days', NOW())
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name, dose = EXCLUDED.dose, treatment_class = EXCLUDED.treatment_class,
active = true, starts_on = EXCLUDED.starts_on, updated_at = NOW();
INSERT INTO medication_schedules
(id, medication_id, window_start, window_end, label, times_of_day, days_of_week, rrule)
VALUES
('sched_demo_medB_ramipril', 'cmq2m0mzk006101nrdpijbij0',
'08:00', '18:00', 'Morgens & Abends', ARRAY['08:00','18:00'], NULL, 'FREQ=DAILY')
ON CONFLICT (id) DO UPDATE SET
window_start = EXCLUDED.window_start, window_end = EXCLUDED.window_end,
label = EXCLUDED.label, times_of_day = EXCLUDED.times_of_day,
days_of_week = EXCLUDED.days_of_week, rrule = EXCLUDED.rrule;
COMMIT;
\echo === demo meds ===
SELECT id, name, dose, treatment_class, active FROM medications
WHERE user_id = 'usr_demo_cf31025295714ece8d91f5af13afd76d' ORDER BY name;
-153
View File
@@ -1,153 +0,0 @@
-- HealthLog demo computed-wellness-score seeder for apps01 (1 year).
--
-- WHAT THIS DOES
-- Seeds the three PERSISTED nightly wellness scores for the demo account so
-- all of the dashboard's "Deine Gesundheitswerte" rings render with a full
-- year of history. These three are normally written by the nightly score
-- crons (src/lib/jobs/{recovery,stress,strain}-score.ts) as COMPUTED
-- Measurement rows; the demo never had them computed, so only the two
-- compute-on-read composites (READINESS + SLEEP_SCORE — derived live from the
-- demo's RHR/HRV/sleep/mood, NOT stored) were showing.
--
-- channel type external_id unit value
-- Recovery RECOVERY_SCORE recovery:YYYY-MM-DD score 0..100 (55-90)
-- Strain STRAIN_SCORE strain:YYYY-MM-DD score 0..100 (20-75)
-- Stress STRESS_SCORE stress:YYYY-MM-DD score 0..100 (25-65)
--
-- READINESS and SLEEP_SCORE are NOT seeded here: neither exists in the
-- measurement_type enum (verified live) — both are recomputed on read from
-- the underlying signals the demo already carries, so they already render and
-- a stored row would be ignored. Seeding them would be dead data.
--
-- Row shape mirrors src/lib/insights/score-row.ts::upsertScoreRow exactly:
-- source = COMPUTED, unit = 'score', measured_at = noon UTC on the scored day,
-- external_id = '<prefix>:YYYY-MM-DD', sleep_stage / device_type /
-- external_source_version / deleted_at all NULL.
--
-- Coverage: one row per (channel, UTC day) for the last 365 days (offsets
-- 1..365 — offset 0 is "today", which the nightly cron scores as the PREVIOUS
-- day, so the freshest persisted day is yesterday; this matches the job and
-- keeps every row strictly measured_at <= NOW()). 365 days x 3 channels =
-- 1,095 rows on a clean apply.
--
-- DETERMINISM
-- Values + ids are hash-derived from the day stamp (hashtextextended /
-- md5 — NO random()), so a re-run is byte-stable. Idempotent re-runs are
-- additionally guarded by ON CONFLICT DO NOTHING on the live unique index
-- (user_id, type, source, external_id) — the exact key the cron upserts on.
--
-- SCOPE + SAFETY
-- - Demo-user-scoped: EVERY statement hardcodes the demo user id.
-- - Idempotent: ON CONFLICT DO NOTHING; re-running adds nothing.
-- - No DDL (no CREATE/ALTER/DROP/TRUNCATE), no other-user writes, no
-- table-wide DELETE/UPDATE, no future-dated rows (measured_at <= NOW()).
--
-- Run: ssh apps-01 'docker exec -i <db-container> psql -U healthlog -d healthlog' < scripts/seed-demo-scores-1y.sql
\set uid 'usr_demo_cf31025295714ece8d91f5af13afd76d'
BEGIN;
-- Refuse to run if the demo user does not exist.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM users WHERE id = 'usr_demo_cf31025295714ece8d91f5af13afd76d') THEN
RAISE EXCEPTION 'seed-demo-scores-1y: demo user not found';
END IF;
END $$;
-- ── The three persisted score channels, one row per (channel, day) ─────────────
-- offsets 1..365 (the scored day is the day that just ended; offset 0 = today is
-- never persisted by the cron). day_key is the UTC calendar day; measured_at is
-- noon UTC on it, mirroring scoreMeasuredAt(). Deterministic values:
-- - week phase : a gentle 7-day sinusoid keyed to the day-of-year (weekly
-- rhythm — lighter mid-week, heavier toward the weekend).
-- - noise : per-(channel,day) hash in [0,1), no random().
-- - recovery : 55..90, dips after a "hard" (high-strain) day.
-- - strain : 20..75, weekend / mid-week training spikes (~25% of days).
-- - stress : 25..65, inversely coupled to recovery (recovery up → stress
-- down) so the two rings tell a coherent story.
WITH days AS (
SELECT
d,
(NOW() AT TIME ZONE 'UTC')::date - d AS day_key
FROM generate_series(1, 365) AS d
),
calc AS (
SELECT
d,
day_key,
(day_key + 12)::timestamp AS measured_at, -- noon UTC on the scored day
-- weekly rhythm in [-1, 1] from ISO day-of-week (Mon=1..Sun=7)
sin(2 * pi() * (extract(isodow FROM day_key)::numeric - 1) / 7.0) AS week_phase,
-- deterministic per-day "is this a training day" flag (~25%)
( (hashtextextended('score-train-' || day_key::text, 0) & 2147483647)::numeric / 2147483647.0 ) AS train_r,
-- independent deterministic noise streams per channel, all in [0,1)
( (hashtextextended('score-rec-' || day_key::text, 0) & 2147483647)::numeric / 2147483647.0 ) AS rec_r,
( (hashtextextended('score-str-' || day_key::text, 0) & 2147483647)::numeric / 2147483647.0 ) AS str_r,
( (hashtextextended('score-stress-' || day_key::text, 0) & 2147483647)::numeric / 2147483647.0 ) AS stress_r
FROM days
),
vals AS (
SELECT
d, day_key, measured_at, train_r,
-- STRAIN 20..75: base + weekly rhythm + a spike on training days.
LEAST(75, GREATEST(20, round(
40 + week_phase * 8
+ (CASE WHEN train_r < 0.25 THEN 22 ELSE 0 END)
+ (str_r - 0.5) * 14
)))::int AS strain,
-- RECOVERY 55..90: base + weekly rhythm, suppressed the day after a hard day.
LEAST(90, GREATEST(55, round(
74 + week_phase * 6
- (CASE WHEN train_r < 0.25 THEN 7 ELSE 0 END)
+ (rec_r - 0.5) * 16
)))::int AS recovery
FROM calc
),
final AS (
SELECT
v.d, v.day_key, v.measured_at, v.strain, v.recovery,
-- STRESS 25..65: inverse to recovery (high recovery → low stress) + noise.
LEAST(65, GREATEST(25, round(
90 - v.recovery * 0.5
+ (c.stress_r - 0.5) * 14
)))::int AS stress
FROM vals v JOIN calc c USING (d)
),
rows AS (
SELECT day_key, measured_at, type, ext_prefix, value FROM final,
LATERAL (VALUES
('RECOVERY_SCORE', 'recovery:', recovery),
('STRAIN_SCORE', 'strain:', strain),
('STRESS_SCORE', 'stress:', stress)
) AS ch(type, ext_prefix, value)
)
INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, external_id, created_at, updated_at)
SELECT
-- deterministic id from the per-row external_id → byte-stable re-runs.
'm_score_' || md5(ext_prefix || to_char(day_key, 'YYYY-MM-DD')),
'usr_demo_cf31025295714ece8d91f5af13afd76d',
type::measurement_type,
value::float8,
'score',
'COMPUTED'::measurement_source,
measured_at,
ext_prefix || to_char(day_key, 'YYYY-MM-DD'),
measured_at,
measured_at
FROM rows
WHERE measured_at <= NOW()
ON CONFLICT (user_id, type, source, external_id) DO NOTHING;
COMMIT;
-- ── Verification (demo-user-scoped, all five score channels) ───────────────────
\echo === post-apply score coverage (demo user) ===
SELECT type, count(*) AS rows, min(measured_at)::date AS oldest, max(measured_at)::date AS newest
FROM measurements
WHERE user_id = :'uid'
AND type IN ('RECOVERY_SCORE', 'STRAIN_SCORE', 'STRESS_SCORE')
GROUP BY type
ORDER BY type;
\echo (READINESS + SLEEP_SCORE are compute-on-read composites — not stored, render from underlying signals.)
+3 -3
View File
@@ -1,7 +1,7 @@
-- v1.5.0 demo seed — sliding-window, NOW()-anchored, full iOS+Withings story.
--
-- The pre-v1.5 demo seed shipped only WEIGHT / BLOOD_PRESSURE / PULSE /
-- BODY_FAT / SLEEP / STEPS — and the demo DB on edge-01 had drifted to
-- BODY_FAT / SLEEP / STEPS, and the demo DB had drifted to
-- a static snapshot whose last reading was ten days old. For the iOS
-- public-beta cut we need a fresh picture: data through today, every
-- type the dashboard tiles can render, a source mix that shows the
@@ -17,8 +17,8 @@
-- falls back to live SQL (the slim rollup-tier reader) and the boot
-- backfill repopulates on the next entrypoint sweep.
--
-- Usage on edge-01:
-- docker exec -i agos0oo88gsgg88kcg4swosw \
-- Usage on the demo host:
-- docker exec -i <demo-db-container> \
-- psql -U healthlog -d healthlog < seed-demo-v15.sql
BEGIN;
-198
View File
@@ -1,198 +0,0 @@
#!/usr/bin/env node
import { readFileSync, statSync } from "node:fs";
import { resolve } from "node:path";
const ledgerArg = process.argv[2];
if (!ledgerArg) {
console.error("usage: node scripts/v1341-audit-ledger-check.mjs <ledger.md>");
process.exit(2);
}
const ledgerPath = resolve(ledgerArg);
const MAX_BYTES = 256 * 1024;
const REQUIRED_FIELDS = [
"Source",
"Status",
"Severity",
"Category",
"Evidence",
"Affected path",
"Attempted refutation",
"Impact",
"Requirement",
"Planned verification",
"Confidence",
"Unresolved",
];
const ALLOWED_STATUS = new Set([
"confirmed",
"suspected",
"blocked",
"cleared",
"refuted",
]);
const ALLOWED_SEVERITY = new Set(["critical", "high", "medium", "low", "info"]);
const ALLOWED_CONFIDENCE = new Set(["high", "medium", "low"]);
const REQUIRED_SOURCES = ["research:", "correctness:", "live:", "performance:"];
const REQUIRED_CATEGORIES = [
"day-boundary",
"silent-success",
"cache-freshness",
"persistence",
"outcome-honesty",
];
const PRIVACY_PATTERNS = [
{
name: "email address",
pattern: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i,
},
{
name: "authorization credential",
pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]+/i,
},
{
name: "secret assignment",
pattern:
/\b(?:token|secret|password|api[_-]?key|client[_-]?secret)\s*[:=]\s*["']?[A-Za-z0-9._~+/=-]{8,}/i,
},
{
name: "URL or endpoint with authority",
pattern: /\bhttps?:\/\/[^\s)>\]]+/i,
},
{
name: "IPv4 address",
pattern: /\b(?:\d{1,3}\.){3}\d{1,3}\b/,
},
{
name: "UUID-like identifier",
pattern:
/\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/i,
},
{
name: "long hexadecimal identifier",
pattern: /\b[0-9a-f]{32,}\b/i,
},
{
name: "local user filesystem path",
pattern: /(?:\/Users\/|\/home\/)[^\s`]+/,
},
{
name: "explicit health value",
pattern:
/\b(?:glucose|blood pressure|heart rate|weight|bmi|hba1c)\s*(?:value\s*)?[:=]\s*-?\d+(?:\.\d+)?/i,
},
];
const errors = [];
let source;
try {
if (statSync(ledgerPath).size > MAX_BYTES) {
errors.push(`ledger exceeds ${MAX_BYTES} bytes`);
}
source = readFileSync(ledgerPath, "utf8");
} catch (error) {
console.error(
`v1341-audit-ledger-check: cannot read ledger (${error instanceof Error ? error.message : "unknown error"})`,
);
process.exit(2);
}
for (const check of PRIVACY_PATTERNS) {
if (check.pattern.test(source))
errors.push(`privacy-dangerous ${check.name} detected`);
}
const headingPattern = /^## (AUD-\d{3}) — (.+)$/gm;
const headings = [...source.matchAll(headingPattern)];
if (headings.length === 0) errors.push("no finding headings found");
if (headings.length > 200) errors.push("finding count exceeds 200");
const ids = new Set();
const sourceRecords = new Set();
const records = [];
for (let index = 0; index < headings.length; index += 1) {
const match = headings[index];
const id = match[1];
const title = match[2].trim();
const bodyStart = match.index + match[0].length;
const bodyEnd = headings[index + 1]?.index ?? source.length;
const body = source.slice(bodyStart, bodyEnd);
const fields = new Map();
if (ids.has(id)) errors.push(`${id}: duplicate finding ID`);
ids.add(id);
if (!title) errors.push(`${id}: missing title`);
for (const line of body.split("\n")) {
const fieldMatch = line.match(/^- \*\*([^*]+):\*\* (.+)$/);
if (!fieldMatch) continue;
const [, name, value] = fieldMatch;
if (fields.has(name)) errors.push(`${id}: duplicate field ${name}`);
fields.set(name, value.trim());
}
for (const field of REQUIRED_FIELDS) {
if (!fields.get(field)) errors.push(`${id}: missing ${field}`);
}
const status = fields.get("Status")?.toLowerCase();
const severity = fields.get("Severity")?.toLowerCase();
const confidence = fields.get("Confidence")?.toLowerCase();
const unresolved = fields.get("Unresolved")?.toLowerCase();
const sourceField = fields.get("Source");
if (status && !ALLOWED_STATUS.has(status)) {
errors.push(`${id}: invalid Status ${status}`);
}
if (severity && !ALLOWED_SEVERITY.has(severity)) {
errors.push(`${id}: invalid Severity ${severity}`);
}
if (confidence && !ALLOWED_CONFIDENCE.has(confidence)) {
errors.push(`${id}: invalid Confidence ${confidence}`);
}
if (unresolved && !["yes", "no"].includes(unresolved)) {
errors.push(`${id}: Unresolved must be yes or no`);
}
if (["cleared", "refuted"].includes(status) && unresolved !== "no") {
errors.push(`${id}: ${status} findings must set Unresolved to no`);
}
if (sourceField) {
if (sourceRecords.has(sourceField))
errors.push(`${id}: duplicate Source record`);
sourceRecords.add(sourceField);
}
records.push({ id, fields });
}
for (let index = 0; index < records.length; index += 1) {
const expected = `AUD-${String(index + 1).padStart(3, "0")}`;
if (records[index].id !== expected) {
errors.push(`${records[index].id}: expected sequential ID ${expected}`);
}
}
for (const prefix of REQUIRED_SOURCES) {
if (
!records.some((record) => record.fields.get("Source")?.includes(prefix))
) {
errors.push(`missing source family ${prefix}`);
}
}
for (const category of REQUIRED_CATEGORIES) {
if (!records.some((record) => record.fields.get("Category") === category)) {
errors.push(`missing required category ${category}`);
}
}
if (errors.length > 0) {
console.error(`v1341-audit-ledger-check: FAIL (${errors.length} issue(s))`);
for (const error of errors) console.error(`- ${error}`);
process.exit(1);
}
console.log(
`v1341-audit-ledger-check: PASS (${records.length} unique findings; privacy scan clean)`,
);
-356
View File
@@ -1,356 +0,0 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { writeFileSync } from "node:fs";
import { arch, cpus, platform, release } from "node:os";
import { resolve } from "node:path";
import { performance } from "node:perf_hooks";
const LIMITS = Object.freeze({
deadlineMs: 30_000,
warmSamples: 11,
googlePages: 786,
googleRowsPerPage: 32,
reminderMedications: 2_000,
reminderSlotsPerMedication: 4,
reminderCleanupBacklog: 2_000,
healthDenseDays: 42,
healthSamplesPerDay: 1_440,
healthSparseDays: 379,
healthSparseSamplesPerDay: 8,
});
const args = process.argv.slice(2);
const fixtureMode = args.includes("--fixture");
const outputIndex = args.indexOf("--output");
const outputPath =
outputIndex >= 0 && args[outputIndex + 1]
? resolve(args[outputIndex + 1])
: null;
function fail(message) {
console.error(`v1341-performance-baseline: ${message}`);
process.exit(2);
}
if (!fixtureMode) {
fail(
"refusing to run without --fixture; live and production probes are unsupported",
);
}
if (process.env.NODE_ENV === "production") {
fail("refusing to run with NODE_ENV=production");
}
if (!outputPath) {
fail("--output <markdown-path> is required");
}
if (args.some((arg) => /^(--url|--host|--database|--token|--live)/.test(arg))) {
fail(
"network, database, credential, and live-target arguments are unsupported",
);
}
const startedAt = performance.now();
const deadlineAt = startedAt + LIMITS.deadlineMs;
function assertWithinDeadline() {
if (performance.now() > deadlineAt) {
throw new Error(`fixture deadline exceeded (${LIMITS.deadlineMs} ms)`);
}
}
function seededNumber(index, salt) {
let value = (index + 1) ^ salt;
value = Math.imul(value ^ (value >>> 16), 0x45d9f3b);
value = Math.imul(value ^ (value >>> 16), 0x45d9f3b);
return (value ^ (value >>> 16)) >>> 0;
}
function percentile(sorted, fraction) {
return sorted[
Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)
];
}
function formatMs(value) {
return value.toFixed(3);
}
function formatBytes(value) {
const sign = value < 0 ? "-" : "";
return `${sign}${(Math.abs(value) / 1024 / 1024).toFixed(2)} MiB`;
}
function measure(operation) {
assertWithinDeadline();
const beforeHeap = process.memoryUsage().heapUsed;
const before = performance.now();
const result = operation();
const elapsedMs = performance.now() - before;
const heapDeltaBytes = process.memoryUsage().heapUsed - beforeHeap;
assertWithinDeadline();
return { elapsedMs, heapDeltaBytes, result };
}
function benchmark(name, operation) {
const cold = measure(operation);
const warm = [];
for (let index = 0; index < LIMITS.warmSamples; index += 1) {
warm.push(measure(operation));
}
const elapsed = warm.map((sample) => sample.elapsedMs).sort((a, b) => a - b);
const heaps = warm.map((sample) => sample.heapDeltaBytes);
const checksums = new Set([
cold.result.checksum,
...warm.map((sample) => sample.result.checksum),
]);
if (checksums.size !== 1) {
throw new Error(`${name} fixture produced a non-deterministic checksum`);
}
return {
name,
coldMs: cold.elapsedMs,
coldHeapBytes: cold.heapDeltaBytes,
medianMs: percentile(elapsed, 0.5),
p95Ms: percentile(elapsed, 0.95),
maxMs: elapsed.at(-1),
maxWarmHeapBytes: Math.max(...heaps),
checksum: cold.result.checksum,
counters: cold.result.counters,
};
}
function googleHistoryFixture() {
const accumulated = [];
let checksum = 0;
let finalTokenPresent = false;
for (let page = 0; page < LIMITS.googlePages; page += 1) {
const encodedPage = JSON.stringify({
rows: Array.from({ length: LIMITS.googleRowsPerPage }, (_, row) => ({
t: page * LIMITS.googleRowsPerPage + row,
v: seededNumber(page * LIMITS.googleRowsPerPage + row, 17) % 10_000,
})),
next: page + 1 < LIMITS.googlePages ? `fixture-page-${page + 1}` : null,
});
const decodedPage = JSON.parse(encodedPage);
accumulated.push(...decodedPage.rows);
finalTokenPresent = decodedPage.next !== null;
}
for (const row of accumulated) {
checksum = (checksum + row.t * 17 + row.v) >>> 0;
}
return {
checksum,
counters: {
pages: LIMITS.googlePages,
rows: accumulated.length,
concurrency: 1,
finalTokenPresent,
},
};
}
function reminderCohortFixture() {
let checksum = 0;
let dueCandidates = 0;
let modeledQueries = 1;
for (let cleanup = 0; cleanup < LIMITS.reminderCleanupBacklog; cleanup += 1) {
checksum = (checksum + seededNumber(cleanup, 31)) >>> 0;
}
for (
let medication = 0;
medication < LIMITS.reminderMedications;
medication += 1
) {
modeledQueries += 2;
for (let slot = 0; slot < LIMITS.reminderSlotsPerMedication; slot += 1) {
const phase = seededNumber(
medication * LIMITS.reminderSlotsPerMedication + slot,
43,
);
if (phase % 5 === 0) {
dueCandidates += 1;
modeledQueries += 2;
}
checksum = (checksum + (phase & 0xffff)) >>> 0;
}
}
return {
checksum,
counters: {
medications: LIMITS.reminderMedications,
slots: LIMITS.reminderMedications * LIMITS.reminderSlotsPerMedication,
cleanupBacklog: LIMITS.reminderCleanupBacklog,
dueCandidates,
modeledQueries,
concurrency: 1,
},
};
}
function healthScoreFixture() {
const denseRows = Array.from(
{ length: LIMITS.healthDenseDays * LIMITS.healthSamplesPerDay },
(_, index) => ({
day: Math.floor(index / LIMITS.healthSamplesPerDay),
source: index % 3,
value: seededNumber(index, 59) % 1_000,
}),
);
const sparseRows = Array.from(
{ length: LIMITS.healthSparseDays * LIMITS.healthSparseSamplesPerDay },
(_, index) => ({
day: Math.floor(index / LIMITS.healthSparseSamplesPerDay),
domain: index % 8,
value: seededNumber(index, 71) % 1_000,
}),
);
const dailyCanonical = new Map();
for (const row of denseRows) {
const prior = dailyCanonical.get(row.day);
if (!prior || row.source < prior.source) dailyCanonical.set(row.day, row);
}
const domainTotals = new Array(8).fill(0);
const domainCounts = new Array(8).fill(0);
for (const row of sparseRows) {
domainTotals[row.domain] += row.value;
domainCounts[row.domain] += 1;
}
let checksum = 0;
for (const row of dailyCanonical.values())
checksum = (checksum + row.value) >>> 0;
for (let domain = 0; domain < domainTotals.length; domain += 1) {
const normalized = Math.round(domainTotals[domain] / domainCounts[domain]);
checksum = (checksum * 31 + normalized) >>> 0;
}
return {
checksum,
counters: {
denseRows: denseRows.length,
sparseRows: sparseRows.length,
canonicalDays: dailyCanonical.size,
domains: domainTotals.length,
concurrency: 1,
parity: "stable-checksum",
},
};
}
function commitSha() {
try {
return execFileSync("git", ["rev-parse", "--short", "HEAD"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
} catch {
return "unavailable";
}
}
function counterSummary(result) {
return Object.entries(result.counters)
.map(([key, value]) => `${key}=${value}`)
.join("; ");
}
try {
const results = [
benchmark("Google history pagination shape", googleHistoryFixture),
benchmark("Medication reminder cohort shape", reminderCohortFixture),
benchmark("Health Score read/canonicalization shape", healthScoreFixture),
];
const durationMs = performance.now() - startedAt;
const generatedAt = new Date().toISOString();
const markdown = `# v1.34.1 Performance Baseline
**Generated:** ${generatedAt}
**Commit:** \`${commitSha()}\`
**Exit status:** PASS
**Mode:** deterministic local fixture only
## Safety and interpretation
- No network, database, provider, production, or staging endpoint was contacted.
- Fixtures contain generated counters only. The report emits no tokens, identifiers, payloads, health values, hostnames, usernames, or working-directory paths.
- Concurrency is fixed at one, warm sample count is ${LIMITS.warmSamples}, and the whole run has a ${LIMITS.deadlineMs / 1000}-second deadline.
- Timing is a local code-shape baseline, not a production latency or capacity claim. Database, provider, network, framework, cache, and rendering costs remain unmeasured.
- The Health Score fixture exercises dense/sparse row construction, source canonicalization, and a stable composite proxy. It does not replace production algorithm parity or database-backed performance tests.
## Environment
| Attribute | Value |
| --- | --- |
| Node | ${process.version} |
| Platform | ${platform()} ${release()} |
| Architecture | ${arch()} |
| Logical CPUs | ${cpus().length} |
| Process | single local Node process |
| Total harness duration | ${formatMs(durationMs)} ms |
## Fixture scale
| Path shape | Fixed scale |
| --- | --- |
| Google history | ${LIMITS.googlePages} serial pages × ${LIMITS.googleRowsPerPage} generated rows; terminal page has no remaining token |
| Reminder cohort | ${LIMITS.reminderMedications} medications × ${LIMITS.reminderSlotsPerMedication} slots plus ${LIMITS.reminderCleanupBacklog} cleanup records |
| Health Score | ${LIMITS.healthDenseDays} dense days × ${LIMITS.healthSamplesPerDay} samples plus ${LIMITS.healthSparseDays} sparse days × ${LIMITS.healthSparseSamplesPerDay} samples |
## Measurements
| Local path shape | Cold | Warm median | Warm p95 | Warm max | Cold heap delta | Max warm heap delta |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
${results
.map(
(result) =>
`| ${result.name} | ${formatMs(result.coldMs)} ms | ${formatMs(result.medianMs)} ms | ${formatMs(result.p95Ms)} ms | ${formatMs(result.maxMs)} ms | ${formatBytes(result.coldHeapBytes)} | ${formatBytes(result.maxWarmHeapBytes)} |`,
)
.join("\n")}
### Determinism and bounded counters
${results
.map(
(result) =>
`- **${result.name}:** checksum \`${result.checksum.toString(16).padStart(8, "0")}\`; ${counterSummary(result)}.`,
)
.join("\n")}
## Route/path interpretation
| Representative path | What this fixture measures | What it does not measure |
| --- | --- | --- |
| Google full-sync pagination | Serial JSON page materialization and accumulation at the observed 786-page shape | Provider latency, DB writes, token refresh, rollups, or a safe universal page ceiling |
| Medication reminder worker | Serial cohort/slot decisions, cleanup scan, and modeled query amplification | Prisma latency, locks, dispatch latency, or the live cohort size |
| Dashboard / analytics Health Score | Dense/sparse row processing, canonical selection, and stable composite proxy | SQL plans, cache behavior, exact production score parity, or user-visible route latency |
## Hypotheses and unmeasured risks
- **Hypothesis:** Serial provider latency and write statements dominate the Google full-sync wall time; this local CPU result neither confirms nor refutes that hypothesis.
- **Hypothesis:** Reminder query/dispatch amplification, not local loop cost, determines whether a high-water pass approaches its 15-minute cadence.
- **Hypothesis:** Cold database row materialization and canonicalization dominate dense-user Health Score work; production impact remains unconfirmed.
- **Unmeasured:** network and database tails, cache hit/miss ratios, cross-process invalidation, framework rendering, device/browser performance, and production cohort distributions.
- **Do not infer:** a provider defect, a safe page cap, a production regression, or permission to optimize from this report alone.
## Reproduction
\`node scripts/v1341-performance-baseline.mjs --fixture --output .planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-PERFORMANCE-BASELINE.md\`
`;
writeFileSync(outputPath, markdown, { encoding: "utf8", mode: 0o600 });
console.log(
`v1341-performance-baseline: PASS (${results.length} fixtures, ${formatMs(durationMs)} ms)`,
);
} catch (error) {
console.error(
`v1341-performance-baseline: FAIL (${error instanceof Error ? error.message : "unknown error"})`,
);
process.exit(1);
}
-305
View File
@@ -1,305 +0,0 @@
/**
* v1.37.0 integration verifier — the inventories, and whether any of them is empty.
*
* ## What this is for
*
* The release is proved by inventories: a migration allocation, a set of
* admitted mutating handlers, a notification delivery matrix, a set of security
* principals, five accessibility states and eight journeys. Every one of them is
* consumed by a test that iterates it, and every one of those tests passes
* trivially when the list it iterates is empty. That is the failure this script
* exists to catch, and it is not hypothetical — three separate checks in this
* repository have been found green because their matcher matched nothing.
*
* So the rule here is that a count is asserted before it is used, and asserted
* against a number written down rather than against "more than zero" alone.
* A list that lost half its entries is not caught by a non-empty check.
*
* ## What this is NOT
*
* It does not run the suites and it cannot tell whether the tests that consume
* these inventories assert anything worth asserting. It answers one question —
* "is there something in each of these, and is it the amount there is supposed
* to be" — and leaves the rest to the tests themselves.
*
* ## Usage
*
* pnpm dlx tsx scripts/verify-v137-adversarial.ts # everything
* pnpm dlx tsx scripts/verify-v137-adversarial.ts --check-inventories # counts only
* pnpm dlx tsx scripts/verify-v137-adversarial.ts --check-migrations # allocation only
*
* Exits non-zero on the first failing assertion, and prints every check it ran
* so a run that checked nothing is visible as a run that printed nothing.
*/
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import {
ACCESSIBILITY_STATES,
SHARING_ACCESSIBILITY_STATES,
V137_E2E_JOURNEYS,
} from "../tests/fixtures/v137/e2e-journeys";
import { NOTIFICATION_DELIVERY_MATRIX } from "../tests/fixtures/v137/notification-matrix";
import { SECURITY_PRINCIPALS } from "../tests/fixtures/v137/security-principals";
import { ADMITTED_MUTATING_HANDLERS } from "../tests/fixtures/v137/sharing-matrix";
const ROOT = process.cwd();
let failures = 0;
let checks = 0;
function check(label: string, ok: boolean, detail: string): void {
checks += 1;
if (ok) {
console.log(` ok ${label} — ${detail}`);
return;
}
console.error(` FAIL ${label} — ${detail}`);
failures += 1;
}
function expectCount(label: string, actual: number, expected: number): void {
check(label, actual === expected, `${actual} (expected ${expected})`);
}
/* -------------------------------------------------------------------------- */
/* Migration allocation */
/* -------------------------------------------------------------------------- */
/**
* The release's allocation, corrected. The synthesis said 0296-0299 and called
* the range frozen; the release shipped seven. The list is here so a builder
* inventing an eighth number, or a merge quietly renumbering one of these,
* fails rather than being noticed at the release audit or not at all.
*/
const MIGRATION_ALLOCATION = [
"0296_account_grant_scope_and_manage",
"0297_managed_profile",
"0298_notification_delivery_identity",
"0299_medication_intake_import_actor_attribution",
"0300_notification_event_created_at_index",
"0301_notification_egress_authorization",
"0302_session_record_epoch",
] as const;
/**
* Triggers this release installs. Neither is modelled in the Prisma schema, so
* `migrate diff` has nothing to compare them against and cannot report one
* going missing — the drift check is structurally blind here. Their presence in
* the migration text is the cheapest honest signal; the behaviour is proved by
* the integration suites named beside each one.
*/
const MIGRATION_TRIGGERS: ReadonlyArray<{
trigger: string;
migration: string;
provenBy: string;
}> = [
{
trigger: "push_attempt_attribution_guard",
migration: "0298_notification_delivery_identity",
provenBy: "tests/integration/notification-attribution.test.ts",
},
{
trigger: "sessions_record_epoch_bump",
migration: "0302_session_record_epoch",
provenBy: "tests/integration/record-session-epoch-trigger.test.ts",
},
];
function checkMigrations(): void {
console.log("migration allocation");
const dir = join(ROOT, "prisma/migrations");
const present = readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
check(
"the migrations directory really loaded",
present.length > 100,
`${present.length} migrations on disk`,
);
const release = present.filter((name) => /^(029[6-9]|030[0-2])_/.test(name));
expectCount(
"the release allocates exactly seven",
release.length,
MIGRATION_ALLOCATION.length,
);
for (const expected of MIGRATION_ALLOCATION) {
check(
`allocated ${expected.slice(0, 4)}`,
present.includes(expected),
expected,
);
}
// Every number used once. A collision is the thing renumbering exists to
// prevent, and two directories sharing a prefix apply in an order nobody
// chose.
const releaseNumbers = release.map((name) => name.slice(0, 4));
const duplicated = releaseNumbers.filter(
(n, i) => releaseNumbers.indexOf(n) !== i,
);
check(
"no number is allocated twice in the release range",
duplicated.length === 0,
duplicated.length === 0 ? "none" : duplicated.join(", "),
);
for (const { trigger, migration, provenBy } of MIGRATION_TRIGGERS) {
const sql = readFileSync(join(dir, migration, "migration.sql"), "utf8");
check(
`${migration} installs ${trigger}`,
sql.includes(trigger),
`behaviour proved by ${provenBy}`,
);
}
}
/* -------------------------------------------------------------------------- */
/* Inventories */
/* -------------------------------------------------------------------------- */
function checkInventories(): void {
console.log("inventories");
// Handlers. Every admitted mutation is meant to run level, owner, actor,
// effect and encrypted-field cases; an empty list runs none of them and the
// matrix test reports the same green either way.
check(
"the admitted mutating handlers are non-empty",
ADMITTED_MUTATING_HANDLERS.length > 0,
`${ADMITTED_MUTATING_HANDLERS.length} handlers`,
);
// Keyed on route AND action, because one route legitimately appears more
// than once: an `[id]` route carries an update and a delete, and they are
// different contracts with different admitted levels. Keying on the route
// alone reported eleven false collisions on the first run of this script.
const handlerKeys = new Set(
ADMITTED_MUTATING_HANDLERS.map((h) => `${h.action} ${h.route}`),
);
expectCount(
"no route and action pair is admitted twice",
handlerKeys.size,
ADMITTED_MUTATING_HANDLERS.length,
);
check(
"every handler names a module that exists",
ADMITTED_MUTATING_HANDLERS.every((h) => {
try {
return (
readFileSync(join(ROOT, "src", h.handlerModule), "utf8").length > 0
);
} catch {
return false;
}
}),
"each handlerModule resolves under src/",
);
// The notification matrix is two records by two Guardians by four channels.
// Written as a product, so a dropped channel shows up here as sixteen minus
// four rather than as a suite that simply ran fewer cases.
expectCount(
"the notification delivery matrix is 2 records x 2 Guardians x 4 channels",
NOTIFICATION_DELIVERY_MATRIX.length,
16,
);
check(
"no delivery in the matrix is interactive",
NOTIFICATION_DELIVERY_MATRIX.every((row) => row.interactive === false),
"a Guardian's notification offers no action buttons",
);
// Security principals. Each names an actor, a record and a recipient; a
// principal missing one of the three would let a test assert against
// undefined and pass.
const principals = Object.entries(SECURITY_PRINCIPALS);
check(
"the security principals are non-empty",
principals.length > 0,
`${principals.length} principals`,
);
for (const [name, principal] of principals) {
const { actorUserId, recordUserId, recipientUserId } = principal.identities;
check(
`${name} names all three identities`,
Boolean(actorUserId && recordUserId && recipientUserId),
`${actorUserId} / ${recordUserId} / ${recipientUserId}`,
);
}
// Accessibility states and journeys. Both are enumerations the browser suite
// is read back against, and both are exact rather than non-empty: a ninth
// journey nobody counted and a fifth state quietly absorbed into a fourth are
// the two failures the enumeration exists to make visible.
expectCount(
"exactly five accessibility states",
ACCESSIBILITY_STATES.length,
5,
);
expectCount(
"every state carries a case",
SHARING_ACCESSIBILITY_STATES.length,
5,
);
expectCount("exactly eight journeys", V137_E2E_JOURNEYS.length, 8);
expectCount(
"every journey id is distinct",
new Set(V137_E2E_JOURNEYS.map((j) => j.id)).size,
8,
);
// Each journey id must appear in a test title somewhere under e2e/ or tests/
// or src/__tests__/. `sharing-accessibility-states.test.tsx` pins WHICH file
// carries each one; this leg is the cheaper cross-check that none of the
// eight has become a claim with no test anywhere at all.
const searched = [
...readdirSync(join(ROOT, "e2e")).map((f) => join("e2e", f)),
...readdirSync(join(ROOT, "tests/integration")).map((f) =>
join("tests/integration", f),
),
...readdirSync(join(ROOT, "src/__tests__")).map((f) =>
join("src/__tests__", f),
),
].filter((f) => f.endsWith(".ts") || f.endsWith(".tsx"));
check(
"the test tree really loaded",
searched.length > 50,
`${searched.length} files scanned`,
);
const corpus = searched
.map((f) => readFileSync(join(ROOT, f), "utf8"))
.join("\n");
for (const journey of V137_E2E_JOURNEYS) {
check(
`${journey.id} is marked in a test title`,
corpus.includes(`[${journey.id}]`),
journey.claim.slice(0, 60) + "…",
);
}
}
/* -------------------------------------------------------------------------- */
const argv = process.argv.slice(2);
const only = argv.filter((a) => a.startsWith("--"));
const runMigrations = only.length === 0 || only.includes("--check-migrations");
const runInventories =
only.length === 0 || only.includes("--check-inventories");
if (runMigrations) checkMigrations();
if (runInventories) checkInventories();
// A run that asserted nothing is a run that proves nothing, and it would exit 0.
if (checks === 0) {
console.error("no checks ran — refusing to report success");
process.exit(1);
}
console.log(
`\n${checks} check(s), ${failures} failure(s)` +
(failures === 0 ? " — inventories are non-empty and exact" : ""),
);
process.exit(failures === 0 ? 0 : 1);
+1 -1
View File
@@ -23,4 +23,4 @@ client → src/proxy.ts → src/app/<route>/route.ts → src/lib/api-handler.ts
## Conventions
See [`../CLAUDE.md`](../CLAUDE.md) for the full set. The load-bearing ones: RSC by default (`"use client"` only when a hook/state/browser API needs it); every API route wraps `apiHandler` and parses its body with Zod `safeParse`; `userId` is always narrowed from session or Bearer, never accepted as a body field; TanStack Query keys come only from `src/lib/query-keys/`; outbound HTTP goes through `src/lib/safe-fetch.ts`.
The load-bearing ones: RSC by default (`"use client"` only when a hook/state/browser API needs it); every API route wraps `apiHandler` and parses its body with Zod `safeParse`; `userId` is always narrowed from session or Bearer, never accepted as a body field; TanStack Query keys come only from `src/lib/query-keys/`; outbound HTTP goes through `src/lib/safe-fetch.ts`.
+1 -1
View File
@@ -51,7 +51,7 @@ import {
stripComments,
walkSourceFiles,
} from "./helpers/source-files";
import { QUEUE_RUNTIME, type QueueRuntime } from "@/lib/jobs/queue-runtime";
import { QUEUE_RUNTIME, type QueueRuntime } from "./helpers/queue-runtime";
import {
COMPLETION_CEILING_MS,
COMPREHENSIVE_WARM_CEILING_MS,
@@ -10,7 +10,23 @@ import {
NOT_IN_BACKUP_MODELS,
RAW_SQL_TABLES,
} from "@/lib/export/backup-plan";
import { backupVerdict } from "@/lib/export/backup-verdict";
type BackupVerdict = "BACKED_UP" | "DERIVED" | "NOT_IN_BACKUP" | "AUTH";
/**
* The verdict for a model, or `null` when the schema carries a model the plan
* has never ruled on. The guard below turns that `null` into a failing test:
* a new model gets a decision, not a default.
*/
function backupVerdict(model: string): BackupVerdict | null {
if ((BACKED_UP_MODELS as readonly string[]).includes(model)) {
return "BACKED_UP";
}
if (model in DERIVED_MODELS) return "DERIVED";
if (model in NOT_IN_BACKUP_MODELS) return "NOT_IN_BACKUP";
if (AUTH_MODELS_OUT_OF_SCOPE.includes(model)) return "AUTH";
return null;
}
/**
* Every model in the schema has a written verdict about the backup.
@@ -1,7 +1,7 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 84,
"total": 93830,
"total": 93802,
"entries": {
"app/medications/page-client.tsx": 839,
"components/charts/mood-chart.tsx": 1166,
@@ -13,7 +13,7 @@
"components/insights/health-score-card.tsx": 1023,
"components/insights/insights-tab-strip.tsx": 1084,
"components/measurements/measurement-form.tsx": 815,
"components/medications/detail/medication-detail-tabs.tsx": 1157,
"components/medications/detail/medication-detail-tabs.tsx": 1129,
"components/medications/medication-table.tsx": 853,
"components/medications/wizard/wizard-payload.ts": 1414,
"components/mood/mood-list.tsx": 1408,
@@ -1,7 +1,7 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 336,
"total": 109328,
"total": 109312,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275,
@@ -145,10 +145,10 @@
"components/measurement-reminders/vorsorge-section.tsx :: VorsorgeSection": 352,
"components/measurements/measurement-form.tsx :: MeasurementForm": 496,
"components/medications/detail/efficacy/efficacy-chart.tsx :: EfficacyChart": 361,
"components/medications/detail/medication-detail-tabs.tsx :: MedicationDetailTabs": 767,
"components/medications/detail/medication-detail-tabs.tsx :: MedicationDetailTabs": 760,
"components/medications/dose-history-add-dialog.tsx :: LedgerAddDialog": 221,
"components/medications/dose-strength-curve.tsx :: DoseStrengthCurve": 266,
"components/medications/glp1-medication-card.tsx :: Glp1MedicationCard": 405,
"components/medications/glp1-medication-card.tsx :: Glp1MedicationCard": 396,
"components/medications/injection-site-picker.tsx :: InjectionSitePicker": 287,
"components/medications/intake-history-list-v2.tsx :: IntakeHistoryListV2": 403,
"components/medications/intake-import-dialog.tsx :: IntakeImportDialog": 201,
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$comment": "as never casts in tests, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
"count": 523,
"total": 5459,
"total": 5458,
"entries": {
"__tests__/api/auth/login/mfa-branch.test.ts": 10,
"__tests__/api/auth/mfa/verify.test.ts": 9,
@@ -496,7 +496,7 @@
"lib/medications/scheduling/__tests__/dose-history.test.ts": 1,
"lib/medications/scheduling/__tests__/pin-conflict.test.ts": 2,
"lib/modules/__tests__/measurement-scope.test.ts": 3,
"lib/notifications/__tests__/admin-locale.test.ts": 7,
"lib/notifications/__tests__/admin-locale.test.ts": 6,
"lib/notifications/__tests__/client-managed-apns.test.ts": 9,
"lib/notifications/__tests__/dispatch-localised-cache.test.ts": 5,
"lib/notifications/__tests__/dispatcher-global-channel-switch.test.ts": 8,
@@ -1,7 +1,7 @@
{
"$comment": "tests that read a source file by path, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
"count": 337,
"total": 337,
"count": 332,
"total": 332,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 1,
"__tests__/admin-data-wipe-completeness.test.ts": 1,
@@ -87,7 +87,6 @@
"__tests__/module-gate-record-scoped.test.ts": 1,
"__tests__/mood-backup-completeness.test.ts": 1,
"__tests__/mood-prediction-write-surface-guard.test.ts": 1,
"__tests__/moodlog-removal-guard.test.ts": 1,
"__tests__/next-image-optimizer-guard.test.ts": 1,
"__tests__/no-server-actions-guard.test.ts": 1,
"__tests__/oidc-native-error-reasons-documented.test.ts": 1,
@@ -141,7 +140,6 @@
"__tests__/unit-preference-display-guard.test.ts": 1,
"__tests__/unit-suite-browser-install-guard.test.ts": 1,
"__tests__/utc-day-key-allowlist-guard.test.ts": 1,
"__tests__/v1341-locale-parity.test.ts": 1,
"__tests__/visit-link-guard.test.ts": 1,
"__tests__/workout-canonical-identity-guard.test.ts": 1,
"__tests__/write-rate-limit-guard.test.ts": 1,
@@ -174,7 +172,6 @@
"app/insights/__tests__/page-skeletons.test.ts": 1,
"app/insights/sleep/__tests__/empty-state-href.test.ts": 1,
"app/measurements/__tests__/add-token-resolution.test.ts": 1,
"app/medications/__tests__/detail-page-history-surface.test.ts": 1,
"components/__tests__/design-consistency-guards.test.tsx": 1,
"components/__tests__/failed-read-never-empty.test.tsx": 1,
"components/__tests__/motion-reduce-spin-coverage.test.ts": 1,
@@ -182,7 +179,6 @@
"components/admin/__tests__/notification-test-failures.test.ts": 1,
"components/charts/__tests__/chart-error-state.test.tsx": 1,
"components/charts/__tests__/health-chart-overlay-defaults.test.tsx": 1,
"components/charts/__tests__/health-chart-target-link-removed.test.ts": 1,
"components/charts/__tests__/mood-chart-polish.test.tsx": 1,
"components/charts/__tests__/touch-action-guard.test.ts": 1,
"components/cycle/__tests__/use-cycle-ring-has-dial.test.tsx": 1,
@@ -206,7 +202,6 @@
"components/insights/coach-panel/__tests__/open-at-end.test.ts": 1,
"components/insights/coach-panel/__tests__/panel-touch-targets.test.ts": 1,
"components/insights/coach-panel/__tests__/scroll-to-bottom-button.test.tsx": 1,
"components/insights/mood/__tests__/mood-notes-timeline-removed.test.ts": 1,
"components/layout/__tests__/auth-shell-coach-hoist.test.ts": 1,
"components/layout/__tests__/auth-shell-shared-record-doors.test.tsx": 1,
"components/layout/__tests__/offline-banner.test.tsx": 1,
@@ -1,7 +1,7 @@
{
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
"count": 2883,
"total": 9186,
"count": 2876,
"total": 9172,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -68,7 +68,6 @@
"__tests__/success-affordance-guard.test.ts": 8,
"__tests__/timezone-explicit-formatter-guard.test.ts": 1,
"__tests__/unit-preference-display-guard.test.ts": 2,
"__tests__/v1341-locale-parity.test.ts": 1,
"__tests__/v1391-additive-contract.test.ts": 1,
"__tests__/write-rate-limit-guard.test.ts": 1,
"app/__tests__/dashboard-analytics-split.test.ts": 2,
@@ -654,7 +653,6 @@
"app/mcp/__tests__/route.test.ts": 5,
"app/measurements/page.tsx": 5,
"app/medications/__tests__/category-filter.test.tsx": 2,
"app/medications/__tests__/detail-page-history-surface.test.ts": 1,
"app/medications/__tests__/list-view-toggle.test.tsx": 5,
"app/medications/__tests__/page-prefetch.test.tsx": 1,
"app/medications/__tests__/take-all-due-affordance.test.tsx": 1,
@@ -735,7 +733,6 @@
"components/charts/__tests__/health-chart-polish.test.tsx": 2,
"components/charts/__tests__/health-chart-preloaded-coverage.test.tsx": 1,
"components/charts/__tests__/health-chart-range.test.tsx": 3,
"components/charts/__tests__/health-chart-target-link-removed.test.ts": 1,
"components/charts/__tests__/health-chart-vertical-markers.test.ts": 1,
"components/charts/__tests__/medication-chart-polish.test.tsx": 1,
"components/charts/__tests__/medication-compliance-chart-empty-state-gate.test.tsx": 1,
@@ -1001,7 +998,6 @@
"components/insights/mood/__tests__/mood-discovered-relations.test.tsx": 3,
"components/insights/mood/__tests__/mood-insights-sections-in-target.test.tsx": 1,
"components/insights/mood/__tests__/mood-insights-sections-reveal.test.tsx": 1,
"components/insights/mood/__tests__/mood-notes-timeline-removed.test.ts": 4,
"components/insights/mood/__tests__/mood-tag-bars-muted.test.tsx": 1,
"components/insights/mood/mood-better-days.tsx": 2,
"components/insights/mood/mood-correlation-cards.tsx": 3,
@@ -1127,7 +1123,7 @@
"components/medications/__tests__/inventory-section-actions.test.tsx": 1,
"components/medications/__tests__/inventory-section-summary.test.tsx": 2,
"components/medications/__tests__/log-injection-site-dialog.test.tsx": 1,
"components/medications/__tests__/medication-card-glp1.test.tsx": 14,
"components/medications/__tests__/medication-card-glp1.test.tsx": 12,
"components/medications/__tests__/medication-card-header.test.tsx": 1,
"components/medications/__tests__/medication-card-next-due.test.tsx": 4,
"components/medications/__tests__/medication-card-symmetry.test.tsx": 4,
@@ -1164,7 +1160,7 @@
"components/medications/log-injection-site-dialog.tsx": 2,
"components/medications/log-intake-dialog.tsx": 4,
"components/medications/medication-card-header.tsx": 5,
"components/medications/medication-card-menu.tsx": 3,
"components/medications/medication-card-menu.tsx": 2,
"components/medications/medication-card.tsx": 37,
"components/medications/medication-categories-sheet.tsx": 1,
"components/medications/medication-category-add-row.tsx": 1,
@@ -2367,7 +2363,6 @@
"lib/labs/__tests__/note-update.test.ts": 1,
"lib/labs/biomarker-catalog.ts": 10,
"lib/labs/biomarker-store.ts": 2,
"lib/labs/homa-ir.ts": 1,
"lib/labs/lab-trend.ts": 1,
"lib/labs/local-ocr.ts": 1,
"lib/labs/note-update.ts": 1,
@@ -2691,7 +2686,7 @@
"lib/queries/use-profile-claim.ts": 4,
"lib/query-keys/admin.ts": 12,
"lib/query-keys/ai-runs.ts": 1,
"lib/query-keys/auth.ts": 9,
"lib/query-keys/auth.ts": 8,
"lib/query-keys/coach.ts": 13,
"lib/query-keys/custom-metrics.ts": 1,
"lib/query-keys/cycle.ts": 1,
@@ -2768,11 +2763,9 @@
"lib/sync/cursor.ts": 1,
"lib/targets/__tests__/builders.test.ts": 1,
"lib/targets/__tests__/glucose-targets.test.ts": 1,
"lib/targets/__tests__/mood-stability-label.test.ts": 1,
"lib/targets/__tests__/source-link.test.ts": 1,
"lib/targets/__tests__/target-unit-display.test.ts": 1,
"lib/targets/build-response.ts": 3,
"lib/targets/mood-stability-label.ts": 1,
"lib/targets/target-unit-display.ts": 2,
"lib/targets/types.ts": 2,
"lib/targets/vitals-builder.ts": 2,
@@ -215,7 +215,7 @@ describe("the customise shortcut is withheld inside somebody else's record", ()
export function Page() {
const { inSharedRecord } = useRecordCapabilities();
const wrench = !inSharedRecord ? (
<Link href="/settings/layout/vorsorge">Customise</Link>
<Link href="/settings/layout/checkups">Customise</Link>
) : null;
return wrench;
}
File renamed without changes.
@@ -8,7 +8,7 @@
* so could every other job that legitimately needs longer: the nightly status
* passes, the pre-generation, the record scan, the key rotation, the backups.
*
* `src/lib/jobs/queue-runtime.ts` declares every queue `short`, `budgeted` or
* `helpers/queue-runtime.ts` declares every queue `short`, `budgeted` or
* `long`. This guard holds the tree to it:
*
* - every queue the worker registry declares (`jobs/registry/`) is in the
@@ -41,7 +41,7 @@ import {
DEFAULT_JOB_EXPIRE_SECONDS,
QUEUE_RUNTIME,
type QueueRuntime,
} from "@/lib/jobs/queue-runtime";
} from "./helpers/queue-runtime";
import { SCHEDULED_AND_SENT_QUEUES } from "@/lib/jobs/reminder/registrar-shared";
import { HEAVY_QUEUES } from "@/lib/jobs/heavy-lane";
import type { JobDefinition } from "@/lib/jobs/registry/define-job";
@@ -22,8 +22,7 @@
* that file does, or is listed below by file and function with the reason
* its rows cannot reach a prompt. A new reader fails here until it does one
* of those. The data routes are not in scope: a module decides what a model
* is given, not what the owner can read (`CLAUDE.md`, "An AI capability
* gates model work").
* is given, not what the owner can read.
*
* The check is per read, not per file. It used to ask whether a file that
* reads the table mentions the gate anywhere, so one ungated read in a file
-114
View File
@@ -1,114 +0,0 @@
/**
* The moodLog bridge stays removed.
*
* It was retired once before, in pieces: the Settings card went in June, the
* cron, webhook, credentials, ledger entry and roughly twenty i18n keys stayed
* behind, and for a month the code ran hourly against an upstream that had
* stopped answering while nothing on any screen said so. Half-removed was
* worse than either finished state.
*
* This test pins the finished state. It is a structural guard, not a
* behavioural one: it fails the moment a moodLog surface reappears, so a
* reintroduction has to be a decision someone writes down rather than a merge
* that slips through.
*
* What it deliberately does NOT forbid: the `MOODLOG` mood-entry source value
* and its label. Rows the bridge imported are the user's own history, they
* keep their provenance, and every read path must go on resolving it.
*/
import { readFileSync, existsSync } from "node:fs";
import path from "node:path";
import { allJobDefinitions } from "@/lib/jobs/registry/index";
import { describe, expect, it } from "vitest";
import { INTEGRATION_CADENCE } from "@/lib/integrations/sync-verdict";
import { INTEGRATION_DISPLAY_NAMES } from "@/components/settings/integrations/integration-fallback-row";
const ROOT = path.resolve(__dirname, "../..");
const LOCALES = [
"de",
"en",
"es",
"fr",
"it",
"pl",
"ko",
"nl",
"pt-BR",
] as const;
/** Every path the retired bridge occupied. */
const REMOVED_PATHS = [
"src/lib/moodlog",
"src/lib/moodlog-secret.ts",
"src/lib/jobs/reminder/moodlog-sync.ts",
"src/app/api/integrations/moodlog",
"src/app/api/settings/moodlog",
"src/lib/validations/moodlog.ts",
];
/** Columns migration 0271 dropped. */
const REMOVED_SCHEMA_FIELDS = [
"moodLogUrlEncrypted",
"moodLogApiKeyEncrypted",
"moodLogEnabled",
"moodLogLastSyncedAt",
"moodLogWebhookSecret",
"moodLogGlobal",
];
describe("moodLog integration stays removed", () => {
it("has no moodLog module, route or validation file", () => {
for (const rel of REMOVED_PATHS) {
expect(existsSync(path.join(ROOT, rel)), `${rel} is back`).toBe(false);
}
});
it("declares no moodLog column on the Prisma schema", () => {
const schema = readFileSync(
path.join(ROOT, "prisma/schema.prisma"),
"utf8",
);
for (const field of REMOVED_SCHEMA_FIELDS) {
expect(schema, `${field} is back on the schema`).not.toContain(field);
}
});
it("registers no moodlog queue, cron or handler binding", () => {
const queues = allJobDefinitions().map((job) => job.queue);
expect(queues.length).toBeGreaterThan(100);
expect(queues.filter((queue) => /mood-?log/i.test(queue))).toEqual([]);
});
it("carries no moodlog integration key on the status envelope", () => {
expect(Object.keys(INTEGRATION_CADENCE)).not.toContain("moodlog");
expect(Object.keys(INTEGRATION_DISPLAY_NAMES)).not.toContain("moodlog");
});
it("keeps no orphaned moodLog message key in any locale", () => {
for (const locale of LOCALES) {
const bundle = JSON.parse(
readFileSync(path.join(ROOT, `messages/${locale}.json`), "utf8"),
) as {
settings: Record<string, unknown>;
admin: Record<string, unknown>;
};
const orphans = [
...Object.keys(bundle.settings),
...Object.keys(bundle.admin),
].filter((key) => key.startsWith("moodLog"));
expect(orphans, `${locale} still carries moodLog keys`).toEqual([]);
}
});
it("still resolves the MOODLOG provenance of imported mood rows", () => {
// The other half of the contract: removing the bridge must not erase the
// history it wrote. The source badge label has to survive the removal.
const en = JSON.parse(
readFileSync(path.join(ROOT, "messages/en.json"), "utf8"),
) as { mood: Record<string, string> };
expect(en.mood.sourceMoodlog).toBeTruthy();
});
});
+1 -1
View File
@@ -43,7 +43,7 @@ const SRC = join(process.cwd(), "src");
* Every non-test `.ts` / `.tsx` under `src/app/`, which is the whole of the
* request-handling tree: API routes, RSC pages, layouts, route handlers.
* The generated Prisma client is excluded by construction (it is not under
* `app/`) and never read — see CLAUDE.md.
* `app/`) and never read.
*/
function appFiles(): string[] {
return walkSourceFiles(SRC, { floor: 3000 })
@@ -50,6 +50,7 @@ const HISTORICAL_SETTINGS_PATHS: readonly string[] = [
"/settings/api",
"/settings/mcp",
"/settings/gesundheitsakte",
"/settings/health-record",
"/settings/sharing",
"/settings/export",
"/settings/advanced",
@@ -72,6 +73,7 @@ const HISTORICAL_SETTINGS_PATHS: readonly string[] = [
"/settings/layout/illness",
"/settings/layout/documents",
"/settings/layout/vorsorge",
"/settings/layout/checkups",
];
interface Redirect {
@@ -151,6 +153,22 @@ describe("historical Settings addresses", () => {
expect(unlisted).toEqual([]);
});
it("sends the old German addresses to their English ones", async () => {
const redirects = await settingsRedirects();
expect(resolve("/settings/gesundheitsakte", redirects)).toBe(
"/settings/health-record",
);
expect(resolve("/settings/sharing", redirects)).toBe(
"/settings/health-record",
);
expect(resolve("/settings/layout/vorsorge", redirects)).toBe(
"/settings/layout/checkups",
);
expect(resolve("/settings/vorsorge", redirects)).toBe(
"/settings/layout/checkups",
);
});
it("keeps the v1.44 moves addressable", async () => {
const redirects = await settingsRedirects();
expect(resolve("/settings/layout/mood", redirects)).toBe(
+1 -1
View File
@@ -161,7 +161,7 @@ const BARE_REQUIRE_AUTH_FLOOR = 200;
* omits the three routes under `src/app/.well-known/` — three request handlers
* that a glob-based sweep reports as if they did not exist. Leg (a) pins one of
* them by name so this cannot quietly regress. Names only; nothing under
* `generated/` is ever read (CLAUDE.md).
* `generated/` is ever read.
*/
function allPaths(): string[] {
return readdirSync(SRC, { recursive: true })
-187
View File
@@ -1,187 +0,0 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const ROOT = join(__dirname, "../..");
const LOCALES = [
"en",
"de",
"es",
"fr",
"it",
"pl",
"ko",
"nl",
"pt-BR",
] as const;
const REQUIRED_KEYS = [
// Security: adding a passkey now requires an existing sign-in factor.
"settings.passkeyReauth.title",
"settings.passkeyReauth.description",
"settings.passkeyReauth.methodLabel",
"settings.passkeyReauth.methods.password",
"settings.passkeyReauth.methods.totp",
"settings.passkeyReauth.methods.passkey",
"settings.passkeyReauth.methods.webauthn",
"settings.passkeyReauth.currentPassword",
"settings.passkeyReauth.authenticatorCode",
"settings.passkeyReauth.authenticatorPlaceholder",
"settings.passkeyReauth.verifyAndAdd",
// Integrations/imports: operator policy and Apple ECG terminal outcome.
"settings.nightscoutPrivateOperatorTitle",
"settings.nightscoutPrivateOperatorDescription",
"settings.sections.export.import.appleHealth.ecgResult",
// Existing terminal contracts reused by the final v1.34.1 call sites.
"insights.assessmentPreparing",
"insights.noAnalysisYet",
"common.loadFailed",
"common.networkError",
"settings.googleHealthSyncResult",
"settings.googleHealthSyncFailed",
// `settings.googleHealthBackfillInProgress` left this list in v1.42.4: the
// history-loading line became a sentence variant of the shared integration
// card (`settings.integrationCard.reason.dataArrivingHistory`).
"settings.syncOutcome.empty",
"settings.syncOutcome.partial",
"settings.syncOutcome.failed",
// Retained score, records, and navigation contracts.
// `insights.healthScore.method` was listed obsolete in v1.34.1 because its
// only reference was `METRIC_PROVENANCE.HEALTH_SCORE.methodKey`, which the
// card then passed as `method={null}` — a dangling reference, not a dead
// one. The score card now renders that method paragraph in its disclosure
// footer, so the key is a live contract again.
"insights.healthScore.method",
"insights.healthScore.bandSetter",
"insights.healthScore.versionAndNoise",
"insights.healthScore.methodVersion",
"insights.healthScore.source",
"records.allergies.add",
"records.allergies.addFirst",
"records.family.add",
"records.family.addFirst",
"admin.shell.sectionsNav",
"settings.shell.sectionsNav",
] as const;
const OBSOLETE_KEYS = [
"insights.healthScore.algorithmChanged",
"insights.healthScore.dismissAlgorithmChanged",
"insights.healthScore.composition",
"documents.detail.summary.regenerate",
"records.profileFacts.correct",
// v1.43 — the profile-facts card became the life-circumstances card, whose
// sections save on their own.
"records.profileFacts.save",
"settings.nightscoutPrivateHost",
"settings.nightscoutPrivateHostHelp",
] as const;
const NEW_CALL_SITES = {
"src/components/settings/security-section/passkey-list-section.tsx": [
"settings.passkeyReauth.title",
"settings.passkeyReauth.description",
"settings.passkeyReauth.methodLabel",
"settings.passkeyReauth.methods.password",
"settings.passkeyReauth.methods.totp",
"settings.passkeyReauth.methods.passkey",
"settings.passkeyReauth.methods.webauthn",
"settings.passkeyReauth.currentPassword",
"settings.passkeyReauth.authenticatorCode",
"settings.passkeyReauth.authenticatorPlaceholder",
"settings.passkeyReauth.verifyAndAdd",
],
"src/components/settings/integrations/nightscout-card.tsx": [
"settings.nightscoutPrivateOperatorTitle",
"settings.nightscoutPrivateOperatorDescription",
],
"src/components/settings/import-panel/apple-health-import-card.tsx": [
"settings.sections.export.import.appleHealth.ecgResult",
],
} as const;
type Catalog = Record<string, unknown>;
function readCatalog(locale: (typeof LOCALES)[number]): Catalog {
return JSON.parse(
readFileSync(join(ROOT, "messages", `${locale}.json`), "utf8"),
) as Catalog;
}
function resolveLeaf(catalog: Catalog, dottedKey: string): string | undefined {
let value: unknown = catalog;
for (const segment of dottedKey.split(".")) {
if (typeof value !== "object" || value === null || !(segment in value)) {
return undefined;
}
value = (value as Record<string, unknown>)[segment];
}
return typeof value === "string" ? value : undefined;
}
function placeholders(message: string): string[] {
return [
...new Set(
[...message.matchAll(/\{([A-Za-z_][A-Za-z0-9_]*)\b/g)].map(
(match) => match[1],
),
),
].sort();
}
describe("v1.34.1 locale reconciliation", () => {
it("wires every newly introduced user-facing state through a literal t() call", () => {
for (const [relativePath, keys] of Object.entries(NEW_CALL_SITES)) {
const source = readFileSync(join(ROOT, relativePath), "utf8");
for (const key of keys) {
expect(source, `${relativePath} must translate ${key}`).toMatch(
new RegExp(`\\bt\\(\\s*["']${key.replaceAll(".", "\\.")}["']`),
);
}
}
});
it("provides every final contract in all six locales with ICU parity", () => {
const catalogs = Object.fromEntries(
LOCALES.map((locale) => [locale, readCatalog(locale)]),
) as Record<(typeof LOCALES)[number], Catalog>;
for (const key of REQUIRED_KEYS) {
const english = resolveLeaf(catalogs.en, key);
expect(english, `messages/en.json is missing ${key}`).toBeTruthy();
for (const locale of LOCALES) {
const translated = resolveLeaf(catalogs[locale], key);
expect(
translated,
`messages/${locale}.json is missing ${key}`,
).toBeTruthy();
expect(
placeholders(translated ?? ""),
`messages/${locale}.json changed ICU placeholders for ${key}`,
).toEqual(placeholders(english ?? ""));
}
}
});
it("removes only the v1.34.1 copy proven obsolete after final call-site inventory", () => {
for (const locale of LOCALES) {
const catalog = readCatalog(locale);
for (const key of OBSOLETE_KEYS) {
expect(
resolveLeaf(catalog, key),
`messages/${locale}.json still contains obsolete ${key}`,
).toBeUndefined();
}
}
const productionSource = Object.keys(NEW_CALL_SITES)
.map((relativePath) => readFileSync(join(ROOT, relativePath), "utf8"))
.join("\n");
for (const key of OBSOLETE_KEYS) {
expect(productionSource).not.toContain(`t("${key}")`);
expect(productionSource).not.toContain(`t('${key}')`);
}
});
});
+1 -1
View File
@@ -22,4 +22,4 @@ Every endpoint wraps its handler in `apiHandler` (`@/lib/api-handler`), parses a
## Conventions
See [`../../CLAUDE.md`](../../CLAUDE.md). The request-flow walk in `.planning/codebase/arch.md` annotates each layer with file:line citations. Related dirs: handlers compose [`../lib/`](../lib/README.md); pages render [`../components/`](../components/README.md).
Related dirs: handlers compose [`../lib/`](../lib/README.md); pages render [`../components/`](../components/README.md).
+5 -2
View File
@@ -34,6 +34,7 @@ import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose";
import { glucoseUnitPatchSchema } from "@/lib/validations/user-prefs";
import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh";
import { invalidateUserProfile } from "@/lib/cache/invalidate";
export const dynamic = "force-dynamic";
@@ -103,9 +104,11 @@ export const PATCH = apiHandler(async (req: Request) => {
},
});
// The status notes and narratives quote glucose in this unit; re-warm
// them so today's stored text does not keep the old one.
// The targets payload and the status notes and narratives carry glucose in
// this unit: evict the cached payloads and re-warm today's stored text so
// neither keeps the old one.
if (resolveGlucoseUnit(previous?.glucoseUnit) !== next) {
invalidateUserProfile(user.id);
void refreshTextsAfterUnitChange(user.id);
}
+4 -1
View File
@@ -44,7 +44,10 @@ describe("GET /api/version", () => {
);
expect(body.data.license).toBe("PolyForm-Noncommercial-1.0.0");
expect(body.data.repository).toContain("github.com");
expect(body.data.changelog).toContain("CHANGELOG");
// The release notes, not the multi-megabyte CHANGELOG.md.
expect(body.data.changelog).toBe(
"https://github.com/MBombeck/HealthLog/releases",
);
expect(body.data.docs).toContain("docs.healthlog.dev");
});
+1 -1
View File
@@ -63,7 +63,7 @@ export const GET = apiHandler(async () => {
builtAt,
license: "PolyForm-Noncommercial-1.0.0",
repository: "https://github.com/MBombeck/HealthLog",
changelog: "https://github.com/MBombeck/HealthLog/blob/main/CHANGELOG.md",
changelog: "https://github.com/MBombeck/HealthLog/releases",
docs: "https://docs.healthlog.dev",
offlineGeoEnabled,
geoProviderHost,
+1 -1
View File
@@ -21,7 +21,7 @@ export default function IntakeHistoryRedirectPage({
const router = useRouter();
useEffect(() => {
router.replace(`/medications/${id}?tab=verlauf`);
router.replace(`/medications/${id}?tab=history`);
}, [id, router]);
return (
@@ -1,38 +0,0 @@
/**
* v1.15.18 — the `/medications/[id]` detail page is the tabbed shell.
*
* The page owns auth + the medication read and delegates the rest to
* `<MedicationDetailTabs>` (the tab strip, `?tab=` URL state, the hero
* and the wizard / advanced affordances). Source-level guards (the page
* is a client component with `use()` + hooks, so a full render is
* heavier than the contract warrants):
* - it renders `MedicationDetailTabs` and nothing tab-specific itself;
* - it carries no `TodaysDoseCard` (lives only on the list page);
* - it hosts neither the wizard nor the modal advanced sheet directly
* (those live inside the tab component).
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const source = readFileSync(
join(process.cwd(), "src/app/medications/[id]/page.tsx"),
"utf8",
);
describe("medication detail page is the tabbed shell (v1.15.18)", () => {
it("delegates to MedicationDetailTabs", () => {
expect(source).toContain("MedicationDetailTabs");
});
it("does not import or render TodaysDoseCard", () => {
expect(source).not.toContain("TodaysDoseCard");
expect(source).not.toContain("todays-dose-card");
});
it("does not host the wizard or the modal advanced sheet directly", () => {
expect(source).not.toContain("AdvancedSettingsSheet");
expect(source).not.toContain("MedicationWizardDialog");
});
});
+1 -1
View File
@@ -435,7 +435,7 @@ export default function MedicationsPageClient() {
function openHistory(med: Medication) {
seedDetail(med);
router.push(`/medications/${med.id}?tab=verlauf`);
router.push(`/medications/${med.id}?tab=history`);
}
function closeDialog() {
+1 -1
View File
@@ -21,4 +21,4 @@ React components. The UI primitives are shadcn/ui (new-york style, zinc base) ov
## Conventions
Kebab-case filenames; React component exports stay PascalCase regardless. (A handful of PascalCase-filename outliers under `medications/` + `onboarding/` are pre-existing drift, noted in [`../../CLAUDE.md`](../../CLAUDE.md).) Every `t()` call needs a matching key in `messages/en.json` — guarded by `i18n-call-site-coverage.test.ts`. Data reads go through TanStack Query with keys from `@/lib/query-keys` and unwrap `(await res.json()).data`. Tests live in [`__tests__/`](__tests__/).
Kebab-case filenames; React component exports stay PascalCase regardless. Every `t()` call needs a matching key in `messages/en.json` — guarded by `i18n-call-site-coverage.test.ts`. Data reads go through TanStack Query with keys from `@/lib/query-keys` and unwrap `(await res.json()).data`. Tests live in [`__tests__/`](__tests__/).
@@ -320,20 +320,16 @@ describe("marking a dose", () => {
describe("the medication card menu", () => {
const node = (
<MedicationCardMenu
onEdit={() => {}}
onOpenHistory={() => {}}
onLogSideEffect={() => {}}
/>
<MedicationCardMenu onEdit={() => {}} onOpenHistory={() => {}} />
);
it("renders its trigger in the caller's own record", () => {
expect(render(OWN_RECORD, node)).toContain("<button");
});
it("keeps a trigger for a delegate who may write", () => {
// One item survives for them — noting a side effect — so the menu stays.
expect(render(WRITABLE, node)).toContain("<button");
it("disappears for a delegate who may write but not manage", () => {
// Editing and the history editor are owner work; nothing survives.
expect(render(WRITABLE, node)).toBe("");
});
it("disappears entirely for a read-only delegate", () => {
@@ -1,53 +0,0 @@
/**
* v1.16.8 — the in-header "adjust targets" link is gone from the chart
* card. The affordance duplicated the page-level target-adjust control
* in the insights sub-page header (`<TargetAdjustButton>`); the chart
* stays a read surface. Pinned at the source level (same convention as
* the auth-shell hoist contract) so the link doesn't quietly return on
* a future header rework.
*/
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { readSource, readSourceFolder } from "@/__tests__/helpers/source-files";
const WEIGHT_PAGE_PATH = resolve(
__dirname,
"../../../app/insights/weight/page.tsx",
);
const BP_PAGE_PATH = resolve(
__dirname,
"../../../app/insights/blood-pressure/page.tsx",
);
const EN_BUNDLE_PATH = resolve(__dirname, "../../../../messages/en.json");
describe("v1.16.8 — chart-card target-adjust link removed", () => {
it("ships no targetSettingsHref prop or header link in the chart", () => {
// The component and every part it is built from (header included).
const source = [
readSource("src/components/charts/health-chart.tsx", "HealthChart"),
...readSourceFolder("src/components/charts/health-chart", {
floor: 9,
}).map(([, text]) => text),
].join("\n");
expect(source).not.toContain("targetSettingsHref");
expect(source).not.toContain("chart-target-settings-link");
expect(source).not.toContain("charts.adjustTargets");
});
it("weight and blood-pressure pages no longer pass the link href", () => {
for (const path of [WEIGHT_PAGE_PATH, BP_PAGE_PATH]) {
const source = readFileSync(path, "utf8");
expect(source).not.toContain("targetSettingsHref");
}
});
it("the orphaned locale key is gone", () => {
const bundle = JSON.parse(readFileSync(EN_BUNDLE_PATH, "utf8")) as {
charts: Record<string, unknown>;
};
expect(bundle.charts.adjustTargets).toBeUndefined();
});
});
@@ -38,7 +38,7 @@ const DAY: DayResponse = {
until: "2026-03-31",
dayIndex: 64,
dayCount: 151,
href: "/medications/m1?tab=verlauf",
href: "/medications/m1?tab=history",
},
],
values: [
+1 -1
View File
@@ -321,7 +321,7 @@ export function SinceLastVisit({
className="min-h-11 sm:min-h-9"
>
<Link
href={`/settings/gesundheitsakte?reportFrom=${fromDay}&reportTo=${nextDay < today ? nextDay : today}`}
href={`/settings/health-record?reportFrom=${fromDay}&reportTo=${nextDay < today ? nextDay : today}`}
data-slot="since-last-visit-report"
>
<FileText className="size-4" aria-hidden="true" />
@@ -0,0 +1,55 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { resolveKey } from "@/lib/i18n/resolve-key";
/**
* The illness screens name the vital that drives a recovery gap and label the
* day picker of the log sheet through these keys. They were once copied from
* English into five catalogs, so a German reader saw "Weight", "Pulse",
* "Recovery" and "Day" among translated neighbours. The English leak guard
* skips single words, so it cannot see this; the pin below can.
*/
const MESSAGES_DIR = join(__dirname, "../../../../messages");
function catalog(locale: string): Record<string, unknown> {
return JSON.parse(
readFileSync(join(MESSAGES_DIR, `${locale}.json`), "utf8"),
) as Record<string, unknown>;
}
const KEYS = [
"illness.vital.WEIGHT",
"illness.vital.PULSE",
"illness.vital.RECOVERY_SCORE",
"illness.sheet.day",
] as const;
const english = catalog("en");
describe("illness labels", () => {
for (const locale of [
"de",
"fr",
"es",
"it",
"pl",
"nl",
"pt-BR",
"ko",
"ru",
]) {
it(`are translated in ${locale}`, () => {
const messages = catalog(locale);
for (const key of KEYS) {
const value = resolveKey(messages, key);
expect(value, `${locale} ${key}`).toBeTruthy();
expect(value, `${locale} ${key} is still English`).not.toBe(
resolveKey(english, key),
);
}
});
}
});
@@ -47,7 +47,10 @@ vi.mock("@/components/symptoms/symptoms-section", () => ({
SymptomsSection: () => null,
}));
vi.mock("../new-episode-sheet", () => ({ NewEpisodeSheet: () => null }));
vi.mock("../log-day-sheet", () => ({ LogDaySheet: () => null }));
vi.mock("../log-day-sheet", () => ({
LogDaySheet: () => null,
useLastLoggableDay: (today: string) => today,
}));
vi.mock("@/components/day/day-link", () => ({
DAY_LINK_SLOT: "{date}",
DayLinkStated: ({ children }: { children: React.ReactNode }) => children,
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { lastLoggableDay } from "../log-day-sheet";
/**
* "Log day" on an illness opens on the day it suggests and bounds the picker
* there. For a recovered episode that used to be today, so one tap on Save
* filed a sick day days after the recovery, and the day view then showed it
* with no illness running beside it.
*/
describe("lastLoggableDay", () => {
it("is today while the episode runs", () => {
expect(lastLoggableDay("2026-11-12", null, "Europe/Berlin")).toBe(
"2026-11-12",
);
});
it("is the recovery day once the episode ended before today", () => {
expect(
lastLoggableDay(
"2026-11-12",
"2026-11-09T08:00:00.000Z",
"Europe/Berlin",
),
).toBe("2026-11-09");
});
it("stays today when the episode ended today", () => {
expect(
lastLoggableDay(
"2026-11-12",
"2026-11-12T08:00:00.000Z",
"Europe/Berlin",
),
).toBe("2026-11-12");
});
it("reads the recovery day in the profile zone", () => {
// 23:30 UTC is already the next day in Berlin.
expect(
lastLoggableDay(
"2026-11-12",
"2026-11-09T23:30:00.000Z",
"Europe/Berlin",
),
).toBe("2026-11-10");
});
});
@@ -30,7 +30,7 @@ import { canReadBodySites } from "@/hooks/use-body-sites";
import { IllnessCorrelationCard } from "./illness-correlation-card";
import { IllnessDayTimeline } from "./illness-day-timeline";
import { LogDaySheet } from "./log-day-sheet";
import { LogDaySheet, useLastLoggableDay } from "./log-day-sheet";
import { NewEpisodeSheet } from "./new-episode-sheet";
import { EpisodeMenu } from "./episode-menu";
import { useIllnessEpisode, useResolveEpisode } from "./use-illness";
@@ -66,6 +66,7 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
const [logOpen, setLogOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const today = todayLocal();
const logLastDay = useLastLoggableDay(today, episode?.resolvedAt);
if (isError) {
return (
@@ -226,7 +227,7 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
open={logOpen}
onOpenChange={setLogOpen}
episodeId={episode.id}
date={today}
date={logLastDay}
onsetDate={episode.onsetAt.slice(0, 10)}
/>
<NewEpisodeSheet
+3 -2
View File
@@ -42,7 +42,7 @@ import { cn } from "@/lib/utils";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs";
import { LogDaySheet } from "./log-day-sheet";
import { LogDaySheet, useLastLoggableDay } from "./log-day-sheet";
import { NewEpisodeSheet } from "./new-episode-sheet";
import { EpisodeMenu } from "./episode-menu";
import { IllnessInsightsCard } from "./illness-insights-card";
@@ -384,6 +384,7 @@ export function IllnessView() {
const [logEpisodeId, setLogEpisodeId] = useState<string | null>(null);
const logEpisode = episodes?.find((e) => e.id === logEpisodeId) ?? null;
const logLastDay = useLastLoggableDay(today, logEpisode?.resolvedAt);
/**
* Group the flat list into active / resolved, nesting flares and recurring
@@ -532,7 +533,7 @@ export function IllnessView() {
if (!open) setLogEpisodeId(null);
}}
episodeId={logEpisodeId}
date={today}
date={logLastDay}
onsetDate={logEpisode?.onsetAt.slice(0, 10)}
/>
) : null}
Loaded 100 of 164 files, more files were not shown because too many files have changed in this diff. Show more