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 ? (