From 102845c64a04bc97ebab3bbf951f1af563e82be0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 16:21:39 +0200 Subject: [PATCH 1/5] =?UTF-8?q?chore(release):=20v1.37.2=20=E2=80=94=20the?= =?UTF-8?q?=20mood=20forecast,=20doctor=20visits,=20and=20a=20settings=20p?= =?UTF-8?q?olish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 17 +++++++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ba2aa447..c9356f4be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,25 @@ ## [Unreleased] +## [1.37.2] — 2026-08-08 + + ### Added +- Doctor visits and the practices behind them have a home now. A visit records + what kind it was, when it happened, its status, an optional practice from a + contact list you keep, an encrypted note, and links to the lab results, + documents and illness episodes it concerned — linked, never copied, so a + correction in the source shows through. Visits and their checkups live under + a segmented view; a preventive-care checkup can be closed by recording the + visit that fulfilled it, and a due checkup can carry a planned appointment. + A planned visit sets a reminder that the server re-anchors when you move it + and clears when the visit happens or is cancelled; it fires once and never + nags, and it carries no "done" button, because the appointment is not a task + you tick off. The whole thing rides the record-sharing model as its own + section, and it shows up in the doctor report as a visit list you can leave + out. + - The mood module can now say what a day would have looked like on your own past patterns, and how far the day you recorded sits from that. It is a second, separate reading: your own rating stays the leading one, it is diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 7f54e76f5..723112e8b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.37.1 + version: 1.37.2 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 558a306c7..97cec2d6c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.37.1", + "version": "1.37.2", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", From 0a9917a09d8fe6ef0295b9a91bc5e9d8ed86a164 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 16:22:59 +0200 Subject: [PATCH 2/5] docs(changelog): drop a stray blank line --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9356f4be..b926b4820 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,6 @@ ## [1.37.2] — 2026-08-08 - ### Added - Doctor visits and the practices behind them have a home now. A visit records From 08d3cd2c31d67a273b5c37388da931b1dc1b0a36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 17:56:40 +0200 Subject: [PATCH 3/5] Split the ECG cap and one provenance entry off their label maps The /insights overview crept a kilobyte over its ceiling: the ECG teaser imported its overview cap from the classification module, which dragged that module's label map and helpers into the eager page, and the Health Score card pulled the whole metric-provenance map for its one citation. The cap now lives in a value-only module and the single provenance entry is split out, so the overview imports the bare number and citation it renders. Both routes are back under budget. The aggregate ceiling rises to hold the six-locale copy the new encounter, mood-context, mood-prognosis and practitioner surfaces added, which the whole-build sum counts independently and no single route downloads. --- bundle-budget.json | 4 +-- src/app/insights/page-client.tsx | 7 +++-- .../derived/health-score-provenance.ts | 31 +++++++++++++++++++ src/components/insights/derived/standards.ts | 22 +++---------- src/components/insights/ecg-section.tsx | 2 +- src/components/insights/health-score-card.tsx | 6 ++-- src/lib/insights/ecg-classification.ts | 9 ------ src/lib/insights/ecg-overview-limit.ts | 11 +++++++ 8 files changed, 57 insertions(+), 35 deletions(-) create mode 100644 src/components/insights/derived/health-score-provenance.ts create mode 100644 src/lib/insights/ecg-overview-limit.ts diff --git a/bundle-budget.json b/bundle-budget.json index b0fe17b50..a470f1783 100644 --- a/bundle-budget.json +++ b/bundle-budget.json @@ -1,11 +1,11 @@ { - "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.35.0: / 431, /insights 421, /measurements 417, /insights/mood 439, total 3286, baseline 130, lazy catalogs 804. Every per-route ceiling is unchanged and every measured route is within a kilobyte of its v1.34.1 value, so nothing a person actually downloads grew. The 48 KB aggregate rise from 3238 is the score-configuration settings route, which is a new mutually exclusive chunk the whole-build sum counts independently, plus about 12 KB of its strings across the six locale catalogs. No catalog is statically route-referenced and Recharts remains exactly one fingerprinted chunk. The aggregate cap therefore moves from 3260 to 3310 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged.", + "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged.", "routesKbGz": { "/page": 460, "/insights/page": 445, "/measurements/page": 445, "/insights/mood/page": 460 }, - "totalClientKbGz": 3310, + "totalClientKbGz": 3352, "maxRechartsChunks": 1 } diff --git a/src/app/insights/page-client.tsx b/src/app/insights/page-client.tsx index 1f7f54db6..936a33c09 100644 --- a/src/app/insights/page-client.tsx +++ b/src/app/insights/page-client.tsx @@ -9,9 +9,10 @@ import { SlidersHorizontal, TrendingUp } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; -// Pure constant — importing it here does NOT pull the dynamically-loaded ECG -// component into this bundle, which is exactly why it lives in the lib module. -import { ECG_OVERVIEW_LIMIT } from "@/lib/insights/ecg-classification"; +// Bare number — its own React- and label-map-free module, so importing it here +// pulls neither the ECG label map/helpers nor the dynamically-loaded ECG +// component into this eager bundle. +import { ECG_OVERVIEW_LIMIT } from "@/lib/insights/ecg-overview-limit"; import { useFeatureFlags } from "@/hooks/use-feature-flags"; import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout"; import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; diff --git a/src/components/insights/derived/health-score-provenance.ts b/src/components/insights/derived/health-score-provenance.ts new file mode 100644 index 000000000..9d4d3b59f --- /dev/null +++ b/src/components/insights/derived/health-score-provenance.ts @@ -0,0 +1,31 @@ +import type { MetricProvenanceMeta } from "./standards"; + +/** + * The Health Score's own provenance entry, split out of the full + * `METRIC_PROVENANCE` map. + * + * The eager insights overview reaches this value through + * HeroStrip → HealthScoreCard, and it needs only this one metric's method key + * and cited standard. Importing the whole map there dragged every derived + * metric's citation (~30 entries of DOIs and key strings) into the overview's + * initial bundle. Isolating the entry keeps the eager page down to the number + * it renders; `METRIC_PROVENANCE.HEALTH_SCORE` re-exports this same value, so + * the lazy score surfaces still read one source. + * + * Cited for the risk-factor set the pillars are drawn from, and for nothing + * beyond it. HEARTS names the cardiometabolic factors worth following; it does + * not prescribe an equal-weighted average of them, and since v1.35.0 it could + * not, because which pillars enter that average is the account's own + * selection. Naming the package plainly, as this entry used to, reads as a + * standard endorsing whatever recipe the person happens to have chosen, which + * is a claim nobody can make. The pillar-level citations in the map are + * unaffected: each of those really does describe the band its pillar is graded + * against. + */ +export const HEALTH_SCORE_PROVENANCE: MetricProvenanceMeta = { + methodKey: "insights.healthScore.method", + standard: { + name: "WHO HEARTS technical package (risk-factor set)", + url: "https://www.who.int/publications/i/item/9789240001367", + }, +}; diff --git a/src/components/insights/derived/standards.ts b/src/components/insights/derived/standards.ts index 0ebc4f957..39d1e3c12 100644 --- a/src/components/insights/derived/standards.ts +++ b/src/components/insights/derived/standards.ts @@ -23,6 +23,7 @@ * covers has to live in `insights.healthScore.method` — the standard entry * alone would say it to nobody. */ +import { HEALTH_SCORE_PROVENANCE } from "./health-score-provenance"; import type { ProvenanceStandard } from "./provenance-explainer"; import type { DerivedMetricId } from "@/lib/insights/derived/registry"; @@ -198,21 +199,8 @@ export const METRIC_PROVENANCE: Record = }, caveatKey: "insights.derived.composite.SAME_TIME_BASELINE.caveat", }, - HEALTH_SCORE: { - methodKey: "insights.healthScore.method", - standard: { - // Cited for the risk-factor set the pillars are drawn from, and for - // nothing beyond it. HEARTS names the cardiometabolic factors worth - // following; it does not prescribe an equal-weighted average of - // them, and since v1.35.0 it could not, because which pillars enter - // that average is the account's own selection. Naming the package - // plainly, as this entry used to, reads as a standard endorsing - // whatever recipe the person happens to have chosen, which is a - // claim nobody can make. The pillar-level citations elsewhere in - // this map are unaffected: each of those really does describe the - // band its pillar is graded against. - name: "WHO HEARTS technical package (risk-factor set)", - url: "https://www.who.int/publications/i/item/9789240001367", - }, - }, + // The HEALTH_SCORE entry lives in its own module so the eager insights + // overview can import just this metric's citation without pulling the whole + // map; the rationale for its wording is documented there. + HEALTH_SCORE: HEALTH_SCORE_PROVENANCE, }; diff --git a/src/components/insights/ecg-section.tsx b/src/components/insights/ecg-section.tsx index eee82e990..fdd8563dc 100644 --- a/src/components/insights/ecg-section.tsx +++ b/src/components/insights/ecg-section.tsx @@ -12,9 +12,9 @@ import { cn } from "@/lib/utils"; import { SectionHeading } from "@/components/ui/section-heading"; import { ecgResultLabel, - ECG_OVERVIEW_LIMIT, type EcgClassification, } from "@/lib/insights/ecg-classification"; +import { ECG_OVERVIEW_LIMIT } from "@/lib/insights/ecg-overview-limit"; import { InsightSectionCard } from "./insight-section-card"; // Re-exported so the tests (and any teaser call site) can keep importing the diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx index f6f43969b..9c75a3f9a 100644 --- a/src/components/insights/health-score-card.tsx +++ b/src/components/insights/health-score-card.tsx @@ -17,7 +17,7 @@ import { } from "@/components/insights/derived/band-tokens"; import { CoverageMeter } from "@/components/insights/derived/coverage-meter"; import { ProvenanceExplainer } from "@/components/insights/derived/provenance-explainer"; -import { METRIC_PROVENANCE } from "@/components/insights/derived/standards"; +import { HEALTH_SCORE_PROVENANCE } from "@/components/insights/derived/health-score-provenance"; import { pillarDetailLines, pillarObservedText, @@ -643,8 +643,8 @@ export function HealthScoreCard({ )} {/* v1.35.0 — the one way into the surface that decides what diff --git a/src/lib/insights/ecg-classification.ts b/src/lib/insights/ecg-classification.ts index 0b2b86efa..42883c274 100644 --- a/src/lib/insights/ecg-classification.ts +++ b/src/lib/insights/ecg-classification.ts @@ -13,15 +13,6 @@ export type EcgClassification = "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null; -/** - * How many strips the OVERVIEW teaser paints. It is a glance, not a history; - * the dedicated `/insights/ecg` page passes no limit and shows every - * recording. The constant lives here — a pure value with no React import — so - * the `/insights` page can pass it without pulling the ECG component eagerly - * across its dynamic boundary. - */ -export const ECG_OVERVIEW_LIMIT = 5; - /** i18n key per device verdict, under `insights.ecg.result.*`. */ export const ECG_RESULT_LABEL_KEYS: Record = { IRREGULAR: "insights.ecg.result.irregular", diff --git a/src/lib/insights/ecg-overview-limit.ts b/src/lib/insights/ecg-overview-limit.ts new file mode 100644 index 000000000..344b07446 --- /dev/null +++ b/src/lib/insights/ecg-overview-limit.ts @@ -0,0 +1,11 @@ +/** + * How many strips the OVERVIEW teaser paints. It is a glance, not a history; + * the dedicated `/insights/ecg` page passes no limit and shows every + * recording. + * + * The value lives in its own React- and label-map-free module so the + * `/insights` overview page can pass it without pulling the ECG label map, + * its helpers, or the recording component across the dynamic boundary — a + * bare number is all the eager page imports. + */ +export const ECG_OVERVIEW_LIMIT = 5; From 9456b18497ef6815c58e60ab1f71f32e2987abc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 18:02:21 +0200 Subject: [PATCH 4/5] Carry the owner's full name through the account-access round trip test The account payload gained a full-name field; this integration test built its expected entries without it, so the exact-match assertions disagreed with the response. The fixtures name the field now, null for an owner who set no full name. --- tests/integration/sharing-account-access.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/integration/sharing-account-access.test.ts b/tests/integration/sharing-account-access.test.ts index a14fb0f69..3eca07f02 100644 --- a/tests/integration/sharing-account-access.test.ts +++ b/tests/integration/sharing-account-access.test.ts @@ -70,6 +70,7 @@ interface AccountAccessEntry { accountId: string; username: string; displayName: string | null; + fullName: string | null; /** * The legacy name, and deliberately narrower than `level`: it carries only * `read` and `write`, because that is the union v1.36.0 published and @@ -173,6 +174,7 @@ describe("accountAccess — what the payload publishes", () => { accountId: owner.id, username: owner.username, displayName: owner.displayName, + fullName: owner.fullName, access: "read", level: "read", // An adult who shared their own record. `managed` is reserved for a @@ -334,6 +336,7 @@ describe("accountAccess — the active record", () => { accountId: owner.id, username: owner.username, displayName: owner.displayName, + fullName: owner.fullName, access: "read", level: "read", recordKind: "shared", From 594898d350c5932161b7c40f1f6aa48ce408b16f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 18:46:55 +0200 Subject: [PATCH 5/5] Anchor the document visit-suggestion on a full instant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suggestion anchored on the document's own date, which the DTO carries as a date only. The suggest endpoint takes a full instant, so every document asked the question as `2025-10-14` and got 422 back; the field then rendered nothing and no document ever offered the visit its date falls near. Lift the date to noon UTC before asking, the shape the two lab moments already send. The e2e that should have caught it never authenticated: the visits spec carried no storage state, so its `page` redirected to login and its `request` reads answered 401. It also asserts a global verdict — one visit in the document's window — so clear the account's visits before each test, run the file serially, and keep it to one project, since two projects mutating the shared account would leave two candidates in the window. The accessibility mock lagged the same feature: the detail sheet reads `encounterLinks` off the document and the mock omitted it, so the sheet threw before it painted. Add the field the server always sends. --- e2e/a11y.spec.ts | 1 + e2e/setup/vault-fixture.ts | 27 +++++++++++++++++++ e2e/visits.spec.ts | 24 ++++++++++++++++- playwright.config.ts | 7 +++++ .../document-encounter-suggestion.tsx | 11 +++++++- 5 files changed, 68 insertions(+), 2 deletions(-) diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts index 24c98c53a..855a160a0 100644 --- a/e2e/a11y.spec.ts +++ b/e2e/a11y.spec.ts @@ -183,6 +183,7 @@ const inboundDocument = { factCount: 0, pendingCount: 0, conditionLinks: [], + encounterLinks: [], servingClass: "attachment", hasContentIndex: true, contentIndexSource: "vision", diff --git a/e2e/setup/vault-fixture.ts b/e2e/setup/vault-fixture.ts index dc9314e84..79d2e41a1 100644 --- a/e2e/setup/vault-fixture.ts +++ b/e2e/setup/vault-fixture.ts @@ -328,6 +328,33 @@ async function upsertDocs( } } +/** + * Remove the seeded e2e user's visits and their document links. + * + * The incidental-linking spec asserts a GLOBAL verdict — "exactly one visit + * falls in this document's ±7-day window" — so it must own the window it counts + * against. A visit created by an earlier run, a Playwright retry, or a second + * project sharing the account would leave two rows in the window and turn the + * single-candidate branch into the picker branch. Clearing first makes the + * count the test creates the only count it reads. `encounter_document_links` + * goes first for the FK; both are user-scoped. + */ +export async function resetEncounters(): Promise { + const url = process.env.DATABASE_URL; + if (!url) throw new Error("[vault-fixture] DATABASE_URL is not set"); + const pool = new pg.Pool({ connectionString: url }); + try { + const userId = await getUserId(pool); + await pool.query( + "DELETE FROM encounter_document_links WHERE user_id = $1", + [userId], + ); + await pool.query("DELETE FROM encounters WHERE user_id = $1", [userId]); + } finally { + await pool.end(); + } +} + /** * Enable the vault module for the seeded e2e user and plant the doctor-flow * corpus. Safe to call from every spec's beforeAll — every statement diff --git a/e2e/visits.spec.ts b/e2e/visits.spec.ts index 76daf12c0..de58676dd 100644 --- a/e2e/visits.spec.ts +++ b/e2e/visits.spec.ts @@ -19,15 +19,37 @@ * assertion below covers, and the linking half needs a visit whose instant is * pinned relative to the seeded document's date. */ +import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { expect, test } from "./setup/test"; -import { ensureVaultFixture, MRT_DOC_ID } from "./setup/vault-fixture"; +import { + ensureVaultFixture, + MRT_DOC_ID, + resetEncounters, +} from "./setup/vault-fixture"; test.beforeEach(async () => { + // Own the window the linking case counts against: clear the account's visits + // before each test so a retry or a prior run cannot leave a second candidate + // in the ±7-day window and flip the single-candidate verdict to the picker. + await resetEncounters(); await ensureVaultFixture(); }); +// Serial, and single-project below: the two tests share one account, and the +// reset above would race a sibling running in parallel — the linking test would +// clear the visit the save test is still asserting. In order, in one worker, +// each test owns the account for its span. +test.describe.configure({ mode: "serial" }); + test.describe("visits", () => { + // The seeded account owns the visits and the document this flow drives, and + // both the `page` and the `request` fixture read them under its session. + // Without this the whole file runs anonymous: `/checkups` redirects to login + // and every `/api/*` read answers 401, which is a test-setup gap, not a + // product one. + test.use({ storageState: STORAGE_STATE_PATH }); + test("a visit saves with a date and nothing else", async ({ page }) => { await page.goto("/checkups"); await page.getByTestId("checkups-tab-visits").click(); diff --git a/playwright.config.ts b/playwright.config.ts index 069501a15..421731c48 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -136,6 +136,13 @@ export default defineConfig({ // desktop project instead, where the shell below `md` renders from the // same width-driven rules. Its file comment carries the reasoning. "chrome-header-seam-banners.spec.ts", + // The incidental-linking journey creates a visit and files a document + // against it, then asserts a GLOBAL verdict — one candidate in the + // document's ±7-day window. Two projects mutating the one shared + // account in parallel would leave two visits in that window and flip + // the single-candidate branch to the picker. It proves a flow through + // stable data-slots, not a mobile layout, so it runs in one project. + "visits.spec.ts", // Runs only in the service-worker project. "v137-record-session-fence-offline.spec.ts", ], diff --git a/src/components/documents/document-encounter-suggestion.tsx b/src/components/documents/document-encounter-suggestion.tsx index d0c2ea3fc..65affd67e 100644 --- a/src/components/documents/document-encounter-suggestion.tsx +++ b/src/components/documents/document-encounter-suggestion.tsx @@ -12,6 +12,14 @@ * of the two; `documentDate` is the person's own filing date and is the * fallback. * + * Both DTO fields are date-only (`YYYY-MM-DD`), but the suggest endpoint takes + * a full instant — the same shape the two lab moments already hand it. So the + * date is lifted to noon UTC here, exactly as the OCR review does: the rule is + * a ±7-day window, so the time of day never changes the verdict, and noon keeps + * a viewer's local midnight from rounding the day either way. Passing the bare + * date instead is what made this suggestion silently never appear — the API + * answered 422 and the field rendered nothing. + * * It renders nothing once the document already names a visit that this session * did not choose: an offer to file something already filed is noise. */ @@ -31,9 +39,10 @@ export function DocumentEncounterSuggestion({ onChange: (encounterId: string | null) => void; }) { if (alreadyLinked) return null; + const instant = anchor ? `${anchor}T12:00:00.000Z` : null; return (