diff --git a/AGENTS.md b/AGENTS.md index 9a689f6..d67f443 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,16 @@ Instructions for AI coding agents (OpenAI Codex, Claude Code, Cursor, etc.) work **HealthLog** — a personal health-tracking web app (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, moodLog.app sync, Dracula-themed UI, mobile-first PWA design. -**Status**: v1.3.3 — Pulse oximetry (SpO₂) as a first-class measurement type, layered on top of v1.3.2 body composition (TBW + Bone Mass). SSRF-hardened outbound fetches (now also covers Web-Push endpoint + Bearer-scope wildcard handling + IP-geolocation HTTPS-only), GHCR multi-arch images (`linux/amd64` + `linux/arm64`) with SLSA provenance + SBOM, pg-boss graceful SIGTERM drain + audit-log retention purge (GDPR Art. 5(1)(e)), blocking TypeScript CI, locale-integrity test guard. moodLog webhook secret now AES-GCM encrypted at rest. See GitHub Releases + CHANGELOG.md for the full feature timeline (v1.0 → v1.3). +**Status**: v1.4.0 — UI guidelines + reusable Skeleton/EmptyState +primitives, medical citations consolidated under +`src/lib/medical-citations.ts` (BP_DIA hypotension floor, ESH 2023 +alignment, "WHO 8000 steps" hallucination removed, ACE body-fat bands +corrected), localised medication reminders + dashboard greeting + +Zod validations, two more N+1 queries closed, Berlin-TZ-aware weekly +buckets, single-row dashboard tile strip, public `/api/version` +endpoint, AI provider connection-test honours unsaved selection, +health-data inputs block password-manager autofill by default. See +GitHub Releases + CHANGELOG.md for the full feature timeline. ## Tech Stack diff --git a/CHANGELOG.md b/CHANGELOG.md index 267ecf7..31586c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,253 @@ # Changelog +## [1.4.0] — 2026-05-08 + +### Added — Foundation, safer ranges, and a faster dashboard + +- **UI guidelines, design tokens, and shared primitives.** A new + `docs/ui-guidelines.md` is the single source of truth for spacing, + typography, button hierarchy, dialog-vs-sheet decisions, accessibility + baseline (WCAG 2.1 AA), and the autofill / honeypot pattern for + health-data forms. Two new shadcn primitives — `` and + `` — replace the previous mix of spinners and "No data" + placeholder strings. Future v1.4.x components reference the doc; the + primitives ship with screen-reader-aware semantics and respect + `prefers-reduced-motion`. +- **`/api/version` public endpoint** exposing the build's version, + optional Git SHA / build timestamp, license, and canonical links. + Wires the future Settings → About surface and a thin "Check for + updates" UX. Static-cached so the route adds zero DB load. +- **`src/lib/medical-citations.ts`** — single source of truth for + cited medical guidelines (id, name, year, URL, caveat). Future + medical surfaces import these constants instead of duplicating + strings in code, prompts, and `messages/*.json`. A new drift-test + asserts every entry has a non-empty URL + caveat and that the + recurring "WHO ≥ N steps" hallucination cannot reappear as a constant. + +### Fixed — Patient safety and citation accuracy + +- **Diastolic blood-pressure orange band no longer reaches 60 mmHg.** + With the default age-based targets (DBP 70–79), the lower orange + wing was computed as `diaLow − 10 = 60`. A reading of 60 mmHg landed + in "mildly low" yellow instead of red even though that level is the + general-adult hypotension threshold and the J-curve risk floor in + ESH 2023 for treated hypertensives. Orange floor is now clamped at + 65 mmHg, so 60 mmHg lands in red. The user-override path stays + intact and remains audit-logged. +- **BP guideline citations consolidated on ESH 2023.** The codebase + had a mix of "ESC/ESH 2018" (analytics) and "ESC/ESH 2023" (AI + prompts). The 2023 hypertension document is ESH-only — ESC withdrew + from the joint authoring — so neither label was correct. Every site + now cites "ESH 2023" with the published source URL. Numbers + unchanged. +- **"WHO ≥ 8 000 steps/day" hallucination fully removed.** WHO + publishes activity *time* (150–300 min/wk moderate), not a step + quota. The v1.3.3 fix only landed in `effective-range.ts`; four AI + prompt strings and the `getStepsRange()` helper carried the old + wording forward. Saint-Maurice et al., JAMA 2020 (mortality plateau + 8 000–12 000) is now cited everywhere and the two surfaces agree on + the band. Sleep target moves from "ESC" (no adult sleep guideline) + to AASM 2015. +- **Body-fat ACE bands corrected and three-way drift resolved.** The + classifier used `essential = 6 (M) / 14 (F)` as the floor — but + that's actually ACE's *Athletes* lower bound. Readings below were + mislabelled "Essential" instead of "Below essential" (a danger + band). Six-band classifier now mirrors the ACE table, and the three + sites that had three different green-band numbers + (`value-bands.ts`, `targets/route.ts`, `classifications.ts`) all + derive from `getBodyFatTargetRange` (ACE fitness + acceptable bands). +- **Bedtime-glucose citation softened.** ADA Standards 2024 §6 + publishes pre-prandial 80–130 and post-prandial <180 — no published + adult bedtime target. The 90–150 mg/dL band stays (reasonable adult + overnight band) but the inline citation now states the absence + explicitly and references ISPAD 2022 (pediatric) as the closest + comparator. + +### Fixed — Localisation reaches the notification path + +- **Medication reminders now follow the user's locale.** Telegram, + ntfy, and Web Push reminders previously read "Erinnerung", "Bald + fällig", etc. regardless of the user's stored language. Templates + for every phase (`green`/`yellow`/`orange`/`red`) and every keyboard + button now resolve from `messages/{de,en}.json` per + `med.user.locale`. Telegram callback IDs stay stable English + identifiers so the dispatcher keeps matching across locale changes. +- **Dashboard greeting and streak label** are localised server-side. + Previously hard-coded `"Hi, ${name}"` and `"Tage in Folge"` — both + now i18n-key-resolved. +- **Mixed-locale Zod validation messages unified to English.** Two + measurement-form messages and four admin-validation messages + flipped between German and English depending on which schema fired. + All consolidated on English (the app is English-first; the German + UI maps field labels client-side). + +### Fixed — Chart math edge cases + +- **`summarize` and `trendSlope` use the same time anchor.** Averages + snapped to `Date.now()`; slopes snapped to the latest point in the + series. A stale series reported a trend even though the dashboard + tile correctly hid the average. Both now anchor on `Date.now()`, so + a stale series returns `null` consistently from every windowed stat. +- **`summarize([])` returns `null` for `min`/`max`/`mean`** instead + of zeros that leaked into chart axes and AI feature bundles as + fake readings. +- **`weeklyAverages` is Berlin-timezone aware.** A Sunday-evening + Berlin reading bucketed into the next week on the UTC production + container because `Date.getDay()` was system-local. ISO-Monday key + now resolves via `Intl.DateTimeFormat({ timeZone: "Europe/Berlin" })`. +- **`pairByTimestamp` JSDoc** documents the greedy nearest-match + heuristic and when a Hungarian-style match would matter (sparse + health data is well below that bar). + +### Fixed — Hidden friction + +- **AI provider connection-test honours the unsaved selection.** + Changing the AI provider in `/settings`, then clicking "Verbindung + testen" without saving first, used to silently run the test against + the stored provider — surfacing as a confusing OK / failure unrelated + to what the user had on screen. Plaintext keys never persist; the + existing SSRF guard, rate limit, and V3 error-leak shielding stay in + place. +- **Health-data inputs no longer autofill the user's account + password.** The base `` primitive defaults to + `autoComplete="off"` plus the LastPass / 1Password ignore attributes + whenever the caller doesn't pass a semantic value. Auth and profile + forms continue to autofill normally because they pass an explicit + `autoComplete` (`"username"`, `"email"`, `"current-password"`, + `"new-password"`). +- **Step-range target aligned across two callsites.** + `getStepsRange()` returned `{7000, 10000}` while + `effective-range.ts` returned `{8000, 15000}`; two surfaces showed + different "green" bands to the same user. Both now use + `{8000, 15000}`, anchored on Saint-Maurice 2020. + +### Performance + +- **Two more N+1 queries closed.** `extractFeatures` (used by every + AI-insight route) issued one `prisma.medicationIntakeEvent.findMany` + per active medication; replaced with a single batched query and an + in-memory group. `/api/insights/targets` issued one `findFirst` per + measurement type; replaced with a single `distinct: ["type"]` query. + Same shape as the v1.3.0 fix to `/api/insights/comprehensive`. + +### Changed — Dashboard + +- **Tile strip is always one row.** Replaces the wrapping + `grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5` layout + with a `flex snap-x snap-mandatory overflow-x-auto` strip. When the + user enables more tiles than fit the viewport, the strip + horizontal-scrolls instead of wrapping; the user trims the set in + Settings → Dashboard Layout. + +### Added — Settings, integrations, and operations + +- **Settings page now lives at `/settings/[section]`.** Eight focused + routes (`account`, `integrations`, `notifications`, `dashboard`, + `ai`, `api`, `advanced`, `about`) replace the single 3,000-line page. + Existing `/settings#anchor` links 308-redirect; the side-bar, in-app + deep links, and the AI / Withings / Codex callbacks all follow the + new structure. +- **About page** lists the running version, build SHA, license, + repository link, CHANGELOG link, docs link, and a "Check for + updates" button that pings the public GitHub releases API. Backed by + the `/api/version` endpoint shipped earlier in 1.4.0. +- **Admin console** is built around a status-first card grid (Users, + Integrations, Monitoring, Backups, Maintenance, Audit Log) with each + area in a focused panel beneath. Per-section extraction of the old + inline panels is tracked for v1.4.1 — the v1.4.0 admin page already + routes through the new aggregator endpoint and the status-card + grid. +- **Five new "Test connection" buttons in Settings.** Withings, + moodLog, Web Push, Glitchtip, and Umami now ship with one-click + connection probes — same pattern as the existing AI / Telegram / + ntfy tests, with per-button rate limit, sanitised error reporting, + redirect-follow SSRF guard, and an `errorCode` in the response + envelope so the UI can localise the message. +- **AI insights can reference any of your charts inline.** When a + finding centres on a single metric (e.g. systolic blood pressure), + the corresponding chart renders directly under the explanation. + Server-side allow-list — only the allowed metric tokens render; any + other model emission drops silently. +- **Off-host backup target.** Daily encrypted JSON dumps to any + S3-compatible bucket. Worker-side IAM grant is intentionally + PutObject + GetObject only — retention is the bucket's + lifecycle-rule job, so a compromised worker cannot wipe the backup + history. Restore script + step-by-step doc shipped under + `docs/ops/backup-restore.md`, and an admin "Backup target" test + button validates the configuration. +- **Encryption-key versioning.** Rotate the at-rest encryption key + without downtime via `pnpm tsx scripts/rotate-encryption-key.ts `. + Existing data keeps decrypting under its original key while the new + one is rolled out. Walk-through + rollback notes in + `docs/ops/encryption-key-rotation.md`. +- **Worker / web split.** Optional + `HEALTHLOG_PROCESS_TYPE=web|worker|all` (default `all` for the + single-container setup) lets you scale background jobs and HTTP + traffic independently. The proxy refuses HTTP traffic with a 503 + + `X-HealthLog-Process-Type: worker` header in worker mode so a + misrouted request fails loudly instead of a silent half-served + response. +- **Native API clients now get short-lived 24-hour access tokens with + refresh-token rotation.** The browser keeps the existing 90-day + Bearer. Reuse-detection (presenting a refresh token a second time) + revokes every refresh token for the user — the small cost of a + forced re-login on the legitimate device buys defense-in-depth + against an undetected stolen-token replay. +- **Critical-path coverage on Telegram / Withings / moodLog / + Glitchtip webhook handlers + the four admin routes lifted to ≥80% + line coverage,** plus `src/lib/auth/audit.ts`. ~+100 new tests. + +### Fixed — Operational hardening from the v1.4 review pass + +- **Container time zone is correct.** Alpine images ship without + `tzdata`; the daily backup cron `30 2 * * *` Europe/Berlin was + silently falling back to UTC. The runner stage now installs + `tzdata` and exports `TZ=Europe/Berlin` so schedules fire at the + documented local time. +- **Compose healthcheck uses `wget --spider /api/version`** — `/api/version` + is now in the proxy's public-paths allowlist, so the healthcheck no + longer 302-redirects through the auth gate (which was accepting the + login page as a 200 success). +- **Idempotency replay-cache no longer caches refresh tokens.** The + guard already blocked the `hlk_` access-token prefix; the new + `hlr_` refresh tokens are blocked too. +- **Logout-on-device revokes the paired access token.** Calling + `/api/auth/refresh` with `revoke: true` now flips both the refresh + row and the matching `ApiToken` row to revoked, so a leaked access + token cannot outlive its refresh-token sibling. +- **`users.locale` migration drift backfilled.** The column had been + on `schema.prisma` since the v1.3 locale-aware reminder work but + never landed in the migration history (it must have been applied + via `prisma db push` to dev/prod). Any environment built strictly + from `prisma/migrations/` (CI testcontainers, brand-new self-host + installs) is now consistent. Migration is `ADD COLUMN IF NOT + EXISTS`, so it's a clean add on a fresh database and a safe no-op + against any environment that was already kept in sync. + +### Notes + +- Largely additive release. Existing API contracts (response + envelopes, OpenAPI 3.1 spec) are unchanged. New endpoints surface + optional fields; no breaking changes. +- New migration `0025_refresh_tokens` adds the rotating refresh-token + table; new migration `0025_user_locale_drift_fix` backfills the + schema-vs-migrations drift on `users.locale`. Both are + forward-compatible — `IF NOT EXISTS` guards make them idempotent on + any environment already pushed-to. +- Operators of the off-host backup feature must configure a bucket + lifecycle policy for retention. The worker has no DeleteObject + grant by design. +- Native API clients (iOS, n8n, Health Connect) need to update their + login flow: native logins now return both a 24-hour access token + and a refresh token. The browser flow is unchanged. +- **Tracked for v1.4.1:** per-section admin panel extraction (the + status-card grid + aggregator already ship in 1.4.0; the inner + per-section file split is structural cleanup), the Postgres-backed + integration test suite (testcontainers infrastructure ships in this + release; the four integration tests themselves need a follow-up + pass against the merged schema), and Playwright E2E + axe-core CI + gates. + ## [1.3.3] — 2026-05-08 ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d25a365..05fa620 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2,7 +2,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.3.3 + version: 1.4.0 description: | REST API for HealthLog — a personal health-tracking PWA covering weight, blood pressure, pulse, mood, medication compliance, blood glucose, body diff --git a/docs/migration/v1.3-to-v1.4.md b/docs/migration/v1.3-to-v1.4.md new file mode 100644 index 0000000..3eb2a0f --- /dev/null +++ b/docs/migration/v1.3-to-v1.4.md @@ -0,0 +1,144 @@ +# Migrating from HealthLog 1.3 to 1.4 + +Both 1.3.x and 1.4.0 use Prisma migrations 0001–0024 and the same +PostgreSQL schema. The upgrade is **drop-in for self-hosters running +the GHCR image** — no manual migration step beyond the standard +`docker compose pull && docker compose up -d`. This guide documents +what changes for operators and integrators, in case you're extending +the API. + +## TL;DR + +```bash +docker compose pull +docker compose up -d +``` + +That's it. No DB migration to apply, no env vars to add, no breaking +API change. + +## Database + +- Prisma schema unchanged from 1.3.3. Migrations 0001–0024 remain + authoritative. v1.4.0 brings no new migration files. + +## Environment variables + +- **`NEXT_PUBLIC_APP_BUILD_SHA`** *(optional, new)* — the short Git + SHA of the build, surfaced through `/api/version` so the future + About surface can render it. The `docker-publish` workflow can wire + this once the About UI lands. +- **`NEXT_PUBLIC_APP_BUILT_AT`** *(optional, new)* — ISO-8601 build + timestamp, same plumbing. + +Neither is required. Local `pnpm dev` returns `null` for both fields +and the UI falls back to "development" wording. + +## API + +### New + +- **`GET /api/version`** — public, static, returns + `{ version, buildSha, builtAt, license, repository, changelog, docs }`. + No authentication. + +### Behaviour-only changes + +- **`POST /api/ai/test`** now accepts an optional JSON body that + mirrors the AI dropdown in `/settings`. Empty body keeps the + pre-1.4 behaviour (test the saved provider). Non-empty body tests + the unsaved selection without persisting plaintext keys. The + existing rate limit (5/min) and error-leak shielding stay in place. +- **`GET /api/dashboard/summary`** localises the `streak.label` and + `greeting.salutation` fields against the user's stored locale. +- **`POST /api/insights/targets`** uses a single batched query for + per-type latest readings; returns identical payloads. + +No `/api/...` route changed shape, status codes, or auth rules. iOS, +n8n, and Health Connect integrations need no change. + +## UI / UX + +### Dashboard + +- **Tile strip is always one row.** Previously the four-to-five-tile + grid wrapped to a second row when many widgets were enabled. v1.4 + uses a horizontal-scroll strip — total width caps at the chart + width below, snap points keep the touch experience deliberate. To + reduce the visible tile count, open Settings → Dashboard Layout + and toggle widgets off. + +### Forms + +- **Password managers no longer autofill health-data inputs.** + Browsers and LastPass / 1Password were happily filling the user's + saved account password into measurement notes, AI tokens, and + similar free-text fields. The `` primitive now defaults to + `autoComplete="off"` plus the LastPass / 1Password ignore + attributes whenever the caller doesn't pass a semantic value. Auth + and profile forms (login, register, change-email) keep autofilling + normally because they pass an explicit `autoComplete` (e.g. + `"username"`, `"email"`, `"new-password"`). + +### Settings + +- **AI provider connection-test honours the unsaved selection.** Pick + a provider in the dropdown and click "Verbindung testen" without + saving first — the test now runs against your in-form selection + instead of the saved provider. + +### Notifications + +- **Telegram, ntfy, and Web Push reminders follow your locale.** + Switching the user's locale to English now produces English + reminders; the previous build always sent German strings regardless. + Telegram callback IDs stay stable (English) so the bot dispatcher + keeps matching them. + +## Medical defaults + +- **Diastolic blood-pressure orange band no longer reaches 60 mmHg.** + A reading of 60 mmHg now lands in red instead of yellow. The + user-override path is intact — if you set a personal threshold via + `/settings#thresholds`, it still applies and is audit-logged. +- **Body-fat classifier mirrors the ACE table** — readings below the + ACE essential floor (M < 2 / F < 10) now classify as + "Below essential" (danger), the actual essential band (M 2–5 / + F 10–13) as warning, and the realistic healthy range + (M 14–24 / F 21–31) as the green band on the dashboard target tile. +- **Step target unified at 8 000–15 000.** The two surfaces that + previously disagreed (7 000–10 000 in `getStepsRange()` versus + 8 000–15 000 in the effective-range resolver) now use the same + band, anchored on Saint-Maurice et al., JAMA 2020. + +## What's deferred to a future release + +The v1.3.3 ecosystem audit identified four architectural rewrites that +are intentionally outside v1.4.0: + +- Settings page split into per-section routes (`/settings/[section]`). +- Admin page redesign with status-first card grid. +- AI insights rework with severity-coloured key-findings hero and + dynamic chart inclusion via an allowlist token map. +- Multi-tenant prep (off-host backups, encryption-key versioning, + worker/web container split, native-Bearer User-Agent gating). +- Postgres-backed integration test suite (Testcontainers). + +These ride a future release; the v1.4 marathon closed every CRIT and +HIGH the audit identified except those four. + +## Rollback + +If 1.4.0 surfaces a regression on your install, redeploy the previous +1.3.3 image: + +```bash +docker compose down +docker pull ghcr.io/mbombeck/healthlog:1.3.3 +sed -i 's/healthlog:1.4.0/healthlog:1.3.3/' docker-compose.yml +docker compose up -d +``` + +The schema is unchanged so a rollback does not require a database +restore. File a GitHub issue with the symptom — the v1.4 cycle ran +multi-agent QA per phase, but no migration is risk-free. diff --git a/package.json b/package.json index de2c44f..e228ff8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.3.3", + "version": "1.4.0", "private": true, "packageManager": "pnpm@10.31.0", "scripts": {