diff --git a/.env.production.example b/.env.production.example index 0cfdc6a33..e4f73447e 100644 --- a/.env.production.example +++ b/.env.production.example @@ -65,6 +65,9 @@ API_TOKEN_HMAC_KEY="" # Deploy webhook -- Coolify -> HealthLog auto-deploy feedback # ----------------------------------------------------------------------------- # DEPLOY_WEBHOOK_SECRET="" +# Optional: deployment-dashboard URL (e.g. the Coolify UI) linked in +# deploy-failure notifications. When unset the notification omits the link. +# DEPLOY_LOGS_URL="" # ----------------------------------------------------------------------------- diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index b92a36a19..45d38017b 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -313,15 +313,15 @@ jobs: run: | docker buildx imagetools inspect '${{ env.IMAGE_REF }}:${{ steps.meta.outputs.version }}' - # Phase C2 (v1.4.15): trigger Coolify deploy automatically once GHCR has - # the new image. Removes the manual SSH + force-pull recipe documented - # in `.planning/phase-8-report.md`. + # v1.4.15: trigger the Coolify deploy automatically once GHCR has + # the new image. Replaces the former manual SSH + force-pull recipe + # on the deployment host. # # Runs only on the publish path (push to `main` or to a `v*` tag), never # on PRs. `continue-on-error: true` because the image is *already* # published to GHCR by the time we reach this step — a Coolify-side - # outage must not retroactively fail the build. Marc still has the - # manual recipe as fallback. + # outage must not retroactively fail the build. The maintainer still + # has the manual recipe as fallback. # # Required GitHub repository secrets (set by the maintainer once): # COOLIFY_WEBHOOK = full deploy URL incl. ?uuid=...&force=false @@ -377,8 +377,8 @@ jobs: # new digest. The webhook fires inside that window, so # Coolify pulls "latest" while the edge still serves the # prior digest, gets "no change", and returns success. - # See `.planning/round-coolify-auto-deploy-fix-2026-05-16.md` - # for the full root-cause + remediation note. + # Waiting out the propagation window before firing the + # webhook closes that gap. echo "Waiting 90 s for GHCR cache propagation before triggering Coolify (image: $IMAGE_VERSION)…" sleep 90 # v1.4.22 C3 — force=true tells Coolify to skip its image-cache @@ -405,8 +405,8 @@ jobs: echo "Coolify response (HTTP $status):" echo "$body" if [ "$status" -lt 200 ] || [ "$status" -ge 300 ]; then - echo "::error::Coolify deploy webhook returned HTTP $status — image is published, but auto-deploy did not fire. Marc: deploy manually via SSH recipe in .planning/phase-8-report.md." + echo "::error::Coolify deploy webhook returned HTTP $status — image is published, but auto-deploy did not fire. Maintainer: deploy manually on the deployment host (SSH force-pull recipe, see docs/ops/deploy.md)." exit 1 fi - echo "::notice::Coolify auto-deploy webhook fired at $(date -u +%FT%TZ) for ref ${{ github.ref_name }} (sha ${GITHUB_SHA::7}). If /api/version doesn't reach the new tag within ~60s, see .planning/coolify-auto-deploy-howto.md." + echo "::notice::Coolify auto-deploy webhook fired at $(date -u +%FT%TZ) for ref ${{ github.ref_name }} (sha ${GITHUB_SHA::7}). If /api/version doesn't reach the new tag within ~60s, re-trigger the deploy from the Coolify UI or deploy manually on the deployment host (docs/ops/deploy.md)." echo "Coolify deploy queued — awaiting outgoing notification webhook to /api/internal/deploy-webhook for final status." diff --git a/docker-compose.yml b/docker-compose.yml index 5cc35e198..1f382c67a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -65,6 +65,13 @@ services: # container (the `environment:` block is a whitelist). WHOOP_WEBHOOK_SECRET: "${WHOOP_WEBHOOK_SECRET:-}" WHOOP_REDIRECT_URI: "${WHOOP_REDIRECT_URI:-}" + # Deploy-result webhook (optional). DEPLOY_WEBHOOK_SECRET authenticates + # POST /api/internal/deploy-webhook; DEPLOY_LOGS_URL is the + # deployment-dashboard URL (e.g. the Coolify UI) linked in + # deploy-failure notifications. Listed here so operator values in + # .env reach the container (the `environment:` block is a whitelist). + DEPLOY_WEBHOOK_SECRET: "${DEPLOY_WEBHOOK_SECRET:-}" + DEPLOY_LOGS_URL: "${DEPLOY_LOGS_URL:-}" # TLS leaf SPKI-change alarm baseline (optional). The native client # pins the served TLS leaf; this is the known-good pin set the monitor # alarms against on a rotation. Comma-separated for the dual-pin diff --git a/docs/apple-store-connect-checklist.md b/docs/apple-store-connect-checklist.md index 323a419b6..88806d1d1 100644 --- a/docs/apple-store-connect-checklist.md +++ b/docs/apple-store-connect-checklist.md @@ -1,7 +1,7 @@ --- file: docs/apple-store-connect-checklist.md purpose: Step-by-step ASC submission checklist for HealthLog iOS — covering medical-device declaration, privacy nutrition labels, and ASC metadata -audience: Marc (the maintainer) before submitting to App Store +audience: the maintainer, before submitting to App Store estimated_read_time: 15 min last_updated: 2026-05-15 --- @@ -10,7 +10,7 @@ last_updated: 2026-05-15 ## TL;DR -Three submission gates that need Marc's hand at ASC: +Three submission gates that need the maintainer's hand at ASC: 1. Regulated-Medical-Device declaration (Guideline 1.4 + 5.1.1(ix)) 2. Privacy Nutrition Labels matching the iOS Privacy Manifest (Guideline 5.1.2) 3. Standard metadata (name, description, screenshots, categories, age rating) @@ -29,7 +29,7 @@ Plus pre-submit final checks. **What to check**: - [ ] Check "No" on the medical-device toggle - [ ] Add the description above to the medical-device-justification field if Apple shows one -- [ ] Cross-reference the in-app `/privacy` page (https://healthlog.bombeck.io/privacy) §7 MDR-boundary +- [ ] Cross-reference the in-app `/privacy` page (https:///privacy) §7 MDR-boundary **Why this matters**: Apple's first-submission rejection rate for health apps is ~40%. The most common reason is unclear medical-device classification. The above wording mirrors the in-app disclaimer + the per-feature gating; consistency between the form and the app is what Apple looks for. @@ -37,7 +37,7 @@ Plus pre-submit final checks. **Where**: App Store Connect → App Privacy → "Edit" the privacy choices. -**Cross-reference**: every data category here MUST match what the iOS Privacy Manifest (in the iOS repo) declares. iOS-Claude's v0.3.0 release extended the manifest to: Email + OtherUserContent + SensitiveInfo + DeviceID + UserID + AccountManagement-purpose. +**Cross-reference**: every data category here MUST match what the iOS Privacy Manifest (in the iOS repo) declares. The iOS client's v0.3.0 release extended the manifest to: Email + OtherUserContent + SensitiveInfo + DeviceID + UserID + AccountManagement-purpose. ### Data categories to declare @@ -86,7 +86,7 @@ For each category below: **Linked to user, used for App Functionality + Account - Location (Precise or Coarse) — workout GPS routes are opt-in per-workout; if the iOS app implements them, declare; otherwise omit - Purchases - Financial Info -- Diagnostics, Crash Data, Performance Data, Other Diagnostic Data — if iOS uses any analytics SDK, declare; otherwise omit. Verify with iOS-Claude. +- Diagnostics, Crash Data, Performance Data, Other Diagnostic Data — if iOS uses any analytics SDK, declare; otherwise omit. Verify against the iOS client. - Advertising Data - Other Usage Data @@ -101,7 +101,7 @@ For each category below: **Linked to user, used for App Functionality + Account - [ ] Subtitle: 30-char tagline (suggestion: "Personal health-data log + Coach") - [ ] Primary category: Health & Fitness - [ ] Secondary category: Medical (if Apple accepts non-medical-device classification for it) -- [ ] Content rights: confirm Marc owns the app rights +- [ ] Content rights: confirm the maintainer owns the app rights - [ ] Age rating: 17+ (because Sensitive Health Info content) ### Description @@ -114,36 +114,36 @@ For each category below: **Linked to user, used for App Functionality + Account - [ ] iPad screenshots if supporting iPad. ### URLs -- [ ] Marketing URL: https://healthlog.bombeck.io (or marketing site once landing repo's v1.4.25 deployed) -- [ ] Privacy Policy URL: **https://healthlog.bombeck.io/privacy** (ships with v1.4.25.1 hotfix) +- [ ] Marketing URL: https:// (or the marketing site once the landing repo's v1.4.25 is deployed) +- [ ] Privacy Policy URL: **https:///privacy** (ships with v1.4.25.1 hotfix) - [ ] Support URL: https://github.com/MBombeck/HealthLog/issues ### App Review Information -- [ ] Demo account credentials: Marc creates a read-only demo account on `demo.healthlog.dev` and provides credentials in the demo account fields +- [ ] Demo account credentials: the maintainer creates a read-only demo account on `demo.healthlog.dev` and provides credentials in the demo account fields - [ ] Review notes: explain MDR boundary + AI Coach safety contract + Research Mode gating; reference `/privacy` §7 MDR-boundary. Apple reviewers DO read this and DO check the in-app behavior matches the claim. - [ ] Sign-in required: yes (provide demo credentials) ### Pricing + Availability - [ ] Free -- [ ] Availability: select countries (Marc-decision — recommend EU + US + Canada + Australia + Japan for first launch) +- [ ] Availability: select countries (maintainer decision — recommend EU + US + Canada + Australia + Japan for first launch) ## Section 4 — Pre-Submit Final Check Run through this list: -- [ ] Privacy Policy URL responds 200 (curl https://healthlog.bombeck.io/privacy) +- [ ] Privacy Policy URL responds 200 (curl https:///privacy) - [ ] iOS Privacy Manifest categories match the App Privacy declarations in §2 - [ ] Demo account works on the demo server - [ ] iOS app's TestFlight build is on the latest commit -- [ ] Marc has the APNs .p8 backed up to 1Password or similar (see `.planning/v15-apns-key-handling.md`) -- [ ] Marc's Apple Developer Program subscription is active +- [ ] The maintainer has the APNs .p8 backed up to a password manager +- [ ] The maintainer's Apple Developer Program subscription is active ## Section 5 — Common First-Submission Rejection Reasons (Apple Health-App-Specific) - Guideline 1.4.1: "Medical claims" — wording in app or screenshots implies diagnosis/treatment → fix: tone down wording, lean on the MDR-boundary disclaimer -- Guideline 5.1.1(v): "Account Deletion missing" — iOS-Claude added this in v0.3.0 -- Guideline 5.1.2(i): "Third-Party AI consent" — DEFERRED to v0.3.1 per iOS-Claude release notes; resubmission may flag -- Guideline 5.1.2: "Privacy Manifest mismatch" — see §2; iOS-Claude needs to ensure the manifest matches +- Guideline 5.1.1(v): "Account Deletion missing" — the iOS client added this in v0.3.0 +- Guideline 5.1.2(i): "Third-Party AI consent" — DEFERRED to v0.3.1 per the iOS release notes; resubmission may flag +- Guideline 5.1.2: "Privacy Manifest mismatch" — see §2; the iOS release process needs to ensure the manifest matches ## Section 6 — Post-Submit / If Rejected diff --git a/eslint.config.mjs b/eslint.config.mjs index 74782b5a5..a1d07e8eb 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -13,8 +13,8 @@ const eslintConfig = defineConfig([ "out/**", "build/**", "next-env.d.ts", - // v1.4.41 — ignore Claude Code session worktrees so a mid-marathon - // worktree on an older commit can't poison `pnpm lint` locally. + // v1.4.41 — ignore local tool worktrees so a worktree on an older + // commit can't poison `pnpm lint` locally. // CI never sees this path; the rule is for the dev environment. ".claude/**", // The project-local ESLint rule plugin is CommonJS (require/module @@ -22,11 +22,11 @@ const eslintConfig = defineConfig([ // is not linted by the app's TypeScript ruleset. "eslint-plugins/**", ]), - // v1.4.41 W-PROCESS-DOCS — custom rule that flags any bare-array + // v1.4.41 — custom rule that flags any bare-array // `queryKey: [ … ]` / `mutationKey: [ … ]` declaration inside the - // files the queryKeys factory currently guards. Promotes the - // test-guard substitute from v1.4.40 W-RSC to a real ESLint rule - // so IDE + CI both fail fast on a factory bypass. See + // files the queryKeys factory currently guards. Promotes the earlier + // v1.4.40 test-guard substitute to a real ESLint rule so IDE + CI + // both fail fast on a factory bypass. See // `eslint-plugins/healthlog/queryKey-factory.js` for the // whitelist + rationale. { diff --git a/scripts/assert-deploy.ts b/scripts/assert-deploy.ts index ea1e677d7..554d450ee 100644 --- a/scripts/assert-deploy.ts +++ b/scripts/assert-deploy.ts @@ -13,7 +13,11 @@ * Run it after every deploy — the queued-deploy status is not the source * of truth, the served version is: * - * pnpm dlx tsx scripts/assert-deploy.ts + * HEALTHLOG_PROD_URL=https://healthlog.example.com \ + * pnpm dlx tsx scripts/assert-deploy.ts + * + * The prod target's base URL comes from HEALTHLOG_PROD_URL (required + * unless --only=demo). * * Examples: * @@ -36,8 +40,15 @@ interface Target { url: string; } -const TARGETS: Record = { - prod: { name: "prod", url: "https://healthlog.bombeck.io/api/version" }, +// The production base URL is instance-specific — there is no meaningful +// default for a self-hosted app. Set HEALTHLOG_PROD_URL (e.g. +// `https://healthlog.example.com`) before asserting the prod target. +const PROD_BASE_URL = process.env.HEALTHLOG_PROD_URL?.replace(/\/+$/, ""); + +const TARGETS: Record = { + prod: PROD_BASE_URL + ? { name: "prod", url: `${PROD_BASE_URL}/api/version` } + : undefined, demo: { name: "demo", url: "https://demo.healthlog.dev/api/version" }, }; @@ -140,14 +151,26 @@ async function main(): Promise { let selected: Target[]; if (only) { + if (!(only in TARGETS)) { + console.error(`unknown --only target: ${only} (use prod or demo)`); + process.exit(2); + } const picked = TARGETS[only]; if (!picked) { - console.error(`unknown --only target: ${only} (use prod or demo)`); + console.error( + `target "${only}" needs HEALTHLOG_PROD_URL set to the instance base URL (e.g. https://healthlog.example.com)`, + ); process.exit(2); } selected = [picked]; } else { - selected = Object.values(TARGETS); + if (!TARGETS.prod) { + console.error( + "HEALTHLOG_PROD_URL is not set — set it to the instance base URL (e.g. https://healthlog.example.com) or use --only=demo", + ); + process.exit(2); + } + selected = Object.values(TARGETS).filter((t): t is Target => Boolean(t)); } console.log( diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json index 08c4fbea2..ab266a882 100644 --- a/scripts/env-manifest.json +++ b/scripts/env-manifest.json @@ -87,6 +87,10 @@ { "name": "DEPLOY_WEBHOOK_SECRET", "purpose": "Shared secret for the X-Deploy-Webhook-Secret header on POST /api/internal/deploy-webhook. 64 hex chars." + }, + { + "name": "DEPLOY_LOGS_URL", + "purpose": "URL of the deployment dashboard (e.g. the Coolify UI) linked in deploy-failure notifications. Optional; when unset the notification omits the link." } ] }, diff --git a/src/__tests__/proxy-csp-hsts-hardening.test.ts b/src/__tests__/proxy-csp-hsts-hardening.test.ts index 39023d124..2624e1f1d 100644 --- a/src/__tests__/proxy-csp-hsts-hardening.test.ts +++ b/src/__tests__/proxy-csp-hsts-hardening.test.ts @@ -6,7 +6,7 @@ import { NextRequest } from "next/server"; * needed to harden against first-visit MITM and to scope the * Withings backend host to its surface only. * - * - HSTS now carries `; preload` so `healthlog.bombeck.io` is + * - HSTS now carries `; preload` so the served host is * eligible for the Chromium HSTS preload list. * - The `wbsapi.withings.net` `connect-src` entry only ships on * the Withings surfaces (`/settings/integrations/withings/*` diff --git a/src/app/.well-known/apple-app-site-association/route.ts b/src/app/.well-known/apple-app-site-association/route.ts index 5cddf1821..2f692e636 100644 --- a/src/app/.well-known/apple-app-site-association/route.ts +++ b/src/app/.well-known/apple-app-site-association/route.ts @@ -21,8 +21,8 @@ import { NextResponse } from "next/server"; * it to devices, so the origin only needs to respond reliably; Apple's * one-hour CDN TTL pairs with the `Cache-Control` directive below. * - * Both `healthlog.bombeck.io` (maintainer prod) and `demo.healthlog.dev` - * (demo) share this handler — the response is host-independent. + * Every host serving this app (an operator's instance, the public demo) + * shares this handler — the response is host-independent. */ const AASA_APP_ID = "S8WDX4W5KX.dev.healthlog.app"; diff --git a/src/app/api/internal/deploy-webhook/route.ts b/src/app/api/internal/deploy-webhook/route.ts index 12baf3bb0..d60655740 100644 --- a/src/app/api/internal/deploy-webhook/route.ts +++ b/src/app/api/internal/deploy-webhook/route.ts @@ -9,7 +9,7 @@ import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-loca import { prisma } from "@/lib/db"; /** - * Deploy-status webhook (phase C2 / v1.4.15). + * Deploy-status webhook (v1.4.15). * * Coolify (Settings → Notifications → Webhook) calls this endpoint after * every deploy attempt. We verify the request via a shared secret in the @@ -135,7 +135,10 @@ async function notifyAdminsOfFailure(event: NormalizedEvent): Promise { application: event.applicationName, error: event.error ?? "", deployment: event.deploymentUuid ?? "", - logsUrl: "https://apps-01.bombeck.io", + // Optional: operators can point this at their deployment + // dashboard (e.g. the Coolify UI). Falls back to an empty + // string so the body template still composes cleanly. + logsUrl: process.env.DEPLOY_LOGS_URL ?? "", }, metadata: { source: "deploy-webhook", diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx index 511b9f588..737aea725 100644 --- a/src/app/privacy/page.tsx +++ b/src/app/privacy/page.tsx @@ -8,7 +8,7 @@ import Link from "next/link"; * * 1. **Apple App Store Connect** requires a publicly reachable Privacy- * Policy URL before the iOS native app's first submission. The URL - * registered with ASC is `https://healthlog.bombeck.io/privacy`. + * registered with ASC is this route on the maintainer's instance. * 2. **GDPR / DSGVO Art. 13 / 14 + Art. 15-22** — data-subject rights * enumerated with concrete in-app routes the user can hit. * 3. **EU MDR 2017/745 + MDCG 2021-24** — the boundary "HealthLog is @@ -342,14 +342,12 @@ export default function PrivacyPage() { > github.com/MBombeck/HealthLog {" "} - veröffentlicht. Diese Erklärung beschreibt, wie die unter{" "} + veröffentlicht. Diese Erklärung beschreibt, wie die + Instanz, über die Sie diese Seite abrufen, und die + zugehörige iOS-Anwendung HealthLog for iOS{" "} + (Bundle-ID{" "} - healthlog.bombeck.io - {" "} - betriebene Instanz und die zugehörige iOS-Anwendung{" "} - HealthLog for iOS (Bundle-ID{" "} - - io.bombeck.healthlog + dev.healthlog.app ) personenbezogene Daten verarbeiten.

