mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge remote-tracking branch 'origin/main' into feat/vaccinations-phase-1
This commit is contained in:
commit
4f2dd51390
17 files changed
+146
-39
No files matched your search
@@ -2,8 +2,24 @@
|
||||
|
||||
## [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
|
||||
|
||||
+2
-2
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -183,6 +183,7 @@ const inboundDocument = {
|
||||
factCount: 0,
|
||||
pendingCount: 0,
|
||||
conditionLinks: [],
|
||||
encounterLinks: [],
|
||||
servingClass: "attachment",
|
||||
hasContentIndex: true,
|
||||
contentIndexSource: "vision",
|
||||
|
||||
@@ -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<void> {
|
||||
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
|
||||
|
||||
+23
-1
@@ -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();
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 (
|
||||
<EncounterSuggestionField
|
||||
anchor={anchor}
|
||||
anchor={instant}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
@@ -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<DerivedMetricId, MetricProvenanceMeta> =
|
||||
},
|
||||
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,
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
)}
|
||||
<ProvenanceExplainer
|
||||
provenance={composite.provenance}
|
||||
method={t(METRIC_PROVENANCE.HEALTH_SCORE.methodKey)}
|
||||
standard={METRIC_PROVENANCE.HEALTH_SCORE.standard}
|
||||
method={t(HEALTH_SCORE_PROVENANCE.methodKey)}
|
||||
standard={HEALTH_SCORE_PROVENANCE.standard}
|
||||
className="block"
|
||||
/>
|
||||
{/* v1.35.0 — the one way into the surface that decides what
|
||||
|
||||
@@ -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<string, string> = {
|
||||
IRREGULAR: "insights.ecg.result.irregular",
|
||||
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user