mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
Merge pull request #1375 from MBombeck/release/v1.47.5
This commit is contained in:
395 files changed
+5311
-17848
No files matched your search
+1
-7
@@ -53,11 +53,8 @@ public/sw-version.js
|
||||
*.har
|
||||
*.heapsnapshot
|
||||
|
||||
# Editor / agent worktrees + caches.
|
||||
.claude
|
||||
# Editor worktrees and caches.
|
||||
.worktrees
|
||||
.cursor
|
||||
.aider*
|
||||
.vscode
|
||||
.idea
|
||||
.DS_Store
|
||||
@@ -80,15 +77,12 @@ Dockerfile
|
||||
# Documentation not needed for runtime image
|
||||
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
|
||||
# Noncommercial 1.0.0 notice terms require the licence text (or its URL)
|
||||
# to ship with every copy of the software, so it ships inside the image.
|
||||
AGENTS.md
|
||||
.planning
|
||||
docs
|
||||
docs/*.md
|
||||
|
||||
@@ -541,24 +541,6 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# FHIR_MAX_MEDICATION_ADMINISTRATIONS="5000"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Dashboard snapshot -- BUILD-TIME toggle (default ON)
|
||||
# -----------------------------------------------------------------------------
|
||||
# The dashboard hydrates every above-the-fold tile from the single
|
||||
# `GET /api/dashboard/snapshot` cell so the whole strip shares one
|
||||
# completion moment. This is the default and the desired behaviour.
|
||||
#
|
||||
# `NEXT_PUBLIC_*` vars are inlined into the client bundle at BUILD time,
|
||||
# not read at runtime -- so this has NO effect on the prebuilt GHCR
|
||||
# image and is intentionally absent from the `docker-compose.yml`
|
||||
# runtime `environment:` whitelist. To fall back to the legacy
|
||||
# four-cell path you must rebuild the image with the var set to "false":
|
||||
#
|
||||
# $ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build # or a Dockerfile ARG
|
||||
#
|
||||
# NEXT_PUBLIC_DASHBOARD_SNAPSHOT="false"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Client-IP resolution -- optional (required behind Cloudflare)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
name: Translation feedback
|
||||
description: Report a wording, grammar, or register problem in HealthLog's community-maintained locales (FR / ES / IT / PL / KO / NL / PT-BR).
|
||||
description: Report a wording, grammar, or register problem in HealthLog's community-reviewed locales (FR / ES / IT / PL / KO / NL / PT-BR / RU).
|
||||
title: "[Translation] "
|
||||
labels: ["i18n", "translation-feedback"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
HealthLog ships German and English as maintainer-curated translations. French, Spanish, Italian, Polish, Korean, Dutch, and Brazilian Portuguese ship as **AI-initial, community-maintained translations** — the prose passes the locale-integrity test (full key parity, no empty values) but has not been hand-reviewed for register or regional vocabulary.
|
||||
HealthLog ships German and English as maintainer-curated translations. French, Spanish, Italian, Polish, Korean, Dutch, Brazilian Portuguese and Russian started as machine-translated first drafts and are reviewed by the community. They pass the locale-integrity test (full key parity, no empty values), but not every line has been read by a native speaker for register or regional vocabulary.
|
||||
|
||||
This template captures concrete fixes you would like to see. Phrasing nuance, regional vocabulary (e.g. "Tú" vs "Usted"), gendered forms, and domain-specific health terms are all in scope. The maintainer applies fixes during the regular release cycle.
|
||||
|
||||
@@ -23,6 +23,7 @@ body:
|
||||
- Korean (ko)
|
||||
- Dutch (nl)
|
||||
- Brazilian Portuguese (pt-BR)
|
||||
- Russian (ru)
|
||||
- Other (specify under "Surface")
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -28,16 +28,16 @@
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Targets the `main` branch (trunk-based — see [CONTRIBUTING.md](../CONTRIBUTING.md))
|
||||
- [ ] Targets the `main` branch (trunk-based, see [CONTRIBUTING.md](../CONTRIBUTING.md))
|
||||
- [ ] `pnpm typecheck` passes locally
|
||||
- [ ] `pnpm lint` passes locally
|
||||
- [ ] `pnpm test` passes locally
|
||||
- [ ] `pnpm format:check` passes locally
|
||||
- [ ] `pnpm build` passes locally
|
||||
- [ ] User-facing strings go through `t("key")` with both `messages/en.json` and `messages/de.json` updated
|
||||
- [ ] User-facing strings go through `t("key")`, with the key in all ten `messages/*.json` files
|
||||
- [ ] No secrets, personal data, or maintainer-name references in committed files
|
||||
- [ ] Updated `CHANGELOG.md` if user-visible
|
||||
- [ ] Updated `docs/audit/` or `docs.healthlog.dev` if behavior or self-hosting docs change
|
||||
- [ ] Updated `docs/` or `docs.healthlog.dev` if behaviour or self-hosting steps change
|
||||
|
||||
## Linked issues
|
||||
|
||||
|
||||
+2
-11
@@ -66,17 +66,8 @@ next-env.d.ts
|
||||
# COPY target always exists.
|
||||
/assets/geolite2/*.mmdb
|
||||
|
||||
# Local workspace files, never committed
|
||||
/docs/superpowers/
|
||||
.claude/
|
||||
.cursor/
|
||||
.codex/
|
||||
.aider*
|
||||
|
||||
# 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
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"shadcn": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "shadcn@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
-6
@@ -2,6 +2,75 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.47.5] — 2026-11-12
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Sheets no longer throw away what you typed.** A tap outside, a swipe
|
||||
down or Escape closed a sheet and dropped its input without a word in
|
||||
the medication wizard, the add sheets on Measurements, Mood and Labs,
|
||||
the visit, vaccination, practitioner and check-up forms, and the edit
|
||||
sheets for illnesses, cycle days, custom metrics, targets, mood tags,
|
||||
symptoms and life events. They now ask first, and an untouched form
|
||||
closes at once. A sheet that asks before closing slides back after a
|
||||
swipe instead of staying half off the screen.
|
||||
- **Hints open on a tap.** Information that only appeared on mouse hover
|
||||
now opens on a tap: the measurement-pattern tip, the sleep-source
|
||||
warning, score coverage, target status, range-bar values, the colour
|
||||
of dashboard averages and long admin token names.
|
||||
- **No more zoom on iPhone when a field gets focus.** The medical-history
|
||||
notes, the import and passphrase fields, the Coach memory editor, the
|
||||
managed profile's handover link, the cycle length fields and the
|
||||
command search use 16 px text now.
|
||||
- The "More" sheet scrolls when the phone is held sideways, the
|
||||
practitioner and vaccine pickers open as wide as their field, the
|
||||
remove buttons on chips are easier to hit without catching the label,
|
||||
the first-run tour card sits above the iPhone home indicator, and the
|
||||
Coach's New conversation button no longer covers the last message.
|
||||
- Illness screens no longer show "Weight", "Pulse", "Recovery" and "Day"
|
||||
in English in German, French, Spanish, Italian and Polish, and the
|
||||
medication wizard's "Name" step and the dashboard layout's "Chart"
|
||||
column are translated too. The Coach question on an empty metric page
|
||||
is in your language on every metric page.
|
||||
- The check-up list view shows the Coach and switched-off badges like
|
||||
the cards. "Log day" on a recovered illness opens on the recovery day.
|
||||
After switching the glucose unit, the target panels switch at once.
|
||||
- Blood ketones (new in 1.47.2) can be logged from the metric catalog
|
||||
and tied to a check-up, and a ketone check-up goes quiet with the
|
||||
Glucose module like the glucose one.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Insights pages load faster.** The tab bar no longer downloads the
|
||||
whole overview, a workout and the ECG list on every Insights page; one
|
||||
small read, `GET /api/insights/presence`, tells it which tabs have
|
||||
data for the record you are looking at. The Coach line under a metric
|
||||
chart is worked out once per account and language instead of once per
|
||||
page, the health status card loads from cache after the first open,
|
||||
and Insights > Medications asks for each medication's efficacy only
|
||||
when you scroll to the summary.
|
||||
- The dose history of a long-taken medication loads the window it shows
|
||||
instead of every dose ever recorded.
|
||||
- A background tab no longer checks for a new version every minute; it
|
||||
checks once when you come back. Every page load saves one request, the
|
||||
dashboard's mood chart reuses what the dashboard already loaded, and
|
||||
the admin Coach page loads its quality summary once.
|
||||
- The admin backups page and the cycle page no longer jump while they
|
||||
load.
|
||||
- Medication tabs, the health record page and the check-ups layout page
|
||||
have English addresses. Old links and bookmarks still work.
|
||||
- Settings, About opens the release notes instead of the full changelog
|
||||
file.
|
||||
|
||||
### Removed
|
||||
|
||||
- The build-time switch `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` and the
|
||||
dashboard's old fallback behind it; the dashboard always reads its one
|
||||
snapshot. If you build your own image with that switch, drop it.
|
||||
- The unused `GET /api/monitoring/settings` endpoint.
|
||||
- One-off scripts nothing runs, code paths nothing reaches and tests
|
||||
about features removed long ago, about 15,000 lines in all.
|
||||
|
||||
## [1.47.4] — 2026-11-12
|
||||
|
||||
### Fixed
|
||||
@@ -19279,9 +19348,8 @@ NOT NULL`) is the defence-in-depth backstop.
|
||||
- **DE+EN bilingual CHANGELOG entries (v1.4.14 + v1.4.15)
|
||||
normalised to English-only.** Per the English-only voice
|
||||
rule.
|
||||
- **`CLAUDE.md` filename retired** so the filename is no longer
|
||||
AI-vendor-specific; `CONTRIBUTING.md` reference updated.
|
||||
`AGENTS.md` stays for multi-agent compatibility.
|
||||
- **Maintainer notes renamed**; the `CONTRIBUTING.md` reference is
|
||||
updated.
|
||||
|
||||
### Deferred to v1.4.23
|
||||
|
||||
@@ -20599,8 +20667,8 @@ token '<', "<!DOCTYPE "`. 401/403 from the provider now map to 422,
|
||||
- **Repo-internal docs synced for v1.4.** README adds the
|
||||
Multi-tenant ready and Test connection buttons feature blocks, the
|
||||
API reference table includes the eleven new v1.4 endpoints, and
|
||||
the model count is corrected to 26 (RefreshToken). AGENTS.md and
|
||||
CLAUDE.md reflect the per-route `/settings/[section]` layout and
|
||||
the model count is corrected to 26 (RefreshToken). The maintainer
|
||||
notes reflect the per-route `/settings/[section]` layout and
|
||||
the per-section admin layout. `docs/api/openapi.yaml` documents
|
||||
the new endpoints (version, refresh, refresh/revoke,
|
||||
status-overview, backup/test, the five test-connection probes).
|
||||
@@ -21157,7 +21225,7 @@ timeout: 30s })` so in-flight handlers finish instead of being killed.
|
||||
- `.env.example` now points at the in-container hostname `db:5432` (was `localhost:5432`, which never resolves inside the app container).
|
||||
- **Documentation**:
|
||||
- `package.json` synced to 1.2.0 (was lagging on 1.1.0).
|
||||
- `CLAUDE.md` and `AGENTS.md` corrected to 23 models (the `Feedback` model added in v1.2 was missing from the count).
|
||||
- The maintainer notes are corrected to 23 models (the `Feedback` model added in v1.2 was missing from the count).
|
||||
- `README.md` Quick Start gives a realistic time estimate, generates the four secrets in one block straight into `.env`, and points reverse-proxy users at the docs.
|
||||
|
||||
### Added — Tooling & Supply Chain
|
||||
|
||||
@@ -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.
|
||||
+6
-6
@@ -70,13 +70,13 @@ pnpm typecheck && pnpm lint && pnpm test && pnpm format:check && pnpm build
|
||||
## Code Conventions
|
||||
|
||||
- **TypeScript strict mode** -- avoid `any`. Where it's truly unavoidable (e.g. variadic Next.js handler signatures in `src/lib/api-handler.ts`), document the reason inline.
|
||||
- **English-default UI** -- the default locale is English. German is user-selectable. Code, comments, commit messages, and docs are English. All user-facing strings go through `t("key")` with translations in both `messages/en.json` and `messages/de.json`. The `i18n-locale-integrity.test.ts` suite blocks merges if keys drift between locales or duplicate at any nesting depth.
|
||||
- **English-default UI**: the default locale is English, and nine more languages are user-selectable. Code, comments, commit messages, and docs are English. All user-facing strings go through `t("key")`, with the key in every `messages/*.json`. The `i18n-locale-integrity.test.ts` suite blocks merges if keys drift between locales or duplicate at any nesting depth.
|
||||
- **API responses** follow the `{ data, error }` envelope via `apiSuccess()` / `apiError()` in `src/lib/api-response.ts`. Wrap every route handler in `apiHandler()` (`src/lib/api-handler.ts`) for error handling, structured Wide-Event logging, and `x-request-id` propagation.
|
||||
- **Zod v4** (`import { ... } from "zod/v4"`) for input validation in API routes and forms.
|
||||
- **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
|
||||
|
||||
|
||||
@@ -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> ·
|
||||
<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://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,27 +36,27 @@
|
||||
<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.
|
||||
- **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 that keeps the open page readable when the connection drops, 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, Brazilian Portuguese and Russian end to end.
|
||||
- **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, Polar and Strava sync over OAuth2, Garmin through a Personal API key, Google Health connects Fitbit and Pixel Watch through your own Google Cloud client, Nightscout streams CGM glucose, an Apple Health `export.zip` or a Health Connect export 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.
|
||||
- **Encryption and privacy.** 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.
|
||||
- **PWA, reminders and languages.** Installable PWA that keeps the open page readable when the connection drops, reminders over APNs, Telegram, ntfy, Web Push, email and a webhook, and a dashboard that stays fast on years of imported history. The app is available in English, German, Spanish, French, Italian, Polish, Korean, Dutch, Brazilian Portuguese and Russian.
|
||||
|
||||
The full feature tour, integration guides, and API reference live at [docs.healthlog.dev](https://docs.healthlog.dev).
|
||||
|
||||
@@ -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> ·
|
||||
<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://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>
|
||||
@@ -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.
|
||||
|
||||
|
||||
+80
-1
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.47.4
|
||||
version: 1.47.5
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -13116,6 +13116,27 @@ paths:
|
||||
"403": *a20
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
/api/insights/presence:
|
||||
get:
|
||||
tags:
|
||||
- Insights
|
||||
summary: Which Insights pages have data
|
||||
description: "What the Insights tab strip gates its pills on, for the record on screen: the reading count per
|
||||
measurement type (after the record's module switches) and whether the record has mood entries in the last 90
|
||||
days, a tracked scheduled medication, workouts and ECG recordings. A flag whose module is off is false. Counts
|
||||
and flags only; each page reads its own data. No provider is called and no AI switch refuses it. Delegable at
|
||||
MANAGE level over the whole record. Cookie or Bearer auth."
|
||||
responses:
|
||||
"200":
|
||||
description: The presence answer.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InsightsPresenceEnvelope"
|
||||
"401": *a1
|
||||
"403": *a20
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
/api/insights/cards:
|
||||
get:
|
||||
tags:
|
||||
@@ -34935,6 +34956,7 @@ components:
|
||||
- BLOOD_PRESSURE_SYS
|
||||
- PULSE
|
||||
- BLOOD_GLUCOSE
|
||||
- BLOOD_KETONES
|
||||
- OXYGEN_SATURATION
|
||||
- BODY_TEMPERATURE
|
||||
- BODY_FAT
|
||||
@@ -68521,6 +68543,63 @@ components:
|
||||
additionalProperties: false
|
||||
description: A significant Pearson correlation (n >= 20, p < 0.05). The field holding it is null when the pairing is not
|
||||
significant.
|
||||
InsightsPresenceEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/InsightsPresence"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
InsightsPresence:
|
||||
type: object
|
||||
properties:
|
||||
metrics:
|
||||
type: object
|
||||
propertyNames:
|
||||
type: string
|
||||
additionalProperties:
|
||||
type: object
|
||||
properties:
|
||||
count:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
required:
|
||||
- count
|
||||
additionalProperties: false
|
||||
description: "Keyed by `MeasurementType`: the reading count of every type with at least one reading, after the record's
|
||||
module switches. The same counts `GET /api/analytics?slice=summaries` reports."
|
||||
hasMood:
|
||||
type: boolean
|
||||
description: A mood entry in the last 90 days. False while the mood module is off.
|
||||
hasMedication:
|
||||
type: boolean
|
||||
description: An active medication with a schedule whose intake is tracked. False while the medications module is off.
|
||||
hasWorkouts:
|
||||
type: boolean
|
||||
description: At least one workout. False while the workouts module is off.
|
||||
hasEcgRecordings:
|
||||
type: boolean
|
||||
description: At least one ECG recording.
|
||||
required:
|
||||
- metrics
|
||||
- hasMood
|
||||
- hasMedication
|
||||
- hasWorkouts
|
||||
- hasEcgRecordings
|
||||
additionalProperties: false
|
||||
description: "Which Insights pages have something to show for the record: what the tab strip gates its pills on. Counts
|
||||
and flags only, no values."
|
||||
InsightsCardsEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
+12
-10
@@ -1,7 +1,9 @@
|
||||
# Fitbit integration (Fitbit & Pixel)
|
||||
|
||||
> **Experimental.** Field mapping is still being verified against live accounts,
|
||||
> so some values may be missing or change in a future update.
|
||||
> **Ended.** Google turned the classic Fitbit Web API off on 30 October 2026.
|
||||
> HealthLog no longer calls it; existing connections and what they imported
|
||||
> stay. Fitbit and Pixel Watch data now come through
|
||||
> [Google Health](google-health.md).
|
||||
|
||||
HealthLog reads Fitbit and Pixel Watch data through the **classic Fitbit Web
|
||||
API** (`api.fitbit.com`). The OAuth app is a **Fitbit developer app registered
|
||||
@@ -16,13 +18,13 @@ Settings. The internal integration key is `fitbit`.
|
||||
> consent screen with `unauthorized_client — Invalid client_id`, because Fitbit
|
||||
> and Google Cloud are separate client registries.
|
||||
|
||||
> **The classic Fitbit Web API is turned off on 30 October 2026.** Google
|
||||
> ended support for it on 30 September 2026 and turns it off on
|
||||
> **The classic Fitbit Web API was turned off on 30 October 2026.** Google
|
||||
> ended support for it on 30 September 2026 and turned it off on
|
||||
> **30 October 2026**, after which it "will no longer function or sync data"
|
||||
> ([Google's migration guide](https://developers.google.com/health/migration)).
|
||||
> New app registrations at `dev.fitbit.com/apps/new` are already closed.
|
||||
> New app registrations at `dev.fitbit.com/apps/new` are closed.
|
||||
>
|
||||
> From that day HealthLog stops calling the Web API for every Fitbit
|
||||
> Since that day HealthLog no longer calls the Web API for any Fitbit
|
||||
> connection: no hourly sync, no backfill, no token refresh, and no new
|
||||
> connection. Nothing is deleted. The connection and every reading it imported
|
||||
> stay, and the Fitbit card explains the move instead of asking for a
|
||||
@@ -30,10 +32,10 @@ Settings. The internal integration key is `fitbit`.
|
||||
> [Google Health integration](google-health.md). It reads the same devices,
|
||||
> but Google asks for consent once more: Fitbit tokens cannot be carried over.
|
||||
>
|
||||
> Both integrations can run side by side while you move. Where both deliver the
|
||||
> same day, the source-priority ladder keeps one of them per day (Fitbit ranks
|
||||
> just above Google Health by default), so steps, sleep and the other daily
|
||||
> values are not counted twice. After the turndown only Google Health delivers.
|
||||
> Where a day imported from Fitbit before the turndown also comes in through
|
||||
> Google Health, the source-priority ladder keeps one of them per day (Fitbit
|
||||
> ranks just above Google Health by default), so steps, sleep and the other
|
||||
> daily values are not counted twice.
|
||||
|
||||
## 1. Use an existing Fitbit developer app
|
||||
|
||||
|
||||
@@ -195,26 +195,6 @@ bare Nginx — including the `X-Forwarded-For` chain shape that the
|
||||
rate-limiter trusts and the `TRUST_PROXY_HOPS` env var that pins how
|
||||
many hops to read back.
|
||||
|
||||
## Build-time feature toggles
|
||||
|
||||
A small number of toggles are `NEXT_PUBLIC_*` variables: the framework
|
||||
inlines them into the client bundle at **build time**, so the pre-built
|
||||
GHCR image cannot read them at runtime. Setting them in `.env` or the
|
||||
compose `environment:` block has no effect — they only take hold when
|
||||
you build your own image. They are intentionally absent from the
|
||||
compose runtime whitelist for that reason.
|
||||
|
||||
| Variable | Default | Effect when set |
|
||||
| -------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `NEXT_PUBLIC_DASHBOARD_SNAPSHOT` | on | The dashboard hydrates every above-the-fold tile from the single `GET /api/dashboard/snapshot` cell so the whole strip shares one completion moment. Build with `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` to fall back to the legacy four-cell path. |
|
||||
|
||||
To flip one, rebuild the image with the variable exported:
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build
|
||||
# or pass it as a Dockerfile build ARG / Docker build --build-arg
|
||||
```
|
||||
|
||||
## 7. Where to go next
|
||||
|
||||
| Next step | File |
|
||||
|
||||
+197
-373
@@ -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,59 @@ 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.
|
||||
- A password form with no visible username field can carry a hidden
|
||||
`<input type="text" name="username" autoComplete="username" readOnly tabIndex={-1}>`
|
||||
so a password manager files the new password under the right account.
|
||||
The password-change form in Settings has none.
|
||||
|
||||
### 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 +291,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
@@ -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.
|
||||
@@ -137,7 +137,7 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
|
||||
data: [
|
||||
{
|
||||
id: "tok1",
|
||||
name: "iOS auto-login 2026-05-05T19:42:11Z device-AABBCCDDEEFF-marathon",
|
||||
name: "iOS auto-login 2026-05-05T19:42:11Z device-AABBCCDDEEFF-long-name",
|
||||
permissions: [
|
||||
"measurements:write:audit:legacy:extended",
|
||||
"medications:read",
|
||||
|
||||
@@ -221,7 +221,7 @@ async function enterSharedRecord(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap the context's pinned locale for one HealthLog ships as an AI-initial
|
||||
* Swap the context's pinned locale for one HealthLog ships as a machine
|
||||
* translation, so the maintainership strip has a reason to paint.
|
||||
*
|
||||
* The page is parked on `about:blank` for the swap. `I18nProvider` mirrors its
|
||||
|
||||
@@ -21,12 +21,10 @@ test.describe("authenticated dashboard render", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// v1.7.2 — the dashboard snapshot flag is default-ON, so the page
|
||||
// reads every tile from `/api/dashboard/snapshot` and ignores the
|
||||
// legacy cells below. Mock the snapshot with the same populated
|
||||
// four-metric set so the tile strip + charts paint. The legacy
|
||||
// `/api/analytics` + `/api/mood/analytics` mocks stay for the
|
||||
// reversible `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` path.
|
||||
// The page reads every tile from `/api/dashboard/snapshot`; mock it
|
||||
// with the same populated four-metric set so the tile strip + charts
|
||||
// paint. The dashboard ignores the `/api/analytics` and
|
||||
// `/api/mood/analytics` mocks below.
|
||||
await mockDashboardSnapshot(page);
|
||||
|
||||
// Mock analytics with a populated weight summary so the weight tile
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -88,20 +88,16 @@ function mockTargetBands(): DashboardTargetBands {
|
||||
/**
|
||||
* v1.7.2 — shared mock for `GET /api/dashboard/snapshot`.
|
||||
*
|
||||
* The dashboard snapshot flag flipped default-ON in v1.7.2, so
|
||||
* `src/app/page.tsx` reads every above-the-fold tile from the single
|
||||
* `/api/dashboard/snapshot` cell unless `NEXT_PUBLIC_DASHBOARD_SNAPSHOT`
|
||||
* is `"false"`. The e2e build sets no such var, so the snapshot path is
|
||||
* live in CI. Specs that previously mocked only the legacy cells
|
||||
* (`/api/analytics` + `/api/mood/analytics` + `/api/dashboard/widgets`)
|
||||
* now hit the REAL snapshot route, which returns empty `summaries` for
|
||||
* the seed user (zero measurements) and blanks the tile strip.
|
||||
* `/api/dashboard/snapshot` cell. Without a mock a spec hits the REAL
|
||||
* snapshot route, which returns empty `summaries` for the seed user (zero
|
||||
* measurements) and blanks the tile strip.
|
||||
*
|
||||
* `mockDashboardSnapshot` registers a `page.route` for the snapshot
|
||||
* endpoint returning a correctly-typed `DashboardSnapshot` so the
|
||||
* default-ON path paints deterministic tiles. Callers keep their legacy
|
||||
* mocks alongside it — harmless, and they protect the reversible
|
||||
* `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` path.
|
||||
* endpoint returning a correctly-typed `DashboardSnapshot` so the tiles
|
||||
* paint deterministically. Mocks of the older cells (`/api/analytics`,
|
||||
* `/api/mood/analytics`, `/api/dashboard/widgets`) beside it are unused by
|
||||
* the dashboard.
|
||||
*/
|
||||
|
||||
/** Fill a `DataSummary` from a sparse partial, defaulting the rest. */
|
||||
|
||||
+5
-9
@@ -13,15 +13,11 @@ const eslintConfig = defineConfig([
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
// v1.31.4 — ignore local tool worktrees so a worktree on another
|
||||
// commit cannot poison `pnpm lint` locally.
|
||||
// CI never sees these paths; the rules protect the dev environment.
|
||||
// `.wt-*` is the convention actually in use; the two names below predate
|
||||
// it. Without the first pattern `pnpm lint` walks every worktree in the
|
||||
// root, which is both slow and exactly the poisoning this list prevents.
|
||||
".wt-*/**",
|
||||
".claude/**",
|
||||
".worktrees/**",
|
||||
// Hidden top-level folders hold local worktrees and caches, and no
|
||||
// tracked code lives in one (`.github` and `.githooks` hold no JS or
|
||||
// TS). Without this `pnpm lint` walks every worktree on another commit
|
||||
// in the root, which is both slow and poisons the result.
|
||||
".*/**",
|
||||
// The project-local ESLint rule plugin is CommonJS (require/module
|
||||
// .exports) by ESLint convention; it is not application source and
|
||||
// is not linted by the app's TypeScript ruleset.
|
||||
|
||||
+13
-11
@@ -16,20 +16,22 @@
|
||||
# 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.
|
||||
# Commit messages. Commits are authored by the maintainer's git identity;
|
||||
# a co-author trailer is only for a contributor whose work is in the
|
||||
# commit, under their GitHub noreply address. A message is refused when it
|
||||
# carries a "Generated with" / "Generated by" line, a "<Word>-Session:"
|
||||
# trailer, or a co-author trailer with any other address.
|
||||
commit-msg:
|
||||
commands:
|
||||
no-tooling-trailer:
|
||||
message-trailers:
|
||||
run: |
|
||||
if grep -qiE '^(Co-Authored-By:.*(claude|anthropic|copilot|gpt|gemini)|Claude-Session:|Generated with)' "{1}"; then
|
||||
echo "ERROR: the commit message carries a tooling co-author trailer or session link." >&2
|
||||
echo "Project policy: commits are authored by the maintainer's git identity alone." >&2
|
||||
msg="{1}"
|
||||
if grep -qiE '^[^[:alnum:]]*Generated (with|by)[[:space:]]' "$msg" ||
|
||||
grep -qiE '^[[:alpha:]]+-Session:' "$msg" ||
|
||||
grep -iE '^Co-authored-by:' "$msg" |
|
||||
grep -qviE '<[^<>[:space:]]+@users\.noreply\.github\.com>[[:space:]]*$'; then
|
||||
echo "ERROR: the commit message carries a generator line, a session trailer," >&2
|
||||
echo "or a co-author trailer without a GitHub noreply address." >&2
|
||||
echo "Remove the offending line(s) from the message and commit again." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
+38
-24
@@ -764,7 +764,7 @@
|
||||
"noneActionableCta": "Zu den Medikamenten"
|
||||
},
|
||||
"quickEntryDiscard": {
|
||||
"title": "Eintrag verwerfen?",
|
||||
"title": "Eingaben verwerfen?",
|
||||
"description": "Du hast nicht gespeicherte Eingaben. Formular schließen und das Getippte verwerfen?",
|
||||
"confirm": "Verwerfen",
|
||||
"cancel": "Weiter bearbeiten"
|
||||
@@ -802,7 +802,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",
|
||||
@@ -1547,7 +1547,6 @@
|
||||
"glp1NextInjectionToday": "heute",
|
||||
"glp1NextInjectionTomorrow": "morgen",
|
||||
"glp1RotationSuggested": "Empfohlen: {site}",
|
||||
"glp1LogSideEffect": "Nebenwirkung loggen",
|
||||
"sideEffects": {
|
||||
"section": "Nebenwirkungen",
|
||||
"addCta": "Erfassen",
|
||||
@@ -3583,7 +3582,8 @@
|
||||
"emptyState": {
|
||||
"title": "Noch keine HRV-Werte",
|
||||
"description": "HRV-Werte erscheinen hier, sobald die Apple Watch (oder ein kompatibles Withings-Gerät) nächtliche Messungen synchronisiert."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Ich habe noch keine HRV-Daten erfasst. Was sagt mir die Herzfrequenzvariabilität, und wie erfasse ich sie?"
|
||||
},
|
||||
"restingHr": {
|
||||
"title": "Ruhepuls",
|
||||
@@ -3602,7 +3602,8 @@
|
||||
"emptyState": {
|
||||
"title": "Noch keine SpO₂-Werte",
|
||||
"description": "SpO₂-Werte erscheinen, sobald ein kompatibles Wearable (Apple Watch, Withings ScanWatch) eine Messung erfasst."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Ich habe noch keine Sauerstoffsättigung erfasst. Was sagt mir die SpO₂, und welchen Bereich sollte ich anstreben?"
|
||||
},
|
||||
"bodyTemperature": {
|
||||
"title": "Körpertemperatur",
|
||||
@@ -3612,7 +3613,8 @@
|
||||
"title": "Noch keine Körpertemperatur",
|
||||
"description": "Erfasse eine Körpertemperatur oder lass dein Wearable einen Wert synchronisieren. Der Verlauf erscheint dann hier.",
|
||||
"cta": "Körpertemperatur erfassen"
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Ich habe noch keine Körpertemperatur erfasst. Was ist der gesunde Bereich, und was sollte ich tun, wenn sie abweicht?"
|
||||
},
|
||||
"steps": {
|
||||
"title": "Schritte",
|
||||
@@ -3621,7 +3623,8 @@
|
||||
"emptyState": {
|
||||
"title": "Noch keine Schrittdaten",
|
||||
"description": "Tägliche Schritt-Summen erscheinen hier, sobald Apple Health oder ein Aktivitätsgerät einen Tagesumfang synchronisiert."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Ich habe noch keine Schritte erfasst. Was ist ein sinnvolles tägliches Schrittziel, und wie hilft mehr Gehen meiner Gesundheit?"
|
||||
},
|
||||
"activeEnergy": {
|
||||
"title": "Aktive Energie",
|
||||
@@ -3630,7 +3633,8 @@
|
||||
"emptyState": {
|
||||
"title": "Noch keine Aktive-Energie-Werte",
|
||||
"description": "Aktive-Energie-Summen erscheinen hier, sobald Apple Health oder ein Withings-Aktivitätsgerät einen Tagesumfang synchronisiert."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Ich habe noch keine aktive Energie erfasst. Was ist ein sinnvolles Tagesziel, und wie verhält es sich zum gesamten Kalorienverbrauch?"
|
||||
},
|
||||
"subPage": {
|
||||
"loadError": "Diese Messgröße konnte gerade nicht geladen werden.",
|
||||
@@ -3725,7 +3729,8 @@
|
||||
"valuesBack": "Zurück zu Insights",
|
||||
"valuesBackToMetric": "Zurück zu {metric}",
|
||||
"scoresBack": "Zurück zu Insights",
|
||||
"ecgDescription": "Einkanal-EKG-Aufzeichnungen deines Geräts, jeweils mit dem Ergebnis des Geräts."
|
||||
"ecgDescription": "Einkanal-EKG-Aufzeichnungen deines Geräts, jeweils mit dem Ergebnis des Geräts.",
|
||||
"emptyCoachPrefill": "{metric}: Ich habe hier noch nichts erfasst. Was sagt dieser Wert über meine Gesundheit aus, und wie verbessere ich ihn?"
|
||||
},
|
||||
"units": {
|
||||
"steps": "Schritte",
|
||||
@@ -3825,27 +3830,32 @@
|
||||
"bloodPressure": {
|
||||
"title": "Noch keine Blutdruckwerte",
|
||||
"description": "Sobald du Blutdruck loggst, erscheinen hier Trend, Zielbereichs-Anteil und WHO-Klassifizierung.",
|
||||
"cta": "Blutdruck loggen"
|
||||
"cta": "Blutdruck loggen",
|
||||
"coachPrefill": "Ich habe noch keinen Blutdruck erfasst. Warum ist er wichtig, und was sollte ich wissen, bevor ich anfange?"
|
||||
},
|
||||
"weight": {
|
||||
"title": "Noch keine Gewichtswerte",
|
||||
"description": "Logge ein Gewicht, und Verlauf, gleitender Durchschnitt und Zielbereich erscheinen automatisch.",
|
||||
"cta": "Gewicht loggen"
|
||||
"cta": "Gewicht loggen",
|
||||
"coachPrefill": "Ich habe noch kein Gewicht erfasst. Warum ist es wichtig, und was sollte ich wissen, bevor ich mit dem Erfassen anfange?"
|
||||
},
|
||||
"pulse": {
|
||||
"title": "Noch keine Pulswerte",
|
||||
"description": "Logge einen Puls, dann erscheinen Ruhepuls, HRV und VO₂ max in dieser Ansicht.",
|
||||
"cta": "Puls loggen"
|
||||
"cta": "Puls loggen",
|
||||
"coachPrefill": "Ich habe noch keinen Ruhepuls erfasst. Warum ist er wichtig, und was sollte ich wissen, bevor ich anfange?"
|
||||
},
|
||||
"bmi": {
|
||||
"title": "BMI noch nicht berechnet",
|
||||
"description": "Sobald Größe und mindestens ein Gewichtswert vorliegen, erscheint hier der BMI-Verlauf samt WHO-Zonen.",
|
||||
"cta": "Gewicht loggen"
|
||||
"cta": "Gewicht loggen",
|
||||
"coachPrefill": "Ich habe noch kein Gewicht erfasst. Warum ist der BMI für mich wichtig, und was sollte ich wissen, bevor ich ihn verfolge?"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Noch keine Stimmungseinträge",
|
||||
"description": "Logge eine Stimmung, dann erscheinen Verlauf, Stabilitätsband und Korrelationen.",
|
||||
"cta": "Stimmung loggen"
|
||||
"cta": "Stimmung loggen",
|
||||
"coachPrefill": "Ich habe noch keine Stimmung erfasst. Warum lohnt es sich, die Stimmung zu erfassen, und wie fange ich am besten an?"
|
||||
},
|
||||
"medication": {
|
||||
"title": "Noch keine Medikamente angelegt",
|
||||
@@ -3855,7 +3865,8 @@
|
||||
"sleep": {
|
||||
"title": "Noch keine Schlafwerte",
|
||||
"description": "Sobald Schlafdaten vorliegen (manuell oder via Withings / Apple Health), erscheint hier der nächtliche Stadien-Verlauf.",
|
||||
"cta": "Quelle verbinden"
|
||||
"cta": "Quelle verbinden",
|
||||
"coachPrefill": "Ich habe noch keine Schlafdaten. Warum lohnt es sich, Schlaf zu erfassen, und was sollte ich wissen, bevor ich eine Quelle verbinde?"
|
||||
}
|
||||
},
|
||||
"navWorkouts": "Workouts",
|
||||
@@ -3865,7 +3876,8 @@
|
||||
"description": "Letzte Läufe, Touren, Spaziergänge und Krafteinheiten, quellenübergreifend entdoppelt zwischen Apple Watch und Withings.",
|
||||
"emptyState": {
|
||||
"title": "Noch keine Workouts",
|
||||
"description": "Workouts kommen per Apple Health vom iPhone und von Withings. Du kannst eins auch von Hand erfassen."
|
||||
"description": "Workouts kommen per Apple Health vom iPhone und von Withings. Du kannst eins auch von Hand erfassen.",
|
||||
"coachPrefill": "Ich habe noch keine Workouts erfasst. Warum lohnt es sich, sie zu erfassen, und worauf sollte ich mich zuerst konzentrieren?"
|
||||
},
|
||||
"detail": {
|
||||
"backToList": "Zurück zu Workouts",
|
||||
@@ -4745,7 +4757,8 @@
|
||||
"emptyState": {
|
||||
"title": "Noch keine Sturzdaten",
|
||||
"description": "Erkannte Stürze erscheinen hier, sobald Apple Health synchronisiert. Keine Stürze ist das Ziel."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Ich habe noch keine Sturzdaten erfasst. Was sagt dieser Wert über meine Gesundheit aus, und wie senke ich mein Sturzrisiko?"
|
||||
},
|
||||
"navFalls": "Stürze",
|
||||
"sixMinuteWalk": {
|
||||
@@ -5695,7 +5708,7 @@
|
||||
"title": "Krankheitstagebuch",
|
||||
"description": "Listenansicht und Reihenfolge der Episoden."
|
||||
},
|
||||
"vorsorge": {
|
||||
"checkups": {
|
||||
"title": "Vorsorge",
|
||||
"description": "Listenansicht und Reihenfolge der Erinnerungen."
|
||||
},
|
||||
@@ -6121,7 +6134,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."
|
||||
},
|
||||
@@ -7058,7 +7071,7 @@
|
||||
"builtAtLabel": "Erstellt",
|
||||
"license": "Lizenz",
|
||||
"repository": "Quellcode",
|
||||
"changelog": "Changelog",
|
||||
"changelog": "Versionshinweise",
|
||||
"docs": "Dokumentation",
|
||||
"credits": "Credits & Datenquellen",
|
||||
"linksHeading": "Quellen & Dokumentation",
|
||||
@@ -10229,6 +10242,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",
|
||||
@@ -11073,7 +11087,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",
|
||||
@@ -11162,14 +11176,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."
|
||||
|
||||
+33
-19
@@ -764,7 +764,7 @@
|
||||
"noneActionableCta": "Open medications"
|
||||
},
|
||||
"quickEntryDiscard": {
|
||||
"title": "Discard this entry?",
|
||||
"title": "Discard your changes?",
|
||||
"description": "You have unsaved input. Close the form and lose what you typed?",
|
||||
"confirm": "Discard",
|
||||
"cancel": "Keep editing"
|
||||
@@ -1547,7 +1547,6 @@
|
||||
"glp1NextInjectionToday": "today",
|
||||
"glp1NextInjectionTomorrow": "tomorrow",
|
||||
"glp1RotationSuggested": "Recommended next: {site}",
|
||||
"glp1LogSideEffect": "Log side effect",
|
||||
"sideEffects": {
|
||||
"section": "Side effects",
|
||||
"addCta": "Log",
|
||||
@@ -3583,7 +3582,8 @@
|
||||
"emptyState": {
|
||||
"title": "No HRV samples yet",
|
||||
"description": "HRV samples land here once Apple Watch (or a compatible Withings device) starts syncing nightly readings."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "I haven't logged any HRV data yet. What does heart-rate variability tell me, and how do I capture it?"
|
||||
},
|
||||
"restingHr": {
|
||||
"title": "Resting heart rate",
|
||||
@@ -3602,7 +3602,8 @@
|
||||
"emptyState": {
|
||||
"title": "No SpO₂ samples yet",
|
||||
"description": "SpO₂ samples land here once a compatible wearable (Apple Watch, Withings ScanWatch) captures a reading."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "I haven't logged any oxygen-saturation data yet. What does SpO₂ tell me, and what range should I aim for?"
|
||||
},
|
||||
"bodyTemperature": {
|
||||
"title": "Body temperature",
|
||||
@@ -3612,7 +3613,8 @@
|
||||
"title": "No body temperature yet",
|
||||
"description": "Log a body-temperature reading or let your wearable sync one, and the trend will appear here.",
|
||||
"cta": "Log body temperature"
|
||||
}
|
||||
},
|
||||
"coachPrefill": "I haven't logged any body temperature yet. What's the healthy range, and what should I do if it drifts?"
|
||||
},
|
||||
"steps": {
|
||||
"title": "Steps",
|
||||
@@ -3621,7 +3623,8 @@
|
||||
"emptyState": {
|
||||
"title": "No step data yet",
|
||||
"description": "Daily step totals appear here once Apple Health or an activity device syncs a day's worth of movement."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "I haven't logged any steps yet. What's a reasonable daily step target, and how does walking more help my health?"
|
||||
},
|
||||
"activeEnergy": {
|
||||
"title": "Active energy",
|
||||
@@ -3630,7 +3633,8 @@
|
||||
"emptyState": {
|
||||
"title": "No active-energy data yet",
|
||||
"description": "Active-energy totals land here once Apple Health or a Withings activity device syncs a day's worth of movement."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "I haven't logged any active energy yet. What's a reasonable daily target, and how does it compare to total calories?"
|
||||
},
|
||||
"subPage": {
|
||||
"loadError": "Could not load this metric right now.",
|
||||
@@ -3725,7 +3729,8 @@
|
||||
"valuesBack": "Back to insights",
|
||||
"valuesBackToMetric": "Back to {metric}",
|
||||
"scoresBack": "Back to insights",
|
||||
"ecgDescription": "Single-lead ECG recordings from your device, each with the device's own result."
|
||||
"ecgDescription": "Single-lead ECG recordings from your device, each with the device's own result.",
|
||||
"emptyCoachPrefill": "{metric}: I haven't logged anything here yet. What does this metric tell me about my health, and how do I improve it?"
|
||||
},
|
||||
"units": {
|
||||
"steps": "steps",
|
||||
@@ -3825,27 +3830,32 @@
|
||||
"bloodPressure": {
|
||||
"title": "No blood pressure entries yet",
|
||||
"description": "Log a blood pressure reading and the trend chart, in-range share, and WHO classification will appear here.",
|
||||
"cta": "Log blood pressure"
|
||||
"cta": "Log blood pressure",
|
||||
"coachPrefill": "I haven't recorded any blood pressure yet. Why does it matter, and what should I know before I start?"
|
||||
},
|
||||
"weight": {
|
||||
"title": "No weight entries yet",
|
||||
"description": "Log a weight and the trend, moving average, and target band will appear automatically.",
|
||||
"cta": "Log weight"
|
||||
"cta": "Log weight",
|
||||
"coachPrefill": "I haven't recorded any weight yet. Why does it matter, and what should I know before I start tracking?"
|
||||
},
|
||||
"pulse": {
|
||||
"title": "No pulse entries yet",
|
||||
"description": "Log a pulse reading and resting pulse, HRV and VO₂ max will appear in this view.",
|
||||
"cta": "Log pulse"
|
||||
"cta": "Log pulse",
|
||||
"coachPrefill": "I haven't recorded any resting pulse yet. Why does it matter, and what should I know before I start?"
|
||||
},
|
||||
"bmi": {
|
||||
"title": "BMI not yet computed",
|
||||
"description": "Once height and at least one weight entry are present, the BMI trend with WHO zones will appear here.",
|
||||
"cta": "Log weight"
|
||||
"cta": "Log weight",
|
||||
"coachPrefill": "I haven't recorded any weight yet. Why does BMI matter for me, and what should I know before I start tracking it?"
|
||||
},
|
||||
"mood": {
|
||||
"title": "No mood entries yet",
|
||||
"description": "Log a mood entry and the trend, stability band and correlations will appear.",
|
||||
"cta": "Log mood"
|
||||
"cta": "Log mood",
|
||||
"coachPrefill": "I haven't logged any mood entries yet. Why does mood tracking matter, and how should I start?"
|
||||
},
|
||||
"medication": {
|
||||
"title": "No medications set up yet",
|
||||
@@ -3855,7 +3865,8 @@
|
||||
"sleep": {
|
||||
"title": "No sleep entries yet",
|
||||
"description": "Once sleep data lands (manual or via Withings / Apple Health), the per-night stage breakdown will appear here.",
|
||||
"cta": "Connect a source"
|
||||
"cta": "Connect a source",
|
||||
"coachPrefill": "I don't have any sleep data yet. Why does sleep tracking matter, and what should I know before I connect a source?"
|
||||
}
|
||||
},
|
||||
"navWorkouts": "Workouts",
|
||||
@@ -3865,7 +3876,8 @@
|
||||
"description": "Recent runs, rides, walks and strength sessions, deduped across Apple Watch and Withings sources.",
|
||||
"emptyState": {
|
||||
"title": "No workouts yet",
|
||||
"description": "Workouts sync from Apple Health on your iPhone and from Withings. You can also log one by hand."
|
||||
"description": "Workouts sync from Apple Health on your iPhone and from Withings. You can also log one by hand.",
|
||||
"coachPrefill": "I haven't logged any workouts yet. Why does tracking them matter, and what should I focus on first?"
|
||||
},
|
||||
"detail": {
|
||||
"backToList": "Back to workouts",
|
||||
@@ -4745,7 +4757,8 @@
|
||||
"emptyState": {
|
||||
"title": "No fall data yet",
|
||||
"description": "Detected falls appear here once Apple Health syncs. No falls is the goal."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "I haven't logged any fall data yet. What does this metric tell me about my health, and how do I lower my fall risk?"
|
||||
},
|
||||
"navFalls": "Falls",
|
||||
"sixMinuteWalk": {
|
||||
@@ -5695,7 +5708,7 @@
|
||||
"title": "Illness journal",
|
||||
"description": "List view and episode order."
|
||||
},
|
||||
"vorsorge": {
|
||||
"checkups": {
|
||||
"title": "Checkups",
|
||||
"description": "List view and reminder order."
|
||||
},
|
||||
@@ -6121,7 +6134,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."
|
||||
},
|
||||
@@ -7058,7 +7071,7 @@
|
||||
"builtAtLabel": "Built",
|
||||
"license": "License",
|
||||
"repository": "Source code",
|
||||
"changelog": "Changelog",
|
||||
"changelog": "Release notes",
|
||||
"docs": "Documentation",
|
||||
"credits": "Credits & data attributions",
|
||||
"linksHeading": "Sources & docs",
|
||||
@@ -10229,6 +10242,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",
|
||||
|
||||
+39
-25
@@ -764,7 +764,7 @@
|
||||
"noneActionableCta": "Abrir medicamentos"
|
||||
},
|
||||
"quickEntryDiscard": {
|
||||
"title": "¿Descartar esta entrada?",
|
||||
"title": "¿Descartar los cambios?",
|
||||
"description": "Tienes datos sin guardar. ¿Cerrar el formulario y perder lo que escribiste?",
|
||||
"confirm": "Descartar",
|
||||
"cancel": "Seguir editando"
|
||||
@@ -1547,7 +1547,6 @@
|
||||
"glp1NextInjectionToday": "hoy",
|
||||
"glp1NextInjectionTomorrow": "mañana",
|
||||
"glp1RotationSuggested": "Sugerido: {site}",
|
||||
"glp1LogSideEffect": "Registrar efecto secundario",
|
||||
"sideEffects": {
|
||||
"section": "Efectos secundarios",
|
||||
"addCta": "Registrar",
|
||||
@@ -1741,7 +1740,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"
|
||||
},
|
||||
@@ -3583,7 +3582,8 @@
|
||||
"emptyState": {
|
||||
"title": "Aún no hay valores de VFC",
|
||||
"description": "Los valores de VFC aparecerán aquí en cuanto Apple Watch (o un dispositivo Withings compatible) sincronice las mediciones nocturnas."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Aún no he registrado datos de VFC: ¿qué me dice la variabilidad de la frecuencia cardíaca y cómo la registro?"
|
||||
},
|
||||
"restingHr": {
|
||||
"title": "FC en reposo",
|
||||
@@ -3602,7 +3602,8 @@
|
||||
"emptyState": {
|
||||
"title": "Aún no hay muestras de SpO₂",
|
||||
"description": "Las muestras de SpO₂ llegan aquí en cuanto un wearable compatible (Apple Watch, Withings ScanWatch) capture una lectura."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Aún no he registrado la saturación de oxígeno: ¿qué me dice la SpO₂ y a qué rango debería aspirar?"
|
||||
},
|
||||
"bodyTemperature": {
|
||||
"title": "Temperatura corporal",
|
||||
@@ -3612,7 +3613,8 @@
|
||||
"title": "Aún no hay temperatura corporal",
|
||||
"description": "Registra una lectura o deja que tu wearable sincronice una, y la tendencia aparecerá aquí.",
|
||||
"cta": "Registrar temperatura corporal"
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Aún no he registrado la temperatura corporal: ¿cuál es el rango saludable y qué debo hacer si se desvía?"
|
||||
},
|
||||
"steps": {
|
||||
"title": "Pasos",
|
||||
@@ -3621,7 +3623,8 @@
|
||||
"emptyState": {
|
||||
"title": "Aún no hay datos de pasos",
|
||||
"description": "Los totales diarios de pasos aparecerán aquí en cuanto Apple Salud o un dispositivo de actividad sincronice un día completo."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Aún no he registrado pasos: ¿cuál es un objetivo diario de pasos razonable y cómo ayuda a mi salud caminar más?"
|
||||
},
|
||||
"activeEnergy": {
|
||||
"title": "Energía activa",
|
||||
@@ -3630,7 +3633,8 @@
|
||||
"emptyState": {
|
||||
"title": "Aún no hay datos de energía activa",
|
||||
"description": "Los totales de energía activa aparecerán aquí en cuanto Apple Salud o un dispositivo de actividad Withings sincronice un día completo."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Aún no he registrado energía activa: ¿cuál es un objetivo diario razonable y cómo se compara con las calorías totales?"
|
||||
},
|
||||
"subPage": {
|
||||
"loadError": "No se pudo cargar esta métrica ahora mismo.",
|
||||
@@ -3725,7 +3729,8 @@
|
||||
"valuesBack": "Volver a Insights",
|
||||
"valuesBackToMetric": "Volver a {metric}",
|
||||
"scoresBack": "Volver a Insights",
|
||||
"ecgDescription": "Registros de ECG de una derivación de tu dispositivo, cada uno con el resultado del propio dispositivo."
|
||||
"ecgDescription": "Registros de ECG de una derivación de tu dispositivo, cada uno con el resultado del propio dispositivo.",
|
||||
"emptyCoachPrefill": "{metric}: aún no he registrado nada aquí. ¿Qué dice esta métrica sobre mi salud y cómo la mejoro?"
|
||||
},
|
||||
"units": {
|
||||
"steps": "pasos",
|
||||
@@ -3825,27 +3830,32 @@
|
||||
"bloodPressure": {
|
||||
"title": "Aún no hay registros de presión arterial",
|
||||
"description": "Registra una medición de presión y aquí aparecerán la tendencia, el porcentaje en rango y la clasificación de la OMS.",
|
||||
"cta": "Registrar presión"
|
||||
"cta": "Registrar presión",
|
||||
"coachPrefill": "Aún no he registrado la presión arterial: ¿por qué importa y qué debería saber antes de empezar?"
|
||||
},
|
||||
"weight": {
|
||||
"title": "Aún no hay registros de peso",
|
||||
"description": "Registra un peso y aparecerán automáticamente la tendencia, la media móvil y la zona objetivo.",
|
||||
"cta": "Registrar peso"
|
||||
"cta": "Registrar peso",
|
||||
"coachPrefill": "Aún no he registrado mi peso: ¿por qué importa y qué debería saber antes de empezar a registrarlo?"
|
||||
},
|
||||
"pulse": {
|
||||
"title": "Aún no hay registros de pulso",
|
||||
"description": "Registra un pulso y aquí aparecerán pulso en reposo, VFC y VO₂ máx.",
|
||||
"cta": "Registrar pulso"
|
||||
"cta": "Registrar pulso",
|
||||
"coachPrefill": "Aún no he registrado el pulso en reposo: ¿por qué importa y qué debería saber antes de empezar?"
|
||||
},
|
||||
"bmi": {
|
||||
"title": "IMC aún no calculado",
|
||||
"description": "Cuando haya altura y al menos un registro de peso, aquí aparecerá la tendencia del IMC con las zonas de la OMS.",
|
||||
"cta": "Registrar peso"
|
||||
"cta": "Registrar peso",
|
||||
"coachPrefill": "Aún no he registrado mi peso: ¿por qué me importa el IMC y qué debería saber antes de empezar a seguirlo?"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Aún no hay entradas de estado de ánimo",
|
||||
"description": "Registra un estado de ánimo y aparecerán la tendencia, la banda de estabilidad y las correlaciones.",
|
||||
"cta": "Registrar ánimo"
|
||||
"cta": "Registrar ánimo",
|
||||
"coachPrefill": "Aún no he registrado mi estado de ánimo: ¿por qué importa registrarlo y cómo debería empezar?"
|
||||
},
|
||||
"medication": {
|
||||
"title": "Aún no hay medicamentos configurados",
|
||||
@@ -3855,7 +3865,8 @@
|
||||
"sleep": {
|
||||
"title": "Aún no hay registros de sueño",
|
||||
"description": "Una vez disponibles los datos de sueño (manual o vía Withings / Apple Health), aquí aparecerá el desglose por fases.",
|
||||
"cta": "Conectar una fuente"
|
||||
"cta": "Conectar una fuente",
|
||||
"coachPrefill": "Aún no tengo datos de sueño: ¿por qué importa registrar el sueño y qué debería saber antes de conectar una fuente?"
|
||||
}
|
||||
},
|
||||
"navWorkouts": "Entrenamientos",
|
||||
@@ -3865,7 +3876,8 @@
|
||||
"description": "Carreras, salidas en bici, paseos y sesiones de fuerza recientes, deduplicados entre Apple Watch y Withings.",
|
||||
"emptyState": {
|
||||
"title": "Aún no hay entrenamientos",
|
||||
"description": "Los entrenamientos llegan desde Apple Salud en el iPhone y desde Withings. También puedes registrar uno a mano."
|
||||
"description": "Los entrenamientos llegan desde Apple Salud en el iPhone y desde Withings. También puedes registrar uno a mano.",
|
||||
"coachPrefill": "Aún no he registrado entrenamientos: ¿por qué importa registrarlos y en qué debería centrarme primero?"
|
||||
},
|
||||
"detail": {
|
||||
"backToList": "Volver a entrenamientos",
|
||||
@@ -4745,7 +4757,8 @@
|
||||
"emptyState": {
|
||||
"title": "Aún no hay datos de caídas",
|
||||
"description": "Las caídas detectadas aparecen aquí cuando Apple Health se sincroniza. Cero caídas es el objetivo."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Aún no he registrado datos de caídas: ¿qué dice esta métrica sobre mi salud y cómo reduzco mi riesgo de caídas?"
|
||||
},
|
||||
"navFalls": "Caídas",
|
||||
"sixMinuteWalk": {
|
||||
@@ -5695,7 +5708,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."
|
||||
},
|
||||
@@ -6121,7 +6134,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."
|
||||
},
|
||||
@@ -7058,7 +7071,7 @@
|
||||
"builtAtLabel": "Compilado",
|
||||
"license": "Licencia",
|
||||
"repository": "Código fuente",
|
||||
"changelog": "Changelog",
|
||||
"changelog": "Notas de la versión",
|
||||
"docs": "Documentación",
|
||||
"credits": "Créditos y atribuciones de datos",
|
||||
"linksHeading": "Fuentes y documentación",
|
||||
@@ -10206,7 +10219,7 @@
|
||||
"snoozedUntil": "Aplazado hasta {date}",
|
||||
"skippedOn": "Omitido el {date}",
|
||||
"skipCount": "omitido {count} veces",
|
||||
"disabledBadge": "Apagado",
|
||||
"disabledBadge": "Desactivado",
|
||||
"nextDue": {
|
||||
"none": "Sin programar",
|
||||
"today": "Vence hoy",
|
||||
@@ -10229,6 +10242,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",
|
||||
@@ -11073,7 +11087,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",
|
||||
@@ -11162,14 +11176,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."
|
||||
|
||||
+38
-24
@@ -764,7 +764,7 @@
|
||||
"noneActionableCta": "Ouvrir les médicaments"
|
||||
},
|
||||
"quickEntryDiscard": {
|
||||
"title": "Abandonner cette saisie ?",
|
||||
"title": "Abandonner les modifications ?",
|
||||
"description": "Vous avez des données non enregistrées. Fermer le formulaire et perdre ce que vous avez saisi ?",
|
||||
"confirm": "Abandonner",
|
||||
"cancel": "Continuer la saisie"
|
||||
@@ -1547,7 +1547,6 @@
|
||||
"glp1NextInjectionToday": "aujourd’hui",
|
||||
"glp1NextInjectionTomorrow": "demain",
|
||||
"glp1RotationSuggested": "Conseillé : {site}",
|
||||
"glp1LogSideEffect": "Consigner un effet secondaire",
|
||||
"sideEffects": {
|
||||
"section": "Effets secondaires",
|
||||
"addCta": "Consigner",
|
||||
@@ -1741,7 +1740,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"
|
||||
},
|
||||
@@ -3583,7 +3582,8 @@
|
||||
"emptyState": {
|
||||
"title": "Aucune mesure de VFC",
|
||||
"description": "Les mesures de VFC apparaîtront ici dès que l'Apple Watch (ou un appareil Withings compatible) synchronisera des relevés nocturnes."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Je n’ai pas encore enregistré de données de VFC. Que m’apprend la variabilité de la fréquence cardiaque, et comment la mesurer ?"
|
||||
},
|
||||
"restingHr": {
|
||||
"title": "FC au repos",
|
||||
@@ -3602,7 +3602,8 @@
|
||||
"emptyState": {
|
||||
"title": "Aucune mesure SpO₂",
|
||||
"description": "Les mesures SpO₂ apparaîtront dès qu'un wearable compatible (Apple Watch, Withings ScanWatch) en capturera une."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Je n’ai pas encore enregistré de saturation en oxygène. Que m’apprend la SpO₂, et quelle plage viser ?"
|
||||
},
|
||||
"bodyTemperature": {
|
||||
"title": "Température corporelle",
|
||||
@@ -3612,7 +3613,8 @@
|
||||
"title": "Aucune température corporelle",
|
||||
"description": "Enregistrez une mesure ou laissez votre wearable en synchroniser une, et la tendance apparaîtra ici.",
|
||||
"cta": "Enregistrer la température"
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Je n’ai pas encore enregistré de température corporelle. Quelle est la plage normale, et que faire si elle s’en écarte ?"
|
||||
},
|
||||
"steps": {
|
||||
"title": "Pas",
|
||||
@@ -3621,7 +3623,8 @@
|
||||
"emptyState": {
|
||||
"title": "Aucune donnée de pas",
|
||||
"description": "Les totaux de pas quotidiens apparaîtront ici dès qu'Apple Santé ou un appareil d'activité synchronise une journée complète."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Je n’ai pas encore enregistré de pas. Quel objectif quotidien de pas est raisonnable, et en quoi le fait de marcher davantage aide-t-il ma santé ?"
|
||||
},
|
||||
"activeEnergy": {
|
||||
"title": "Énergie active",
|
||||
@@ -3630,7 +3633,8 @@
|
||||
"emptyState": {
|
||||
"title": "Aucune donnée d'énergie active",
|
||||
"description": "Les totaux d'énergie active apparaîtront dès qu'Apple Santé ou un appareil Withings activité synchronise une journée complète."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Je n’ai pas encore enregistré d’énergie active. Quel objectif quotidien est raisonnable, et comment se compare-t-il aux calories totales ?"
|
||||
},
|
||||
"subPage": {
|
||||
"loadError": "Impossible de charger cette mesure pour le moment.",
|
||||
@@ -3725,7 +3729,8 @@
|
||||
"valuesBack": "Retour aux Insights",
|
||||
"valuesBackToMetric": "Retour à {metric}",
|
||||
"scoresBack": "Retour aux Insights",
|
||||
"ecgDescription": "Enregistrements ECG à dérivation unique de votre appareil, chacun accompagné du résultat de l'appareil."
|
||||
"ecgDescription": "Enregistrements ECG à dérivation unique de votre appareil, chacun accompagné du résultat de l'appareil.",
|
||||
"emptyCoachPrefill": "{metric} : je n’ai encore rien enregistré ici. Que dit cette mesure sur ma santé, et comment l’améliorer ?"
|
||||
},
|
||||
"units": {
|
||||
"steps": "pas",
|
||||
@@ -3825,27 +3830,32 @@
|
||||
"bloodPressure": {
|
||||
"title": "Aucune mesure de tension artérielle",
|
||||
"description": "Enregistrez une mesure de tension : la tendance, la part dans la cible et la classification OMS s'afficheront ici.",
|
||||
"cta": "Enregistrer une tension"
|
||||
"cta": "Enregistrer une tension",
|
||||
"coachPrefill": "Je n’ai pas encore enregistré de tension artérielle. Pourquoi est-ce important, et que dois-je savoir avant de commencer ?"
|
||||
},
|
||||
"weight": {
|
||||
"title": "Aucune mesure de poids",
|
||||
"description": "Enregistrez un poids : la tendance, la moyenne glissante et la zone cible apparaîtront automatiquement.",
|
||||
"cta": "Enregistrer un poids"
|
||||
"cta": "Enregistrer un poids",
|
||||
"coachPrefill": "Je n’ai pas encore enregistré de poids. Pourquoi est-ce important, et que dois-je savoir avant de commencer le suivi ?"
|
||||
},
|
||||
"pulse": {
|
||||
"title": "Aucune mesure de pouls",
|
||||
"description": "Enregistrez un pouls : pouls au repos, VFC et VO₂ max apparaîtront dans cette vue.",
|
||||
"cta": "Enregistrer un pouls"
|
||||
"cta": "Enregistrer un pouls",
|
||||
"coachPrefill": "Je n’ai pas encore enregistré de pouls au repos. Pourquoi est-ce important, et que dois-je savoir avant de commencer ?"
|
||||
},
|
||||
"bmi": {
|
||||
"title": "IMC pas encore calculé",
|
||||
"description": "Dès qu'une taille et au moins un poids sont enregistrés, la tendance IMC avec les zones OMS s'affichera ici.",
|
||||
"cta": "Enregistrer un poids"
|
||||
"cta": "Enregistrer un poids",
|
||||
"coachPrefill": "Je n’ai pas encore enregistré de poids. Pourquoi l’IMC compte-t-il pour moi, et que dois-je savoir avant de le suivre ?"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Aucune entrée d'humeur",
|
||||
"description": "Enregistrez une humeur : la tendance, la bande de stabilité et les corrélations s'afficheront.",
|
||||
"cta": "Enregistrer une humeur"
|
||||
"cta": "Enregistrer une humeur",
|
||||
"coachPrefill": "Je n’ai pas encore enregistré d’humeur. Pourquoi suivre mon humeur, et comment commencer ?"
|
||||
},
|
||||
"medication": {
|
||||
"title": "Aucun médicament configuré",
|
||||
@@ -3855,7 +3865,8 @@
|
||||
"sleep": {
|
||||
"title": "Aucune donnée de sommeil",
|
||||
"description": "Dès que des données de sommeil sont disponibles (manuel ou via Withings / Apple Health), la répartition par stade apparaîtra ici.",
|
||||
"cta": "Connecter une source"
|
||||
"cta": "Connecter une source",
|
||||
"coachPrefill": "Je n’ai pas encore de données de sommeil. Pourquoi suivre mon sommeil, et que dois-je savoir avant de connecter une source ?"
|
||||
}
|
||||
},
|
||||
"navWorkouts": "Entraînements",
|
||||
@@ -3865,7 +3876,8 @@
|
||||
"description": "Courses, sorties à vélo, marches et séances de musculation récentes, dédupliquées entre Apple Watch et Withings.",
|
||||
"emptyState": {
|
||||
"title": "Aucun entraînement",
|
||||
"description": "Vos entraînements arrivent d'Apple Santé sur l'iPhone et de Withings. Vous pouvez aussi en saisir un à la main."
|
||||
"description": "Vos entraînements arrivent d'Apple Santé sur l'iPhone et de Withings. Vous pouvez aussi en saisir un à la main.",
|
||||
"coachPrefill": "Je n’ai pas encore enregistré d’entraînement. Pourquoi les suivre, et sur quoi me concentrer en premier ?"
|
||||
},
|
||||
"detail": {
|
||||
"backToList": "Retour aux entraînements",
|
||||
@@ -4745,7 +4757,8 @@
|
||||
"emptyState": {
|
||||
"title": "Pas encore de données de chutes",
|
||||
"description": "Les chutes détectées apparaissent ici dès qu'Apple Santé se synchronise. L'objectif est zéro chute."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Je n’ai pas encore enregistré de données de chute. Que dit cette mesure sur ma santé, et comment réduire mon risque de chute ?"
|
||||
},
|
||||
"navFalls": "Chutes",
|
||||
"sixMinuteWalk": {
|
||||
@@ -5695,7 +5708,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."
|
||||
},
|
||||
@@ -6121,7 +6134,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."
|
||||
},
|
||||
@@ -7058,7 +7071,7 @@
|
||||
"builtAtLabel": "Compilé",
|
||||
"license": "Licence",
|
||||
"repository": "Code source",
|
||||
"changelog": "Changelog",
|
||||
"changelog": "Notes de version",
|
||||
"docs": "Documentation",
|
||||
"credits": "Crédits et attributions de données",
|
||||
"linksHeading": "Sources et documentation",
|
||||
@@ -10229,6 +10242,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",
|
||||
@@ -11073,7 +11087,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",
|
||||
@@ -11162,14 +11176,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."
|
||||
|
||||
+38
-24
@@ -764,7 +764,7 @@
|
||||
"noneActionableCta": "Apri i farmaci"
|
||||
},
|
||||
"quickEntryDiscard": {
|
||||
"title": "Scartare questa voce?",
|
||||
"title": "Scartare le modifiche?",
|
||||
"description": "Hai dati non salvati. Chiudere il modulo e perdere ciò che hai digitato?",
|
||||
"confirm": "Scarta",
|
||||
"cancel": "Continua a modificare"
|
||||
@@ -1547,7 +1547,6 @@
|
||||
"glp1NextInjectionToday": "oggi",
|
||||
"glp1NextInjectionTomorrow": "domani",
|
||||
"glp1RotationSuggested": "Suggerita: {site}",
|
||||
"glp1LogSideEffect": "Registra effetto collaterale",
|
||||
"sideEffects": {
|
||||
"section": "Effetti collaterali",
|
||||
"addCta": "Registra",
|
||||
@@ -1741,7 +1740,7 @@
|
||||
"step1": {
|
||||
"title": "Come si chiama il farmaco?",
|
||||
"subline": "Esattamente come riportato sulla confezione.",
|
||||
"label": "Name",
|
||||
"label": "Nome",
|
||||
"placeholder": "ad es. Ramipril",
|
||||
"short": "Nome"
|
||||
},
|
||||
@@ -3583,7 +3582,8 @@
|
||||
"emptyState": {
|
||||
"title": "Ancora nessun valore HRV",
|
||||
"description": "I valori HRV appariranno qui non appena Apple Watch (o un dispositivo Withings compatibile) sincronizza le misurazioni notturne."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Non ho ancora registrato dati HRV. Cosa mi dice la variabilità della frequenza cardiaca e come la rilevo?"
|
||||
},
|
||||
"restingHr": {
|
||||
"title": "FC a riposo",
|
||||
@@ -3602,7 +3602,8 @@
|
||||
"emptyState": {
|
||||
"title": "Ancora nessun campione SpO₂",
|
||||
"description": "I campioni SpO₂ appariranno qui non appena un wearable compatibile (Apple Watch, Withings ScanWatch) cattura una lettura."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Non ho ancora registrato la saturazione di ossigeno. Cosa mi dice la SpO₂ e a quale intervallo dovrei puntare?"
|
||||
},
|
||||
"bodyTemperature": {
|
||||
"title": "Temperatura corporea",
|
||||
@@ -3612,7 +3613,8 @@
|
||||
"title": "Ancora nessuna temperatura corporea",
|
||||
"description": "Registra una misura o lascia che il tuo wearable ne sincronizzi una: l'andamento apparirà qui.",
|
||||
"cta": "Registra temperatura corporea"
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Non ho ancora registrato la temperatura corporea. Qual è l’intervallo sano e cosa dovrei fare se si discosta?"
|
||||
},
|
||||
"steps": {
|
||||
"title": "Passi",
|
||||
@@ -3621,7 +3623,8 @@
|
||||
"emptyState": {
|
||||
"title": "Ancora nessun dato sui passi",
|
||||
"description": "I totali giornalieri dei passi appariranno qui non appena Apple Salute o un dispositivo di attività sincronizza un'intera giornata."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Non ho ancora registrato passi. Qual è un obiettivo giornaliero di passi ragionevole e in che modo camminare di più aiuta la mia salute?"
|
||||
},
|
||||
"activeEnergy": {
|
||||
"title": "Energia attiva",
|
||||
@@ -3630,7 +3633,8 @@
|
||||
"emptyState": {
|
||||
"title": "Ancora nessun dato di energia attiva",
|
||||
"description": "I totali di energia attiva appariranno qui non appena Apple Salute o un dispositivo di attività Withings sincronizza un'intera giornata."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Non ho ancora registrato energia attiva. Qual è un obiettivo giornaliero ragionevole e come si confronta con le calorie totali?"
|
||||
},
|
||||
"subPage": {
|
||||
"loadError": "Impossibile caricare questa metrica al momento.",
|
||||
@@ -3725,7 +3729,8 @@
|
||||
"valuesBack": "Torna a Insights",
|
||||
"valuesBackToMetric": "Torna a {metric}",
|
||||
"scoresBack": "Torna a Insights",
|
||||
"ecgDescription": "Registrazioni ECG a singola derivazione dal tuo dispositivo, ciascuna con il risultato del dispositivo."
|
||||
"ecgDescription": "Registrazioni ECG a singola derivazione dal tuo dispositivo, ciascuna con il risultato del dispositivo.",
|
||||
"emptyCoachPrefill": "{metric}: non ho ancora registrato nulla qui. Cosa dice questo valore sulla mia salute e come lo miglioro?"
|
||||
},
|
||||
"units": {
|
||||
"steps": "passi",
|
||||
@@ -3825,27 +3830,32 @@
|
||||
"bloodPressure": {
|
||||
"title": "Nessuna misurazione della pressione",
|
||||
"description": "Registra una misurazione e qui appariranno l'andamento, la percentuale in target e la classificazione OMS.",
|
||||
"cta": "Registra pressione"
|
||||
"cta": "Registra pressione",
|
||||
"coachPrefill": "Non ho ancora registrato la pressione arteriosa. Perché è importante e cosa dovrei sapere prima di iniziare?"
|
||||
},
|
||||
"weight": {
|
||||
"title": "Nessuna misurazione del peso",
|
||||
"description": "Registra un peso: andamento, media mobile e fascia obiettivo appariranno automaticamente.",
|
||||
"cta": "Registra peso"
|
||||
"cta": "Registra peso",
|
||||
"coachPrefill": "Non ho ancora registrato il peso. Perché è importante e cosa dovrei sapere prima di iniziare a monitorarlo?"
|
||||
},
|
||||
"pulse": {
|
||||
"title": "Nessuna misurazione del polso",
|
||||
"description": "Registra una misurazione: polso a riposo, HRV e VO₂ max appariranno in questa vista.",
|
||||
"cta": "Registra polso"
|
||||
"cta": "Registra polso",
|
||||
"coachPrefill": "Non ho ancora registrato il polso a riposo. Perché è importante e cosa dovrei sapere prima di iniziare?"
|
||||
},
|
||||
"bmi": {
|
||||
"title": "BMI non ancora calcolato",
|
||||
"description": "Una volta inseriti altezza e almeno un peso, qui apparirà l'andamento del BMI con le zone OMS.",
|
||||
"cta": "Registra peso"
|
||||
"cta": "Registra peso",
|
||||
"coachPrefill": "Non ho ancora registrato il peso. Perché il BMI conta per me e cosa dovrei sapere prima di iniziare a monitorarlo?"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Nessuna voce d'umore",
|
||||
"description": "Registra un umore: appariranno andamento, fascia di stabilità e correlazioni.",
|
||||
"cta": "Registra umore"
|
||||
"cta": "Registra umore",
|
||||
"coachPrefill": "Non ho ancora registrato l’umore. Perché conta monitorarlo e come dovrei iniziare?"
|
||||
},
|
||||
"medication": {
|
||||
"title": "Nessun farmaco configurato",
|
||||
@@ -3855,7 +3865,8 @@
|
||||
"sleep": {
|
||||
"title": "Nessuna voce di sonno",
|
||||
"description": "Quando saranno disponibili dati sul sonno (manuale o tramite Withings / Apple Health), qui apparirà la suddivisione per fase.",
|
||||
"cta": "Collega una fonte"
|
||||
"cta": "Collega una fonte",
|
||||
"coachPrefill": "Non ho ancora dati sul sonno. Perché conta monitorare il sonno e cosa dovrei sapere prima di collegare una fonte?"
|
||||
}
|
||||
},
|
||||
"navWorkouts": "Allenamenti",
|
||||
@@ -3865,7 +3876,8 @@
|
||||
"description": "Corse, uscite in bici, camminate e sessioni di forza recenti, deduplicati tra Apple Watch e Withings.",
|
||||
"emptyState": {
|
||||
"title": "Nessun allenamento",
|
||||
"description": "Gli allenamenti arrivano da Salute di Apple sull'iPhone e da Withings. Puoi anche registrarne uno a mano."
|
||||
"description": "Gli allenamenti arrivano da Salute di Apple sull'iPhone e da Withings. Puoi anche registrarne uno a mano.",
|
||||
"coachPrefill": "Non ho ancora registrato allenamenti. Perché conta monitorarli e su cosa dovrei concentrarmi per primo?"
|
||||
},
|
||||
"detail": {
|
||||
"backToList": "Torna agli allenamenti",
|
||||
@@ -4745,7 +4757,8 @@
|
||||
"emptyState": {
|
||||
"title": "Nessun dato sulle cadute",
|
||||
"description": "Le cadute rilevate compaiono qui quando Apple Health si sincronizza. Nessuna caduta è l'obiettivo."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Non ho ancora registrato dati sulle cadute. Cosa dice questo valore sulla mia salute e come riduco il rischio di cadute?"
|
||||
},
|
||||
"navFalls": "Cadute",
|
||||
"sixMinuteWalk": {
|
||||
@@ -5695,7 +5708,7 @@
|
||||
"title": "Diario delle malattie",
|
||||
"description": "Vista elenco e ordine degli episodi."
|
||||
},
|
||||
"vorsorge": {
|
||||
"checkups": {
|
||||
"title": "Controlli",
|
||||
"description": "Vista elenco e ordine dei promemoria."
|
||||
},
|
||||
@@ -6121,7 +6134,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."
|
||||
},
|
||||
@@ -7058,7 +7071,7 @@
|
||||
"builtAtLabel": "Compilato",
|
||||
"license": "Licenza",
|
||||
"repository": "Codice sorgente",
|
||||
"changelog": "Changelog",
|
||||
"changelog": "Note di rilascio",
|
||||
"docs": "Documentazione",
|
||||
"credits": "Crediti e attribuzioni dei dati",
|
||||
"linksHeading": "Fonti e documentazione",
|
||||
@@ -10229,6 +10242,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",
|
||||
@@ -11073,7 +11087,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",
|
||||
@@ -11162,14 +11176,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."
|
||||
|
||||
+33
-19
@@ -764,7 +764,7 @@
|
||||
"noneActionableCta": "약 목록 열기"
|
||||
},
|
||||
"quickEntryDiscard": {
|
||||
"title": "이 기록을 버릴까요?",
|
||||
"title": "변경 내용을 버릴까요?",
|
||||
"description": "저장하지 않은 입력이 있어요. 폼을 닫고 입력한 내용을 버릴까요?",
|
||||
"confirm": "버리기",
|
||||
"cancel": "계속 작성"
|
||||
@@ -1547,7 +1547,6 @@
|
||||
"glp1NextInjectionToday": "오늘",
|
||||
"glp1NextInjectionTomorrow": "내일",
|
||||
"glp1RotationSuggested": "다음 추천 부위: {site}",
|
||||
"glp1LogSideEffect": "부작용 기록",
|
||||
"sideEffects": {
|
||||
"section": "부작용",
|
||||
"addCta": "기록",
|
||||
@@ -3583,7 +3582,8 @@
|
||||
"emptyState": {
|
||||
"title": "아직 심박변이도 기록이 없어요",
|
||||
"description": "Apple Watch(또는 호환 Withings 기기)가 밤마다 측정값을 동기화하기 시작하면 심박변이도 기록이 여기에 쌓여요."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "아직 심박변이도 데이터를 기록한 적이 없어요. 심박변이도는 무엇을 알려주고, 어떻게 측정하나요?"
|
||||
},
|
||||
"restingHr": {
|
||||
"title": "안정 시 심박수",
|
||||
@@ -3602,7 +3602,8 @@
|
||||
"emptyState": {
|
||||
"title": "아직 SpO₂ 기록이 없어요",
|
||||
"description": "호환 웨어러블(Apple Watch, Withings ScanWatch)이 측정하면 SpO₂ 기록이 여기에 쌓여요."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "아직 산소포화도를 기록한 적이 없어요. SpO₂는 무엇을 알려주고, 어느 범위를 목표로 해야 하나요?"
|
||||
},
|
||||
"bodyTemperature": {
|
||||
"title": "체온",
|
||||
@@ -3612,7 +3613,8 @@
|
||||
"title": "아직 체온 기록이 없어요",
|
||||
"description": "체온을 기록하거나 웨어러블이 동기화하면 여기에 추이가 나타나요.",
|
||||
"cta": "체온 기록"
|
||||
}
|
||||
},
|
||||
"coachPrefill": "아직 체온을 기록한 적이 없어요. 건강한 범위는 어느 정도이고, 벗어나면 어떻게 해야 하나요?"
|
||||
},
|
||||
"steps": {
|
||||
"title": "걸음 수",
|
||||
@@ -3621,7 +3623,8 @@
|
||||
"emptyState": {
|
||||
"title": "아직 걸음 수 데이터가 없어요",
|
||||
"description": "Apple Health나 활동 기기가 하루치 움직임을 동기화하면 여기에 하루 걸음 수가 나타나요."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "아직 걸음 수를 기록한 적이 없어요. 적당한 하루 걸음 목표는 얼마이고, 더 많이 걸으면 건강에 어떻게 도움이 되나요?"
|
||||
},
|
||||
"activeEnergy": {
|
||||
"title": "활동 에너지",
|
||||
@@ -3630,7 +3633,8 @@
|
||||
"emptyState": {
|
||||
"title": "아직 활동 에너지 데이터가 없어요",
|
||||
"description": "Apple Health나 Withings 활동 기기가 하루치 움직임을 동기화하면 여기에 활동 에너지 합계가 나타나요."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "아직 활동 에너지를 기록한 적이 없어요. 적당한 하루 목표는 얼마이고, 총 칼로리와는 어떻게 비교되나요?"
|
||||
},
|
||||
"subPage": {
|
||||
"loadError": "지금은 이 지표를 불러오지 못했어요.",
|
||||
@@ -3725,7 +3729,8 @@
|
||||
"valuesBack": "인사이트로 돌아가기",
|
||||
"valuesBackToMetric": "돌아가기: {metric}",
|
||||
"scoresBack": "인사이트로 돌아가기",
|
||||
"ecgDescription": "기기에서 기록한 단일 유도 ECG로, 각각 기기가 낸 결과가 함께 있어요."
|
||||
"ecgDescription": "기기에서 기록한 단일 유도 ECG로, 각각 기기가 낸 결과가 함께 있어요.",
|
||||
"emptyCoachPrefill": "{metric}: 아직 여기에 기록한 게 없어요. 이 지표는 내 건강에 대해 무엇을 알려주고, 어떻게 개선하나요?"
|
||||
},
|
||||
"units": {
|
||||
"steps": "걸음",
|
||||
@@ -3825,27 +3830,32 @@
|
||||
"bloodPressure": {
|
||||
"title": "아직 혈압 기록이 없어요",
|
||||
"description": "혈압을 기록하면 추이 차트, 정상 범위 비율, WHO 분류가 여기에 나타나요.",
|
||||
"cta": "혈압 기록"
|
||||
"cta": "혈압 기록",
|
||||
"coachPrefill": "아직 혈압을 기록한 적이 없어요. 혈압이 왜 중요하고, 시작하기 전에 무엇을 알아야 하나요?"
|
||||
},
|
||||
"weight": {
|
||||
"title": "아직 체중 기록이 없어요",
|
||||
"description": "체중을 기록하면 추이, 이동 평균, 목표 구간이 자동으로 나타나요.",
|
||||
"cta": "체중 기록"
|
||||
"cta": "체중 기록",
|
||||
"coachPrefill": "아직 체중을 기록한 적이 없어요. 체중이 왜 중요하고, 기록을 시작하기 전에 무엇을 알아야 하나요?"
|
||||
},
|
||||
"pulse": {
|
||||
"title": "아직 맥박 기록이 없어요",
|
||||
"description": "맥박을 기록하면 안정 시 맥박, 심박변이도, VO₂ max가 이 화면에 나타나요.",
|
||||
"cta": "맥박 기록"
|
||||
"cta": "맥박 기록",
|
||||
"coachPrefill": "아직 안정 시 맥박을 기록한 적이 없어요. 맥박이 왜 중요하고, 시작하기 전에 무엇을 알아야 하나요?"
|
||||
},
|
||||
"bmi": {
|
||||
"title": "아직 BMI를 계산하지 못했어요",
|
||||
"description": "키와 체중 기록이 하나 이상 있으면 WHO 구간과 함께 BMI 추이가 여기에 나타나요.",
|
||||
"cta": "체중 기록"
|
||||
"cta": "체중 기록",
|
||||
"coachPrefill": "아직 체중을 기록한 적이 없어요. BMI가 내게 왜 중요하고, 기록을 시작하기 전에 무엇을 알아야 하나요?"
|
||||
},
|
||||
"mood": {
|
||||
"title": "아직 기분 기록이 없어요",
|
||||
"description": "기분을 기록하면 추이, 안정성 구간, 상관관계가 나타나요.",
|
||||
"cta": "기분 기록"
|
||||
"cta": "기분 기록",
|
||||
"coachPrefill": "아직 기분을 기록한 적이 없어요. 기분 기록이 왜 중요하고, 어떻게 시작하면 좋을까요?"
|
||||
},
|
||||
"medication": {
|
||||
"title": "아직 등록한 약이 없어요",
|
||||
@@ -3855,7 +3865,8 @@
|
||||
"sleep": {
|
||||
"title": "아직 수면 기록이 없어요",
|
||||
"description": "수면 데이터가 들어오면(직접 기록하거나 Withings / Apple Health를 통해) 밤별 단계 구성이 여기에 나타나요.",
|
||||
"cta": "소스 연결"
|
||||
"cta": "소스 연결",
|
||||
"coachPrefill": "아직 수면 데이터가 없어요. 수면 기록이 왜 중요하고, 데이터 소스를 연결하기 전에 무엇을 알아야 하나요?"
|
||||
}
|
||||
},
|
||||
"navWorkouts": "운동",
|
||||
@@ -3865,7 +3876,8 @@
|
||||
"description": "최근 달리기, 자전거, 걷기, 근력 운동 세션이에요. Apple Watch와 Withings 소스 사이 중복은 정리했어요.",
|
||||
"emptyState": {
|
||||
"title": "아직 운동 기록이 없어요",
|
||||
"description": "운동은 iPhone의 Apple Health와 Withings에서 동기화돼요. 직접 기록할 수도 있어요."
|
||||
"description": "운동은 iPhone의 Apple Health와 Withings에서 동기화돼요. 직접 기록할 수도 있어요.",
|
||||
"coachPrefill": "아직 운동을 기록한 적이 없어요. 운동 기록이 왜 중요하고, 무엇부터 신경 써야 하나요?"
|
||||
},
|
||||
"detail": {
|
||||
"backToList": "운동으로 돌아가기",
|
||||
@@ -4745,7 +4757,8 @@
|
||||
"emptyState": {
|
||||
"title": "아직 낙상 데이터가 없어요",
|
||||
"description": "Apple Health가 동기화하면 감지된 낙상이 여기에 표시돼요. 낙상이 없는 게 가장 좋아요."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "아직 낙상 데이터를 기록한 적이 없어요. 이 지표는 내 건강에 대해 무엇을 알려주고, 낙상 위험은 어떻게 줄이나요?"
|
||||
},
|
||||
"navFalls": "낙상",
|
||||
"sixMinuteWalk": {
|
||||
@@ -5695,7 +5708,7 @@
|
||||
"title": "아픈 날 일지",
|
||||
"description": "목록 보기와 에피소드 순서."
|
||||
},
|
||||
"vorsorge": {
|
||||
"checkups": {
|
||||
"title": "건강검진",
|
||||
"description": "목록 보기와 알림 순서."
|
||||
},
|
||||
@@ -6121,7 +6134,7 @@
|
||||
"moduleSettingsDescription": "순서와 보기 외의 설정은 모듈의 페이지에 있습니다."
|
||||
}
|
||||
},
|
||||
"gesundheitsakte": {
|
||||
"health-record": {
|
||||
"title": "건강 기록",
|
||||
"subtitle": "임상의에게 바로 건넬 수 있는 완전한 건강 기록을 내보내요."
|
||||
},
|
||||
@@ -7058,7 +7071,7 @@
|
||||
"builtAtLabel": "빌드 시각",
|
||||
"license": "라이선스",
|
||||
"repository": "소스 코드",
|
||||
"changelog": "변경 이력",
|
||||
"changelog": "릴리스 노트",
|
||||
"docs": "문서",
|
||||
"credits": "크레딧 및 데이터 출처",
|
||||
"linksHeading": "출처 및 문서",
|
||||
@@ -10229,6 +10242,7 @@
|
||||
"BLOOD_PRESSURE_SYS": "혈압",
|
||||
"PULSE": "맥박",
|
||||
"BLOOD_GLUCOSE": "혈당",
|
||||
"BLOOD_KETONES": "혈중 케톤",
|
||||
"OXYGEN_SATURATION": "산소포화도",
|
||||
"BODY_FAT": "체지방",
|
||||
"BODY_TEMPERATURE": "체온",
|
||||
|
||||
+33
-19
@@ -764,7 +764,7 @@
|
||||
"noneActionableCta": "Medicatie openen"
|
||||
},
|
||||
"quickEntryDiscard": {
|
||||
"title": "Deze invoer weggooien?",
|
||||
"title": "Wijzigingen weggooien?",
|
||||
"description": "Je hebt niet-opgeslagen invoer. Het formulier sluiten en verliezen wat je hebt getypt?",
|
||||
"confirm": "Weggooien",
|
||||
"cancel": "Verder bewerken"
|
||||
@@ -1547,7 +1547,6 @@
|
||||
"glp1NextInjectionToday": "vandaag",
|
||||
"glp1NextInjectionTomorrow": "morgen",
|
||||
"glp1RotationSuggested": "Volgende aanbevolen plek: {site}",
|
||||
"glp1LogSideEffect": "Bijwerking vastleggen",
|
||||
"sideEffects": {
|
||||
"section": "Bijwerkingen",
|
||||
"addCta": "Vastleggen",
|
||||
@@ -3583,7 +3582,8 @@
|
||||
"emptyState": {
|
||||
"title": "Nog geen HRV-metingen",
|
||||
"description": "HRV-metingen verschijnen hier zodra Apple Watch (of een compatibel Withings-apparaat) nachtelijke metingen begint te synchroniseren."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Ik heb nog geen HRV-gegevens vastgelegd. Wat zegt hartslagvariabiliteit me, en hoe leg ik die vast?"
|
||||
},
|
||||
"restingHr": {
|
||||
"title": "Rusthartslag",
|
||||
@@ -3602,7 +3602,8 @@
|
||||
"emptyState": {
|
||||
"title": "Nog geen SpO₂-metingen",
|
||||
"description": "SpO₂-metingen verschijnen hier zodra een compatibele wearable (Apple Watch, Withings ScanWatch) een meting vastlegt."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Ik heb nog geen zuurstofverzadiging vastgelegd. Wat zegt SpO₂ me, en naar welk bereik moet ik streven?"
|
||||
},
|
||||
"bodyTemperature": {
|
||||
"title": "Lichaamstemperatuur",
|
||||
@@ -3612,7 +3613,8 @@
|
||||
"title": "Nog geen lichaamstemperatuur",
|
||||
"description": "Leg een temperatuurmeting vast of laat je wearable er een synchroniseren, dan verschijnt de trend hier.",
|
||||
"cta": "Lichaamstemperatuur vastleggen"
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Ik heb nog geen lichaamstemperatuur vastgelegd. Wat is het gezonde bereik, en wat moet ik doen als die afwijkt?"
|
||||
},
|
||||
"steps": {
|
||||
"title": "Stappen",
|
||||
@@ -3621,7 +3623,8 @@
|
||||
"emptyState": {
|
||||
"title": "Nog geen stappengegevens",
|
||||
"description": "Dagtotalen van je stappen verschijnen hier zodra Apple Health of een activiteitsapparaat een volledige dag beweging synchroniseert."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Ik heb nog geen stappen vastgelegd. Wat is een redelijk dagelijks stappendoel, en hoe helpt meer lopen mijn gezondheid?"
|
||||
},
|
||||
"activeEnergy": {
|
||||
"title": "Actieve energie",
|
||||
@@ -3630,7 +3633,8 @@
|
||||
"emptyState": {
|
||||
"title": "Nog geen gegevens over actieve energie",
|
||||
"description": "Dagtotalen van actieve energie verschijnen hier zodra Apple Health of een Withings-activiteitsapparaat een volledige dag beweging synchroniseert."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Ik heb nog geen actieve energie vastgelegd. Wat is een redelijk dagdoel, en hoe verhoudt het zich tot het totale aantal calorieën?"
|
||||
},
|
||||
"subPage": {
|
||||
"loadError": "Deze meetwaarde kan nu niet worden geladen.",
|
||||
@@ -3725,7 +3729,8 @@
|
||||
"valuesBack": "Terug naar inzichten",
|
||||
"valuesBackToMetric": "Terug naar {metric}",
|
||||
"scoresBack": "Terug naar inzichten",
|
||||
"ecgDescription": "Eenkanaals-ECG-opnamen van je apparaat, elk met de eigen uitslag van het apparaat."
|
||||
"ecgDescription": "Eenkanaals-ECG-opnamen van je apparaat, elk met de eigen uitslag van het apparaat.",
|
||||
"emptyCoachPrefill": "{metric}: ik heb hier nog niets vastgelegd. Wat zegt deze waarde over mijn gezondheid, en hoe verbeter ik die?"
|
||||
},
|
||||
"units": {
|
||||
"steps": "stappen",
|
||||
@@ -3825,27 +3830,32 @@
|
||||
"bloodPressure": {
|
||||
"title": "Nog geen bloeddrukmetingen",
|
||||
"description": "Leg een bloeddrukmeting vast, dan verschijnen hier de trendgrafiek, het aandeel binnen het bereik en de WHO-classificatie.",
|
||||
"cta": "Bloeddruk vastleggen"
|
||||
"cta": "Bloeddruk vastleggen",
|
||||
"coachPrefill": "Ik heb nog geen bloeddruk vastgelegd. Waarom is die belangrijk, en wat moet ik weten voordat ik begin?"
|
||||
},
|
||||
"weight": {
|
||||
"title": "Nog geen gewichtsmetingen",
|
||||
"description": "Leg je gewicht vast, dan verschijnen de trend, het voortschrijdend gemiddelde en de doelzone automatisch.",
|
||||
"cta": "Gewicht vastleggen"
|
||||
"cta": "Gewicht vastleggen",
|
||||
"coachPrefill": "Ik heb nog geen gewicht vastgelegd. Waarom is dat belangrijk, en wat moet ik weten voordat ik het ga bijhouden?"
|
||||
},
|
||||
"pulse": {
|
||||
"title": "Nog geen hartslagmetingen",
|
||||
"description": "Leg een hartslagmeting vast, dan verschijnen rusthartslag, HRV en VO₂max in deze weergave.",
|
||||
"cta": "Hartslag vastleggen"
|
||||
"cta": "Hartslag vastleggen",
|
||||
"coachPrefill": "Ik heb nog geen rusthartslag vastgelegd. Waarom is die belangrijk, en wat moet ik weten voordat ik begin?"
|
||||
},
|
||||
"bmi": {
|
||||
"title": "BMI nog niet berekend",
|
||||
"description": "Zodra je lengte en minstens één gewichtsmeting bekend zijn, verschijnt hier de BMI-trend met de WHO-zones.",
|
||||
"cta": "Gewicht vastleggen"
|
||||
"cta": "Gewicht vastleggen",
|
||||
"coachPrefill": "Ik heb nog geen gewicht vastgelegd. Waarom is BMI belangrijk voor mij, en wat moet ik weten voordat ik het ga bijhouden?"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Nog geen stemmingsregistraties",
|
||||
"description": "Leg je stemming vast, dan verschijnen de trend, de stabiliteitszone en de correlaties.",
|
||||
"cta": "Stemming vastleggen"
|
||||
"cta": "Stemming vastleggen",
|
||||
"coachPrefill": "Ik heb nog geen stemming vastgelegd. Waarom is stemming bijhouden belangrijk, en hoe begin ik?"
|
||||
},
|
||||
"medication": {
|
||||
"title": "Nog geen medicatie ingesteld",
|
||||
@@ -3855,7 +3865,8 @@
|
||||
"sleep": {
|
||||
"title": "Nog geen slaapregistraties",
|
||||
"description": "Zodra er slaapgegevens binnenkomen (handmatig of via Withings / Apple Health), verschijnt hier de verdeling over slaapfasen per nacht.",
|
||||
"cta": "Bron koppelen"
|
||||
"cta": "Bron koppelen",
|
||||
"coachPrefill": "Ik heb nog geen slaapgegevens. Waarom is slaap bijhouden belangrijk, en wat moet ik weten voordat ik een bron koppel?"
|
||||
}
|
||||
},
|
||||
"navWorkouts": "Workouts",
|
||||
@@ -3865,7 +3876,8 @@
|
||||
"description": "Recente hardloop-, fiets-, wandel- en krachttrainingen, ontdubbeld over bronnen van Apple Watch en Withings.",
|
||||
"emptyState": {
|
||||
"title": "Nog geen workouts",
|
||||
"description": "Workouts worden gesynchroniseerd vanuit Apple Health op je iPhone en vanuit Withings. Je kunt er ook zelf een vastleggen."
|
||||
"description": "Workouts worden gesynchroniseerd vanuit Apple Health op je iPhone en vanuit Withings. Je kunt er ook zelf een vastleggen.",
|
||||
"coachPrefill": "Ik heb nog geen workouts vastgelegd. Waarom is bijhouden belangrijk, en waar moet ik me eerst op richten?"
|
||||
},
|
||||
"detail": {
|
||||
"backToList": "Terug naar workouts",
|
||||
@@ -4745,7 +4757,8 @@
|
||||
"emptyState": {
|
||||
"title": "Nog geen valgegevens",
|
||||
"description": "Gedetecteerde valpartijen verschijnen hier zodra Apple Health synchroniseert. Geen valpartijen is het doel."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Ik heb nog geen valgegevens vastgelegd. Wat zegt deze waarde over mijn gezondheid, en hoe verklein ik mijn valrisico?"
|
||||
},
|
||||
"navFalls": "Valpartijen",
|
||||
"sixMinuteWalk": {
|
||||
@@ -5695,7 +5708,7 @@
|
||||
"title": "Ziektedagboek",
|
||||
"description": "Lijstweergave en volgorde van episodes."
|
||||
},
|
||||
"vorsorge": {
|
||||
"checkups": {
|
||||
"title": "Preventieve controles",
|
||||
"description": "Lijstweergave en volgorde van herinneringen."
|
||||
},
|
||||
@@ -6121,7 +6134,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."
|
||||
},
|
||||
@@ -7058,7 +7071,7 @@
|
||||
"builtAtLabel": "Gebouwd",
|
||||
"license": "Licentie",
|
||||
"repository": "Broncode",
|
||||
"changelog": "Wijzigingslogboek",
|
||||
"changelog": "Releasenotes",
|
||||
"docs": "Documentatie",
|
||||
"credits": "Credits en bronvermeldingen",
|
||||
"linksHeading": "Bronnen en documentatie",
|
||||
@@ -10229,6 +10242,7 @@
|
||||
"BLOOD_PRESSURE_SYS": "Bloeddruk",
|
||||
"PULSE": "Hartslag",
|
||||
"BLOOD_GLUCOSE": "Bloedglucose",
|
||||
"BLOOD_KETONES": "Bloedketonen",
|
||||
"OXYGEN_SATURATION": "Zuurstofsaturatie",
|
||||
"BODY_FAT": "Vetpercentage",
|
||||
"BODY_TEMPERATURE": "Lichaamstemperatuur",
|
||||
|
||||
+38
-24
@@ -764,7 +764,7 @@
|
||||
"noneActionableCta": "Otwórz leki"
|
||||
},
|
||||
"quickEntryDiscard": {
|
||||
"title": "Odrzucić ten wpis?",
|
||||
"title": "Odrzucić zmiany?",
|
||||
"description": "Masz niezapisane dane. Zamknąć formularz i utracić to, co wpisano?",
|
||||
"confirm": "Odrzuć",
|
||||
"cancel": "Kontynuuj edycję"
|
||||
@@ -1547,7 +1547,6 @@
|
||||
"glp1NextInjectionToday": "dziś",
|
||||
"glp1NextInjectionTomorrow": "jutro",
|
||||
"glp1RotationSuggested": "Sugerowane: {site}",
|
||||
"glp1LogSideEffect": "Zapisz działanie niepożądane",
|
||||
"sideEffects": {
|
||||
"section": "Skutki uboczne",
|
||||
"addCta": "Dodaj",
|
||||
@@ -1741,7 +1740,7 @@
|
||||
"step1": {
|
||||
"title": "Jak nazywa się lek?",
|
||||
"subline": "Dokładnie tak, jak na opakowaniu.",
|
||||
"label": "Name",
|
||||
"label": "Nazwa",
|
||||
"placeholder": "np. Ramipryl",
|
||||
"short": "Nazwa"
|
||||
},
|
||||
@@ -3583,7 +3582,8 @@
|
||||
"emptyState": {
|
||||
"title": "Brak wartości HRV",
|
||||
"description": "Wartości HRV pojawią się tutaj, gdy Apple Watch (lub zgodne urządzenie Withings) zsynchronizuje pomiary nocne."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Nie zapisałem jeszcze danych HRV. Co mówi mi zmienność rytmu serca i jak ją rejestrować?"
|
||||
},
|
||||
"restingHr": {
|
||||
"title": "Tętno spoczynkowe",
|
||||
@@ -3602,7 +3602,8 @@
|
||||
"emptyState": {
|
||||
"title": "Brak próbek SpO₂",
|
||||
"description": "Próbki SpO₂ trafią tutaj, gdy zgodne wearable (Apple Watch, Withings ScanWatch) wykona pomiar."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Nie zapisałem jeszcze saturacji tlenu. Co mówi mi SpO₂ i do jakiego zakresu powinienem dążyć?"
|
||||
},
|
||||
"bodyTemperature": {
|
||||
"title": "Temperatura ciała",
|
||||
@@ -3612,7 +3613,8 @@
|
||||
"title": "Brak temperatury ciała",
|
||||
"description": "Zarejestruj pomiar lub pozwól wearable zsynchronizować jeden, a trend pojawi się tutaj.",
|
||||
"cta": "Zarejestruj temperaturę ciała"
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Nie zapisałem jeszcze temperatury ciała. Jaki zakres jest prawidłowy i co powinienem zrobić, gdy od niego odbiega?"
|
||||
},
|
||||
"steps": {
|
||||
"title": "Kroki",
|
||||
@@ -3621,7 +3623,8 @@
|
||||
"emptyState": {
|
||||
"title": "Brak danych o krokach",
|
||||
"description": "Dzienne sumy kroków pojawią się tutaj, gdy Apple Zdrowie lub urządzenie aktywności zsynchronizuje pełny dzień."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Nie zapisałem jeszcze kroków. Jaki dzienny cel kroków jest rozsądny i jak więcej chodzenia pomaga mojemu zdrowiu?"
|
||||
},
|
||||
"activeEnergy": {
|
||||
"title": "Energia czynna",
|
||||
@@ -3630,7 +3633,8 @@
|
||||
"emptyState": {
|
||||
"title": "Brak danych o energii czynnej",
|
||||
"description": "Sumy energii czynnej pojawią się tutaj, gdy Apple Zdrowie lub urządzenie Withings zsynchronizuje pełny dzień."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Nie zapisałem jeszcze energii czynnej. Jaki dzienny cel jest rozsądny i jak ma się on do łącznej liczby kalorii?"
|
||||
},
|
||||
"subPage": {
|
||||
"loadError": "Nie udało się teraz wczytać tej metryki.",
|
||||
@@ -3725,7 +3729,8 @@
|
||||
"valuesBack": "Powrót do Insights",
|
||||
"valuesBackToMetric": "Powrót do {metric}",
|
||||
"scoresBack": "Powrót do Insights",
|
||||
"ecgDescription": "Zapisy EKG jednoodprowadzeniowe z Twojego urządzenia, każdy z wynikiem samego urządzenia."
|
||||
"ecgDescription": "Zapisy EKG jednoodprowadzeniowe z Twojego urządzenia, każdy z wynikiem samego urządzenia.",
|
||||
"emptyCoachPrefill": "{metric}: nie mam tu jeszcze żadnych wpisów. Co ten wskaźnik mówi o moim zdrowiu i jak mogę go poprawić?"
|
||||
},
|
||||
"units": {
|
||||
"steps": "kroki",
|
||||
@@ -3825,27 +3830,32 @@
|
||||
"bloodPressure": {
|
||||
"title": "Brak pomiarów ciśnienia krwi",
|
||||
"description": "Zarejestruj pomiar ciśnienia, a pojawi się tu trend, udział w zakresie docelowym i klasyfikacja WHO.",
|
||||
"cta": "Zarejestruj ciśnienie"
|
||||
"cta": "Zarejestruj ciśnienie",
|
||||
"coachPrefill": "Nie zapisałem jeszcze ciśnienia krwi. Dlaczego ma ono znaczenie i co powinienem wiedzieć, zanim zacznę?"
|
||||
},
|
||||
"weight": {
|
||||
"title": "Brak pomiarów wagi",
|
||||
"description": "Zarejestruj wagę, a trend, średnia krocząca i zakres docelowy pojawią się automatycznie.",
|
||||
"cta": "Zarejestruj wagę"
|
||||
"cta": "Zarejestruj wagę",
|
||||
"coachPrefill": "Nie zapisałem jeszcze masy ciała. Dlaczego ma ona znaczenie i co powinienem wiedzieć, zanim zacznę ją śledzić?"
|
||||
},
|
||||
"pulse": {
|
||||
"title": "Brak pomiarów tętna",
|
||||
"description": "Zarejestruj tętno, a w tym widoku pojawi się tętno spoczynkowe, HRV i VO₂ max.",
|
||||
"cta": "Zarejestruj tętno"
|
||||
"cta": "Zarejestruj tętno",
|
||||
"coachPrefill": "Nie zapisałem jeszcze tętna spoczynkowego. Dlaczego ma ono znaczenie i co powinienem wiedzieć, zanim zacznę?"
|
||||
},
|
||||
"bmi": {
|
||||
"title": "BMI jeszcze nie obliczone",
|
||||
"description": "Po wpisaniu wzrostu i co najmniej jednej wagi pojawi się tu trend BMI z zakresami WHO.",
|
||||
"cta": "Zarejestruj wagę"
|
||||
"cta": "Zarejestruj wagę",
|
||||
"coachPrefill": "Nie zapisałem jeszcze masy ciała. Dlaczego BMI ma dla mnie znaczenie i co powinienem wiedzieć, zanim zacznę je śledzić?"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Brak wpisów nastroju",
|
||||
"description": "Zarejestruj nastrój, a pojawi się trend, pasmo stabilności i korelacje.",
|
||||
"cta": "Zarejestruj nastrój"
|
||||
"cta": "Zarejestruj nastrój",
|
||||
"coachPrefill": "Nie zapisałem jeszcze nastroju. Dlaczego warto go śledzić i jak powinienem zacząć?"
|
||||
},
|
||||
"medication": {
|
||||
"title": "Brak skonfigurowanych leków",
|
||||
@@ -3855,7 +3865,8 @@
|
||||
"sleep": {
|
||||
"title": "Brak danych o śnie",
|
||||
"description": "Gdy pojawią się dane o śnie (ręcznie lub przez Withings / Apple Health), wyświetli się tu rozkład faz na noc.",
|
||||
"cta": "Połącz źródło"
|
||||
"cta": "Połącz źródło",
|
||||
"coachPrefill": "Nie mam jeszcze danych o śnie. Dlaczego warto śledzić sen i co powinienem wiedzieć, zanim podłączę źródło?"
|
||||
}
|
||||
},
|
||||
"navWorkouts": "Treningi",
|
||||
@@ -3865,7 +3876,8 @@
|
||||
"description": "Ostatnie biegi, jazdy, marsze i treningi siłowe, zdeduplikowane między Apple Watch a Withings.",
|
||||
"emptyState": {
|
||||
"title": "Brak treningów",
|
||||
"description": "Treningi trafiają tu z Apple Zdrowie na iPhonie i z Withings. Możesz też wpisać trening ręcznie."
|
||||
"description": "Treningi trafiają tu z Apple Zdrowie na iPhonie i z Withings. Możesz też wpisać trening ręcznie.",
|
||||
"coachPrefill": "Nie zapisałem jeszcze treningów. Dlaczego warto je śledzić i na czym powinienem się najpierw skupić?"
|
||||
},
|
||||
"detail": {
|
||||
"backToList": "Powrót do treningów",
|
||||
@@ -4745,7 +4757,8 @@
|
||||
"emptyState": {
|
||||
"title": "Brak danych o upadkach",
|
||||
"description": "Wykryte upadki pojawią się tutaj, gdy Apple Health się zsynchronizuje. Celem jest brak upadków."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Nie zapisałem jeszcze danych o upadkach. Co ten wskaźnik mówi o moim zdrowiu i jak mogę zmniejszyć ryzyko upadku?"
|
||||
},
|
||||
"navFalls": "Upadki",
|
||||
"sixMinuteWalk": {
|
||||
@@ -5695,7 +5708,7 @@
|
||||
"title": "Dziennik chorób",
|
||||
"description": "Widok listy i kolejność epizodów."
|
||||
},
|
||||
"vorsorge": {
|
||||
"checkups": {
|
||||
"title": "Badania kontrolne",
|
||||
"description": "Widok listy i kolejność przypomnień."
|
||||
},
|
||||
@@ -6121,7 +6134,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."
|
||||
},
|
||||
@@ -7058,7 +7071,7 @@
|
||||
"builtAtLabel": "Skompilowano",
|
||||
"license": "Licencja",
|
||||
"repository": "Kod źródłowy",
|
||||
"changelog": "Changelog",
|
||||
"changelog": "Informacje o wersjach",
|
||||
"docs": "Dokumentacja",
|
||||
"credits": "Autorzy i źródła danych",
|
||||
"linksHeading": "Źródła i dokumentacja",
|
||||
@@ -10229,6 +10242,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",
|
||||
@@ -11073,7 +11087,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ść",
|
||||
@@ -11162,14 +11176,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."
|
||||
|
||||
+33
-19
@@ -764,7 +764,7 @@
|
||||
"noneActionableCta": "Abrir medicamentos"
|
||||
},
|
||||
"quickEntryDiscard": {
|
||||
"title": "Descartar esta entrada?",
|
||||
"title": "Descartar as alterações?",
|
||||
"description": "Você tem entrada não salva. Fechar o formulário e perder o que digitou?",
|
||||
"confirm": "Descartar",
|
||||
"cancel": "Continuar editando"
|
||||
@@ -1547,7 +1547,6 @@
|
||||
"glp1NextInjectionToday": "hoje",
|
||||
"glp1NextInjectionTomorrow": "amanhã",
|
||||
"glp1RotationSuggested": "Próximo recomendado: {site}",
|
||||
"glp1LogSideEffect": "Registrar efeito colateral",
|
||||
"sideEffects": {
|
||||
"section": "Efeitos colaterais",
|
||||
"addCta": "Registrar",
|
||||
@@ -3583,7 +3582,8 @@
|
||||
"emptyState": {
|
||||
"title": "Ainda não há amostras de VFC",
|
||||
"description": "As amostras de VFC chegam aqui assim que o Apple Watch (ou um dispositivo Withings compatível) começa a sincronizar leituras noturnas."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Ainda não registrei dados de VFC. O que a variabilidade da frequência cardíaca me diz, e como eu a registro?"
|
||||
},
|
||||
"restingHr": {
|
||||
"title": "Frequência cardíaca em repouso",
|
||||
@@ -3602,7 +3602,8 @@
|
||||
"emptyState": {
|
||||
"title": "Ainda não há amostras de SpO₂",
|
||||
"description": "As amostras de SpO₂ chegam aqui assim que um wearable compatível (Apple Watch, Withings ScanWatch) captura uma leitura."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Ainda não registrei saturação de oxigênio. O que a SpO₂ me diz, e qual faixa devo buscar?"
|
||||
},
|
||||
"bodyTemperature": {
|
||||
"title": "Temperatura corporal",
|
||||
@@ -3612,7 +3613,8 @@
|
||||
"title": "Ainda não há temperatura corporal",
|
||||
"description": "Registre uma leitura de temperatura corporal ou deixe seu wearable sincronizar uma, e a tendência aparecerá aqui.",
|
||||
"cta": "Registrar temperatura corporal"
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Ainda não registrei temperatura corporal. Qual é a faixa saudável, e o que devo fazer se ela se desviar?"
|
||||
},
|
||||
"steps": {
|
||||
"title": "Passos",
|
||||
@@ -3621,7 +3623,8 @@
|
||||
"emptyState": {
|
||||
"title": "Ainda não há dados de passos",
|
||||
"description": "Os totais diários de passos aparecem aqui assim que o Apple Health ou um dispositivo de atividade sincroniza um dia de movimento."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Ainda não registrei passos. Qual é uma meta diária de passos razoável, e como caminhar mais ajuda minha saúde?"
|
||||
},
|
||||
"activeEnergy": {
|
||||
"title": "Energia ativa",
|
||||
@@ -3630,7 +3633,8 @@
|
||||
"emptyState": {
|
||||
"title": "Ainda não há dados de energia ativa",
|
||||
"description": "Os totais de energia ativa chegam aqui assim que o Apple Health ou um dispositivo de atividade Withings sincroniza um dia de movimento."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Ainda não registrei energia ativa. Qual é uma meta diária razoável, e como ela se compara ao total de calorias?"
|
||||
},
|
||||
"subPage": {
|
||||
"loadError": "Não foi possível carregar esta métrica agora.",
|
||||
@@ -3725,7 +3729,8 @@
|
||||
"valuesBack": "Voltar aos insights",
|
||||
"valuesBackToMetric": "Voltar para {metric}",
|
||||
"scoresBack": "Voltar aos insights",
|
||||
"ecgDescription": "Registros de ECG de uma derivação do seu dispositivo, cada um com o resultado do próprio dispositivo."
|
||||
"ecgDescription": "Registros de ECG de uma derivação do seu dispositivo, cada um com o resultado do próprio dispositivo.",
|
||||
"emptyCoachPrefill": "{metric}: ainda não registrei nada aqui. O que essa métrica me diz sobre minha saúde, e como eu a melhoro?"
|
||||
},
|
||||
"units": {
|
||||
"steps": "passos",
|
||||
@@ -3825,27 +3830,32 @@
|
||||
"bloodPressure": {
|
||||
"title": "Ainda não há registros de pressão arterial",
|
||||
"description": "Registre uma leitura de pressão arterial e o gráfico de tendência, a parcela dentro da faixa e a classificação da OMS aparecerão aqui.",
|
||||
"cta": "Registrar pressão arterial"
|
||||
"cta": "Registrar pressão arterial",
|
||||
"coachPrefill": "Ainda não registrei a pressão arterial. Por que ela importa, e o que devo saber antes de começar?"
|
||||
},
|
||||
"weight": {
|
||||
"title": "Ainda não há registros de peso",
|
||||
"description": "Registre um peso e a tendência, a média móvel e a faixa-alvo aparecerão automaticamente.",
|
||||
"cta": "Registrar peso"
|
||||
"cta": "Registrar peso",
|
||||
"coachPrefill": "Ainda não registrei meu peso. Por que ele importa, e o que devo saber antes de começar a acompanhá-lo?"
|
||||
},
|
||||
"pulse": {
|
||||
"title": "Ainda não há registros de pulso",
|
||||
"description": "Registre uma leitura de pulso e o pulso em repouso, a VFC e o VO₂ máx aparecerão nesta visão.",
|
||||
"cta": "Registrar pulso"
|
||||
"cta": "Registrar pulso",
|
||||
"coachPrefill": "Ainda não registrei o pulso em repouso. Por que ele importa, e o que devo saber antes de começar?"
|
||||
},
|
||||
"bmi": {
|
||||
"title": "IMC ainda não calculado",
|
||||
"description": "Assim que a altura e pelo menos um registro de peso estiverem presentes, a tendência do IMC com as zonas da OMS aparecerá aqui.",
|
||||
"cta": "Registrar peso"
|
||||
"cta": "Registrar peso",
|
||||
"coachPrefill": "Ainda não registrei meu peso. Por que o IMC importa para mim, e o que devo saber antes de começar a acompanhá-lo?"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Ainda não há registros de humor",
|
||||
"description": "Registre uma entrada de humor e a tendência, a faixa de estabilidade e as correlações aparecerão.",
|
||||
"cta": "Registrar humor"
|
||||
"cta": "Registrar humor",
|
||||
"coachPrefill": "Ainda não registrei meu humor. Por que acompanhar o humor importa, e como devo começar?"
|
||||
},
|
||||
"medication": {
|
||||
"title": "Ainda não há medicamentos configurados",
|
||||
@@ -3855,7 +3865,8 @@
|
||||
"sleep": {
|
||||
"title": "Ainda não há registros de sono",
|
||||
"description": "Assim que os dados de sono chegarem (manual ou via Withings / Apple Health), a divisão por estágio por noite aparecerá aqui.",
|
||||
"cta": "Conectar uma fonte"
|
||||
"cta": "Conectar uma fonte",
|
||||
"coachPrefill": "Ainda não tenho dados de sono. Por que acompanhar o sono importa, e o que devo saber antes de conectar uma fonte?"
|
||||
}
|
||||
},
|
||||
"navWorkouts": "Treinos",
|
||||
@@ -3865,7 +3876,8 @@
|
||||
"description": "Corridas, pedaladas, caminhadas e sessões de força recentes, deduplicadas entre as fontes Apple Watch e Withings.",
|
||||
"emptyState": {
|
||||
"title": "Ainda não há treinos",
|
||||
"description": "Os treinos sincronizam do Apple Health no seu iPhone e da Withings. Você também pode registrar um manualmente."
|
||||
"description": "Os treinos sincronizam do Apple Health no seu iPhone e da Withings. Você também pode registrar um manualmente.",
|
||||
"coachPrefill": "Ainda não registrei treinos. Por que acompanhá-los importa, e no que devo focar primeiro?"
|
||||
},
|
||||
"detail": {
|
||||
"backToList": "Voltar aos treinos",
|
||||
@@ -4745,7 +4757,8 @@
|
||||
"emptyState": {
|
||||
"title": "Ainda não há dados de quedas",
|
||||
"description": "As quedas detectadas aparecem aqui assim que o Apple Health sincroniza. Nenhuma queda é a meta."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Ainda não registrei dados de quedas. O que essa métrica me diz sobre minha saúde, e como reduzo meu risco de queda?"
|
||||
},
|
||||
"navFalls": "Quedas",
|
||||
"sixMinuteWalk": {
|
||||
@@ -5695,7 +5708,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."
|
||||
},
|
||||
@@ -6121,7 +6134,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."
|
||||
},
|
||||
@@ -7058,7 +7071,7 @@
|
||||
"builtAtLabel": "Compilado em",
|
||||
"license": "Licença",
|
||||
"repository": "Código-fonte",
|
||||
"changelog": "Changelog",
|
||||
"changelog": "Notas de versão",
|
||||
"docs": "Documentação",
|
||||
"credits": "Créditos e atribuições de dados",
|
||||
"linksHeading": "Fontes e documentação",
|
||||
@@ -10229,6 +10242,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",
|
||||
|
||||
+36
-22
@@ -764,9 +764,9 @@
|
||||
"noneActionableCta": "Открыть лекарства"
|
||||
},
|
||||
"quickEntryDiscard": {
|
||||
"title": "Отменить эту запись?",
|
||||
"description": "У вас есть несохранённые данные. Закрыть форму и потерять введённое?",
|
||||
"confirm": "Отменить",
|
||||
"title": "Закрыть без сохранения?",
|
||||
"description": "Введённые данные не сохранены. Закрыть форму и потерять их?",
|
||||
"confirm": "Не сохранять",
|
||||
"cancel": "Продолжить ввод"
|
||||
},
|
||||
"compliance7d": "Приверженность (7 дней)",
|
||||
@@ -1547,7 +1547,6 @@
|
||||
"glp1NextInjectionToday": "сегодня",
|
||||
"glp1NextInjectionTomorrow": "завтра",
|
||||
"glp1RotationSuggested": "Рекомендуется следующее: {site}",
|
||||
"glp1LogSideEffect": "Записать побочный эффект",
|
||||
"sideEffects": {
|
||||
"section": "Побочные эффекты",
|
||||
"addCta": "Записать",
|
||||
@@ -3583,7 +3582,8 @@
|
||||
"emptyState": {
|
||||
"title": "Пока нет данных HRV",
|
||||
"description": "Данные HRV появятся здесь, как только Apple Watch (или совместимое устройство Withings) начнёт синхронизировать ночные измерения."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Я ещё не записывал данные HRV. Что говорит вариабельность сердечного ритма и как её измерять?"
|
||||
},
|
||||
"restingHr": {
|
||||
"title": "ЧСС в покое",
|
||||
@@ -3602,7 +3602,8 @@
|
||||
"emptyState": {
|
||||
"title": "Пока нет данных SpO₂",
|
||||
"description": "Данные SpO₂ появятся здесь, как только совместимое носимое устройство (Apple Watch, Withings ScanWatch) сделает измерение."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Я ещё не записывал насыщение кислородом. Что говорит SpO₂ и к какому диапазону стоит стремиться?"
|
||||
},
|
||||
"bodyTemperature": {
|
||||
"title": "Температура тела",
|
||||
@@ -3612,7 +3613,8 @@
|
||||
"title": "Пока нет температуры тела",
|
||||
"description": "Запишите температуру тела или дайте носимому устройству синхронизировать её, и динамика появится здесь.",
|
||||
"cta": "Записать температуру тела"
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Я ещё не записывал температуру тела. Какой диапазон считается нормальным и что делать, если она отклоняется?"
|
||||
},
|
||||
"steps": {
|
||||
"title": "Шаги",
|
||||
@@ -3621,7 +3623,8 @@
|
||||
"emptyState": {
|
||||
"title": "Пока нет данных о шагах",
|
||||
"description": "Ежедневные итоги шагов появятся здесь, как только Apple Health или устройство активности синхронизирует день движения."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Я ещё не записывал шаги. Какая дневная цель по шагам разумна и как больше ходьбы помогает здоровью?"
|
||||
},
|
||||
"activeEnergy": {
|
||||
"title": "Активная энергия",
|
||||
@@ -3630,7 +3633,8 @@
|
||||
"emptyState": {
|
||||
"title": "Пока нет данных об активной энергии",
|
||||
"description": "Итоги активной энергии появятся здесь, как только Apple Health или устройство активности Withings синхронизирует день движения."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Я ещё не записывал активную энергию. Какая дневная цель разумна и как она соотносится с общим расходом калорий?"
|
||||
},
|
||||
"subPage": {
|
||||
"loadError": "Не удалось загрузить этот показатель прямо сейчас.",
|
||||
@@ -3725,7 +3729,8 @@
|
||||
"valuesBack": "Назад к аналитике",
|
||||
"valuesBackToMetric": "Назад к {metric}",
|
||||
"scoresBack": "Назад к аналитике",
|
||||
"ecgDescription": "Записи ЭКГ с одним отведением с вашего устройства, каждая с собственным результатом устройства."
|
||||
"ecgDescription": "Записи ЭКГ с одним отведением с вашего устройства, каждая с собственным результатом устройства.",
|
||||
"emptyCoachPrefill": "{metric}: здесь у меня ещё нет записей. Что этот показатель говорит о моём здоровье и как его улучшить?"
|
||||
},
|
||||
"units": {
|
||||
"steps": "шаги",
|
||||
@@ -3825,27 +3830,32 @@
|
||||
"bloodPressure": {
|
||||
"title": "Пока нет записей АД",
|
||||
"description": "Запишите измерение АД, и здесь появятся график динамики, доля значений в диапазоне и классификация ВОЗ.",
|
||||
"cta": "Записать АД"
|
||||
"cta": "Записать АД",
|
||||
"coachPrefill": "Я ещё не записывал давление. Почему оно важно и что нужно знать, прежде чем начать?"
|
||||
},
|
||||
"weight": {
|
||||
"title": "Пока нет записей веса",
|
||||
"description": "Запишите вес, и динамика, скользящее среднее и целевой диапазон появятся автоматически.",
|
||||
"cta": "Записать вес"
|
||||
"cta": "Записать вес",
|
||||
"coachPrefill": "Я ещё не записывал вес. Почему он важен и что нужно знать, прежде чем начать его отслеживать?"
|
||||
},
|
||||
"pulse": {
|
||||
"title": "Пока нет записей пульса",
|
||||
"description": "Запишите измерение пульса, и в этом виде появятся пульс в покое, HRV и VO₂ max.",
|
||||
"cta": "Записать пульс"
|
||||
"cta": "Записать пульс",
|
||||
"coachPrefill": "Я ещё не записывал пульс в покое. Почему он важен и что нужно знать, прежде чем начать?"
|
||||
},
|
||||
"bmi": {
|
||||
"title": "ИМТ ещё не рассчитан",
|
||||
"description": "Как только появятся рост и хотя бы одна запись веса, здесь появится динамика ИМТ с зонами ВОЗ.",
|
||||
"cta": "Записать вес"
|
||||
"cta": "Записать вес",
|
||||
"coachPrefill": "Я ещё не записывал вес. Почему ИМТ важен для меня и что нужно знать, прежде чем начать его отслеживать?"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Пока нет записей настроения",
|
||||
"description": "Запишите настроение, и появятся динамика, диапазон стабильности и корреляции.",
|
||||
"cta": "Записать настроение"
|
||||
"cta": "Записать настроение",
|
||||
"coachPrefill": "Я ещё не записывал настроение. Почему важно его отслеживать и с чего начать?"
|
||||
},
|
||||
"medication": {
|
||||
"title": "Пока не настроено ни одного лекарства",
|
||||
@@ -3855,7 +3865,8 @@
|
||||
"sleep": {
|
||||
"title": "Пока нет записей сна",
|
||||
"description": "Как только появятся данные о сне (вручную или через Withings / Apple Health), здесь появится разбивка по фазам за каждую ночь.",
|
||||
"cta": "Подключить источник"
|
||||
"cta": "Подключить источник",
|
||||
"coachPrefill": "У меня ещё нет данных о сне. Почему важно отслеживать сон и что нужно знать, прежде чем подключить источник?"
|
||||
}
|
||||
},
|
||||
"navWorkouts": "Тренировки",
|
||||
@@ -3865,7 +3876,8 @@
|
||||
"description": "Недавние пробежки, поездки на велосипеде, прогулки и силовые занятия, без дублей между источниками Apple Watch и Withings.",
|
||||
"emptyState": {
|
||||
"title": "Пока нет тренировок",
|
||||
"description": "Тренировки синхронизируются из Apple Health на вашем iPhone и из Withings. Вы также можете записать одну вручную."
|
||||
"description": "Тренировки синхронизируются из Apple Health на вашем iPhone и из Withings. Вы также можете записать одну вручную.",
|
||||
"coachPrefill": "Я ещё не записывал тренировки. Почему важно их отслеживать и на чём сосредоточиться в первую очередь?"
|
||||
},
|
||||
"detail": {
|
||||
"backToList": "Назад к тренировкам",
|
||||
@@ -4745,7 +4757,8 @@
|
||||
"emptyState": {
|
||||
"title": "Пока нет данных о падениях",
|
||||
"description": "Обнаруженные падения появятся здесь, когда Apple Health синхронизирует их. Цель — ноль падений."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Я ещё не записывал данные о падениях. Что этот показатель говорит о моём здоровье и как снизить риск падений?"
|
||||
},
|
||||
"navFalls": "Падения",
|
||||
"sixMinuteWalk": {
|
||||
@@ -5695,7 +5708,7 @@
|
||||
"title": "Дневник болезней",
|
||||
"description": "Вид списка и порядок эпизодов."
|
||||
},
|
||||
"vorsorge": {
|
||||
"checkups": {
|
||||
"title": "Профилактика",
|
||||
"description": "Вид списка и порядок напоминаний."
|
||||
},
|
||||
@@ -6121,7 +6134,7 @@
|
||||
"moduleSettingsDescription": "Всё, кроме порядка и вида, настраивается на странице модуля в разделе «Модули»."
|
||||
}
|
||||
},
|
||||
"gesundheitsakte": {
|
||||
"health-record": {
|
||||
"title": "Медицинская карта",
|
||||
"subtitle": "Экспортируйте полную медицинскую карту, готовую для врача."
|
||||
},
|
||||
@@ -7058,7 +7071,7 @@
|
||||
"builtAtLabel": "Собрано",
|
||||
"license": "Лицензия",
|
||||
"repository": "Исходный код",
|
||||
"changelog": "Список изменений",
|
||||
"changelog": "Примечания к выпускам",
|
||||
"docs": "Документация",
|
||||
"credits": "Благодарности и источники данных",
|
||||
"linksHeading": "Источники и документация",
|
||||
@@ -10229,6 +10242,7 @@
|
||||
"BLOOD_PRESSURE_SYS": "АД",
|
||||
"PULSE": "Пульс",
|
||||
"BLOOD_GLUCOSE": "Глюкоза крови",
|
||||
"BLOOD_KETONES": "Кетоны в крови",
|
||||
"OXYGEN_SATURATION": "Насыщение кислородом",
|
||||
"BODY_FAT": "Процент жировой массы",
|
||||
"BODY_TEMPERATURE": "Температура тела",
|
||||
@@ -10278,7 +10292,7 @@
|
||||
"pushBody": "Пора: {label}",
|
||||
"pushLocation": "(в {location})",
|
||||
"edit": "Изменить",
|
||||
"originCoach": "Тренер",
|
||||
"originCoach": "Коуч",
|
||||
"until": "До {date}",
|
||||
"selfPlanned": "Запланировано самостоятельно",
|
||||
"captureValue": "Записать значение",
|
||||
|
||||
+14
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.47.4",
|
||||
"version": "1.47.5",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
+1
-1
@@ -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).
|
||||
+1
-1
@@ -16,7 +16,7 @@ try {
|
||||
}
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.47.4";
|
||||
/* @sw-version-fallback */ "v1.47.5";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// Every HealthLog cache, current and legacy, and nothing another app on the
|
||||
|
||||
+23
-19
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 |
|
||||
@@ -1,9 +1,9 @@
|
||||
# v1.45.0: boot and idle memory
|
||||
|
||||
`scripts/bench/boot-profile.ts`, production build, every role, heap ceiling
|
||||
792 MB, against the seeded account, 300 s idle. `v1.45.0-before-boot` is the
|
||||
v1.44 runtime, `v1.45.0-boot-after` the branch after the boot changes and
|
||||
before the lazy match indexes, `v1.45.0-boot-final` the final tree.
|
||||
792 MB, against the seeded account, 300 s idle. "before" is the v1.44 runtime
|
||||
(`v1.45.0-before-boot.md`), "final" the v1.45.0 tree; its raw dumps are not
|
||||
kept.
|
||||
|
||||
| | before | final |
|
||||
| --- | ---: | ---: |
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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());
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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 "=============================================================="
|
||||
@@ -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');
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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.)
|
||||
@@ -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;
|
||||
|
||||
@@ -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)`,
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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`.
|
||||
@@ -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,19 +1,19 @@
|
||||
{
|
||||
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 84,
|
||||
"total": 93840,
|
||||
"total": 93816,
|
||||
"entries": {
|
||||
"app/medications/page-client.tsx": 839,
|
||||
"components/charts/mood-chart.tsx": 1166,
|
||||
"components/cycle/log-day-sheet.tsx": 1639,
|
||||
"components/cycle/log-day-sheet.tsx": 1646,
|
||||
"components/insights/coach-panel/chat-bubble.tsx": 821,
|
||||
"components/insights/coach-panel/coach-conversation.tsx": 1278,
|
||||
"components/insights/coach-panel/use-coach.ts": 1486,
|
||||
"components/insights/derived/vitals-dashboard.tsx": 1033,
|
||||
"components/insights/health-score-card.tsx": 1023,
|
||||
"components/insights/insights-tab-strip.tsx": 1087,
|
||||
"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": 109290,
|
||||
"count": 335,
|
||||
"total": 109058,
|
||||
"entries": {
|
||||
"app/achievements/page.tsx :: AchievementsPage": 206,
|
||||
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275,
|
||||
@@ -27,7 +27,7 @@
|
||||
"app/api/mcp/oauth/token/route.ts :: POST": 250,
|
||||
"app/api/mcp/oauth/token/route.ts :: POST > withBackgroundEvent(arg)": 248,
|
||||
"app/api/measurement-reminders/[id]/route.ts :: apiHandler(arg) #2": 239,
|
||||
"app/api/medications/[id]/dose-history/route.ts :: apiHandler(arg)": 256,
|
||||
"app/api/medications/[id]/dose-history/route.ts :: apiHandler(arg)": 241,
|
||||
"app/api/medications/[id]/inventory/[itemId]/route.ts :: apiHandler(arg)": 204,
|
||||
"app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts :: apiHandler(arg)": 250,
|
||||
"app/api/mental-health/assessments/route.ts :: postAssessment": 249,
|
||||
@@ -38,14 +38,14 @@
|
||||
"app/api/record-settings/[family]/route.ts :: apiHandler(arg) #2": 262,
|
||||
"app/api/user/ai-provider/route.ts :: apiHandler(arg) #2": 224,
|
||||
"app/api/withings/callback/route.ts :: apiHandler(arg)": 247,
|
||||
"app/auth/login/page.tsx :: LoginPage": 561,
|
||||
"app/auth/login/page.tsx :: LoginPage": 559,
|
||||
"app/auth/register/page.tsx :: RegisterPage": 283,
|
||||
"app/coach/conversations/page.tsx :: CoachConversationsBody": 249,
|
||||
"app/coach/plans/page.tsx :: CoachPlansBody": 216,
|
||||
"app/insights/catalog/page.tsx :: MetricCatalogPage": 204,
|
||||
"app/insights/medications/page.tsx :: InsightsMedikamentePage": 265,
|
||||
"app/insights/page-client.tsx :: InsightsPageClient": 375,
|
||||
"app/labs/page.tsx :: LabsPage": 216,
|
||||
"app/labs/page.tsx :: LabsPage": 217,
|
||||
"app/medications/page-client.tsx :: MedicationsPageClient": 590,
|
||||
"app/notifications/page.tsx :: NotificationsPage": 327,
|
||||
"components/activity/activity-calendar-card.tsx :: ActivityCalendarCard": 470,
|
||||
@@ -70,21 +70,21 @@
|
||||
"components/charts/medication-compliance-chart.tsx :: MedicationComplianceChart": 543,
|
||||
"components/charts/mood-chart.tsx :: MoodChart": 896,
|
||||
"components/charts/mood-heatmap.tsx :: MoodHeatmap": 296,
|
||||
"components/charts/trend-card.tsx :: TrendCard": 365,
|
||||
"components/charts/trend-card.tsx :: TrendCard": 351,
|
||||
"components/clinician/clinician-view.tsx :: ClinicianView": 223,
|
||||
"components/command-palette/command-palette.tsx :: PaletteBody": 333,
|
||||
"components/custom-metrics/custom-metric-chart.tsx :: CustomMetricChart": 228,
|
||||
"components/custom-metrics/custom-metric-detail.tsx :: CustomMetricDetail": 277,
|
||||
"components/custom-metrics/custom-metric-form.tsx :: CustomMetricForm": 210,
|
||||
"components/custom-metrics/custom-metric-detail.tsx :: CustomMetricDetail": 280,
|
||||
"components/custom-metrics/custom-metric-form.tsx :: CustomMetricForm": 208,
|
||||
"components/custom-metrics/custom-metric-history-list.tsx :: CustomMetricHistoryList": 298,
|
||||
"components/cycle/bbt-chart.tsx :: BbtChart": 244,
|
||||
"components/cycle/cycle-calendar.tsx :: CycleCalendar": 292,
|
||||
"components/cycle/cycle-settings.tsx :: CycleSettings": 228,
|
||||
"components/cycle/cycle-view.tsx :: CycleView": 405,
|
||||
"components/cycle/log-day-sheet.tsx :: LogDaySheet": 753,
|
||||
"components/cycle/cycle-view.tsx :: CycleView": 403,
|
||||
"components/cycle/log-day-sheet.tsx :: LogDaySheet": 760,
|
||||
"components/daily/today-hero.tsx :: TodayHero": 488,
|
||||
"components/dashboard/dashboard-header.tsx :: DashboardHeader": 215,
|
||||
"components/dashboard/medication-intake-quick-add.tsx :: MedicationIntakeQuickAdd": 383,
|
||||
"components/dashboard/medication-intake-quick-add.tsx :: MedicationIntakeQuickAdd": 384,
|
||||
"components/day/day-layer.tsx :: DayLayer": 358,
|
||||
"components/day/day-view.tsx :: DayView": 309,
|
||||
"components/documents/document-ai-section.tsx :: DocumentAiSection": 262,
|
||||
@@ -98,13 +98,13 @@
|
||||
"components/documents/documents-view.tsx :: DocumentsView": 303,
|
||||
"components/documents/sources/document-source-picker.tsx :: DocumentSourcePicker": 563,
|
||||
"components/illness/log-day-sheet.tsx :: LogDaySheet": 265,
|
||||
"components/illness/new-episode-sheet.tsx :: NewEpisodeSheet": 264,
|
||||
"components/illness/new-episode-sheet.tsx :: NewEpisodeSheet": 278,
|
||||
"components/insights/coach-panel/chat-bubble.tsx :: ChatBubbleImpl": 417,
|
||||
"components/insights/coach-panel/coach-conversation.tsx :: CoachConversation": 1002,
|
||||
"components/insights/coach-panel/coach-input.tsx :: CoachInput": 259,
|
||||
"components/insights/coach-panel/coach-model-picker.tsx :: CoachModelPicker": 208,
|
||||
"components/insights/coach-panel/conversations-panel.tsx :: ConversationsPanel": 317,
|
||||
"components/insights/coach-panel/message-thread.tsx :: MessageThread": 390,
|
||||
"components/insights/coach-panel/message-thread.tsx :: MessageThread": 389,
|
||||
"components/insights/coach-panel/sources-rail.tsx :: SourcesRail": 203,
|
||||
"components/insights/coach-panel/use-coach.ts :: useSendCoachMessage": 436,
|
||||
"components/insights/coach-panel/use-coach.ts :: useSendCoachMessage > useCallback(arg) #2": 340,
|
||||
@@ -138,18 +138,17 @@
|
||||
"components/labs/ocr-row-editor.tsx :: OcrRowEditor": 378,
|
||||
"components/layout/auth-shell.tsx :: AuthShell": 483,
|
||||
"components/layout/bottom-nav.tsx :: BottomNav": 262,
|
||||
"components/layout/capture-picker.tsx :: CapturePicker": 235,
|
||||
"components/layout/sidebar-nav.tsx :: SidebarNav": 356,
|
||||
"components/layout/top-bar.tsx :: TopBar": 212,
|
||||
"components/links/entity-link-picker.tsx :: EntityLinkPicker": 392,
|
||||
"components/measurement-reminders/vorsorge-dashboard-card.tsx :: VorsorgeDashboardCard": 215,
|
||||
"components/measurement-reminders/vorsorge-section.tsx :: VorsorgeSection": 352,
|
||||
"components/measurement-reminders/vorsorge-section.tsx :: VorsorgeSection": 354,
|
||||
"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,
|
||||
@@ -164,7 +163,7 @@
|
||||
"components/medications/sections/inventory-dialogs.tsx :: AddInventoryDialog": 238,
|
||||
"components/medications/sections/inventory-section.tsx :: InventorySection": 368,
|
||||
"components/medications/side-effects-section.tsx :: SideEffectsSection": 518,
|
||||
"components/medications/wizard/medication-wizard-dialog.tsx :: WizardDialogShell": 516,
|
||||
"components/medications/wizard/medication-wizard-dialog.tsx :: WizardDialogShell": 514,
|
||||
"components/medications/wizard/steps/step3-dose.tsx :: Step3Dose": 222,
|
||||
"components/medications/wizard/steps/step8-summary.tsx :: Step8Summary": 230,
|
||||
"components/mood/manage/tag-groups-card.tsx :: TagGroupsCard": 252,
|
||||
@@ -195,7 +194,7 @@
|
||||
"components/settings/environment-section.tsx :: EnvironmentSection": 666,
|
||||
"components/settings/import-panel/apple-health-import-card.tsx :: AppleHealthImportCard": 298,
|
||||
"components/settings/import-panel/csv-import-card.tsx :: CsvImportCard": 212,
|
||||
"components/settings/import-panel/dose-history-import-card.tsx :: DoseHistoryImportCard": 222,
|
||||
"components/settings/import-panel/dose-history-import-card.tsx :: DoseHistoryImportCard": 221,
|
||||
"components/settings/import-panel/health-connect-import-card.tsx :: HealthConnectImportCard": 293,
|
||||
"components/settings/import-panel/record-csv-import-card.tsx :: RecordCsvImportSheet": 458,
|
||||
"components/settings/insights-pill-order-section.tsx :: InsightsPillOrderSection": 255,
|
||||
@@ -220,18 +219,18 @@
|
||||
"components/settings/thresholds-editor-section.tsx :: MetricRow": 241,
|
||||
"components/symptoms/symptom-entry-form.tsx :: SymptomEntryForm": 317,
|
||||
"components/symptoms/symptoms-section.tsx :: SymptomsSection": 205,
|
||||
"components/targets/range-bar.tsx :: RangeBar": 220,
|
||||
"components/targets/target-edit-sheet.tsx :: TargetEditSheetBody": 473,
|
||||
"components/timeline/life-event-form.tsx :: LifeEventForm": 281,
|
||||
"components/targets/range-bar.tsx :: RangeBar": 215,
|
||||
"components/targets/target-edit-sheet.tsx :: TargetEditSheetBody": 486,
|
||||
"components/timeline/life-event-form.tsx :: LifeEventForm": 287,
|
||||
"components/timeline/timeline-chart.tsx :: ChartBody": 236,
|
||||
"components/timeline/timeline-chronicle.tsx :: TimelineChronicle": 246,
|
||||
"components/timeline/timeline-view.tsx :: TimelineView": 478,
|
||||
"components/ui/responsive-sheet.tsx :: ResponsiveSheet": 239,
|
||||
"components/ui/responsive-sheet.tsx :: ResponsiveSheet": 253,
|
||||
"components/vaccinations/catalog-picker.tsx :: CatalogPicker": 221,
|
||||
"components/vaccinations/custom-vaccine-sheet.tsx :: CustomVaccineSheet": 246,
|
||||
"components/vaccinations/vaccination-form.tsx :: VaccinationForm": 218,
|
||||
"components/workouts/manual-workout-form.tsx :: ManualWorkoutForm": 321,
|
||||
"hooks/use-sheet-drag.ts :: createSheetDrag": 312,
|
||||
"hooks/use-sheet-drag.ts :: createSheetDrag": 319,
|
||||
"lib/activity/calendar-math.ts :: buildActivityCalendar": 204,
|
||||
"lib/ai/anthropic-client.ts :: generateCompletion": 294,
|
||||
"lib/ai/coach/cycle-snapshot.ts :: buildCycleSnapshotBlock": 202,
|
||||
@@ -322,7 +321,7 @@
|
||||
"lib/medications/list-read.ts :: buildMedicationsList": 308,
|
||||
"lib/medications/scheduling/dose-history.ts :: reconstructDoseHistory": 218,
|
||||
"lib/multipart/stream-to-disk.ts :: streamMultipartToDisk": 272,
|
||||
"lib/notifications/dispatcher.ts :: dispatchNotification": 368,
|
||||
"lib/notifications/dispatcher.ts :: dispatchNotification": 366,
|
||||
"lib/notifications/senders/apns.ts :: sendViaApns": 299,
|
||||
"lib/notifications/senders/web-push.ts :: sendViaWebPush": 255,
|
||||
"lib/personal-records/pr-detection-worker.ts :: detectPersonalRecordsForUser": 239,
|
||||
@@ -338,6 +337,6 @@
|
||||
"lib/withings/sync-ecg.ts :: syncUserEcg": 202,
|
||||
"lib/withings/sync-sleep.ts :: syncUserSleep": 414,
|
||||
"lib/withings/sync.ts :: syncUserMeasurements": 337,
|
||||
"proxy.ts :: proxy": 470
|
||||
"proxy.ts :: proxy": 467
|
||||
}
|
||||
}
|
||||
@@ -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": 522,
|
||||
"total": 5448,
|
||||
"total": 5447,
|
||||
"entries": {
|
||||
"__tests__/api/auth/login/mfa-branch.test.ts": 10,
|
||||
"__tests__/api/auth/mfa/verify.test.ts": 9,
|
||||
@@ -495,7 +495,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": 336,
|
||||
"total": 336,
|
||||
"count": 331,
|
||||
"total": 331,
|
||||
"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": 2875,
|
||||
"total": 9162,
|
||||
"count": 2863,
|
||||
"total": 9122,
|
||||
"entries": {
|
||||
"__tests__/account-payload-consumer-guard.test.ts": 2,
|
||||
"__tests__/acting-account-boundary-guard.test.ts": 7,
|
||||
@@ -67,10 +67,8 @@
|
||||
"__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,
|
||||
"app/__tests__/dashboard-chart-reveal.test.tsx": 2,
|
||||
"app/__tests__/dashboard-suspense-boundaries.test.ts": 2,
|
||||
"app/__tests__/dashboard-tile-strip-skeleton.test.ts": 4,
|
||||
@@ -79,7 +77,6 @@
|
||||
"app/__tests__/not-found-copy.test.ts": 1,
|
||||
"app/__tests__/quick-add-labels.test.ts": 3,
|
||||
"app/__tests__/root-loading-silhouette.test.tsx": 1,
|
||||
"app/__tests__/sheet-dirty-check.test.ts": 1,
|
||||
"app/about/page.tsx": 1,
|
||||
"app/achievements/__tests__/page.test.tsx": 2,
|
||||
"app/achievements/page.tsx": 4,
|
||||
@@ -174,9 +171,9 @@
|
||||
"app/api/auth/refresh/route.ts": 2,
|
||||
"app/api/auth/register/__tests__/route.test.ts": 5,
|
||||
"app/api/auth/register/route.ts": 10,
|
||||
"app/api/biomarkers/__tests__/error-contract.test.ts": 1,
|
||||
"app/api/biomarkers/[id]/__tests__/route.test.ts": 2,
|
||||
"app/api/biomarkers/[id]/route.ts": 6,
|
||||
"app/api/biomarkers/__tests__/error-contract.test.ts": 1,
|
||||
"app/api/biomarkers/route.ts": 4,
|
||||
"app/api/c/[token]/unlock/__tests__/route.test.ts": 1,
|
||||
"app/api/c/[token]/unlock/route.ts": 1,
|
||||
@@ -186,12 +183,12 @@
|
||||
"app/api/coach/about-me/questions/__tests__/route-module-gate.test.ts": 1,
|
||||
"app/api/coach/about-me/questions/route.ts": 3,
|
||||
"app/api/coach/about-me/route.ts": 4,
|
||||
"app/api/coach/plans/[id]/route.ts": 2,
|
||||
"app/api/coach/plans/__tests__/route.test.ts": 1,
|
||||
"app/api/coach/plans/[id]/route.ts": 2,
|
||||
"app/api/coach/plans/route.ts": 1,
|
||||
"app/api/coach/reminder-suggestions/route.ts": 1,
|
||||
"app/api/coach/reminders/[id]/route.ts": 1,
|
||||
"app/api/coach/reminders/__tests__/route.test.ts": 1,
|
||||
"app/api/coach/reminders/[id]/route.ts": 1,
|
||||
"app/api/coach/reminders/route.ts": 1,
|
||||
"app/api/coach/suggested-actions/__tests__/route.test.ts": 1,
|
||||
"app/api/coach/suggested-actions/route.ts": 1,
|
||||
@@ -199,9 +196,9 @@
|
||||
"app/api/consent/ai/latest/__tests__/route.test.ts": 3,
|
||||
"app/api/consent/ai/latest/route.ts": 3,
|
||||
"app/api/consent/ai/route.ts": 3,
|
||||
"app/api/custom-metrics/[id]/entries/[entryId]/route.ts": 2,
|
||||
"app/api/custom-metrics/[id]/entries/__tests__/note-encryption.test.ts": 1,
|
||||
"app/api/custom-metrics/[id]/entries/__tests__/soft-delete-restore.test.ts": 1,
|
||||
"app/api/custom-metrics/[id]/entries/[entryId]/route.ts": 2,
|
||||
"app/api/custom-metrics/[id]/entries/route.ts": 4,
|
||||
"app/api/custom-metrics/[id]/route.ts": 1,
|
||||
"app/api/custom-metrics/route.ts": 1,
|
||||
@@ -211,8 +208,8 @@
|
||||
"app/api/cycle/day-logs/[id]/route.ts": 4,
|
||||
"app/api/cycle/day-logs/route.ts": 1,
|
||||
"app/api/cycle/period/route.ts": 4,
|
||||
"app/api/cycle/symptoms/custom/[key]/route.ts": 1,
|
||||
"app/api/cycle/symptoms/custom/__tests__/custom-crud.test.ts": 1,
|
||||
"app/api/cycle/symptoms/custom/[key]/route.ts": 1,
|
||||
"app/api/cycle/symptoms/custom/route.ts": 3,
|
||||
"app/api/dashboard/chart-overlay-prefs/__tests__/route.test.ts": 1,
|
||||
"app/api/dashboard/chart-overlay-prefs/route.ts": 2,
|
||||
@@ -222,10 +219,11 @@
|
||||
"app/api/dashboard/summary/route.ts": 5,
|
||||
"app/api/dashboard/widgets/__tests__/route.test.ts": 4,
|
||||
"app/api/dashboard/widgets/route.ts": 14,
|
||||
"app/api/devices/__tests__/route.test.ts": 1,
|
||||
"app/api/devices/[id]/__tests__/route.test.ts": 1,
|
||||
"app/api/devices/[id]/route.ts": 1,
|
||||
"app/api/devices/__tests__/route.test.ts": 1,
|
||||
"app/api/devices/route.ts": 4,
|
||||
"app/api/documents/inbound/__tests__/route.test.ts": 1,
|
||||
"app/api/documents/inbound/[id]/__tests__/route.test.ts": 1,
|
||||
"app/api/documents/inbound/[id]/chat/__tests__/route.test.ts": 2,
|
||||
"app/api/documents/inbound/[id]/chat/route.ts": 1,
|
||||
@@ -240,7 +238,6 @@
|
||||
"app/api/documents/inbound/[id]/suggest/route.ts": 2,
|
||||
"app/api/documents/inbound/[id]/summary/route.ts": 2,
|
||||
"app/api/documents/inbound/[id]/thumbnail/route.ts": 1,
|
||||
"app/api/documents/inbound/__tests__/route.test.ts": 1,
|
||||
"app/api/documents/inbound/reindex/route.ts": 1,
|
||||
"app/api/documents/inbound/route.ts": 2,
|
||||
"app/api/documents/inbound/usage/route.ts": 1,
|
||||
@@ -259,10 +256,10 @@
|
||||
"app/api/export/mood/route.ts": 2,
|
||||
"app/api/family-history/[id]/route.ts": 2,
|
||||
"app/api/family-history/route.ts": 1,
|
||||
"app/api/fhir/Patient/$everything/route.ts": 1,
|
||||
"app/api/fhir/__tests__/observation-series.test.ts": 1,
|
||||
"app/api/fhir/__tests__/route.test.ts": 1,
|
||||
"app/api/fhir/metadata/route.ts": 1,
|
||||
"app/api/fhir/Patient/$everything/route.ts": 1,
|
||||
"app/api/fitbit/callback/route.ts": 1,
|
||||
"app/api/fitbit/connect/route.ts": 1,
|
||||
"app/api/fitbit/credentials/route.ts": 1,
|
||||
@@ -291,11 +288,6 @@
|
||||
"app/api/insights/breathing-screening/route.ts": 2,
|
||||
"app/api/insights/cards/__tests__/route.test.ts": 2,
|
||||
"app/api/insights/cards/route.ts": 8,
|
||||
"app/api/insights/chat/[id]/attachments/[documentId]/route.ts": 1,
|
||||
"app/api/insights/chat/[id]/attachments/__tests__/route.test.ts": 1,
|
||||
"app/api/insights/chat/[id]/attachments/route.ts": 1,
|
||||
"app/api/insights/chat/[id]/messages/[messageId]/trail/__tests__/route.test.ts": 1,
|
||||
"app/api/insights/chat/[id]/route.ts": 1,
|
||||
"app/api/insights/chat/__tests__/dialog-harness.ts": 1,
|
||||
"app/api/insights/chat/__tests__/golden-transcripts.test.ts": 1,
|
||||
"app/api/insights/chat/__tests__/route-activity.test.ts": 1,
|
||||
@@ -307,14 +299,19 @@
|
||||
"app/api/insights/chat/__tests__/route-snapshot-once.test.ts": 2,
|
||||
"app/api/insights/chat/__tests__/route-tool-mode.test.ts": 4,
|
||||
"app/api/insights/chat/__tests__/route-workout-scope.test.ts": 1,
|
||||
"app/api/insights/chat/[id]/attachments/__tests__/route.test.ts": 1,
|
||||
"app/api/insights/chat/[id]/attachments/[documentId]/route.ts": 1,
|
||||
"app/api/insights/chat/[id]/attachments/route.ts": 1,
|
||||
"app/api/insights/chat/[id]/messages/[messageId]/trail/__tests__/route.test.ts": 1,
|
||||
"app/api/insights/chat/[id]/route.ts": 1,
|
||||
"app/api/insights/chat/fenced/__tests__/route.test.ts": 1,
|
||||
"app/api/insights/chat/fenced/route.ts": 1,
|
||||
"app/api/insights/chat/messages/[id]/feedback/route.ts": 3,
|
||||
"app/api/insights/chat/route.ts": 9,
|
||||
"app/api/insights/coach-read/route.ts": 2,
|
||||
"app/api/insights/coach/facts/[id]/route.ts": 2,
|
||||
"app/api/insights/coach/facts/__tests__/route-memory.test.ts": 1,
|
||||
"app/api/insights/coach/facts/__tests__/route.test.ts": 1,
|
||||
"app/api/insights/coach/facts/[id]/route.ts": 2,
|
||||
"app/api/insights/coach/facts/route.ts": 1,
|
||||
"app/api/insights/coach/nudge-status/route.ts": 1,
|
||||
"app/api/insights/coach/seen/route.ts": 1,
|
||||
@@ -325,9 +322,9 @@
|
||||
"app/api/insights/derived/batch/__tests__/route.test.ts": 3,
|
||||
"app/api/insights/derived/batch/route.ts": 6,
|
||||
"app/api/insights/derived/route.ts": 5,
|
||||
"app/api/insights/ecg/__tests__/route.test.ts": 1,
|
||||
"app/api/insights/ecg/[id]/__tests__/route.test.ts": 1,
|
||||
"app/api/insights/ecg/[id]/route.ts": 2,
|
||||
"app/api/insights/ecg/__tests__/route.test.ts": 1,
|
||||
"app/api/insights/ecg/route.ts": 1,
|
||||
"app/api/insights/feedback/route.ts": 2,
|
||||
"app/api/insights/generate/__tests__/route.test.ts": 11,
|
||||
@@ -366,8 +363,8 @@
|
||||
"app/api/internal/deploy-webhook/route.ts": 1,
|
||||
"app/api/internal/web-vitals/__tests__/route.test.ts": 1,
|
||||
"app/api/internal/web-vitals/route.ts": 1,
|
||||
"app/api/labs/[id]/route.ts": 7,
|
||||
"app/api/labs/__tests__/error-contract.test.ts": 1,
|
||||
"app/api/labs/[id]/route.ts": 7,
|
||||
"app/api/labs/ocr/capability/route.ts": 1,
|
||||
"app/api/labs/ocr/commit/__tests__/route.test.ts": 2,
|
||||
"app/api/labs/ocr/commit/route.ts": 4,
|
||||
@@ -387,13 +384,13 @@
|
||||
"app/api/measurement-reminders/[id]/skip/route.ts": 1,
|
||||
"app/api/measurement-reminders/[id]/snooze/route.ts": 1,
|
||||
"app/api/measurement-reminders/route.ts": 3,
|
||||
"app/api/measurements/[id]/__tests__/route.test.ts": 2,
|
||||
"app/api/measurements/[id]/route.ts": 16,
|
||||
"app/api/measurements/__tests__/arrival-wiring.test.ts": 1,
|
||||
"app/api/measurements/__tests__/group-by-day.test.ts": 8,
|
||||
"app/api/measurements/__tests__/put-duplicate-timestamp.test.ts": 1,
|
||||
"app/api/measurements/__tests__/range-aggregation-route.test.ts": 3,
|
||||
"app/api/measurements/__tests__/zod-multi-issue.test.ts": 4,
|
||||
"app/api/measurements/[id]/__tests__/route.test.ts": 2,
|
||||
"app/api/measurements/[id]/route.ts": 16,
|
||||
"app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts": 1,
|
||||
"app/api/measurements/batch/__tests__/duplicate-prefilter.test.ts": 2,
|
||||
"app/api/measurements/batch/__tests__/hr-bucket-observability.test.ts": 1,
|
||||
@@ -410,6 +407,7 @@
|
||||
"app/api/measurements/series-batch/route.ts": 1,
|
||||
"app/api/measurements/series/__tests__/route.test.ts": 5,
|
||||
"app/api/measurements/series/route.ts": 6,
|
||||
"app/api/medications/__tests__/route.test.ts": 10,
|
||||
"app/api/medications/[id]/__tests__/route.test.ts": 8,
|
||||
"app/api/medications/[id]/api-endpoint/route.ts": 2,
|
||||
"app/api/medications/[id]/cadence/route.ts": 10,
|
||||
@@ -421,10 +419,10 @@
|
||||
"app/api/medications/[id]/dose-history/route.ts": 14,
|
||||
"app/api/medications/[id]/glp1/__tests__/route.test.ts": 3,
|
||||
"app/api/medications/[id]/glp1/route.ts": 8,
|
||||
"app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts": 9,
|
||||
"app/api/medications/[id]/intake/[eventId]/route.ts": 4,
|
||||
"app/api/medications/[id]/intake/__tests__/route.test.ts": 11,
|
||||
"app/api/medications/[id]/intake/__tests__/source-provenance.test.ts": 1,
|
||||
"app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts": 9,
|
||||
"app/api/medications/[id]/intake/[eventId]/route.ts": 4,
|
||||
"app/api/medications/[id]/intake/bulk-delete/route.ts": 7,
|
||||
"app/api/medications/[id]/intake/import/__tests__/route.test.ts": 2,
|
||||
"app/api/medications/[id]/intake/import/route.ts": 6,
|
||||
@@ -432,12 +430,11 @@
|
||||
"app/api/medications/[id]/inventory/[itemId]/route.ts": 8,
|
||||
"app/api/medications/[id]/inventory/route.ts": 4,
|
||||
"app/api/medications/[id]/phase-config/route.ts": 4,
|
||||
"app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts": 9,
|
||||
"app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts": 1,
|
||||
"app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts": 9,
|
||||
"app/api/medications/[id]/schedule-revisions/route.ts": 3,
|
||||
"app/api/medications/[id]/side-effects/[logId]/route.ts": 2,
|
||||
"app/api/medications/[id]/side-effects/route.ts": 5,
|
||||
"app/api/medications/__tests__/route.test.ts": 10,
|
||||
"app/api/medications/categories/[key]/route.ts": 1,
|
||||
"app/api/medications/categories/route.ts": 1,
|
||||
"app/api/medications/compliance/__tests__/route.test.ts": 2,
|
||||
@@ -454,9 +451,9 @@
|
||||
"app/api/medications/route.ts": 2,
|
||||
"app/api/mental-health/assessments/__tests__/route.test.ts": 2,
|
||||
"app/api/mental-health/assessments/route.ts": 6,
|
||||
"app/api/mood-entries/__tests__/route.test.ts": 7,
|
||||
"app/api/mood-entries/[id]/__tests__/route.test.ts": 4,
|
||||
"app/api/mood-entries/[id]/route.ts": 22,
|
||||
"app/api/mood-entries/__tests__/route.test.ts": 7,
|
||||
"app/api/mood-entries/bulk-delete/__tests__/route.test.ts": 1,
|
||||
"app/api/mood-entries/bulk-delete/route.ts": 4,
|
||||
"app/api/mood-entries/bulk/__tests__/route.test.ts": 5,
|
||||
@@ -546,12 +543,12 @@
|
||||
"app/api/withings/connect/__tests__/route.test.ts": 1,
|
||||
"app/api/withings/connect/route.ts": 3,
|
||||
"app/api/withings/status/route.ts": 1,
|
||||
"app/api/withings/webhook/[token]/__tests__/route.test.ts": 1,
|
||||
"app/api/withings/webhook/__tests__/route.test.ts": 1,
|
||||
"app/api/withings/webhook/[token]/__tests__/route.test.ts": 1,
|
||||
"app/api/withings/webhook/route.ts": 2,
|
||||
"app/api/workouts/[id]/route.ts": 2,
|
||||
"app/api/workouts/__tests__/batch-create.test.ts": 3,
|
||||
"app/api/workouts/__tests__/canonical-dedup.test.ts": 3,
|
||||
"app/api/workouts/[id]/route.ts": 2,
|
||||
"app/api/workouts/batch/route.ts": 3,
|
||||
"app/api/workouts/route.ts": 2,
|
||||
"app/auth/claim/page.tsx": 1,
|
||||
@@ -653,20 +650,19 @@
|
||||
"app/loading.tsx": 2,
|
||||
"app/mcp/__tests__/route.test.ts": 5,
|
||||
"app/measurements/page.tsx": 5,
|
||||
"app/medications/[id]/history/page.tsx": 1,
|
||||
"app/medications/[id]/page.tsx": 2,
|
||||
"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,
|
||||
"app/medications/[id]/history/page.tsx": 1,
|
||||
"app/medications/[id]/page.tsx": 2,
|
||||
"app/medications/new/page.tsx": 1,
|
||||
"app/medications/page-client.tsx": 50,
|
||||
"app/mental-wellbeing/page.tsx": 1,
|
||||
"app/mood/page-client.tsx": 2,
|
||||
"app/not-found.tsx": 2,
|
||||
"app/onboarding/[step]/page.tsx": 1,
|
||||
"app/onboarding/__tests__/page.test.tsx": 1,
|
||||
"app/onboarding/[step]/page.tsx": 1,
|
||||
"app/onboarding/handover/page.tsx": 1,
|
||||
"app/onboarding/page.tsx": 2,
|
||||
"app/privacy/__tests__/page.test.tsx": 1,
|
||||
@@ -735,7 +731,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,
|
||||
@@ -807,7 +802,7 @@
|
||||
"components/dashboard/chart-reveal.tsx": 1,
|
||||
"components/dashboard/dashboard-gates.ts": 9,
|
||||
"components/dashboard/medication-intake-quick-add.tsx": 11,
|
||||
"components/dashboard/quick-entry-sheets.tsx": 6,
|
||||
"components/dashboard/quick-entry-sheets.tsx": 3,
|
||||
"components/dashboard/range-display.tsx": 1,
|
||||
"components/dashboard/recent-workouts-tile.tsx": 3,
|
||||
"components/data-list/__tests__/selection.test.ts": 1,
|
||||
@@ -928,7 +923,7 @@
|
||||
"components/insights/coach-panel/guided-questions-card.tsx": 1,
|
||||
"components/insights/coach-panel/guided-questions-machine.ts": 2,
|
||||
"components/insights/coach-panel/memory-note.tsx": 1,
|
||||
"components/insights/coach-panel/message-thread.tsx": 31,
|
||||
"components/insights/coach-panel/message-thread.tsx": 28,
|
||||
"components/insights/coach-panel/method-line.tsx": 1,
|
||||
"components/insights/coach-panel/mobile-rail-tray.tsx": 2,
|
||||
"components/insights/coach-panel/read-aloud.tsx": 8,
|
||||
@@ -985,7 +980,7 @@
|
||||
"components/insights/hero-strip.tsx": 3,
|
||||
"components/insights/insight-status-card.tsx": 12,
|
||||
"components/insights/insights-edit-mode.tsx": 5,
|
||||
"components/insights/insights-layout-shell.tsx": 11,
|
||||
"components/insights/insights-layout-shell.tsx": 3,
|
||||
"components/insights/insights-tab-strip.tsx": 35,
|
||||
"components/insights/intraday-pulse-chart.tsx": 1,
|
||||
"components/insights/labs-changes-card.tsx": 1,
|
||||
@@ -1001,7 +996,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,
|
||||
@@ -1094,7 +1088,7 @@
|
||||
"components/layout/account-switcher-menu.tsx": 1,
|
||||
"components/layout/auth-shell.tsx": 13,
|
||||
"components/layout/bottom-nav.tsx": 8,
|
||||
"components/layout/capture-picker.tsx": 5,
|
||||
"components/layout/capture-picker.tsx": 3,
|
||||
"components/layout/nav-model.ts": 17,
|
||||
"components/layout/record-session-fence-bridge.tsx": 1,
|
||||
"components/layout/shared-record-banner.tsx": 1,
|
||||
@@ -1125,7 +1119,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,
|
||||
@@ -1162,7 +1156,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,
|
||||
@@ -1793,7 +1787,6 @@
|
||||
"lib/analytics/__tests__/effective-range.test.ts": 1,
|
||||
"lib/analytics/__tests__/glucose-panel-coherence.test.ts": 1,
|
||||
"lib/analytics/__tests__/glucose-panel-window.test.ts": 2,
|
||||
"lib/analytics/__tests__/merge-slim-thick.test.ts": 3,
|
||||
"lib/analytics/__tests__/mood-series.test.ts": 1,
|
||||
"lib/analytics/__tests__/range-delta.test.ts": 1,
|
||||
"lib/analytics/__tests__/resting-pulse.test.ts": 1,
|
||||
@@ -1826,7 +1819,6 @@
|
||||
"lib/analytics/correlations.ts": 1,
|
||||
"lib/analytics/intraday-pulse-io.ts": 2,
|
||||
"lib/analytics/intraday-pulse.ts": 3,
|
||||
"lib/analytics/merge-slim-thick.ts": 2,
|
||||
"lib/analytics/mood-series.ts": 2,
|
||||
"lib/analytics/range-delta.ts": 1,
|
||||
"lib/analytics/range-shared.ts": 1,
|
||||
@@ -1931,7 +1923,6 @@
|
||||
"lib/dashboard/__tests__/meds-today.test.ts": 1,
|
||||
"lib/dashboard/__tests__/score-narrative.test.ts": 1,
|
||||
"lib/dashboard/__tests__/score-rings.test.ts": 1,
|
||||
"lib/dashboard/__tests__/snapshot-flag.test.ts": 1,
|
||||
"lib/dashboard/__tests__/snapshot.test.ts": 15,
|
||||
"lib/dashboard/__tests__/target-bands.test.ts": 2,
|
||||
"lib/dashboard/bands.ts": 1,
|
||||
@@ -2318,7 +2309,7 @@
|
||||
"lib/jobs/lab-biomarker-backfill.ts": 2,
|
||||
"lib/jobs/mean-consolidation.ts": 1,
|
||||
"lib/jobs/measurement-fold-repair.ts": 1,
|
||||
"lib/jobs/measurement-reminder.ts": 14,
|
||||
"lib/jobs/measurement-reminder.ts": 13,
|
||||
"lib/jobs/med-notes-encryption-backfill.ts": 2,
|
||||
"lib/jobs/medication-intake-import.ts": 1,
|
||||
"lib/jobs/medication-inventory-expire.ts": 1,
|
||||
@@ -2365,7 +2356,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,
|
||||
@@ -2683,7 +2673,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,
|
||||
@@ -2760,11 +2750,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,
|
||||
|
||||
@@ -136,11 +136,6 @@ const INVENTORY: Readonly<Record<string, Poll>> = {
|
||||
cadence:
|
||||
"25 s, nextAdvisorPollInterval, attempts from the response timeout so the warm can land (advisor); 10 while settling",
|
||||
},
|
||||
"components/insights/insights-layout-shell.tsx": {
|
||||
sites: 1,
|
||||
shape: "bounded",
|
||||
cadence: "25 s, nextAdvisorPollInterval, 10 attempts (briefing body)",
|
||||
},
|
||||
};
|
||||
|
||||
const SITE = /\brefetchInterval\s*:/g;
|
||||
|
||||
@@ -136,10 +136,6 @@ const NOT_OPERATOR_CONFIGURABLE: Readonly<Record<string, string>> = {
|
||||
"CI-only knob for the same nightly eval workflow",
|
||||
HEALTHLOG_MCP_TOKEN:
|
||||
"read by the local stdio MCP bridge the operator runs on their own machine; the production image strips tsx so it cannot run there",
|
||||
|
||||
// --- A whitelist entry would be obeyed by half the readers. ---
|
||||
NEXT_PUBLIC_DASHBOARD_SNAPSHOT:
|
||||
"NEXT_PUBLIC_* is inlined into the client bundle at build time and three of its four readers are client components, so a runtime value would steer the server half only — a split-brain worse than no knob. .env.production.example already documents it as a build-time toggle and says it is deliberately absent from the whitelist; adding it would make that note false.",
|
||||
};
|
||||
|
||||
function sourceFiles(): string[] {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -339,7 +339,6 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
|
||||
"/api/internal/web-vitals": { kind: "internalOps", methods: ["POST"] },
|
||||
"/api/monitoring/csp-report": { kind: "internalOps", methods: ["POST"] },
|
||||
"/api/monitoring/glitchtip": { kind: "internalOps", methods: ["POST"] },
|
||||
"/api/monitoring/settings": { kind: "internalOps", methods: ["GET"] },
|
||||
"/api/monitoring/umami-script": { kind: "internalOps", methods: ["GET"] },
|
||||
"/api/monitoring/umami/{path}": { kind: "internalOps", methods: ["POST"] },
|
||||
"/api/mcp/oauth/authorize": {
|
||||
|
||||
@@ -9,17 +9,21 @@
|
||||
* 5 px content box and pushed the logo across its bottom border).
|
||||
* 2. The stylesheet carries the rules the shell and the keyboard bridge
|
||||
* depend on.
|
||||
* 3. Every form-field primitive keeps 16 px type on a touch screen: iOS
|
||||
* Safari zooms the page into a focused field below 16 px, and a width
|
||||
* 3. Every form field keeps 16 px type on a touch screen: iOS Safari
|
||||
* zooms the page into a focused field below 16 px, and a width
|
||||
* breakpoint alone (`md:text-sm`) handed 14 px to a phone held sideways
|
||||
* and to every iPad. The small size is allowed only behind
|
||||
* `pointer-fine:`.
|
||||
* `pointer-fine:`. That holds for the primitives and for every call
|
||||
* site, whose `text-sm` replaces the primitive's size, and for a raw
|
||||
* `<textarea>` styled from a class constant.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import ts from "typescript";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { stripComments } from "./helpers/source-files";
|
||||
import { parse, productionSources, readSrc } from "./helpers/source-metrics";
|
||||
|
||||
const ROOT = resolve(__dirname, "../..");
|
||||
const read = (path: string) => readFileSync(resolve(ROOT, path), "utf8");
|
||||
@@ -125,6 +129,24 @@ describe("the shell owns the safe area", () => {
|
||||
expect(bottomClass).not.toMatch(/(?<![\w-])bottom-/);
|
||||
});
|
||||
|
||||
it("the More sheet's grid scrolls inside the sheet's height cap", () => {
|
||||
// Held sideways a phone keeps the bottom bar, and 90 dvh of a 393 px
|
||||
// screen is shorter than the hub: the last rows sat below the edge.
|
||||
const nav = code(read("src/components/layout/bottom-nav.tsx"));
|
||||
const grid = nav.match(/"grid [^"]*grid-cols-2[^"]*"/)?.[0] ?? "";
|
||||
expect(grid).toMatch(/\bmin-h-0\b/);
|
||||
expect(grid).toMatch(/\boverflow-y-auto\b/);
|
||||
expect(grid).toMatch(/\boverscroll-contain\b/);
|
||||
});
|
||||
|
||||
it("the onboarding tour's bottom card clears the home indicator", () => {
|
||||
const tour = code(read("src/components/onboarding/tour.tsx"));
|
||||
const card = tour.match(/"[^"]*absolute inset-x-2 [^"]*"/)?.[0] ?? "";
|
||||
expect(card).toMatch(
|
||||
/\bbottom-\[calc\(env\(safe-area-inset-bottom,0px\)\+0\.5rem\)\]/,
|
||||
);
|
||||
});
|
||||
|
||||
it("the sheet primitives name their side for those rules", () => {
|
||||
expect(read("src/components/ui/sheet.tsx")).toMatch(
|
||||
/data-sheet-side=\{side\}/,
|
||||
@@ -165,3 +187,154 @@ describe("form fields keep 16 px on a touch screen", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/** Elements a person types into. */
|
||||
const FIELD_TAGS = new Set([
|
||||
"input",
|
||||
"textarea",
|
||||
"select",
|
||||
"Input",
|
||||
"Textarea",
|
||||
"PasswordInput",
|
||||
"NativeSelect",
|
||||
]);
|
||||
/** `type` values of an `<input>` nobody types into. */
|
||||
const NOT_TYPED = new Set([
|
||||
"checkbox",
|
||||
"radio",
|
||||
"range",
|
||||
"file",
|
||||
"hidden",
|
||||
"color",
|
||||
"button",
|
||||
"submit",
|
||||
"reset",
|
||||
"image",
|
||||
]);
|
||||
/** A type size under 16 px that is not behind `pointer-fine:`. */
|
||||
const SMALL_TYPE =
|
||||
/(?<![\w:-])(?:(?:sm|md|lg|xl):)?text-(?:2xs|xs|sm)(?![\w-])/g;
|
||||
|
||||
/** Every string a `className` expression can produce, constants resolved. */
|
||||
function classStrings(
|
||||
node: ts.Node,
|
||||
constants: ReadonlyMap<string, string>,
|
||||
): string[] {
|
||||
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
||||
return [node.text];
|
||||
}
|
||||
if (ts.isTemplateExpression(node)) {
|
||||
return [
|
||||
node.head.text,
|
||||
...node.templateSpans.flatMap((span) => [
|
||||
...classStrings(span.expression, constants),
|
||||
span.literal.text,
|
||||
]),
|
||||
];
|
||||
}
|
||||
if (ts.isIdentifier(node)) {
|
||||
const value = constants.get(node.text);
|
||||
return value === undefined ? [] : [value];
|
||||
}
|
||||
const out: string[] = [];
|
||||
node.forEachChild((child) => {
|
||||
out.push(...classStrings(child, constants));
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** `rel:line <Tag> class` for each field whose classes go under 16 px. */
|
||||
function smallFields(
|
||||
rel: string,
|
||||
text: string,
|
||||
): {
|
||||
fields: number;
|
||||
small: string[];
|
||||
} {
|
||||
const sf = parse(rel, text);
|
||||
const constants = new Map<string, string>();
|
||||
sf.forEachChild((statement) => {
|
||||
if (!ts.isVariableStatement(statement)) return;
|
||||
for (const decl of statement.declarationList.declarations) {
|
||||
if (
|
||||
ts.isIdentifier(decl.name) &&
|
||||
decl.initializer &&
|
||||
(ts.isStringLiteral(decl.initializer) ||
|
||||
ts.isNoSubstitutionTemplateLiteral(decl.initializer))
|
||||
) {
|
||||
constants.set(decl.name.text, decl.initializer.text);
|
||||
}
|
||||
}
|
||||
});
|
||||
let fields = 0;
|
||||
const small: string[] = [];
|
||||
const visit = (node: ts.Node) => {
|
||||
if (
|
||||
(ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) &&
|
||||
FIELD_TAGS.has(node.tagName.getText(sf))
|
||||
) {
|
||||
const attribute = (name: string) =>
|
||||
node.attributes.properties.find(
|
||||
(prop): prop is ts.JsxAttribute =>
|
||||
ts.isJsxAttribute(prop) && prop.name.getText(sf) === name,
|
||||
);
|
||||
const type = attribute("type")?.initializer;
|
||||
const typed = !(
|
||||
type &&
|
||||
ts.isStringLiteral(type) &&
|
||||
NOT_TYPED.has(type.text)
|
||||
);
|
||||
const className = attribute("className")?.initializer;
|
||||
if (typed && className) {
|
||||
fields += 1;
|
||||
const line = sf.getLineAndCharacterOfPosition(node.getStart()).line + 1;
|
||||
for (const cls of classStrings(className, constants)) {
|
||||
for (const match of cls.matchAll(SMALL_TYPE)) {
|
||||
small.push(
|
||||
`${rel}:${line} <${node.tagName.getText(sf)}> ${match[0]}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
node.forEachChild(visit);
|
||||
};
|
||||
visit(sf);
|
||||
return { fields, small };
|
||||
}
|
||||
|
||||
describe("no field call site goes under 16 px on a touch screen", () => {
|
||||
const scan = productionSources()
|
||||
.filter((rel) => rel.endsWith(".tsx"))
|
||||
.map((rel) => ({ rel, ...smallFields(rel, readSrc(rel)) }));
|
||||
|
||||
it("reads the field call sites (floor)", () => {
|
||||
const fields = scan.reduce((sum, file) => sum + file.fields, 0);
|
||||
expect(fields).toBeGreaterThan(90);
|
||||
});
|
||||
|
||||
it("finds a class constant on a raw field, and spares fine-pointer sizes and checkboxes", () => {
|
||||
const { small } = smallFields(
|
||||
"probe.tsx",
|
||||
[
|
||||
'const FIELD_CLASSES = "border-input w-full text-sm";',
|
||||
"export const A = () => (",
|
||||
" <>",
|
||||
' <textarea className={cn(FIELD_CLASSES, "min-h-16")} />',
|
||||
' <Textarea className="font-mono sm:pointer-fine:text-xs" />',
|
||||
' <input type="checkbox" className="text-sm" />',
|
||||
' <Input className={`h-9 ${wide ? "md:text-sm" : ""}`} />',
|
||||
" </>",
|
||||
");",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(small).toEqual([
|
||||
"probe.tsx:4 <textarea> text-sm",
|
||||
"probe.tsx:7 <Input> md:text-sm",
|
||||
]);
|
||||
});
|
||||
|
||||
it("no typed-into field sets a size under 16 px outside a fine pointer", () => {
|
||||
expect(scan.flatMap((file) => file.small)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
Loaded 100 of 395 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user