mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
Merge branch 'fix/v1475-review' into release/v1.47.5
This commit is contained in:
commit
e6a7e33a31
89 files changed
+733
-308
No files matched your search
+1
-6
@@ -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
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
name: Translation feedback
|
||||
description: Report a wording, grammar, or register problem in HealthLog's community-maintained locales (FR / ES / IT / PL / KO / NL / PT-BR).
|
||||
description: Report a wording, grammar, or register problem in HealthLog's community-reviewed locales (FR / ES / IT / PL / KO / NL / PT-BR / RU).
|
||||
title: "[Translation] "
|
||||
labels: ["i18n", "translation-feedback"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
HealthLog ships German and English as maintainer-curated translations. French, Spanish, Italian, Polish, Korean, Dutch, and Brazilian Portuguese ship as **AI-initial, community-maintained translations** — the prose passes the locale-integrity test (full key parity, no empty values) but has not been hand-reviewed for register or regional vocabulary.
|
||||
HealthLog ships German and English as maintainer-curated translations. French, Spanish, Italian, Polish, Korean, Dutch, Brazilian Portuguese and Russian started as machine-translated first drafts and are reviewed by the community. They pass the locale-integrity test (full key parity, no empty values), but not every line has been read by a native speaker for register or regional vocabulary.
|
||||
|
||||
This template captures concrete fixes you would like to see. Phrasing nuance, regional vocabulary (e.g. "Tú" vs "Usted"), gendered forms, and domain-specific health terms are all in scope. The maintainer applies fixes during the regular release cycle.
|
||||
|
||||
@@ -23,6 +23,7 @@ body:
|
||||
- Korean (ko)
|
||||
- Dutch (nl)
|
||||
- Brazilian Portuguese (pt-BR)
|
||||
- Russian (ru)
|
||||
- Other (specify under "Surface")
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -28,16 +28,16 @@
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Targets the `main` branch (trunk-based — see [CONTRIBUTING.md](../CONTRIBUTING.md))
|
||||
- [ ] Targets the `main` branch (trunk-based, see [CONTRIBUTING.md](../CONTRIBUTING.md))
|
||||
- [ ] `pnpm typecheck` passes locally
|
||||
- [ ] `pnpm lint` passes locally
|
||||
- [ ] `pnpm test` passes locally
|
||||
- [ ] `pnpm format:check` passes locally
|
||||
- [ ] `pnpm build` passes locally
|
||||
- [ ] User-facing strings go through `t("key")` with both `messages/en.json` and `messages/de.json` updated
|
||||
- [ ] User-facing strings go through `t("key")`, with the key in all ten `messages/*.json` files
|
||||
- [ ] No secrets, personal data, or maintainer-name references in committed files
|
||||
- [ ] Updated `CHANGELOG.md` if user-visible
|
||||
- [ ] Updated `docs/audit/` or `docs.healthlog.dev` if behavior or self-hosting docs change
|
||||
- [ ] Updated `docs/` or `docs.healthlog.dev` if behaviour or self-hosting steps change
|
||||
|
||||
## Linked issues
|
||||
|
||||
|
||||
@@ -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/
|
||||
|
||||
+5
-6
@@ -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 '<', "<!DOCTYPE "`. 401/403 from the provider now map to 422,
|
||||
- **Repo-internal docs synced for v1.4.** README adds the
|
||||
Multi-tenant ready and Test connection buttons feature blocks, the
|
||||
API reference table includes the eleven new v1.4 endpoints, and
|
||||
the model count is corrected to 26 (RefreshToken). AGENTS.md and
|
||||
CLAUDE.md reflect the per-route `/settings/[section]` layout and
|
||||
the model count is corrected to 26 (RefreshToken). The maintainer
|
||||
notes reflect the per-route `/settings/[section]` layout and
|
||||
the per-section admin layout. `docs/api/openapi.yaml` documents
|
||||
the new endpoints (version, refresh, refresh/revoke,
|
||||
status-overview, backup/test, the five test-connection probes).
|
||||
@@ -21157,7 +21156,7 @@ timeout: 30s })` so in-flight handlers finish instead of being killed.
|
||||
- `.env.example` now points at the in-container hostname `db:5432` (was `localhost:5432`, which never resolves inside the app container).
|
||||
- **Documentation**:
|
||||
- `package.json` synced to 1.2.0 (was lagging on 1.1.0).
|
||||
- `CLAUDE.md` and `AGENTS.md` corrected to 23 models (the `Feedback` model added in v1.2 was missing from the count).
|
||||
- The maintainer notes are corrected to 23 models (the `Feedback` model added in v1.2 was missing from the count).
|
||||
- `README.md` Quick Start gives a realistic time estimate, generates the four secrets in one block straight into `.env`, and points reverse-proxy users at the docs.
|
||||
|
||||
### Added — Tooling & Supply Chain
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ pnpm typecheck && pnpm lint && pnpm test && pnpm format:check && pnpm build
|
||||
## Code Conventions
|
||||
|
||||
- **TypeScript strict mode** -- avoid `any`. Where it's truly unavoidable (e.g. variadic Next.js handler signatures in `src/lib/api-handler.ts`), document the reason inline.
|
||||
- **English-default UI** -- the default locale is English. German is user-selectable. Code, comments, commit messages, and docs are English. All user-facing strings go through `t("key")` with translations in both `messages/en.json` and `messages/de.json`. The `i18n-locale-integrity.test.ts` suite blocks merges if keys drift between locales or duplicate at any nesting depth.
|
||||
- **English-default UI**: the default locale is English, and nine more languages are user-selectable. Code, comments, commit messages, and docs are English. All user-facing strings go through `t("key")`, with the key in every `messages/*.json`. The `i18n-locale-integrity.test.ts` suite blocks merges if keys drift between locales or duplicate at any nesting depth.
|
||||
- **API responses** follow the `{ data, error }` envelope via `apiSuccess()` / `apiError()` in `src/lib/api-response.ts`. Wrap every route handler in `apiHandler()` (`src/lib/api-handler.ts`) for error handling, structured Wide-Event logging, and `x-request-id` propagation.
|
||||
- **Zod v4** (`import { ... } from "zod/v4"`) for input validation in API routes and forms.
|
||||
- **shadcn/ui** components (new-york style) for UI primitives.
|
||||
|
||||
@@ -51,12 +51,12 @@ Try the [live demo](https://demo.healthlog.dev) to see a working install. Sign i
|
||||
- **Allergies, family history, labs and vaccinations.** Allergies and family history are structured records that flow into the doctor report (reactions and notes are encrypted, the substance and condition names are not). Lab values are keyed in or scanned from a printed report: OCR reads the stated values, units, and dates for you to review and confirm before anything is saved. HealthLog transcribes what a report says; it never interprets or diagnoses from it. A vaccination record keeps a lifetime immunization log, doses grouped under the disease they protect against, and prints in the doctor report.
|
||||
- **Documents.** An opt-in module stores letters, reports, and scans encrypted at rest. PDFs and photos preview inline, Office and text files stay download-only, and nothing a user uploads can execute in the app. One searchable timeline with type, condition, and year filters, drag-and-drop and paste-to-upload, bulk filing, a delete you can undo within 30 days, and per-file and per-account limits the operator sets.
|
||||
- **Weather and environment.** An environmental module adds weather, daylight, temperature, air quality, and pollen as possible influences (winter darkness and mood, heat and blood pressure), based on a home location you set. It is on by default, and nothing leaves the host until someone sets that location.
|
||||
- **Devices and integrations.** Withings, WHOOP, Oura and Polar sync over OAuth2, Google Health connects Fitbit and Pixel Watch through your own Google Cloud client, Nightscout streams CGM glucose, an Apple Health `export.zip` folds your full history in, and the native iOS app streams HealthKit live. A per-metric source priority decides which reading is canonical when wearables overlap.
|
||||
- **Devices and integrations.** Withings, WHOOP, Oura, Polar and Strava sync over OAuth2, Garmin through a Personal API key, Google Health connects Fitbit and Pixel Watch through your own Google Cloud client, Nightscout streams CGM glucose, an Apple Health `export.zip` or a Health Connect export folds your full history in, and the native iOS app streams HealthKit live. A per-metric source priority decides which reading is canonical when wearables overlap.
|
||||
- **Medications.** Flexible schedules (weekly injections, weekday-only, intervals, PRN, cyclic), a configurable intake window per dose, and a dose history where every slot reads taken, late, skipped, or missed. The compliance rate is computed from the same ledger, so the percentage always matches the timeline.
|
||||
- **AI features (optional).** A daily briefing and a conversational coach grounded in your own measurements and read against cited guideline reference bands: where a value sits and what that means, never a diagnosis. The coach proposes concrete if-then plans you accept or decline, and skips a proactive check-in when there is nothing new to say. Bring your own OpenAI or Anthropic key, or point at a local endpoint (Ollama, LM Studio, vLLM) so AI requests stay on your network.
|
||||
- **Doctor report and FHIR.** A doctor-report PDF rendered on your server, a read-only HL7 FHIR R4 API, and scoped, time-limited share links you revoke after the visit.
|
||||
- **Private by construction.** AES-256-GCM encryption at rest for notes and other free text, documents, questionnaire answers, AI-written text and credentials, with zero-downtime key rotation. Measurement values, lab values, medication names and mood scores are stored unencrypted in the database, so the database volume and its backups need the same protection as the host. Passkey login, server-side sessions, strict CSP, and no third-party tracking anywhere.
|
||||
- **Built to be lived in.** Installable PWA that keeps the open page readable when the connection drops, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history, in English, German, Spanish, French, Italian, Polish, Korean, Dutch, Brazilian Portuguese and Russian end to end.
|
||||
- **Encryption and privacy.** AES-256-GCM encryption at rest for notes and other free text, documents, questionnaire answers, AI-written text and credentials, with zero-downtime key rotation. Measurement values, lab values, medication names and mood scores are stored unencrypted in the database, so the database volume and its backups need the same protection as the host. Passkey login, server-side sessions, strict CSP, and no third-party tracking anywhere.
|
||||
- **PWA, reminders and languages.** Installable PWA that keeps the open page readable when the connection drops, reminders over APNs, Telegram, ntfy, Web Push, email and a webhook, and a dashboard that stays fast on years of imported history. The app is available in English, German, Spanish, French, Italian, Polish, Korean, Dutch, Brazilian Portuguese and Russian.
|
||||
|
||||
The full feature tour, integration guides, and API reference live at [docs.healthlog.dev](https://docs.healthlog.dev).
|
||||
|
||||
|
||||
+12
-10
@@ -1,7 +1,9 @@
|
||||
# Fitbit integration (Fitbit & Pixel)
|
||||
|
||||
> **Experimental.** Field mapping is still being verified against live accounts,
|
||||
> so some values may be missing or change in a future update.
|
||||
> **Ended.** Google turned the classic Fitbit Web API off on 30 October 2026.
|
||||
> HealthLog no longer calls it; existing connections and what they imported
|
||||
> stay. Fitbit and Pixel Watch data now come through
|
||||
> [Google Health](google-health.md).
|
||||
|
||||
HealthLog reads Fitbit and Pixel Watch data through the **classic Fitbit Web
|
||||
API** (`api.fitbit.com`). The OAuth app is a **Fitbit developer app registered
|
||||
@@ -16,13 +18,13 @@ Settings. The internal integration key is `fitbit`.
|
||||
> consent screen with `unauthorized_client — Invalid client_id`, because Fitbit
|
||||
> and Google Cloud are separate client registries.
|
||||
|
||||
> **The classic Fitbit Web API is turned off on 30 October 2026.** Google
|
||||
> ended support for it on 30 September 2026 and turns it off on
|
||||
> **The classic Fitbit Web API was turned off on 30 October 2026.** Google
|
||||
> ended support for it on 30 September 2026 and turned it off on
|
||||
> **30 October 2026**, after which it "will no longer function or sync data"
|
||||
> ([Google's migration guide](https://developers.google.com/health/migration)).
|
||||
> New app registrations at `dev.fitbit.com/apps/new` are already closed.
|
||||
> New app registrations at `dev.fitbit.com/apps/new` are closed.
|
||||
>
|
||||
> From that day HealthLog stops calling the Web API for every Fitbit
|
||||
> Since that day HealthLog no longer calls the Web API for any Fitbit
|
||||
> connection: no hourly sync, no backfill, no token refresh, and no new
|
||||
> connection. Nothing is deleted. The connection and every reading it imported
|
||||
> stay, and the Fitbit card explains the move instead of asking for a
|
||||
@@ -30,10 +32,10 @@ Settings. The internal integration key is `fitbit`.
|
||||
> [Google Health integration](google-health.md). It reads the same devices,
|
||||
> but Google asks for consent once more: Fitbit tokens cannot be carried over.
|
||||
>
|
||||
> Both integrations can run side by side while you move. Where both deliver the
|
||||
> same day, the source-priority ladder keeps one of them per day (Fitbit ranks
|
||||
> just above Google Health by default), so steps, sleep and the other daily
|
||||
> values are not counted twice. After the turndown only Google Health delivers.
|
||||
> Where a day imported from Fitbit before the turndown also comes in through
|
||||
> Google Health, the source-priority ladder keeps one of them per day (Fitbit
|
||||
> ranks just above Google Health by default), so steps, sleep and the other
|
||||
> daily values are not counted twice.
|
||||
|
||||
## 1. Use an existing Fitbit developer app
|
||||
|
||||
|
||||
@@ -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
|
||||
`<input type="text" name="username" autoComplete="username" value={user.email} readOnly tabIndex={-1}>`
|
||||
so password managers attribute saved credentials correctly.
|
||||
- A password form with no visible username field can carry a hidden
|
||||
`<input type="text" name="username" autoComplete="username" readOnly tabIndex={-1}>`
|
||||
so a password manager files the new password under the right account.
|
||||
The password-change form in Settings has none.
|
||||
|
||||
### 4.5 Test buttons (every integration)
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
|
||||
data: [
|
||||
{
|
||||
id: "tok1",
|
||||
name: "iOS auto-login 2026-05-05T19:42:11Z device-AABBCCDDEEFF-marathon",
|
||||
name: "iOS auto-login 2026-05-05T19:42:11Z device-AABBCCDDEEFF-long-name",
|
||||
permissions: [
|
||||
"measurements:write:audit:legacy:extended",
|
||||
"medications:read",
|
||||
|
||||
@@ -221,7 +221,7 @@ async function enterSharedRecord(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap the context's pinned locale for one HealthLog ships as an AI-initial
|
||||
* Swap the context's pinned locale for one HealthLog ships as a machine
|
||||
* translation, so the maintainership strip has a reason to paint.
|
||||
*
|
||||
* The page is parked on `about:blank` for the swap. `I18nProvider` mirrors its
|
||||
|
||||
+5
-9
@@ -13,15 +13,11 @@ const eslintConfig = defineConfig([
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
// v1.31.4 — ignore local tool worktrees so a worktree on another
|
||||
// commit cannot poison `pnpm lint` locally.
|
||||
// CI never sees these paths; the rules protect the dev environment.
|
||||
// `.wt-*` is the convention actually in use; the two names below predate
|
||||
// it. Without the first pattern `pnpm lint` walks every worktree in the
|
||||
// root, which is both slow and exactly the poisoning this list prevents.
|
||||
".wt-*/**",
|
||||
".claude/**",
|
||||
".worktrees/**",
|
||||
// Hidden top-level folders hold local worktrees and caches, and no
|
||||
// tracked code lives in one (`.github` and `.githooks` hold no JS or
|
||||
// TS). Without this `pnpm lint` walks every worktree on another commit
|
||||
// in the root, which is both slow and poisons the result.
|
||||
".*/**",
|
||||
// The project-local ESLint rule plugin is CommonJS (require/module
|
||||
// .exports) by ESLint convention; it is not application source and
|
||||
// is not linted by the app's TypeScript ruleset.
|
||||
|
||||
+13
-5
@@ -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 "<Word>-Session:"
|
||||
# trailer, or a co-author trailer with any other address.
|
||||
commit-msg:
|
||||
commands:
|
||||
no-tooling-trailer:
|
||||
message-trailers:
|
||||
run: |
|
||||
if grep -qiE '^(Co-Authored-By:.*(claude|anthropic|copilot|gpt|gemini)|Claude-Session:|Generated with)' "{1}"; then
|
||||
echo "ERROR: the commit message carries a tooling co-author trailer or session link." >&2
|
||||
echo "Project policy: commits are authored by the maintainer's git identity alone." >&2
|
||||
msg="{1}"
|
||||
if grep -qiE '^[^[:alnum:]]*Generated (with|by)[[:space:]]' "$msg" ||
|
||||
grep -qiE '^[[:alpha:]]+-Session:' "$msg" ||
|
||||
grep -iE '^Co-authored-by:' "$msg" |
|
||||
grep -qviE '<[^<>[:space:]]+@users\.noreply\.github\.com>[[:space:]]*$'; then
|
||||
echo "ERROR: the commit message carries a generator line, a session trailer," >&2
|
||||
echo "or a co-author trailer without a GitHub noreply address." >&2
|
||||
echo "Remove the offending line(s) from the message and commit again." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
+3
-3
@@ -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",
|
||||
|
||||
+3
-3
@@ -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",
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
+2
-2
@@ -764,7 +764,7 @@
|
||||
"noneActionableCta": "약 목록 열기"
|
||||
},
|
||||
"quickEntryDiscard": {
|
||||
"title": "이 기록을 버릴까요?",
|
||||
"title": "변경 내용을 버릴까요?",
|
||||
"description": "저장하지 않은 입력이 있어요. 폼을 닫고 입력한 내용을 버릴까요?",
|
||||
"confirm": "버리기",
|
||||
"cancel": "계속 작성"
|
||||
@@ -7071,7 +7071,7 @@
|
||||
"builtAtLabel": "빌드 시각",
|
||||
"license": "라이선스",
|
||||
"repository": "소스 코드",
|
||||
"changelog": "변경 이력",
|
||||
"changelog": "릴리스 노트",
|
||||
"docs": "문서",
|
||||
"credits": "크레딧 및 데이터 출처",
|
||||
"linksHeading": "출처 및 문서",
|
||||
|
||||
+3
-3
@@ -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",
|
||||
|
||||
+3
-3
@@ -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",
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
+7
-7
@@ -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": "Записать значение",
|
||||
|
||||
@@ -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 |
|
||||
| --- | ---: | ---: |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
};
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<Response> {
|
||||
});
|
||||
|
||||
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({
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<Response> {
|
||||
});
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)})
|
||||
</span>
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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<void> {
|
||||
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 <p data-testid="asking">{discardDialogOpen ? "asking" : "quiet"}</p>;
|
||||
}
|
||||
|
||||
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(<EditedForm />);
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<T>({
|
||||
});
|
||||
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);
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { Locale } from "@/lib/i18n/config";
|
||||
/**
|
||||
* v1.4.25 — <MaintainershipBanner> 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("<MaintainershipBanner>", () => {
|
||||
// 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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
<InsightsLayoutShell>
|
||||
@@ -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(
|
||||
<InsightsLayoutShell>
|
||||
<div />
|
||||
</InsightsLayoutShell>,
|
||||
);
|
||||
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;
|
||||
});
|
||||
});
|
||||
@@ -258,8 +258,10 @@ describe("<MessageThread>", () => {
|
||||
html.match(/<div[^>]*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", () => {
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
>
|
||||
{[
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -90,7 +90,7 @@ export function BiomarkerAliases({
|
||||
{canEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
className="hover:text-foreground text-muted-foreground focus-visible:ring-ring/50 relative -my-1 -me-1 inline-flex size-6 items-center justify-center rounded-full after:absolute after:-inset-2.5 focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
className="hover:text-foreground text-muted-foreground focus-visible:ring-ring/50 relative -my-1 -me-1 inline-flex size-6 items-center justify-center rounded-full after:absolute after:-inset-x-1 after:-inset-y-2.5 focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
onClick={() =>
|
||||
save.mutate(aliases.filter((a) => a !== alias))
|
||||
}
|
||||
|
||||
@@ -179,9 +179,6 @@ export function CapturePicker({
|
||||
// sheet that is already open. See `admittedCaptureKind`.
|
||||
const openKind = admittedCaptureKind(kind, offered);
|
||||
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
// v1.30.1 — mirrors `QuickEntrySheets`' `confirmDiscardOpen`: hold a
|
||||
// dismiss attempt here instead of closing outright when the form
|
||||
// body carries unsaved input.
|
||||
|
||||
function chooseKind(next: CaptureKind) {
|
||||
if (!offered.includes(next)) return;
|
||||
|
||||
@@ -430,7 +430,7 @@ export function ShareLinkCreateForm({
|
||||
prev.filter((d) => d.id !== doc.id),
|
||||
)
|
||||
}
|
||||
className="hover:bg-background/80 focus-visible:ring-ring/50 relative flex size-5 shrink-0 items-center justify-center rounded-full after:absolute after:-inset-3 focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
className="hover:bg-background/80 focus-visible:ring-ring/50 relative flex size-5 shrink-0 items-center justify-center rounded-full after:absolute after:-inset-x-1 after:-inset-y-3 focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
aria-label={t("settings.sharing.attachRemove", {
|
||||
title: doc.title,
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* The sheet's fallback dirty check on two real capture forms, rendered in
|
||||
* Chromium: untouched, neither may ask "Discard?" when the sheet closes, and
|
||||
* each reads as edited once something is changed.
|
||||
*
|
||||
* - The symptom form's intensity slider sits in a `<form>`, so Radix adds
|
||||
* an input of its own for it, which carries a value before anyone moves
|
||||
* the slider.
|
||||
* - The medication dose starts as the preselected medication's own dose.
|
||||
*
|
||||
* All data invented.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { userEvent } from "vitest/browser";
|
||||
|
||||
import { SymptomEntryForm } from "@/components/symptoms/symptom-entry-form";
|
||||
import { MedicationIntakeQuickAdd } from "@/components/dashboard/medication-intake-quick-add";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { stubFetch } from "../../../../tests/browser/fetch-stub";
|
||||
import {
|
||||
renderWithApp,
|
||||
testQueryClient,
|
||||
} from "../../../../tests/browser/render";
|
||||
import { sheetBodyHasUnsavedInput } from "../responsive-sheet";
|
||||
|
||||
const bodyOf = (element: Element) => element as HTMLElement;
|
||||
|
||||
describe("the sheet's dirty check on capture forms", () => {
|
||||
it("reads an untouched symptom form as clean, a moved slider as input", async () => {
|
||||
stubFetch().on("GET", /\/api\/symptoms\/definitions/, {
|
||||
data: { definitions: [] },
|
||||
});
|
||||
const { screen } = await renderWithApp(
|
||||
<div data-testid="body">
|
||||
<SymptomEntryForm />
|
||||
</div>,
|
||||
);
|
||||
const slider = screen.getByRole("slider");
|
||||
await expect.element(slider).toBeInTheDocument();
|
||||
const body = bodyOf(screen.getByTestId("body").element());
|
||||
|
||||
expect(sheetBodyHasUnsavedInput(body)).toBe(false);
|
||||
|
||||
await userEvent.click(slider);
|
||||
await userEvent.keyboard("{ArrowRight}");
|
||||
expect(sheetBodyHasUnsavedInput(body)).toBe(true);
|
||||
});
|
||||
|
||||
it("reads the preselected medication's dose as clean, an edited dose as input", async () => {
|
||||
const queryClient = testQueryClient();
|
||||
queryClient.setQueryData(queryKeys.medications(), [
|
||||
{
|
||||
id: "med-1",
|
||||
name: "Example tablet",
|
||||
dose: "500 mg",
|
||||
active: true,
|
||||
intakeActionable: true,
|
||||
schedules: [],
|
||||
lastTakenAt: null,
|
||||
},
|
||||
]);
|
||||
stubFetch().on("GET", "/api/settings/reminder-thresholds", {
|
||||
data: { lateMinutes: 60, missedMinutes: 240, lowStockRunwayDays: null },
|
||||
});
|
||||
const { screen } = await renderWithApp(
|
||||
<div data-testid="body">
|
||||
<MedicationIntakeQuickAdd />
|
||||
</div>,
|
||||
{ queryClient },
|
||||
);
|
||||
const dose = screen.getByTestId("medication-intake-quick-add-dose");
|
||||
await expect.element(dose).toHaveValue("500 mg");
|
||||
const body = bodyOf(screen.getByTestId("body").element());
|
||||
|
||||
expect(sheetBodyHasUnsavedInput(body)).toBe(false);
|
||||
|
||||
await dose.fill("250 mg");
|
||||
expect(sheetBodyHasUnsavedInput(body)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ function fakeBody(fixture: {
|
||||
const fields = (fixture.fields ?? []).map((field) => ({
|
||||
...field,
|
||||
value: field.value ?? "",
|
||||
dataset: {},
|
||||
closest: () => (field.inDateOrTimeField ? {} : null),
|
||||
}));
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Sheet history against Chromium's own session history: `back()` answers
|
||||
* with a real popstate a task or more later, and the entries keep their
|
||||
* state the way the browser keeps it.
|
||||
*
|
||||
* The router's half is played by hand where it matters: Next answers a URL
|
||||
* write one scheduler task later by rewriting the current entry with that
|
||||
* entry's own state copied in (`HistoryUpdater`), and that write can land
|
||||
* after a sheet's close has asked for Back but before Back arrives.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { DAY_HISTORY_KEY } from "@/components/day/day-url";
|
||||
|
||||
import { renderWithApp } from "../../../../tests/browser/render";
|
||||
import { ResponsiveSheet } from "../responsive-sheet";
|
||||
import { sheetHistory } from "../sheet-history";
|
||||
|
||||
type Navigation = { currentEntry: { index: number } };
|
||||
const index = () =>
|
||||
(window as unknown as { navigation: Navigation }).navigation.currentEntry
|
||||
.index;
|
||||
const state = () => (window.history.state ?? {}) as Record<string, unknown>;
|
||||
const MARK = "__healthlogSheet";
|
||||
|
||||
// Registered before the sheet history installs its own listener at the
|
||||
// first open, so it hears every popstate, including the ones it swallows.
|
||||
let popstates = 0;
|
||||
window.addEventListener("popstate", () => (popstates += 1), true);
|
||||
|
||||
/** Wait for `count` more popstates, then until none follows. */
|
||||
async function settle(count: number): Promise<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
describe("sheet history in the browser", () => {
|
||||
it("leaves the page entry unmarked when the close also writes the URL", async () => {
|
||||
const sheets = sheetHistory()!;
|
||||
window.history.pushState(null, "", "?step=before");
|
||||
window.history.pushState(null, "", "?step=page");
|
||||
const page = index();
|
||||
|
||||
// The day's date picker: its handler writes the day into the URL while
|
||||
// the sheet's entry is current, then the sheet closes.
|
||||
const picker = sheets.open(() => {});
|
||||
expect(index()).toBe(page + 1);
|
||||
window.history.replaceState(null, "", "?step=page&day=2026-11-10");
|
||||
picker.release();
|
||||
// The router's answer to that write, copying the entry it finds.
|
||||
window.history.replaceState(
|
||||
{ ...state(), __NA: true },
|
||||
"",
|
||||
window.location.href,
|
||||
);
|
||||
await settle(1);
|
||||
|
||||
expect(index()).toBe(page);
|
||||
expect(window.location.search).toBe("?step=page&day=2026-11-10");
|
||||
expect(state()[MARK]).toBeUndefined();
|
||||
|
||||
// The next sheet closes onto the page, not onto the entry before it.
|
||||
const again = sheets.open(() => {});
|
||||
again.release();
|
||||
await settle(1);
|
||||
expect(index()).toBe(page);
|
||||
expect(window.location.search).toBe("?step=page&day=2026-11-10");
|
||||
});
|
||||
|
||||
it("keeps the page entry's own state through a sheet", async () => {
|
||||
const sheets = sheetHistory()!;
|
||||
window.history.pushState(
|
||||
{ [DAY_HISTORY_KEY]: "2026-11-10" },
|
||||
"",
|
||||
"?day=2026-11-10",
|
||||
);
|
||||
const page = index();
|
||||
|
||||
// Closed by its own button.
|
||||
sheets.open(() => {}).release();
|
||||
await settle(1);
|
||||
expect(index()).toBe(page);
|
||||
expect(state()[DAY_HISTORY_KEY]).toBe("2026-11-10");
|
||||
|
||||
// Closed by Back.
|
||||
const onBack = vi.fn();
|
||||
sheets.open(onBack);
|
||||
window.history.back();
|
||||
await settle(1);
|
||||
expect(onBack).toHaveBeenCalledTimes(1);
|
||||
expect(index()).toBe(page);
|
||||
expect(state()[DAY_HISTORY_KEY]).toBe("2026-11-10");
|
||||
expect(state()[MARK]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("answers Back during the discard question by keeping the sheet open", async () => {
|
||||
window.history.pushState(null, "", "?step=form");
|
||||
const page = index();
|
||||
function EditedSheet() {
|
||||
const [open, setOpen] = useState(true);
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="Add a reading"
|
||||
dirty
|
||||
>
|
||||
<p>Typed but not saved</p>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
const { screen } = await renderWithApp(<EditedSheet />);
|
||||
await expect.element(screen.getByText("Typed but not saved")).toBeVisible();
|
||||
await vi.waitFor(() => expect(index()).toBe(page + 1));
|
||||
|
||||
// Back with unsaved input asks first and puts the sheet's entry back.
|
||||
window.history.back();
|
||||
await settle(1);
|
||||
await expect.element(screen.getByRole("alertdialog")).toBeVisible();
|
||||
expect(index()).toBe(page + 1);
|
||||
|
||||
// Back again means "not now": the question goes, the sheet stays.
|
||||
window.history.back();
|
||||
await settle(1);
|
||||
await expect
|
||||
.element(screen.getByRole("alertdialog"))
|
||||
.not.toBeInTheDocument();
|
||||
await expect.element(screen.getByText("Typed but not saved")).toBeVisible();
|
||||
expect(index()).toBe(page + 1);
|
||||
});
|
||||
});
|
||||
@@ -169,9 +169,12 @@ export function keepKeyboardDownOnOpen(event: Event): void {
|
||||
/**
|
||||
* The fallback dirty check for an add form that keeps its state inside the
|
||||
* sheet body: any text field with a value, a checkbox or radio moved off its
|
||||
* default, a chosen `role="radio"` (the mood faces) or a Select that is open.
|
||||
* The date and time fields are skipped, because the forms fill them with
|
||||
* "now". An edit form starts filled and has to say for itself whether
|
||||
* default, a slider moved off unanswered, a chosen `role="radio"` (the mood
|
||||
* faces) or a Select that is open. The date and time fields are skipped,
|
||||
* because the forms fill them with "now", and so is the input a slider keeps
|
||||
* for its form. A field the form fills itself names that value in
|
||||
* `data-seed` (a controlled input's `defaultValue` follows its value, so it
|
||||
* cannot say). An edit form starts filled and has to say for itself whether
|
||||
* anything changed.
|
||||
*/
|
||||
export function sheetBodyHasUnsavedInput(body: HTMLElement | null): boolean {
|
||||
@@ -181,7 +184,11 @@ export function sheetBodyHasUnsavedInput(body: HTMLElement | null): boolean {
|
||||
);
|
||||
for (const field of fields) {
|
||||
if (field.disabled || field.type === "hidden") continue;
|
||||
if (field.closest('[data-slot="date-field"], [data-slot="time-field"]')) {
|
||||
if (
|
||||
field.closest(
|
||||
'[data-slot="date-field"], [data-slot="time-field"], [data-slot="slider"]',
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (field.type === "checkbox" || field.type === "radio") {
|
||||
@@ -189,7 +196,12 @@ export function sheetBodyHasUnsavedInput(body: HTMLElement | null): boolean {
|
||||
if (box.checked !== box.defaultChecked) return true;
|
||||
continue;
|
||||
}
|
||||
if (field.value.trim() !== "") return true;
|
||||
if (field.value.trim() !== (field.dataset.seed ?? "").trim()) return true;
|
||||
}
|
||||
if (
|
||||
body.querySelector('[data-slot="slider-field"]:not([data-unset])') !== null
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// Mood faces start with none chosen, so any chosen one is input.
|
||||
if (body.querySelector('[role="radio"][aria-checked="true"]') !== null) {
|
||||
@@ -235,9 +247,10 @@ function useSheetDismissal({
|
||||
};
|
||||
|
||||
const handleBack = React.useEffectEvent(() => {
|
||||
if (hasUnsavedInput()) {
|
||||
// Back while the question is open answers it with "keep editing".
|
||||
if (asking || hasUnsavedInput()) {
|
||||
entryRef.current?.restore();
|
||||
setAsking(true);
|
||||
setAsking(!asking);
|
||||
return;
|
||||
}
|
||||
countBack();
|
||||
|
||||
@@ -26,7 +26,13 @@ import { URL_STATE_EVENT } from "@/lib/url-state";
|
||||
* sheet) replaces that entry, so Back from the new page returns to the
|
||||
* page, not to a sheet entry nobody shows any more.
|
||||
* - A replace of a sheet's entry keeps the sheet's mark: the router
|
||||
* rewrites the current entry on every update.
|
||||
* rewrites the current entry on every update. A write that carries the
|
||||
* mark of a sheet that has closed (the router copying an entry it read
|
||||
* before our Back landed) loses it, or the entry would read as a sheet's
|
||||
* and the next close would step one Back too far.
|
||||
*
|
||||
* A sheet's entry starts as a copy of the page entry's state, so landing
|
||||
* on the page entry gives it back its own keys (an open day's, the vault's).
|
||||
*
|
||||
* A sheet whose open state already lives in the URL (the vault's `?doc=`)
|
||||
* has its own entry and opts out (`ResponsiveSheet historyEntry={false}`).
|
||||
@@ -74,6 +80,12 @@ export interface SheetHistory {
|
||||
* entry is gone by then, and `restore` puts it back.
|
||||
*/
|
||||
open(onBack: () => void): SheetHistoryEntry;
|
||||
/**
|
||||
* Whether the popstate being dispatched is ours: a Back we asked for, or
|
||||
* one that lands on a sheet's entry or right below the first sheet. Asked
|
||||
* by other capture listeners, which may hear it before we do.
|
||||
*/
|
||||
ownsPopstate(): boolean;
|
||||
}
|
||||
|
||||
interface Layer {
|
||||
@@ -108,6 +120,14 @@ function samePage(a: string, b: string): boolean {
|
||||
return left.pathname === right.pathname && left.search === right.search;
|
||||
}
|
||||
|
||||
/** `data` without the mark of a sheet that is no longer open. */
|
||||
function withoutDeadMark(data: unknown, open: readonly Layer[]): unknown {
|
||||
const id = markOf(data);
|
||||
return id === undefined || open.some((layer) => layer.id === id)
|
||||
? data
|
||||
: withMark(data, undefined);
|
||||
}
|
||||
|
||||
let sequence = 0;
|
||||
|
||||
export function createSheetHistory(host: SheetHistoryHost): SheetHistory {
|
||||
@@ -149,16 +169,14 @@ export function createSheetHistory(host: SheetHistoryHost): SheetHistory {
|
||||
function install() {
|
||||
if (installed) return;
|
||||
installed = true;
|
||||
const below = {
|
||||
push: history.pushState,
|
||||
replace: history.replaceState,
|
||||
};
|
||||
const below = { push: history.pushState, replace: history.replaceState };
|
||||
push = (data, unused, url) => {
|
||||
if (ours) return below.push.call(history, data, unused, url);
|
||||
if (ownBacks > 0) {
|
||||
held.push(() => push.call(history, data, unused, url));
|
||||
return;
|
||||
}
|
||||
data = withoutDeadMark(data, layers);
|
||||
const current = layerOf(markOf(history.state));
|
||||
if (current && url != null && !samePage(location.href, String(url))) {
|
||||
below.replace.call(history, data, unused, url);
|
||||
@@ -172,6 +190,7 @@ export function createSheetHistory(host: SheetHistoryHost): SheetHistory {
|
||||
held.push(() => replace.call(history, data, unused, url));
|
||||
return;
|
||||
}
|
||||
data = withoutDeadMark(data, layers);
|
||||
const id = markOf(history.state);
|
||||
const keep = layerOf(id) && markOf(data) === undefined;
|
||||
below.replace.call(
|
||||
@@ -264,11 +283,15 @@ export function createSheetHistory(host: SheetHistoryHost): SheetHistory {
|
||||
}
|
||||
if (!layers.includes(layer)) return;
|
||||
layer.baseHref = location.href;
|
||||
write("push", withMark(null, layer.id));
|
||||
write("push", withMark(history.state, layer.id));
|
||||
snapshot();
|
||||
}
|
||||
|
||||
return {
|
||||
ownsPopstate: () =>
|
||||
ownBacks > 0 ||
|
||||
layerOf(markOf(history.state)) !== undefined ||
|
||||
(layers.length > 0 && location.href === layers[0].baseHref),
|
||||
open(onBack) {
|
||||
install();
|
||||
sequence += 1;
|
||||
@@ -314,3 +337,8 @@ export function sheetHistory(): SheetHistory | null {
|
||||
shared ??= createSheetHistory(window as unknown as SheetHistoryHost);
|
||||
return shared;
|
||||
}
|
||||
|
||||
/** Whether the popstate under way belongs to an open or closing sheet. */
|
||||
export function sheetHistoryOwnsPopstate(): boolean {
|
||||
return shared?.ownsPopstate() ?? false;
|
||||
}
|
||||
@@ -31,7 +31,7 @@ describe("parseLocaleFromAcceptLanguage", () => {
|
||||
expect(parseLocaleFromAcceptLanguage("*")).toBe("en");
|
||||
});
|
||||
|
||||
// v1.4.25 — the four AI-initial locales added in this release.
|
||||
// v1.4.25 — the four machine-translated locales added in this release.
|
||||
// Each maps via the same primary-tag prefix match the DE branch uses.
|
||||
it("returns the matching tag for FR / ES / IT / PL", () => {
|
||||
expect(parseLocaleFromAcceptLanguage("fr-FR,fr;q=0.9")).toBe("fr");
|
||||
|
||||
@@ -128,9 +128,9 @@ describe("isLegacyInsightPayload()", () => {
|
||||
expect(isLegacyInsightPayload({ summary: "x" })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for the v1.4.14 pre-strict-schema shape (the maintainer's prod blob)", () => {
|
||||
// The actual cached blob that crashed /insights for the live tenant on
|
||||
// 2026-05-10. No `summary`, no `recommendations[]` — just the
|
||||
it("returns true for the v1.4.14 pre-strict-schema shape", () => {
|
||||
// A cached blob of this shape crashed /insights after the schema
|
||||
// change. No `summary`, no `recommendations[]` — just the
|
||||
// pre-v1.4.16 `{changed, stable, drivers, nextSteps, confidence,
|
||||
// limitations}` shape. The route's `safeParse` failed and fell
|
||||
// through to the raw blob, so the rich card got `summary ===
|
||||
|
||||
@@ -450,7 +450,7 @@ export async function extractAndStoreFacts(
|
||||
|
||||
let proposed = 0;
|
||||
for (const f of toStore) {
|
||||
// Field-by-field, no spread (mass-assignment rule, CLAUDE.md). A health
|
||||
// Field-by-field, no spread (no mass assignment). A health
|
||||
// fact waits for the person's tap; everything else is kept as extracted.
|
||||
const source = backgroundSource(f.category, f.fact, "extracted");
|
||||
if (source === PROPOSED_FACT_SOURCE) proposed += 1;
|
||||
@@ -710,7 +710,7 @@ export async function storeDeterministicFacts(args: {
|
||||
for (const cand of candidates) {
|
||||
if (isNearDuplicate(cand.fact, seen)) continue;
|
||||
seen.push(cand.fact);
|
||||
// Field-by-field, no spread (mass-assignment rule, CLAUDE.md).
|
||||
// Field-by-field, no spread (no mass assignment).
|
||||
await db.coachFact.create({
|
||||
data: {
|
||||
userId: args.userId,
|
||||
|
||||
@@ -380,7 +380,7 @@ export async function extractAndStorePlanProposals(
|
||||
const toStore = accepted.slice(0, remainingCapacity);
|
||||
|
||||
for (const p of toStore) {
|
||||
// Field-by-field, no spread (mass-assignment rule, CLAUDE.md). Always
|
||||
// Field-by-field, no spread (no mass assignment). Always
|
||||
// written as "proposed" — only the user-facing PATCH activates a plan.
|
||||
await db.coachPlan.create({
|
||||
data: {
|
||||
|
||||
@@ -18,7 +18,7 @@ import { loadSafetyContracts } from "@/lib/ai/prompts/safety-contracts";
|
||||
const NATIVE_LOCALES = ["fr", "es", "it", "pl"] as const;
|
||||
|
||||
/**
|
||||
* v1.4.25 — each AI-initial locale (FR / ES / IT / PL) now gets a
|
||||
* v1.4.25 — each machine-translated locale (FR / ES / IT / PL) now gets a
|
||||
* native system prompt assembled from the safety-contract matrix
|
||||
* instead of riding the EN body with a one-line footer.
|
||||
*
|
||||
@@ -34,7 +34,7 @@ const NATIVE_LOCALES = ["fr", "es", "it", "pl"] as const;
|
||||
* 9. The reply-language directive lives at the foot of the prompt.
|
||||
*/
|
||||
|
||||
describe("getCoachSystemPrompt — native AI-initial locales", () => {
|
||||
describe("getCoachSystemPrompt — native machine-translated locales", () => {
|
||||
it.each(NATIVE_LOCALES)(
|
||||
"%s produces a non-empty native prompt embedding PROMPT_VERSION",
|
||||
(locale) => {
|
||||
@@ -140,7 +140,7 @@ describe("getCoachSystemPrompt — native AI-initial locales", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getStrictInsightsSystemPrompt — native AI-initial locales", () => {
|
||||
describe("getStrictInsightsSystemPrompt — native machine-translated locales", () => {
|
||||
it.each(NATIVE_LOCALES)(
|
||||
"%s produces a non-empty native Insights prompt",
|
||||
(locale) => {
|
||||
|
||||
@@ -1032,7 +1032,7 @@ export function buildSystemPromptWithReferences(
|
||||
const refs = selectReferencesForMetrics(metrics);
|
||||
if (refs.length === 0) return base;
|
||||
|
||||
// v1.4.25 — AI-initial locales (FR/ES/IT/PL) ride the EN sources
|
||||
// v1.4.25 — machine-translated locales (FR/ES/IT/PL) ride the EN sources
|
||||
// block. The "title" field is the EN title; the model is told to
|
||||
// surface the citation id, not to translate the source title, so this
|
||||
// is safe — citation labels in the UI use the contract id (lowercase,
|
||||
@@ -1107,7 +1107,7 @@ export const OUT_OF_SCOPE_REFUSAL_DE = {
|
||||
* from the safety-contract matrix so the summary copy stays in sync
|
||||
* with the prompt body that taught the model to emit it. The EN /
|
||||
* DE constants above remain authoritative for their locales — these
|
||||
* helpers cover the AI-initial locales.
|
||||
* helpers cover the machine-translated locales.
|
||||
*/
|
||||
export const OUT_OF_SCOPE_REFUSAL_FR = buildOutOfScopeRefusal("fr");
|
||||
export const OUT_OF_SCOPE_REFUSAL_ES = buildOutOfScopeRefusal("es");
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* v1.15.12 — unit pins for the graded BP pillar score.
|
||||
*
|
||||
* The maintainer's apps01 profile (under-65 ceiling 129/79, avg 134/87)
|
||||
* is the canonical regression: the binary all-time in-target rate read
|
||||
* A profile averaging 134/87 against the under-65 ceiling of 129/79 is the
|
||||
* canonical regression: the binary all-time in-target rate read
|
||||
* ~10-16/100; the graded score must land in the borderline-stage-1 band
|
||||
* (low-to-mid 50s), not in the catastrophic band.
|
||||
*/
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from "../bp-grade";
|
||||
import type { BpTargets } from "../bp-targets";
|
||||
|
||||
// Under-65 ESH band — the maintainer's age band.
|
||||
// Under-65 ESH band.
|
||||
const UNDER65: BpTargets = {
|
||||
sysLow: 120,
|
||||
sysHigh: 129,
|
||||
@@ -34,7 +34,7 @@ function bpScore(input: { sys: number; dia: number; target: BpTargets }) {
|
||||
}
|
||||
|
||||
describe("gradeBpScore", () => {
|
||||
it("grades the maintainer's 134/87 into the borderline band [45,60]", () => {
|
||||
it("grades 134/87 into the borderline band [45,60]", () => {
|
||||
const score = bpScore({ sys: 134, dia: 87, target: UNDER65 });
|
||||
expect(score).toBeGreaterThanOrEqual(45);
|
||||
expect(score).toBeLessThanOrEqual(60);
|
||||
@@ -145,7 +145,7 @@ describe("gradeBpScore", () => {
|
||||
).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("preserves the audit fairness anchors (134/87 → 57, 165/105 → 22)", () => {
|
||||
it("preserves the fairness anchors (134/87 → 57, 165/105 → 22)", () => {
|
||||
expect(bpScore({ sys: 134, dia: 87, target: UNDER65 })).toBe(57);
|
||||
expect(bpScore({ sys: 165, dia: 105, target: UNDER65 })).toBe(22);
|
||||
});
|
||||
|
||||
@@ -21,8 +21,8 @@ describe("isBpReadingInTarget()", () => {
|
||||
* v1.4.16 A2 regression: 117/79 is textbook normotensive
|
||||
* (well-controlled, below the goal ceiling, above hypotension). The
|
||||
* v1.4.15 implementation rejected this because sys < sysLow (120),
|
||||
* which collapsed the live tenant's BD-Zielbereich tile to 0 %. The fix uses a
|
||||
* one-sided ceiling check with a clinical floor.
|
||||
* which collapsed the in-target tile of a well-controlled profile to
|
||||
* 0 %. The fix uses a one-sided ceiling check with a clinical floor.
|
||||
*/
|
||||
it("counts a normotensive reading below the goal band as in-target", () => {
|
||||
expect(isBpReadingInTarget(117, 79, TARGETS_UNDER_65)).toBe(true);
|
||||
@@ -197,11 +197,8 @@ describe("computeBpInTargetPct", () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.4.16 A2 regression — the live tenant's actual production data.
|
||||
*
|
||||
* The maintainer reported BD-Zielbereich = 0 % despite multiple BP readings
|
||||
* that "are clearly in target". A query against production showed
|
||||
* their last 30 days of paired readings include 117/79, 122/76, 108/76,
|
||||
* v1.4.16 A2 regression: a month of ten paired readings, most of them
|
||||
* clearly in target, that the tile read as 0 %: 117/79, 122/76, 108/76,
|
||||
* 106/73, 127/86, 115/78, 108/75, 124/82, 126/80, 133/95.
|
||||
*
|
||||
* Under v1.4.15 narrow-band semantics (sys >= 120 AND sys <= 129 AND
|
||||
@@ -216,9 +213,9 @@ describe("computeBpInTargetPct", () => {
|
||||
* Result: 5/10 = 50 %.
|
||||
*
|
||||
* Compare with the v1.4.15 narrow-band result (0/10 = 0 %) — that's
|
||||
* the regression the maintainer reported.
|
||||
* the regression.
|
||||
*/
|
||||
it("regression: the maintainer's production data produces non-zero % under the ceiling semantics", () => {
|
||||
it("regression: the ten-pair month produces non-zero % under the ceiling semantics", () => {
|
||||
const sys = [
|
||||
reading("2026-05-08T07:38:22Z", 117),
|
||||
reading("2026-05-03T21:22:02Z", 122),
|
||||
@@ -267,12 +264,12 @@ describe("computeBpInTargetPct", () => {
|
||||
|
||||
describe("computeBpInTargetWindows — all-time headline (v1.4.19 A1)", () => {
|
||||
/**
|
||||
* v1.4.19 A1 regression — the maintainer reported the BD-Zielbereich tile shows
|
||||
* **EXACTLY 50 % on 7T, 30T, AND the headline ("total")** for their
|
||||
* production data. Hand-counted hypothesis-1 from the brief (the
|
||||
* three-way coincidence cannot be data — it's a calculation pin):
|
||||
* v1.4.19 A1 regression — the in-target tile showed
|
||||
* **EXACTLY 50 % on 7T, 30T, AND the headline ("total")** for a long
|
||||
* history. Hand-counted (the three-way coincidence cannot be data, it is
|
||||
* a calculation pin):
|
||||
*
|
||||
* - The live tenant has 572 paired BP readings going back to 2022-01.
|
||||
* - A history of 572 paired BP readings over four years.
|
||||
* - Last 7 days: 1 / 2 in target = 50 %.
|
||||
* - Last 30 days: 5 / 10 in target = 50 %.
|
||||
* - All time: 62 / 572 in target ≈ 11 %.
|
||||
@@ -389,8 +386,8 @@ describe("computeBpInTargetWindows — all-time headline (v1.4.19 A1)", () => {
|
||||
expect(result.last90Days).toBeNull();
|
||||
});
|
||||
|
||||
it("regression: the live tenant's 30-day fixture mirrors the live tenant's prod headline bug", () => {
|
||||
// Reuse the maintainer's production fixture (10 pairs, 5 in target = 50 %).
|
||||
it("regression: the ten-pair month alone hides the headline bug", () => {
|
||||
// Reuse the ten-pair fixture (5 in target = 50 %).
|
||||
// When the input is JUST those 10 pairs, allTime equals 30d == 50 %
|
||||
// (this is the legitimate coincidence — it only hides the bug when
|
||||
// the user has no older history). The bug surfaces the moment older
|
||||
@@ -431,7 +428,7 @@ describe("computeBpInTargetWindows — all-time headline (v1.4.19 A1)", () => {
|
||||
|
||||
describe("computeBpInTargetWindows", () => {
|
||||
/**
|
||||
* v1.4.18 A1 regression — the maintainer reported the BD-Zielbereich tile shows
|
||||
* v1.4.18 A1 regression — the in-target tile showed
|
||||
* the 30-day headline (50 %) but `7T: —` and `30T: —` placeholders
|
||||
* even though the user has paired BP readings in both windows. Root
|
||||
* cause: the analytics route only returned `bpInTargetPct` once over
|
||||
@@ -439,7 +436,7 @@ describe("computeBpInTargetWindows", () => {
|
||||
* renders the "—" fallback. The fix is a windowed helper that returns
|
||||
* both 7-day and 30-day shares so the tile can show all three.
|
||||
*
|
||||
* Pinning the live tenant's actual production fixture (10 paired readings over
|
||||
* Pinning a fixture of 10 paired readings over
|
||||
* the last 30 days, 2 of them within the last 7 days) so a future
|
||||
* reviewer can re-derive the expected counts by hand.
|
||||
*/
|
||||
@@ -520,11 +517,11 @@ describe("computeBpInTargetWindows", () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* the live tenant's real fixture: 10 paired readings over the last 30 days (5 in
|
||||
* The fixture: 10 paired readings over the last 30 days (5 in
|
||||
* target, 5 out → 50 %), of which 2 are inside the last 7 days
|
||||
* (1 in target, 1 out → 50 %).
|
||||
*/
|
||||
it("regression: the maintainer's production fixture renders 7d=50% and 30d=50%", () => {
|
||||
it("regression: the ten-pair fixture renders 7d=50% and 30d=50%", () => {
|
||||
const fixtureNow = new Date("2026-05-09T12:00:00Z");
|
||||
const sys = [
|
||||
reading("2026-05-08T07:38:22Z", 117), // 7d IN
|
||||
|
||||
@@ -171,7 +171,7 @@ describe("generateAlerts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// v1.4 marathon — closes the body-fat ACE drift identified in the
|
||||
// v1.4: closes the body-fat ACE drift identified in the
|
||||
// medical-truthfulness audit. Below-essential is now a danger band,
|
||||
// "Essential" is the warning band proper, and the labels match ACE.
|
||||
describe("classifyBodyFat (ACE bands)", () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* v1.15.12 — unit pins for resting-pulse estimation.
|
||||
*
|
||||
* The canonical regression (the maintainer's apps01 profile): a day with
|
||||
* The canonical regression: a day with
|
||||
* ~500 high workout PULSE samples + ~10 resting reads must NOT classify
|
||||
* the day as a resting-HR excursion. A user with clean
|
||||
* RESTING_HEART_RATE (~72) reads healthy.
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
* `sys >= sysLow && sys <= sysHigh && dia >= diaLow && dia <= diaHigh`.
|
||||
* With ESH 2023 narrow targets (Sys 120-129, Dia 70-79 for under-65)
|
||||
* this collapses to 0 % for healthy normotensive users whose
|
||||
* readings sit BELOW the goal band — the live tenant's actual data has many
|
||||
* readings sit BELOW the goal band — a well-controlled profile has many
|
||||
* readings like 117/79 (textbook normotensive, well-controlled) that
|
||||
* were marked OUT of target because sys < 120. Users intuitively
|
||||
* consider those readings *in target* — and so does clinical
|
||||
@@ -222,8 +222,8 @@ export function collectBpPairs(
|
||||
* - v1.4.18 A1 wired `avg7` + `avg30` from `computeBpInTargetWindows`,
|
||||
* BUT the analytics route also routed the **headline**
|
||||
* (`bpInTargetPct`) through `windows.last30Days?.pct` — i.e. the
|
||||
* headline was a literal copy of `bpInTargetPct30d`. For the live tenant's prod
|
||||
* data (572 paired readings since 2022, recent 30 days = 50 %, all-time
|
||||
* headline was a literal copy of `bpInTargetPct30d`. For a long history
|
||||
* (572 paired readings over four years, recent 30 days = 50 %, all-time
|
||||
* ≈ 11 %) the tile rendered `50 %` headline, `7T: 50, 30T: 50` and
|
||||
* looked algorithmically pinned to 50/50/50. That was hypothesis-1
|
||||
* from the v1.4.19 A1 brief: "the 7T/30T branches reuse the all-time
|
||||
|
||||
@@ -2933,7 +2933,7 @@ describe("tallyComplianceFromLedger — the unified % keystone", () => {
|
||||
});
|
||||
|
||||
it("an off-schedule (ad-hoc) take is EXCLUDED + leaves its slot missed", () => {
|
||||
// The maintainer's case: 07:00 dose logged at 11:29 → outside the band → ad-hoc;
|
||||
// The reported case: 07:00 dose logged at 11:29 → outside the band → ad-hoc;
|
||||
// the 07:00 slot is missed; the 19:00 slot is also missed (untouched).
|
||||
const events = [
|
||||
{ scheduledFor: at(7, 0), takenAt: at(11, 29), skipped: false },
|
||||
|
||||
@@ -17,9 +17,8 @@
|
||||
* v1.4.16: fall back to slope30 (projected onto 7 days) when
|
||||
* slope7 is unavailable. Some metrics (mood, sleep) have <2 entries
|
||||
* within the trailing 7-day window for users who don't log daily,
|
||||
* which used to surface as a missing delta on the dashboard tile —
|
||||
* the maintainer's complaint: "Bei Stimmung wird zum Beispiel kein Trend in
|
||||
* Zahlen dargestellt." A 30-day slope still answers the question
|
||||
* which used to surface as a missing delta on the dashboard tile: mood
|
||||
* showed no trend figure at all. A 30-day slope still answers the question
|
||||
* "is this metric drifting up or down?" — we just project it onto
|
||||
* a 7-day window for label consistency. Returning `null` is reserved
|
||||
* for the truly insufficient case where neither slope is available.
|
||||
|
||||
+1
-1
@@ -480,7 +480,7 @@ async function lookupIpOnline(ip: string): Promise<GeoResolved | null> {
|
||||
// metadata address (SSRF/rebinding).
|
||||
// v1.15.12 — keep the online timeout below the audit-log 3 s race
|
||||
// window so a slow-but-successful lookup still lands before the race
|
||||
// resolves null and drops the location (the "—" symptom on apps01).
|
||||
// resolves null and drops the location (the "—" symptom in production).
|
||||
{ timeoutMs: 2_500, requirePublicHost: true },
|
||||
);
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -87,7 +87,7 @@ export const localeLabels: Record<Locale, string> = {
|
||||
|
||||
/**
|
||||
* v1.4.25 — locales actively maintained by the project owner.
|
||||
* Locales outside this set are AI-initial translations and surface a
|
||||
* Locales outside this set are machine-translated translations and surface a
|
||||
* <MaintainershipBanner> notice at the top of the auth shell so users
|
||||
* know to expect rough edges and where to contribute fixes. Promoting
|
||||
* a locale here turns the banner off.
|
||||
|
||||
@@ -61,7 +61,7 @@ beforeEach(() => {
|
||||
resolveModuleMap.mockReset().mockResolvedValue({});
|
||||
});
|
||||
|
||||
describe("coach read — the pattern scan behind the strip", () => {
|
||||
describe("coach read: the pattern scan behind the strip", () => {
|
||||
it("scans once for two metric pages and picks each page's own driver", async () => {
|
||||
const sleep = await strip("SLEEP_DURATION");
|
||||
const pulse = await strip("PULSE");
|
||||
|
||||
@@ -100,8 +100,9 @@ function latestDay(
|
||||
* channel over 180 days and does not depend on the metric, so without the cell
|
||||
* each metric page paid the whole scan again for the one line it picks. Same
|
||||
* cell contract as `/api/insights/correlations`: measurement, mood, medication,
|
||||
* illness, custom-metric and pattern writes sweep the `${userId}|` prefix, and
|
||||
* the switched-off modules are part of the key. A failed read is not cached.
|
||||
* illness, nutrient, custom-metric and pattern writes sweep the `${userId}|`
|
||||
* prefix, and the switched-off modules are part of the key. A failed read is
|
||||
* not cached.
|
||||
*/
|
||||
async function readDrivers(
|
||||
userId: string,
|
||||
|
||||
@@ -834,8 +834,8 @@ export interface AggregatedFeatures {
|
||||
*
|
||||
* Replaces v1.4.35.x `rawMeasurements` (which appended every
|
||||
* measurement row and blew the 10 MB Codex prompt ceiling on power
|
||||
* users with multi-year imports — 25.9 MB observed on the maintainer's account
|
||||
* with 311 775 rows). Reading from `measurement_rollups` instead caps
|
||||
* users with multi-year imports, about 26 MB for an account of some 300 000
|
||||
* rows). Reading from `measurement_rollups` instead caps
|
||||
* the payload at O(types × buckets) regardless of underlying row
|
||||
* count.
|
||||
*/
|
||||
|
||||
@@ -49,8 +49,9 @@ export const REASONING_JOB_TIMEOUT_MS = 90_000;
|
||||
* a thinking model whose first answer was reasoning alone timed out in the
|
||||
* middle of the repeat that would have answered, and a slow local model the
|
||||
* person had given 300 s or more per call never landed its note (#1274).
|
||||
* Every caller is a background job; the job expiries in `queue-runtime.ts`
|
||||
* sit above this at the largest setting.
|
||||
* Every caller is a background job whose queue expiry sits above this at the
|
||||
* largest setting (the queue table in `src/__tests__/helpers/queue-runtime.ts`
|
||||
* holds it there).
|
||||
*/
|
||||
export function statusRunBoundMs(effectiveTimeoutMs: number): number {
|
||||
return effectiveTimeoutMs * STATUS_MODEL_CALLS;
|
||||
|
||||
@@ -548,28 +548,32 @@ describe("runMeasurementReminderTick", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("v1.18.0 — skips a glucose reminder when the Glucose module is off, and advances", async () => {
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [reminder({ measurementType: "BLOOD_GLUCOSE" })],
|
||||
measurementMatch: null,
|
||||
});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
const isModuleEnabled = vi.fn(async () => false);
|
||||
// Ketones come off the glucose meter and follow the same module.
|
||||
it.each(["BLOOD_GLUCOSE", "BLOOD_KETONES"] as const)(
|
||||
"skips a %s reminder when the Glucose module is off, and advances",
|
||||
async (measurementType) => {
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [reminder({ measurementType })],
|
||||
measurementMatch: null,
|
||||
});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
const isModuleEnabled = vi.fn(async () => false);
|
||||
|
||||
const summary = await runMeasurementReminderTick(
|
||||
prisma as never,
|
||||
NINE_LOCAL,
|
||||
{ dispatch, isModuleEnabled },
|
||||
);
|
||||
const summary = await runMeasurementReminderTick(
|
||||
prisma as never,
|
||||
NINE_LOCAL,
|
||||
{ dispatch, isModuleEnabled },
|
||||
);
|
||||
|
||||
expect(summary.skippedModuleDisabled).toBe(1);
|
||||
expect(summary.dispatched).toBe(0);
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
expect(isModuleEnabled).toHaveBeenCalledWith("u1", "glucose");
|
||||
// Advances past the cycle so it does not re-evaluate every tick.
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0].data.nextDueAt).toBeInstanceOf(Date);
|
||||
});
|
||||
expect(summary.skippedModuleDisabled).toBe(1);
|
||||
expect(summary.dispatched).toBe(0);
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
expect(isModuleEnabled).toHaveBeenCalledWith("u1", "glucose");
|
||||
// Advances past the cycle so it does not re-evaluate every tick.
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0].data.nextDueAt).toBeInstanceOf(Date);
|
||||
},
|
||||
);
|
||||
|
||||
it("v1.18.0 — still dispatches a glucose reminder when the Glucose module is on", async () => {
|
||||
const { prisma } = makePrisma({
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("determinePhase", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// v1.4 marathon — closes v3 audit "Locale Mixing" CRIT.
|
||||
// v1.4: closes the "Locale Mixing" finding.
|
||||
// Reminder messages must follow the user's stored locale, not always
|
||||
// German. callback_data values are stable English identifiers and stay
|
||||
// untranslated regardless of UI locale.
|
||||
|
||||
@@ -164,27 +164,30 @@ describe("runReminderSatisfyForUser", () => {
|
||||
expect(updates[0].data.lastSatisfiedAt).toEqual(takenAt);
|
||||
});
|
||||
|
||||
it("produces no engine activity for a disabled module", async () => {
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [reminderRow({ measurementType: "BLOOD_GLUCOSE" })],
|
||||
measurement: { measuredAt: new Date("2026-06-14T18:00:00Z") },
|
||||
});
|
||||
const isModuleEnabled = vi.fn(async () => false);
|
||||
it.each(["BLOOD_GLUCOSE", "BLOOD_KETONES"] as const)(
|
||||
"produces no engine activity for a %s reminder while Glucose is off",
|
||||
async (measurementType) => {
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [reminderRow({ measurementType })],
|
||||
measurement: { measuredAt: new Date("2026-06-14T18:00:00Z") },
|
||||
});
|
||||
const isModuleEnabled = vi.fn(async () => false);
|
||||
|
||||
const summary = await runReminderSatisfyForUser(
|
||||
prisma as never,
|
||||
"u1",
|
||||
new Date(),
|
||||
{ isModuleEnabled },
|
||||
);
|
||||
const summary = await runReminderSatisfyForUser(
|
||||
prisma as never,
|
||||
"u1",
|
||||
new Date(),
|
||||
{ isModuleEnabled },
|
||||
);
|
||||
|
||||
expect(summary.skippedModuleDisabled).toBe(1);
|
||||
expect(summary.satisfied).toBe(0);
|
||||
expect(isModuleEnabled).toHaveBeenCalledWith("u1", "glucose");
|
||||
expect(updates).toHaveLength(0);
|
||||
// The matcher never runs for a gated reminder.
|
||||
expect(prisma.measurement.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(summary.skippedModuleDisabled).toBe(1);
|
||||
expect(summary.satisfied).toBe(0);
|
||||
expect(isModuleEnabled).toHaveBeenCalledWith("u1", "glucose");
|
||||
expect(updates).toHaveLength(0);
|
||||
// The matcher never runs for a gated reminder.
|
||||
expect(prisma.measurement.findFirst).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("satisfies a screening reminder from the server-written score row (v1.27.6)", async () => {
|
||||
// A completed PHQ-9 check-in writes a COMPUTED PHQ9_SCORE measurement;
|
||||
|
||||
@@ -18,11 +18,11 @@
|
||||
* 4. runs the same body the synchronous route runs, and stores the result
|
||||
* or the failure the route would have answered with.
|
||||
*
|
||||
* The queue is `long` (`queue-runtime.ts`): a read at the longest response
|
||||
* setting outlasts pg-boss's fifteen-minute default. Each job runs under the
|
||||
* lock `lockedPass` takes per run, and is sent with `retryLimit: 0`: a run
|
||||
* the worker lost is failed by the reaper and asked for again by the person,
|
||||
* never re-run behind their back.
|
||||
* The queue is `long` (`src/__tests__/helpers/queue-runtime.ts`): a read at
|
||||
* the longest response setting outlasts pg-boss's fifteen-minute default.
|
||||
* Each job runs under the lock `lockedPass` takes per run, and is sent with
|
||||
* `retryLimit: 0`: a run the worker lost is failed by the reaper and asked
|
||||
* for again by the person, never re-run behind their back.
|
||||
*
|
||||
* The queue MUST be registered in the maintenance registrar
|
||||
* (`src/lib/jobs/reminder/register-maintenance.ts`) so pg-boss provisions it.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* Host-load sampler (every five minutes since v1.42) that backs the admin /admin/system-status
|
||||
* "last 2 hours" chart (v1.4.16 phase B3).
|
||||
* "last 2 hours" chart.
|
||||
*
|
||||
* The maintainer asked for a small at-a-glance graph above the system-status facts
|
||||
* grid showing CPU + memory + disk-IO so a glance at /admin tells them
|
||||
* whether the apps-01 host is healthy. We considered scraping Coolify's
|
||||
* A small at-a-glance graph above the system-status facts grid shows
|
||||
* CPU + memory + disk-IO, so a glance at /admin tells an operator whether
|
||||
* the app host is healthy. We considered scraping Coolify's
|
||||
* Sentinel collector, but the Coolify HTTP API does not expose a clean
|
||||
* read endpoint for per-server metrics — every approach ended up either
|
||||
* scraping the Coolify dashboard HTML or hitting a private RPC. An
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* The binding for a queue whose job can outlast pg-boss's default expiry.
|
||||
*
|
||||
* Three things together make a long job safe, and `queue-runtime.ts` names
|
||||
* every queue that needs them:
|
||||
* Three things together make a long job safe, and the queue table in
|
||||
* `src/__tests__/helpers/queue-runtime.ts` names every queue that needs them:
|
||||
*
|
||||
* 1. An explicit `expireInSeconds` on every send and schedule, so pg-boss
|
||||
* does not declare the job dead at fifteen minutes while it is working.
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
} from "@/lib/notifications/reminder-dedup";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import { moduleForMeasurementType } from "@/lib/modules/measurement-scope";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import {
|
||||
byHourTimesOfDay,
|
||||
@@ -69,33 +70,15 @@ import {
|
||||
} from "@/lib/encounters/appointment-reminder";
|
||||
|
||||
/**
|
||||
* v1.18.0 — map a reminder's `measurementType` to the toggleable module
|
||||
* that owns it, or `null` when the type is a CORE domain (weight, blood
|
||||
* pressure, pulse, body composition) or has no module of its own. A
|
||||
* `null` reminder type (free-text Vorsorge entry) also yields `null`.
|
||||
*
|
||||
* Only the secondary-domain types carry a gate: glucose, sleep, and the
|
||||
* mental-wellbeing screenings (v1.27.6 — the PHQ-9 / GAD-7 module is
|
||||
* opt-in, so a screening reminder must fall silent the moment the module
|
||||
* is off). Everything else is core and dispatches regardless — disabling
|
||||
* a module must never silence a core-vital reminder.
|
||||
* The toggleable module a reminder's `measurementType` belongs to, from the
|
||||
* one ownership table (`measurement-scope.ts`): glucose and ketones, sleep,
|
||||
* the mental-wellbeing screenings. A reminder of such a type falls silent
|
||||
* the moment its module is off. A core vital (weight, blood pressure, pulse,
|
||||
* body composition) and a free-text checkup (`null` type) have no module
|
||||
* and dispatch regardless: disabling a module never silences them.
|
||||
*/
|
||||
function moduleForMeasurementType(
|
||||
type: MeasurementType | null,
|
||||
): ModuleKey | null {
|
||||
switch (type) {
|
||||
case "BLOOD_GLUCOSE":
|
||||
return "glucose";
|
||||
case "SLEEP_DURATION":
|
||||
return "sleep";
|
||||
case "PHQ9_SCORE":
|
||||
case "GAD7_SCORE":
|
||||
case "WHO5_SCORE":
|
||||
case "SCI_SCORE":
|
||||
return "mentalHealth";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
function reminderModule(type: MeasurementType | null): ModuleKey | null {
|
||||
return type === null ? null : moduleForMeasurementType(type);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -437,12 +420,12 @@ export async function runMeasurementReminderTick(
|
||||
summary.inWindow += 1;
|
||||
|
||||
// v1.18.0 module gate — a reminder whose measurement type belongs to
|
||||
// a toggleable module (glucose / sleep) must not fire once the user
|
||||
// a toggleable module (`reminderModule`) must not fire once the user
|
||||
// turns that module off. Core-vital reminders (weight / BP / pulse /
|
||||
// body comp) and free-text reminders map to no module and are never
|
||||
// gated. Checked after the due + window gates so the gate read only
|
||||
// fires for reminders that would otherwise dispatch this tick.
|
||||
const gatedModule = moduleForMeasurementType(reminder.measurementType);
|
||||
const gatedModule = reminderModule(reminder.measurementType);
|
||||
if (
|
||||
gatedModule !== null &&
|
||||
!(await moduleGate(reminder.user.id, gatedModule))
|
||||
@@ -633,7 +616,7 @@ export async function runReminderSatisfyForUser(
|
||||
// Module toggle — no engine activity for a reminder whose type
|
||||
// belongs to a disabled module. Core-vital + free-text reminders map
|
||||
// to no module and are never gated.
|
||||
const gatedModule = moduleForMeasurementType(reminder.measurementType);
|
||||
const gatedModule = reminderModule(reminder.measurementType);
|
||||
if (gatedModule !== null && !(await moduleGate(userId, gatedModule))) {
|
||||
summary.skippedModuleDisabled += 1;
|
||||
continue;
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
* bound. pg-boss fails and retries a job that runs past its expiry, and it
|
||||
* does not stop the handler, so an expiry below that work lets a retry run
|
||||
* beside the first attempt (#1274). The bounds here are what the outer aborts
|
||||
* use and what the expiries in `queue-runtime.ts` are derived from, so both
|
||||
* move together when a call count or the setting's ceiling does.
|
||||
* use and what the queue expiries are derived from (the queue table in
|
||||
* `src/__tests__/helpers/queue-runtime.ts` checks them), so both move
|
||||
* together when a call count or the setting's ceiling does.
|
||||
*
|
||||
* No server import: the queue modules that carry an expiry into a send read
|
||||
* these without loading a generator.
|
||||
|
||||
@@ -157,8 +157,8 @@ const resolveLocale = coerceLocale;
|
||||
* `medicationReminders.phase*`. The reminder worker passes the user's
|
||||
* stored locale; absent / unknown locales fall back to the app default.
|
||||
*
|
||||
* v1.4 marathon — closes the v3 audit "Locale-Mixing" CRIT finding
|
||||
* (German-only Telegram / Web Push templates regardless of user locale).
|
||||
* v1.4: closes the "Locale-Mixing" finding (German-only Telegram / Web Push
|
||||
* templates regardless of user locale).
|
||||
*/
|
||||
export function getPhaseMessage(
|
||||
phase: ReminderPhase,
|
||||
|
||||
@@ -122,6 +122,20 @@ describe("module→data ownership table", () => {
|
||||
expect(moduleForMeasurementType("CARDIO_LOAD")).toBe("workouts");
|
||||
expect(moduleForMeasurementType("ANS_CHARGE")).toBe("recovery");
|
||||
expect(moduleForMeasurementType("CARDIO_RECOVERY")).toBe("recovery");
|
||||
expect(moduleForMeasurementType("BLOOD_KETONES")).toBe("glucose");
|
||||
});
|
||||
|
||||
it("gives every module-owned checkup type the module the checkup job gates on", () => {
|
||||
// The checkup job reads this table; a screening checkup must fall silent
|
||||
// with the mental-health module, a ketone one with Glucose.
|
||||
for (const type of [
|
||||
"PHQ9_SCORE",
|
||||
"GAD7_SCORE",
|
||||
"WHO5_SCORE",
|
||||
"SCI_SCORE",
|
||||
] as const) {
|
||||
expect(moduleForMeasurementType(type), type).toBe("mentalHealth");
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves the core clinical figures unowned on purpose", () => {
|
||||
|
||||
@@ -109,6 +109,13 @@ const METRIC_STATUS_MODULE_OWNERS: Partial<Record<MeasurementType, ModuleKey>> =
|
||||
// belong to the glucose module; they stay off the Coach snapshot, so the
|
||||
// scope token cannot reach them.
|
||||
BLOOD_KETONES: "glucose",
|
||||
// The mental-wellbeing screenings: the score row a completed check-in
|
||||
// writes. Off the Coach snapshot and MCP by construction, so layer 1
|
||||
// never reaches them; a screening checkup follows the module too.
|
||||
PHQ9_SCORE: "mentalHealth",
|
||||
GAD7_SCORE: "mentalHealth",
|
||||
WHO5_SCORE: "mentalHealth",
|
||||
SCI_SCORE: "mentalHealth",
|
||||
// The HRV fallback type. `resolveRichMetricForUser` swaps HRV to its RMSSD
|
||||
// fallback for an account that only has ring/strap nightly RMSSD rows, so
|
||||
// leaving this unowned would have let the fallback path serve exactly the
|
||||
|
||||
@@ -11,6 +11,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { invalidateSymptomReads } from "@/components/symptoms/use-symptoms";
|
||||
import {
|
||||
allergyDependentKeys,
|
||||
invalidateKeys,
|
||||
cycleDependentKeys,
|
||||
documentDependentKeys,
|
||||
encounterDependentKeys,
|
||||
@@ -47,6 +48,20 @@ describe("record writes reach the day view and the timeline", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("a workout write marks the Insights presence read stale", async () => {
|
||||
// The Workouts pill appears with the first workout and goes with the
|
||||
// last; the presence read otherwise stays fresh for five minutes.
|
||||
const client = new QueryClient();
|
||||
client.setQueryData(queryKeys.insightsPresence(), { hasWorkouts: false });
|
||||
|
||||
await invalidateKeys(client, workoutDependentKeys);
|
||||
|
||||
expect(
|
||||
client.getQueryState(queryKeys.insightsPresence())?.isInvalidated,
|
||||
).toBe(true);
|
||||
client.clear();
|
||||
});
|
||||
|
||||
it("a symptom write marks an open day and the timeline stale", async () => {
|
||||
const client = new QueryClient();
|
||||
const day = queryKeys.day("2026-11-10");
|
||||
|
||||
@@ -364,10 +364,12 @@ export const vaccinationDependentKeys = [
|
||||
* digest names a workout that just arrived and the write evicts the server's
|
||||
* daily caches, so the daily reads ride along; pair call sites with
|
||||
* `refetchInactiveDailyReads`, because both are unmounted wherever the form
|
||||
* opens outside the dashboard.
|
||||
* opens outside the dashboard. The Insights tab strip shows the Workouts
|
||||
* pill from the presence read.
|
||||
*/
|
||||
export const workoutDependentKeys = [
|
||||
queryKeys.workouts(),
|
||||
queryKeys.insightsPresence(),
|
||||
queryKeys.dashboardSnapshot(),
|
||||
queryKeys.dailyDigest(),
|
||||
queryKeys.dayRoot(),
|
||||
|
||||
+2
-2
@@ -175,8 +175,8 @@ function isPublicPath(pathname: string): boolean {
|
||||
* a unit toggle and a date of birth, and it is why free text, anything that
|
||||
* reaches an AI prompt, and anything creating a second record are refused.
|
||||
*
|
||||
* This whole block only runs under `DEMO_MODE=true`, so production (apps01)
|
||||
* is unaffected by construction.
|
||||
* This whole block only runs under `DEMO_MODE=true`, so production is
|
||||
* unaffected by construction.
|
||||
*/
|
||||
const DEMO_MUTATION_ALLOWLIST: ReadonlyArray<{ path: string; method: string }> =
|
||||
[
|
||||
|
||||
@@ -127,7 +127,7 @@ async function startPeriod(date: string): Promise<Response> {
|
||||
);
|
||||
}
|
||||
|
||||
/** The three months of history the maintainer's record carried. */
|
||||
/** Three months of history, the shape the bug needed. */
|
||||
async function seedHistory(): Promise<void> {
|
||||
await startPeriod("2026-05-04");
|
||||
await startPeriod("2026-06-01");
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* a rolling medication last taken before the window shows the dose that fell
|
||||
* due inside it. All data invented.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { GET as getDoseHistory } from "@/app/api/medications/[id]/dose-history/route";
|
||||
|
||||
@@ -112,6 +112,15 @@ describe("the dose history of a window", () => {
|
||||
expect(shortLedger.filter((r) => r[2] === "taken_on_time")).toEqual(
|
||||
inWindow.map((day) => [day, "slot", "taken_on_time"]),
|
||||
);
|
||||
expect(await ledger(auth, long.id)).toEqual(shortLedger);
|
||||
// The same ledger from the window's rows alone: reading every intake
|
||||
// also answers it, after a year of rows.
|
||||
const read = vi.spyOn(prisma.medicationIntakeEvent, "findMany");
|
||||
try {
|
||||
expect(await ledger(auth, long.id)).toEqual(shortLedger);
|
||||
const rows = await Promise.all(read.mock.results.map((r) => r.value));
|
||||
expect(rows.flat()).toHaveLength(inWindow.length);
|
||||
} finally {
|
||||
read.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -90,7 +90,7 @@ beforeEach(async () => {
|
||||
__resetAllCachesForTests();
|
||||
});
|
||||
|
||||
describe("GET /api/insights/health-status — the cached answer", () => {
|
||||
describe("GET /api/insights/health-status: the cached answer", () => {
|
||||
it("serves the second open from the cell", async () => {
|
||||
const { user, auth } = await signedInWithFortnight();
|
||||
const first = await readHealthStatus(auth);
|
||||
|
||||
Reference in new issue
Block a user