diff --git a/.dockerignore b/.dockerignore
index b95584b3f..2ddf2aa37 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -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,14 +77,12 @@ Dockerfile
# Documentation not needed for runtime image
README.md
CHANGELOG.md
-CLAUDE.md
CONTRIBUTING.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
diff --git a/.github/ISSUE_TEMPLATE/translation.yml b/.github/ISSUE_TEMPLATE/translation.yml
index 331815c1d..d12e9ce63 100644
--- a/.github/ISSUE_TEMPLATE/translation.yml
+++ b/.github/ISSUE_TEMPLATE/translation.yml
@@ -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
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 04679d7a0..be6042671 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -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
diff --git a/.gitignore b/.gitignore
index 55ff4b2ad..3b554dc0b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -66,14 +66,6 @@ next-env.d.ts
# COPY target always exists.
/assets/geolite2/*.mmdb
-# Local workspace files, never committed
-/docs/superpowers/
-.claude/
-.cursor/
-.codex/
-.aider*
-.mcp.json
-
# Internal working notes (per-release reports, backlog, iOS coordination).
# Everything user-facing lives in README, CHANGELOG and docs/.
/.planning/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b2224ad9d..c4cd6aef7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19279,9 +19279,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 +20598,8 @@ token '<', " **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
diff --git a/docs/ui-guidelines.md b/docs/ui-guidelines.md
index c4176a17c..22b8d4827 100644
--- a/docs/ui-guidelines.md
+++ b/docs/ui-guidelines.md
@@ -252,9 +252,10 @@ failed read never falls through to "no data yet".
input that browsers keep guessing wrong.
- API-token and AI-provider key inputs use `autoComplete="new-password"`
so the browser does not fill in the account password.
-- Password-change and passkey screens carry a hidden
- ``
- so password managers attribute saved credentials correctly.
+- A password form with no visible username field can carry a hidden
+ ``
+ 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)
diff --git a/e2e/admin-api-tokens-mobile.spec.ts b/e2e/admin-api-tokens-mobile.spec.ts
index 7c45949ab..d16dfbb14 100644
--- a/e2e/admin-api-tokens-mobile.spec.ts
+++ b/e2e/admin-api-tokens-mobile.spec.ts
@@ -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",
diff --git a/e2e/chrome-header-seam-banners.spec.ts b/e2e/chrome-header-seam-banners.spec.ts
index 87e470274..cb6a89165 100644
--- a/e2e/chrome-header-seam-banners.spec.ts
+++ b/e2e/chrome-header-seam-banners.spec.ts
@@ -221,7 +221,7 @@ async function enterSharedRecord(page: Page): Promise {
}
/**
- * 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
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 0a1d19700..db73867df 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -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.
diff --git a/lefthook.yml b/lefthook.yml
index 77a797709..119054b6e 100644
--- a/lefthook.yml
+++ b/lefthook.yml
@@ -16,14 +16,22 @@
# Per project policy (`--no-verify` is banned), prefer fixing the
# finding over bypassing the hook.
-# Reject tool attribution trailers.
+# 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 "-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
diff --git a/messages/de.json b/messages/de.json
index 0a0d88e44..77b23e200 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -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"
@@ -7071,7 +7071,7 @@
"builtAtLabel": "Erstellt",
"license": "Lizenz",
"repository": "Quellcode",
- "changelog": "Changelog",
+ "changelog": "Versionshinweise",
"docs": "Dokumentation",
"credits": "Credits & Datenquellen",
"linksHeading": "Quellen & Dokumentation",
diff --git a/messages/en.json b/messages/en.json
index 358f3a7a5..6705917dc 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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"
@@ -7071,7 +7071,7 @@
"builtAtLabel": "Built",
"license": "License",
"repository": "Source code",
- "changelog": "Changelog",
+ "changelog": "Release notes",
"docs": "Documentation",
"credits": "Credits & data attributions",
"linksHeading": "Sources & docs",
diff --git a/messages/es.json b/messages/es.json
index f5a71432e..791a45a5b 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -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"
@@ -7071,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",
@@ -10219,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",
diff --git a/messages/fr.json b/messages/fr.json
index 147e5d750..574570c36 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -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"
@@ -3624,7 +3624,7 @@
"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 marcher davantage aide-t-il ma santé ?"
+ "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",
@@ -7071,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",
diff --git a/messages/it.json b/messages/it.json
index 3f181295e..c0158d044 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -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"
@@ -7071,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",
diff --git a/messages/ko.json b/messages/ko.json
index bd3936658..5ff7cafa4 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -764,7 +764,7 @@
"noneActionableCta": "약 목록 열기"
},
"quickEntryDiscard": {
- "title": "이 기록을 버릴까요?",
+ "title": "변경 내용을 버릴까요?",
"description": "저장하지 않은 입력이 있어요. 폼을 닫고 입력한 내용을 버릴까요?",
"confirm": "버리기",
"cancel": "계속 작성"
@@ -7071,7 +7071,7 @@
"builtAtLabel": "빌드 시각",
"license": "라이선스",
"repository": "소스 코드",
- "changelog": "변경 이력",
+ "changelog": "릴리스 노트",
"docs": "문서",
"credits": "크레딧 및 데이터 출처",
"linksHeading": "출처 및 문서",
diff --git a/messages/nl.json b/messages/nl.json
index d85233f20..6c21d8933 100644
--- a/messages/nl.json
+++ b/messages/nl.json
@@ -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"
@@ -3843,7 +3843,7 @@
"title": "Nog geen hartslagmetingen",
"description": "Leg een hartslagmeting vast, dan verschijnen rusthartslag, HRV en VO₂max in deze weergave.",
"cta": "Hartslag vastleggen",
- "coachPrefill": "Ik heb nog geen rustpols vastgelegd. Waarom is die belangrijk, en wat moet ik weten voordat ik begin?"
+ "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",
@@ -7071,7 +7071,7 @@
"builtAtLabel": "Gebouwd",
"license": "Licentie",
"repository": "Broncode",
- "changelog": "Wijzigingslogboek",
+ "changelog": "Releasenotes",
"docs": "Documentatie",
"credits": "Credits en bronvermeldingen",
"linksHeading": "Bronnen en documentatie",
diff --git a/messages/pl.json b/messages/pl.json
index 4085c04fc..d99f6a64d 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -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ę"
@@ -3730,7 +3730,7 @@
"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.",
- "emptyCoachPrefill": "{metric}: nie zapisałem tu jeszcze niczego. Co ten wskaźnik mówi o moim zdrowiu i jak mogę go poprawić?"
+ "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",
@@ -7071,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",
diff --git a/messages/pt-BR.json b/messages/pt-BR.json
index 6cba6c6a8..1ea7da102 100644
--- a/messages/pt-BR.json
+++ b/messages/pt-BR.json
@@ -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"
@@ -7071,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",
diff --git a/messages/ru.json b/messages/ru.json
index bb63fee93..0d451a0c5 100644
--- a/messages/ru.json
+++ b/messages/ru.json
@@ -764,9 +764,9 @@
"noneActionableCta": "Открыть лекарства"
},
"quickEntryDiscard": {
- "title": "Отменить эту запись?",
- "description": "У вас есть несохранённые данные. Закрыть форму и потерять введённое?",
- "confirm": "Отменить",
+ "title": "Закрыть без сохранения?",
+ "description": "Введённые данные не сохранены. Закрыть форму и потерять их?",
+ "confirm": "Не сохранять",
"cancel": "Продолжить ввод"
},
"compliance7d": "Приверженность (7 дней)",
@@ -3730,7 +3730,7 @@
"valuesBackToMetric": "Назад к {metric}",
"scoresBack": "Назад к аналитике",
"ecgDescription": "Записи ЭКГ с одним отведением с вашего устройства, каждая с собственным результатом устройства.",
- "emptyCoachPrefill": "{metric}: я ещё ничего здесь не записывал. Что этот показатель говорит о моём здоровье и как его улучшить?"
+ "emptyCoachPrefill": "{metric}: здесь у меня ещё нет записей. Что этот показатель говорит о моём здоровье и как его улучшить?"
},
"units": {
"steps": "шаги",
@@ -7071,7 +7071,7 @@
"builtAtLabel": "Собрано",
"license": "Лицензия",
"repository": "Исходный код",
- "changelog": "Список изменений",
+ "changelog": "Примечания к выпускам",
"docs": "Документация",
"credits": "Благодарности и источники данных",
"linksHeading": "Источники и документация",
@@ -10242,7 +10242,7 @@
"BLOOD_PRESSURE_SYS": "АД",
"PULSE": "Пульс",
"BLOOD_GLUCOSE": "Глюкоза крови",
- "BLOOD_KETONES": "Кетоны крови",
+ "BLOOD_KETONES": "Кетоны в крови",
"OXYGEN_SATURATION": "Насыщение кислородом",
"BODY_FAT": "Процент жировой массы",
"BODY_TEMPERATURE": "Температура тела",
@@ -10292,7 +10292,7 @@
"pushBody": "Пора: {label}",
"pushLocation": "(в {location})",
"edit": "Изменить",
- "originCoach": "Тренер",
+ "originCoach": "Коуч",
"until": "До {date}",
"selfPlanned": "Запланировано самостоятельно",
"captureValue": "Записать значение",
diff --git a/scripts/bench/results/v1.45.0-boot.md b/scripts/bench/results/v1.45.0-boot.md
index 1ed2ec776..f653e59a8 100644
--- a/scripts/bench/results/v1.45.0-boot.md
+++ b/scripts/bench/results/v1.45.0-boot.md
@@ -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 |
| --- | ---: | ---: |
diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json
index 595676ba3..1add8f02f 100644
--- a/src/__tests__/baselines/source-function-lines.json
+++ b/src/__tests__/baselines/source-function-lines.json
@@ -84,7 +84,7 @@
"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,
diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json
index b9b9a0eee..cf865896c 100644
--- a/src/__tests__/baselines/version-stamp-comments.json
+++ b/src/__tests__/baselines/version-stamp-comments.json
@@ -1088,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": 4,
+ "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,
@@ -2309,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,
diff --git a/src/app/api/ingest/medication/__tests__/route.test.ts b/src/app/api/ingest/medication/__tests__/route.test.ts
index 254d84f47..c269e4740 100644
--- a/src/app/api/ingest/medication/__tests__/route.test.ts
+++ b/src/app/api/ingest/medication/__tests__/route.test.ts
@@ -163,7 +163,7 @@ describe("POST /api/ingest/medication — 422 multi-issue (v1.4.43 W6)", () => {
});
describe("POST /api/ingest/medication — slot attribution + convergence (v1.16.9)", () => {
- // The maintainer's live bug: a daily 09:00 med, the worker pre-minted the
+ // The bug as it was seen: a daily 09:00 med, the worker pre-minted the
// pending REMINDER row at 09:00 Berlin, the user ingested the take at
// 08:42 Berlin via the external API. The bare create anchored a SECOND
// row at 08:42 and left the 09:00 pending row open — reminder kept
diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts
index 245c33421..0e06c3503 100644
--- a/src/app/api/insights/correlations/route.ts
+++ b/src/app/api/insights/correlations/route.ts
@@ -85,8 +85,8 @@ export const GET = apiHandler(async () => {
// EVERY mount. Cache the built body in the analytics bucket (60 s fresh /
// 1 h stale — the bucket defaults are exactly the contract this read wants).
// Eviction truth comes free: every measurement / mood / medication /
- // illness / custom-metric write sweeps the `${userId}|` prefix in this
- // bucket, and a pattern dismiss goes through
+ // illness / nutrient / custom-metric write sweeps the `${userId}|` prefix
+ // in this bucket, and a pattern dismiss goes through
// `invalidateUserCorrelationPatterns`, which sweeps it too. The
// `syncAcceptedPatterns` calls are DB writes and live INSIDE the builder,
// so a cache hit performs no write on the read path.
diff --git a/src/app/api/insights/presence/route.ts b/src/app/api/insights/presence/route.ts
index 541ca3d9c..2bb66db73 100644
--- a/src/app/api/insights/presence/route.ts
+++ b/src/app/api/insights/presence/route.ts
@@ -1,5 +1,5 @@
/**
- * GET /api/insights/presence — which Insights pages have something to show.
+ * GET /api/insights/presence: which Insights pages have something to show.
*
* The tab strip on every Insights page gates its pills on this answer alone:
* the reading count per measurement type, and whether the record has mood
diff --git a/src/app/api/internal/deploy-webhook/__tests__/route.test.ts b/src/app/api/internal/deploy-webhook/__tests__/route.test.ts
index 407bac693..b7bf4e115 100644
--- a/src/app/api/internal/deploy-webhook/__tests__/route.test.ts
+++ b/src/app/api/internal/deploy-webhook/__tests__/route.test.ts
@@ -128,7 +128,7 @@ describe("POST /api/internal/deploy-webhook", () => {
{
status: "success",
application_name: "HealthLog",
- application_uuid: "pg8wggwogo8c4gc4ks0kk4ss",
+ application_uuid: "app-uuid-1",
deployment_uuid: "dep-123",
},
{ "x-deploy-webhook-secret": "test-secret" },
@@ -144,7 +144,7 @@ describe("POST /api/internal/deploy-webhook", () => {
expect.objectContaining({
details: expect.objectContaining({
applicationName: "HealthLog",
- applicationUuid: "pg8wggwogo8c4gc4ks0kk4ss",
+ applicationUuid: "app-uuid-1",
deploymentUuid: "dep-123",
error: null,
raw: expect.objectContaining({ status: "success" }),
@@ -166,7 +166,7 @@ describe("POST /api/internal/deploy-webhook", () => {
{
status: "failed",
application_name: "HealthLog",
- application_uuid: "pg8wggwogo8c4gc4ks0kk4ss",
+ application_uuid: "app-uuid-1",
deployment_uuid: "dep-456",
error: "container exited 137",
},
@@ -284,7 +284,7 @@ describe("POST /api/internal/deploy-webhook", () => {
const fullPayload = {
status: "success",
application_name: "HealthLog",
- application_uuid: "pg8wggwogo8c4gc4ks0kk4ss",
+ application_uuid: "app-uuid-1",
deployment_uuid: "dep-789",
future_field: { commit_sha: "abc123", environment: "production" },
};
diff --git a/src/app/api/nutrients/batch/__tests__/route.test.ts b/src/app/api/nutrients/batch/__tests__/route.test.ts
index 63079a20b..1c8f17cf4 100644
--- a/src/app/api/nutrients/batch/__tests__/route.test.ts
+++ b/src/app/api/nutrients/batch/__tests__/route.test.ts
@@ -47,7 +47,7 @@ vi.mock("@/lib/idempotency", () => ({
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/cache/invalidate", () => ({
- invalidateUserDashboardSnapshot: vi.fn(),
+ invalidateUserHealthContext: vi.fn(),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
@@ -65,7 +65,7 @@ import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { auditLog } from "@/lib/auth/audit";
-import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate";
+import { invalidateUserHealthContext } from "@/lib/cache/invalidate";
import { nutrientEntryResultSchema } from "@/lib/validations/nutrients";
const SESSION_OK = {
@@ -317,10 +317,11 @@ describe("POST /api/nutrients/batch — write semantics", () => {
expect(prisma.nutrientIntakeDay.update).not.toHaveBeenCalled();
});
- // v1.30.3 — a landed row must evict the dashboard snapshot so
- // the water/nutrient tile reflects the new total on the very next read,
- // matching the manual water POST's own posture (`nutrients/water/route.ts`).
- it("evicts the dashboard snapshot when a row lands", async () => {
+ // v1.30.3 — a landed row must evict the record's analytics cells so
+ // the water/nutrient tile and the correlation scan reflect the new total
+ // on the very next read, matching the manual water POST's own posture
+ // (`nutrients/water/route.ts`).
+ it("evicts the analytics cells when a row lands", async () => {
const day = recentDay();
const res = await POST(
postReq({
@@ -328,11 +329,11 @@ describe("POST /api/nutrients/batch — write semantics", () => {
}),
);
expect(res.status).toBe(200);
- expect(invalidateUserDashboardSnapshot).toHaveBeenCalledTimes(1);
- expect(invalidateUserDashboardSnapshot).toHaveBeenCalledWith("user-1");
+ expect(invalidateUserHealthContext).toHaveBeenCalledTimes(1);
+ expect(invalidateUserHealthContext).toHaveBeenCalledWith("user-1");
});
- it("does NOT evict the dashboard snapshot when nothing lands (every entry skipped)", async () => {
+ it("does NOT evict the analytics cells when nothing lands (every entry skipped)", async () => {
const res = await POST(
postReq({
entries: [
@@ -348,7 +349,7 @@ describe("POST /api/nutrients/batch — write semantics", () => {
const body = (await res.json()) as BatchResponse;
expect(body.data.inserted).toBe(0);
expect(body.data.updated).toBe(0);
- expect(invalidateUserDashboardSnapshot).not.toHaveBeenCalled();
+ expect(invalidateUserHealthContext).not.toHaveBeenCalled();
});
it("reports updated (never duplicate) when the composite key already exists", async () => {
diff --git a/src/app/api/nutrients/batch/route.ts b/src/app/api/nutrients/batch/route.ts
index ac2d88f2c..ddde79c86 100644
--- a/src/app/api/nutrients/batch/route.ts
+++ b/src/app/api/nutrients/batch/route.ts
@@ -44,7 +44,7 @@ import {
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
-import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate";
+import { invalidateUserHealthContext } from "@/lib/cache/invalidate";
import { NUTRIENT_CATALOG } from "@/lib/nutrients/catalog";
import { maxAcceptableNutrientDay } from "@/lib/nutrients/day-bounds";
import {
@@ -346,13 +346,13 @@ async function postBatch(request: NextRequest): Promise {
});
if (inserted + updated > 0) {
- // v1.30.3 — a landed row changes the dashboard's water/nutrient
- // tile; without this the snapshot kept serving the pre-sync figure
- // until the analytics fresh TTL lapsed (~3 min). Mirrors the manual
- // water POST's own hard-evict (`nutrients/water/route.ts`) so an
- // Apple-Health-synced total reflects on the very next dashboard read,
- // same as a manually logged one.
- invalidateUserDashboardSnapshot(user.id);
+ // v1.30.3 — a landed row changes the dashboard's water/nutrient tile
+ // and the correlation scan behind the metric pages and the Coach read;
+ // without this they kept the pre-sync figure until the analytics TTL
+ // lapsed. Mirrors the manual water POST's own hard-evict
+ // (`nutrients/water/route.ts`) so a synced total reflects on the very
+ // next read, same as a manually logged one.
+ invalidateUserHealthContext(user.id);
}
annotate({
diff --git a/src/app/api/nutrients/water/__tests__/route.test.ts b/src/app/api/nutrients/water/__tests__/route.test.ts
index b1ad8c701..c46353725 100644
--- a/src/app/api/nutrients/water/__tests__/route.test.ts
+++ b/src/app/api/nutrients/water/__tests__/route.test.ts
@@ -26,7 +26,7 @@ vi.mock("@/lib/modules/gate", () => ({
}));
vi.mock("@/lib/cache/invalidate", () => ({
- invalidateUserDashboardSnapshot: vi.fn(),
+ invalidateUserHealthContext: vi.fn(),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
@@ -59,7 +59,7 @@ import { apiError } from "@/lib/api-response";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
-import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate";
+import { invalidateUserHealthContext } from "@/lib/cache/invalidate";
import { NUTRIENT_CATALOG } from "@/lib/nutrients/catalog";
import { auditLog } from "@/lib/auth/audit";
@@ -194,7 +194,7 @@ describe("POST /api/nutrients/water", () => {
},
update: { amount: { increment: 250 } },
});
- expect(invalidateUserDashboardSnapshot).toHaveBeenCalledWith("user-1");
+ expect(invalidateUserHealthContext).toHaveBeenCalledWith("user-1");
});
it("set mode overwrites the MANUAL row's amount instead of incrementing", async () => {
diff --git a/src/app/api/nutrients/water/route.ts b/src/app/api/nutrients/water/route.ts
index 3377f3bd7..daf53e56a 100644
--- a/src/app/api/nutrients/water/route.ts
+++ b/src/app/api/nutrients/water/route.ts
@@ -26,7 +26,7 @@ import { parseJsonBody } from "@/lib/api/body";
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
-import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate";
+import { invalidateUserHealthContext } from "@/lib/cache/invalidate";
import { NUTRIENT_CATALOG } from "@/lib/nutrients/catalog";
import { maxAcceptableNutrientDay } from "@/lib/nutrients/day-bounds";
import { nutrientWaterWriteSchema } from "@/lib/validations/nutrients";
@@ -138,10 +138,11 @@ async function postWater(request: NextRequest): Promise {
});
}
- // Interactive single-entry write — hard-evict (not mark-stale) so the
- // dashboard water tile reflects the new total on the very next read,
- // matching the mood / medication / measurement posture.
- invalidateUserDashboardSnapshot(user.id);
+ // Interactive single-entry write: hard-evict (not mark-stale) the record's
+ // analytics cells, so the dashboard water tile and the correlation scan
+ // behind the metric pages and the Coach read reflect the new total on the
+ // very next read, matching the mood / medication / measurement posture.
+ invalidateUserHealthContext(user.id);
// A health-data write earns a durable row, like every other one. This was
// the last manual write in the nutrients family that only reached the wide
diff --git a/src/components/admin/backups/restore-notices.tsx b/src/components/admin/backups/restore-notices.tsx
index bb1dd6bff..02b7abdaf 100644
--- a/src/components/admin/backups/restore-notices.tsx
+++ b/src/components/admin/backups/restore-notices.tsx
@@ -290,7 +290,7 @@ export function RestoreSkipReport({
/**
* Says out loud whether the SCHEDULE is still alive.
*
- * The weekly pass on the maintainer's own instance stopped producing copies
+ * The weekly pass on one instance stopped producing copies
* and nothing surfaced it for a month and a half: the table listed the rows it
* had, each with an ordinary timestamp, and a copy from six weeks ago reads
* exactly like one made on Sunday until somebody does the subtraction. Two
diff --git a/src/components/charts/trend-card.tsx b/src/components/charts/trend-card.tsx
index e98bd6401..d2991d208 100644
--- a/src/components/charts/trend-card.tsx
+++ b/src/components/charts/trend-card.tsx
@@ -459,7 +459,7 @@ export function TrendCard({
deltaColor,
)}
data-slot="trend7-delta"
- aria-label={`7-day trend ${formatDelta(trend7Delta)}`}
+ aria-label={`${t("charts.trend7dShort")} ${formatDelta(trend7Delta)}`}
>
({formatDelta(trend7Delta)})
diff --git a/src/components/dashboard/medication-intake-quick-add.tsx b/src/components/dashboard/medication-intake-quick-add.tsx
index 0c662b618..1b57d4fdf 100644
--- a/src/components/dashboard/medication-intake-quick-add.tsx
+++ b/src/components/dashboard/medication-intake-quick-add.tsx
@@ -451,6 +451,7 @@ export function MedicationIntakeQuickAdd({
id="medication-intake-dose"
data-testid="medication-intake-quick-add-dose"
value={dose}
+ data-seed={selectedMedication?.dose ?? ""}
onChange={(e) => setDoseOverride(e.target.value)}
maxLength={50}
placeholder={
diff --git a/src/components/forms/__tests__/seeded-form-guard-sheet.browser.test.tsx b/src/components/forms/__tests__/seeded-form-guard-sheet.browser.test.tsx
new file mode 100644
index 000000000..13a59e524
--- /dev/null
+++ b/src/components/forms/__tests__/seeded-form-guard-sheet.browser.test.tsx
@@ -0,0 +1,78 @@
+/**
+ * A form that guards Back (`useSeededFormDismissal({ guardNavigation })`)
+ * and a sheet opened over it both listen for popstate in the capture phase,
+ * so the one registered first hears Back first. Here the guard is: the page
+ * mounts the form, and the sheet history installs at the first sheet open,
+ * which is the order on `/settings/account` when "All sections" is the first
+ * sheet of the visit. A Back that closes the sheet is the sheet's, and the
+ * form must not answer it with its discard question.
+ */
+import { describe, expect, it, vi } from "vitest";
+import { render } from "vitest-browser-react";
+
+import { sheetHistory } from "@/components/ui/sheet-history";
+
+import { useSeededFormDismissal } from "../use-seeded-form-dismissal";
+
+type Navigation = { currentEntry: { index: number } };
+const index = () =>
+ (window as unknown as { navigation: Navigation }).navigation.currentEntry
+ .index;
+
+let popstates = 0;
+window.addEventListener("popstate", () => (popstates += 1), true);
+
+async function settle(count: number): Promise {
+ const target = popstates + count;
+ await vi.waitFor(() => expect(popstates).toBeGreaterThanOrEqual(target), {
+ timeout: 2_000,
+ });
+ for (;;) {
+ const seen = popstates;
+ await new Promise((resolve) => setTimeout(resolve, 150));
+ if (popstates === seen) return;
+ }
+}
+
+function EditedForm() {
+ const { discardDialogOpen } = useSeededFormDismissal({
+ seed: "Ada",
+ value: "Ada L.",
+ guardNavigation: true,
+ navigate: () => {},
+ });
+ return
{discardDialogOpen ? "asking" : "quiet"}
;
+}
+
+describe("a guarded form under a sheet", () => {
+ // One case: the listener order is set once per page, by the first open.
+ it("leaves the sheet's Back to the sheet and guards the page's", async () => {
+ window.history.pushState(null, "", "?section=account");
+ const page = index();
+ const screen = await render();
+ const asking = screen.getByTestId("asking");
+
+ // Back closes the sheet.
+ const onBack = vi.fn();
+ sheetHistory()!.open(onBack);
+ expect(index()).toBe(page + 1);
+ window.history.back();
+ await settle(1);
+ expect(onBack).toHaveBeenCalledTimes(1);
+ expect(index()).toBe(page);
+ await expect.element(asking).toHaveTextContent("quiet");
+
+ // The sheet's own close takes its entry off.
+ sheetHistory()!
+ .open(() => {})
+ .release();
+ await settle(1);
+ expect(index()).toBe(page);
+ await expect.element(asking).toHaveTextContent("quiet");
+
+ // With no sheet open, Back is the form's.
+ window.history.back();
+ await settle(1);
+ await expect.element(asking).toHaveTextContent("asking");
+ });
+});
diff --git a/src/components/forms/use-seeded-form-dismissal.tsx b/src/components/forms/use-seeded-form-dismissal.tsx
index 58a3bec84..361476a5b 100644
--- a/src/components/forms/use-seeded-form-dismissal.tsx
+++ b/src/components/forms/use-seeded-form-dismissal.tsx
@@ -12,6 +12,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
+import { sheetHistoryOwnsPopstate } from "@/components/ui/sheet-history";
import { useTranslations } from "@/lib/i18n/context";
type CloseRequest = () => void;
@@ -347,6 +348,9 @@ export function useSeededFormDismissal({
});
historyTraversalGuardRef.current = historyTraversalGuard;
const handlePopState = (event: PopStateEvent) => {
+ // A Back that closes a sheet over the form is the sheet's to answer;
+ // whichever capture listener was added first hears it first.
+ if (sheetHistoryOwnsPopstate()) return;
historyTraversalGuard.handlePopState(event);
};
diff --git a/src/components/i18n/__tests__/maintainership-banner.test.tsx b/src/components/i18n/__tests__/maintainership-banner.test.tsx
index e21897ad1..e2f7f4f19 100644
--- a/src/components/i18n/__tests__/maintainership-banner.test.tsx
+++ b/src/components/i18n/__tests__/maintainership-banner.test.tsx
@@ -8,7 +8,7 @@ import type { Locale } from "@/lib/i18n/config";
/**
* v1.4.25 — contract:
* 1. Renders nothing on maintained locales (DE / EN).
- * 2. Renders a notice strip on AI-initial locales (FR / ES / IT / PL).
+ * 2. Renders a notice strip on machine-translated locales (FR / ES / IT / PL).
* 3. The notice carries a GitHub-issue CTA so feedback has a clear
* home.
*
@@ -61,7 +61,7 @@ describe("", () => {
// documents that the strip is NOT painted before the first effect
// runs — exactly the contract that prevents the flash-of-banner
// problem.
- it("paints nothing in the SSR pass for AI-initial locales", () => {
+ it("paints nothing in the SSR pass for machine-translated locales", () => {
for (const locale of ["fr", "es", "it", "pl"] as const) {
const html = ssr(locale);
expect(html).not.toContain("maintainership-banner");
diff --git a/src/components/i18n/maintainership-banner.tsx b/src/components/i18n/maintainership-banner.tsx
index 66c6eed5f..3740eafe8 100644
--- a/src/components/i18n/maintainership-banner.tsx
+++ b/src/components/i18n/maintainership-banner.tsx
@@ -6,11 +6,11 @@ import { isMaintainedLocale, type Locale } from "@/lib/i18n/config";
import { useTranslations } from "@/lib/i18n/context";
/**
- * v1.4.25 — notice strip for AI-initial locales.
+ * v1.4.25 — notice strip for machine-translated locales.
*
* HealthLog ships German + English as maintainer-curated translations
* (the maintainer owns both bodies). French, Spanish, Italian
- * and Polish ship as AI-initial translations — they pass the
+ * and Polish ship as machine translations — they pass the
* locale-integrity test (full key parity, no empty values, no TODOs)
* but the prose has not been hand-reviewed for register, regional
* vocabulary, or domain-specific nuance.
@@ -18,7 +18,7 @@ import { useTranslations } from "@/lib/i18n/context";
* This banner renders ONLY on non-maintained locales (FR/ES/IT/PL/KO
* today) and surfaces a small, dismissible strip at the top of the
* authenticated app shell. It tells the user the translation is
- * AI-maintained and links to the GitHub translation-feedback issue
+ * machine-translated and links to the GitHub translation-feedback issue
* template so they can contribute fixes back.
*
* Dismissal is persisted to localStorage per-locale so a user who
diff --git a/src/components/insights/__tests__/insights-layout-shell-reads.test.tsx b/src/components/insights/__tests__/insights-layout-shell-reads.test.tsx
index d50c4da31..8de111cf5 100644
--- a/src/components/insights/__tests__/insights-layout-shell-reads.test.tsx
+++ b/src/components/insights/__tests__/insights-layout-shell-reads.test.tsx
@@ -13,12 +13,25 @@ import { renderToStaticMarkup } from "react-dom/server";
*/
const queries: Array<{ queryKey: readonly unknown[]; enabled?: boolean }> = [];
-const stripProps: Array<{ onRegenerate?: unknown }> = [];
+const stripProps: Array<{
+ onRegenerate?: unknown;
+ availability?: { summaries?: unknown; hasMood?: boolean };
+}> = [];
+/** What the presence read answers, and the record the session is inside. */
+const answer: { presence: unknown; active: unknown } = {
+ presence: undefined,
+ active: null,
+};
vi.mock("@tanstack/react-query", () => ({
useQuery: (options: { queryKey: readonly unknown[]; enabled?: boolean }) => {
queries.push(options);
- return { data: undefined, isLoading: false, isError: false };
+ const presence = options.queryKey.join("/") === "insights/presence";
+ return {
+ data: presence ? answer.presence : undefined,
+ isLoading: false,
+ isError: false,
+ };
},
useMutation: () => ({ mutate: vi.fn(), isPending: false, data: undefined }),
useQueryClient: () => ({
@@ -26,7 +39,10 @@ vi.mock("@tanstack/react-query", () => ({
}),
}));
vi.mock("@/hooks/use-auth", () => ({
- useAuth: () => ({ isAuthenticated: true, user: { modules: {} } }),
+ useAuth: () => ({
+ isAuthenticated: true,
+ user: { modules: {}, accountAccess: { active: answer.active } },
+ }),
}));
vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true }));
vi.mock("@/hooks/use-ai-capability", () => ({
@@ -41,7 +57,7 @@ vi.mock("@/hooks/use-insights-layout", () => ({
},
}));
vi.mock("@/components/insights/insights-tab-strip", () => ({
- InsightsTabStrip: (props: { onRegenerate?: unknown }) => {
+ InsightsTabStrip: (props: (typeof stripProps)[number]) => {
stripProps.push(props);
return null;
},
@@ -50,7 +66,7 @@ vi.mock("@/components/insights/insights-tab-strip", () => ({
import { queryKeys } from "@/lib/query-keys";
import { InsightsLayoutShell } from "../insights-layout-shell";
-describe("InsightsLayoutShell — what it reads on entry", () => {
+describe("InsightsLayoutShell: what it reads on entry", () => {
it("keeps the regenerate button and sends no briefing read", () => {
renderToStaticMarkup(
@@ -82,4 +98,33 @@ describe("InsightsLayoutShell — what it reads on entry", () => {
expect(sent).toEqual([JSON.stringify(queryKeys.insightsPresence())]);
expect(layoutReads).toEqual([true]);
});
+
+ it("offers no metric pill inside a switched-to record", () => {
+ // The metric pages read `/api/analytics`, which serves only the caller's
+ // own record; the mood and medication pills read their own routes.
+ answer.presence = {
+ metrics: { WEIGHT: { count: 12 } },
+ hasMood: true,
+ hasMedication: false,
+ hasWorkouts: false,
+ hasEcgRecordings: false,
+ };
+ const render = (active: unknown) => {
+ answer.active = active;
+ stripProps.length = 0;
+ renderToStaticMarkup(
+
+
+ ,
+ );
+ return stripProps[0]?.availability;
+ };
+
+ expect(render(null)?.summaries).toEqual({ WEIGHT: { count: 12 } });
+ const switched = render({ accountId: "record-2" });
+ expect(switched?.summaries).toBeUndefined();
+ expect(switched?.hasMood).toBe(true);
+ answer.presence = undefined;
+ answer.active = null;
+ });
});
diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx
index ea843d1aa..5142b85f3 100644
--- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx
+++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx
@@ -258,8 +258,10 @@ describe("", () => {
html.match(/
]*data-slot="coach-message-thread"[^>]*>/)?.[0] ?? "";
expect(scroller).toContain("pb-20");
- // Back to the plain gutter once the button sits beside the composer.
+ // Back to the plain gutter once the button sits beside the composer,
+ // and while the keyboard hides it.
expect(scroller).toContain("@min-[57rem]/coach-thread:pb-6");
+ expect(scroller).toContain("keyboard-open:pb-6");
});
it("folds an older message's provenance into the thought-process row, never a raw key", () => {
diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx
index d61fa1c4e..ddd6a20dc 100644
--- a/src/components/insights/coach-panel/message-thread.tsx
+++ b/src/components/insights/coach-panel/message-thread.tsx
@@ -657,8 +657,9 @@ export function MessageThread({
// bottom edge while the composer fills the column (`NewChatFab`),
// so the last message keeps the button's height free, as the
// shell clears the Coach button. The button moves beside a 54rem
- // composer, which the column is at 57rem with its padding.
- "pb-20 @min-[57rem]/coach-thread:pb-6",
+ // composer, which the column is at 57rem with its padding, and
+ // hides while the keyboard is up.
+ "keyboard-open:pb-6 pb-20 @min-[57rem]/coach-thread:pb-6",
)}
>
{[
diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx
index 4e0481c73..36f08a00b 100644
--- a/src/components/insights/insights-layout-shell.tsx
+++ b/src/components/insights/insights-layout-shell.tsx
@@ -72,19 +72,22 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
// on a first row as well left that page unreachable before data existed.
const hasNutrients = user?.modules?.nutrients === true;
const hasEcgRecordings = presence?.hasEcgRecordings === true;
+ // Inside a switched-to record the metric pages read `/api/analytics`, which
+ // serves only the caller's own record, so their pills would open an error.
+ const switched = user?.accountAccess?.active != null;
// Memoised so an unchanged answer keeps the same reference: the strip is
// wrapped in `React.memo` and reruns `buildTabs` on a new object.
const availability: InsightInputs | undefined = useMemo(() => {
if (!isAuthenticated) return undefined;
return {
- summaries: presence?.metrics,
+ summaries: switched ? undefined : presence?.metrics,
hasMood: presence?.hasMood === true,
hasMedication: presence?.hasMedication === true,
hasWorkouts: presence?.hasWorkouts === true,
hasNutrients,
};
- }, [isAuthenticated, presence, hasNutrients]);
+ }, [isAuthenticated, presence, hasNutrients, switched]);
// v1.15.14 — couple the tab strip to the saved insights layout.
// A sub-page pill now shows iff its metric has data AND its slug is
diff --git a/src/components/labs/biomarker-aliases.tsx b/src/components/labs/biomarker-aliases.tsx
index 21be66623..6fffeaa93 100644
--- a/src/components/labs/biomarker-aliases.tsx
+++ b/src/components/labs/biomarker-aliases.tsx
@@ -90,7 +90,7 @@ export function BiomarkerAliases({
{canEdit ? (