@@ -359,7 +357,7 @@ export default function PrivacyPage() { Deutschland). Eigenständig betriebene, selbst-gehostete Instanzen unterliegen der Datenschutzerklärung ihres jeweiligen Betreibers; dieses Dokument gilt ausschließlich - für die genannte Domain. + für diese Instanz.

Für Fragen oder Datenschutz-Anfragen siehe Abschnitt 11 @@ -381,14 +379,11 @@ export default function PrivacyPage() { github.com/MBombeck/HealthLog {" "} under the GNU Affero General Public License v3.0. This - policy describes how the hosted instance at{" "} + policy describes how the instance serving this page and + the companion iOS application HealthLog for iOS{" "} + (bundle identifier{" "} - healthlog.bombeck.io - {" "} - and the companion iOS application{" "} - HealthLog for iOS (bundle identifier{" "} - - io.bombeck.healthlog + dev.healthlog.app ) handle personal data.

@@ -397,7 +392,7 @@ export default function PrivacyPage() { this instance (an individual based in Germany). Self- hosted deployments controlled by a different operator are governed by that operator's own privacy policy; this - document applies only to the hostname above. + document applies only to this instance.

For questions or data-subject requests, see section 11 @@ -1211,8 +1206,8 @@ export default function PrivacyPage() { /> ", () => { // The 13-section strip is ~1700 CSS px wide. Without // `no-scrollbar` (defined in `globals.css`) Chromium/WebKit paint // an always-on horizontal scrollbar at the top of every admin - // page. Production probe at https://healthlog.bombeck.io/admin/ - // api-tokens at Pixel-5 viewport pins this as the *only* element + // page. A live probe of /admin/api-tokens at a Pixel-5 viewport + // pins this as the *only* element // with `overflow-x:auto AND scrollWidth > clientWidth`, so the // class is the load-bearing fix. const html = renderShell({ active: "api-tokens" }); diff --git a/src/components/i18n/maintainership-banner.tsx b/src/components/i18n/maintainership-banner.tsx index 2779d0783..f1ce67ed1 100644 --- a/src/components/i18n/maintainership-banner.tsx +++ b/src/components/i18n/maintainership-banner.tsx @@ -9,7 +9,7 @@ import { useTranslations } from "@/lib/i18n/context"; * v1.4.25 W9e — notice strip for AI-initial locales. * * HealthLog ships German + English as maintainer-curated translations - * (the maintainer, Marc, owns both bodies). French, Spanish, Italian + * (the maintainer owns both bodies). French, Spanish, Italian * and Polish ship as AI-initial 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 diff --git a/src/lib/ai/codex-client.ts b/src/lib/ai/codex-client.ts index 1c0c6b375..0c9ccaea8 100644 --- a/src/lib/ai/codex-client.ts +++ b/src/lib/ai/codex-client.ts @@ -92,7 +92,7 @@ function isSlugRejection(status: number, bodyExcerpt: string): boolean { } const ORIGINATOR = "healthlog"; -const USER_AGENT = "HealthLog/1.0 (+https://healthlog.bombeck.io)"; +const USER_AGENT = "HealthLog/1.0 (+https://github.com/MBombeck/HealthLog)"; interface CodexClientConfig { accessToken: string; diff --git a/src/lib/openapi/registry.ts b/src/lib/openapi/registry.ts index 77cebceb3..16ff106df 100644 --- a/src/lib/openapi/registry.ts +++ b/src/lib/openapi/registry.ts @@ -52,7 +52,10 @@ const openApiBase: Pick< contact: { name: "HealthLog", url: "https://healthlog.dev" }, }, servers: [ - { url: "https://healthlog.bombeck.io", description: "Production" }, + { + url: "https://healthlog.example.com", + description: "Your self-hosted instance", + }, { url: "http://localhost:3000", description: "Local dev" }, ], tags: [ diff --git a/src/lib/tz/resolver.ts b/src/lib/tz/resolver.ts index e3191f4e6..6274e399c 100644 --- a/src/lib/tz/resolver.ts +++ b/src/lib/tz/resolver.ts @@ -65,8 +65,9 @@ const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { * Format a Date as `YYYY-MM-DD` in `Europe/Berlin`. The seven Insight * status helpers (`bmi`, `blood-pressure`, `weight`, `pulse`, `mood`, * `medication-compliance`, `general`) all key their per-day cache on - * this — Marc's primary tenant runs in Berlin and the cache action - * row reads identically across helpers when the formatter lives here. + * this — the maintainer's primary tenant runs in Berlin and the cache + * action row reads identically across helpers when the formatter lives + * here. * * For per-user surfaces, prefer `userDayKey(date, tz)` from `./format`. * This helper exists for the cache-key path where the day-bucket is diff --git a/tests/integration/bp-in-target.test.ts b/tests/integration/bp-in-target.test.ts index 0d5f8e25a..19ce8b601 100644 --- a/tests/integration/bp-in-target.test.ts +++ b/tests/integration/bp-in-target.test.ts @@ -1,8 +1,8 @@ /** * Integration regression for the "BD-Zielbereich" / BP-in-target tile. * - * Marc reported (v1.4.16 marathon) that the dashboard tile rendered - * 0 % despite multiple BP readings clearly inside the well-controlled + * A v1.4.16 bug report showed the dashboard tile rendering 0 % + * despite multiple BP readings clearly inside the well-controlled * range. v1.4.15 A4 had restructured the calculation but kept the * ESH 2023 narrow-band semantics (`sysLow <= sys <= sysHigh AND diaLow * <= dia <= diaHigh`) — which collapses to 0 % for any user whose @@ -73,15 +73,15 @@ describe("BP-in-target % — production data shape", () => { it("computes a non-zero % for a normotensive user born under 65", async () => { const prisma = getPrismaClient(); - // Marc's actual production readings (anonymised but timestamps + values - // verbatim so a future reviewer can re-derive the expected count by - // hand from the test fixture). DOB: 1985-07-09 — under 65, so target - // is sysHigh = 129, diaHigh = 79. + // Synthetic but representative normotensive readings for a user + // under 65, annotated IN/OUT so a future reviewer can re-derive the + // expected count by hand from the test fixture. DOB 1980-01-15 — + // under 65, so target is sysHigh = 129, diaHigh = 79. const user = await prisma.user.create({ data: { username: "bp-in-target-fixture", email: "bp-in-target@example.test", - dateOfBirth: new Date("1985-07-09"), + dateOfBirth: new Date("1980-01-15"), }, }); @@ -90,16 +90,16 @@ describe("BP-in-target % — production data shape", () => { dia: number; measuredAt: string; }> = [ - { sys: 117, dia: 79, measuredAt: "2026-05-08T07:38:22Z" }, // IN - { sys: 122, dia: 86, measuredAt: "2026-05-03T21:22:02Z" }, // OUT (dia) - { sys: 108, dia: 76, measuredAt: "2026-05-03T05:51:45Z" }, // IN - { sys: 106, dia: 73, measuredAt: "2026-05-03T05:50:55Z" }, // IN - { sys: 127, dia: 86, measuredAt: "2026-04-20T05:57:42Z" }, // OUT (dia) - { sys: 115, dia: 78, measuredAt: "2026-04-18T06:59:29Z" }, // IN - { sys: 108, dia: 75, measuredAt: "2026-04-16T05:24:51Z" }, // IN - { sys: 124, dia: 82, measuredAt: "2026-04-15T05:34:35Z" }, // OUT (dia) - { sys: 126, dia: 80, measuredAt: "2026-04-15T05:33:44Z" }, // OUT (dia) - { sys: 133, dia: 95, measuredAt: "2026-04-15T20:52:26Z" }, // OUT (both) + { sys: 118, dia: 78, measuredAt: "2026-05-08T07:40:00Z" }, // IN + { sys: 124, dia: 85, measuredAt: "2026-05-03T21:20:00Z" }, // OUT (dia) + { sys: 110, dia: 75, measuredAt: "2026-05-03T06:00:00Z" }, // IN + { sys: 104, dia: 72, measuredAt: "2026-05-03T05:50:00Z" }, // IN + { sys: 126, dia: 84, measuredAt: "2026-04-20T06:00:00Z" }, // OUT (dia) + { sys: 116, dia: 77, measuredAt: "2026-04-18T07:00:00Z" }, // IN + { sys: 109, dia: 74, measuredAt: "2026-04-16T05:25:00Z" }, // IN + { sys: 123, dia: 83, measuredAt: "2026-04-15T05:35:00Z" }, // OUT (dia) + { sys: 127, dia: 81, measuredAt: "2026-04-15T05:34:00Z" }, // OUT (dia) + { sys: 134, dia: 94, measuredAt: "2026-04-15T20:50:00Z" }, // OUT (both) ]; // Seed both sys and dia rows at the same `measuredAt` so the helper's @@ -154,7 +154,7 @@ describe("BP-in-target % — production data shape", () => { data: { username: "bp-empty-fixture", email: "bp-empty@example.test", - dateOfBirth: new Date("1985-07-09"), + dateOfBirth: new Date("1980-01-15"), }, }); @@ -184,7 +184,7 @@ describe("BP-in-target % — production data shape", () => { data: { username: "bp-sys-only-fixture", email: "bp-sys-only@example.test", - dateOfBirth: new Date("1985-07-09"), + dateOfBirth: new Date("1980-01-15"), }, }); @@ -224,7 +224,7 @@ describe("BP-in-target % — production data shape", () => { data: { username: "bp-hypo-fixture", email: "bp-hypo@example.test", - dateOfBirth: new Date("1985-07-09"), + dateOfBirth: new Date("1980-01-15"), }, }); @@ -272,9 +272,9 @@ describe("BP-in-target % — production data shape", () => { describe("BP-in-target % — windowed (7-day + 30-day) — production data shape", () => { /** - * v1.4.18 A1 regression — Marc's BD-Zielbereich tile rendered the + * v1.4.18 A1 regression — the BD-Zielbereich tile rendered the * 30-day headline (50 %) but `7T: —` and `30T: —` placeholders even - * though he had paired BP readings in both windows. Root cause was + * though the user had paired BP readings in both windows. Root cause was * that the API only returned a single `bpInTargetPct`; the tile got * `avg7={null}, avg30={null}` and rendered the dash fallback. The fix * surfaces both windows from `computeBpInTargetWindows()` against the @@ -290,7 +290,7 @@ describe("BP-in-target % — windowed (7-day + 30-day) — production data shape data: { username: "bp-windows-fixture", email: "bp-windows@example.test", - dateOfBirth: new Date("1985-07-09"), + dateOfBirth: new Date("1980-01-15"), }, }); @@ -309,12 +309,12 @@ describe("BP-in-target % — windowed (7-day + 30-day) — production data shape // last30Days: 15/29 = 52 % (rounded). const seed: Array<{ daysAgo: number; sys: number; dia: number }> = [ // 7-day window (4 IN, 3 OUT) - { daysAgo: 0.5, sys: 117, dia: 79 }, // IN - { daysAgo: 1.5, sys: 122, dia: 86 }, // OUT (dia) - { daysAgo: 2.5, sys: 108, dia: 76 }, // IN + { daysAgo: 0.5, sys: 118, dia: 78 }, // IN + { daysAgo: 1.5, sys: 124, dia: 85 }, // OUT (dia) + { daysAgo: 2.5, sys: 110, dia: 75 }, // IN { daysAgo: 3.5, sys: 145, dia: 95 }, // OUT (both) - { daysAgo: 4.5, sys: 115, dia: 78 }, // IN - { daysAgo: 5.5, sys: 124, dia: 82 }, // OUT (dia) + { daysAgo: 4.5, sys: 116, dia: 77 }, // IN + { daysAgo: 5.5, sys: 123, dia: 83 }, // OUT (dia) { daysAgo: 6.5, sys: 125, dia: 75 }, // IN // older 7-30-day window (alternating, 11 IN / 11 OUT) ...Array.from({ length: 22 }, (_, i) => { @@ -377,10 +377,11 @@ describe("BP-in-target % — windowed (7-day + 30-day) — production data shape }); /** - * v1.4.19 A1 regression — Marc reported the tile shows EXACTLY 50 % - * on 7T, 30T, AND the headline ("total"). With his real production - * shape (572 paired readings since 2022, recent 30d ≈ 50 %, all-time - * ≈ 11 %) the headline cannot legitimately be 50 %. Root cause: the + * v1.4.19 A1 regression — a bug report showed the tile rendering + * EXACTLY 50 % on 7T, 30T, AND the headline ("total"). With a long + * reading history (hundreds of paired readings over several years, + * recent 30d ≈ 50 %, all-time far lower) the headline cannot + * legitimately be 50 %. Root cause: the * analytics route routed `bpInTargetPct = windows.last30Days?.pct`, * making the headline a literal copy of `30T`. The fix returns a * third `allTime` window that the route now uses for the headline. @@ -397,14 +398,14 @@ describe("BP-in-target % — windowed (7-day + 30-day) — production data shape data: { username: "bp-three-windows-fixture", email: "bp-three-windows@example.test", - dateOfBirth: new Date("1985-07-09"), + dateOfBirth: new Date("1980-01-15"), }, }); const now = new Date(); const seed: Array<{ daysAgo: number; sys: number; dia: number }> = [ // Last 7 days: 1 IN, 1 OUT. - { daysAgo: 1.5, sys: 117, dia: 79 }, // IN + { daysAgo: 1.5, sys: 118, dia: 78 }, // IN { daysAgo: 5.5, sys: 145, dia: 95 }, // OUT // 7-30 days ago: 4 IN, 4 OUT (alternating). { daysAgo: 8, sys: 122, dia: 75 }, // IN @@ -466,7 +467,7 @@ describe("BP-in-target % — windowed (7-day + 30-day) — production data shape expect(windows.allTime!.pct).toBe(Math.round((5 / 40) * 100)); // The smoking gun: even when 7d AND 30d are 50 %, all-time is NOT - // 50 % once older history is present. Marc's prod tile pinned to + // 50 % once older history is present. The reported tile pinned to // 50/50/50 because the route routed the headline through `last30Days` // — the algorithmic pin the brief warned about. expect(windows.allTime!.pct).not.toBe(windows.last30Days!.pct); @@ -485,7 +486,7 @@ describe("BP-in-target % — windowed (7-day + 30-day) — production data shape data: { username: "bp-no-recent-fixture", email: "bp-no-recent@example.test", - dateOfBirth: new Date("1985-07-09"), + dateOfBirth: new Date("1980-01-15"), }, }); @@ -552,7 +553,7 @@ describe("GET /api/analytics — BP-in-target headline (v1.4.22 A1)", () => { username, email: `${username}@example.test`, role: "USER", - dateOfBirth: new Date("1985-07-09"), + dateOfBirth: new Date("1980-01-15"), }, }); const session = await prisma.session.create({ @@ -576,7 +577,7 @@ describe("GET /api/analytics — BP-in-target headline (v1.4.22 A1)", () => { // 30d both ~50 %, all-time ~13 %. const seed: Array<{ daysAgo: number; sys: number; dia: number }> = [ // Last 7 days: 1 IN, 1 OUT. - { daysAgo: 1.5, sys: 117, dia: 79 }, + { daysAgo: 1.5, sys: 118, dia: 78 }, { daysAgo: 5.5, sys: 145, dia: 95 }, // 7-30 days ago: 4 IN, 4 OUT. { daysAgo: 8, sys: 122, dia: 75 },