chore: scrub instance details and personal data from committed artefacts

Blood-pressure fixtures in the in-target integration suite were copied
from live readings; they are now synthetic with the same in/out split so
every assertion holds. Hard-coded operator hostnames move behind env vars
(DEPLOY_LOGS_URL, HEALTHLOG_PROD_URL), the codex client identifies via
the public repository URL, the OpenAPI servers entry points at a neutral
example host, and remaining workflow, docs and comment references to the
operator's instance or working notes are generalised. The compose
whitelist also gains DEPLOY_WEBHOOK_SECRET, which was missing and made
the deploy webhook unusable on compose installs.
This commit is contained in:
Marc-André Bombeck committed 2026-06-10 11:00:26 +02:00
1 parent b8c2b2e8c5
commit 76012bf8e2
17 files changed
+145 -105

No files matched your search

+3
View File
@@ -65,6 +65,9 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# 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=""
# -----------------------------------------------------------------------------
+9 -9
View File
@@ -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."
+7
View File
@@ -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
+16 -16
View File
@@ -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://<your-instance>/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://<your-instance> (or the marketing site once the landing repo's v1.4.25 is deployed)
- [ ] Privacy Policy URL: **https://<your-instance>/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://<your-instance>/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
+6 -6
View File
@@ -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.
{
+28 -5
View File
@@ -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 <expected-version>
* HEALTHLOG_PROD_URL=https://healthlog.example.com \
* pnpm dlx tsx scripts/assert-deploy.ts <expected-version>
*
* 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<string, Target> = {
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<string, Target | undefined> = {
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<void> {
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(
+4
View File
@@ -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."
}
]
},
@@ -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/*`
@@ -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";
+5 -2
View File
@@ -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<void> {
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",
+14 -19
View File
@@ -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
</a>{" "}
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 <em>HealthLog for iOS</em>{" "}
(Bundle-ID{" "}
<code className="bg-muted rounded px-1 py-0.5 text-xs">
healthlog.bombeck.io
</code>{" "}
betriebene Instanz und die zugehörige iOS-Anwendung{" "}
<em>HealthLog for iOS</em> (Bundle-ID{" "}
<code className="bg-muted rounded px-1 py-0.5 text-xs">
io.bombeck.healthlog
dev.healthlog.app
</code>
) personenbezogene Daten verarbeiten.
</p>
@@ -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.
</p>
<p>
Für Fragen oder Datenschutz-Anfragen siehe Abschnitt 11
@@ -381,14 +379,11 @@ export default function PrivacyPage() {
github.com/MBombeck/HealthLog
</a>{" "}
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 <em>HealthLog for iOS</em>{" "}
(bundle identifier{" "}
<code className="bg-muted rounded px-1 py-0.5 text-xs">
healthlog.bombeck.io
</code>{" "}
and the companion iOS application{" "}
<em>HealthLog for iOS</em> (bundle identifier{" "}
<code className="bg-muted rounded px-1 py-0.5 text-xs">
io.bombeck.healthlog
dev.healthlog.app
</code>
) handle personal data.
</p>
@@ -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&apos;s own privacy policy; this
document applies only to the hostname above.
document applies only to this instance.
</p>
<p>
For questions or data-subject requests, see section 11
@@ -1211,8 +1206,8 @@ export default function PrivacyPage() {
/>
<SubProcessor
name="Cloudflare, Inc."
roleDe="Autoritative DNS-Auflösung für die bombeck.io-Zonen. Die Anwendung selbst steht nicht hinter dem Cloudflare-Proxy."
roleEn="Authoritative DNS for the bombeck.io zones. The application itself is not behind the Cloudflare proxy; only the DNS resolution path is."
roleDe="Autoritative DNS-Auflösung für die Domain dieser Instanz. Die Anwendung selbst steht nicht hinter dem Cloudflare-Proxy."
roleEn="Authoritative DNS for this instance's domain. The application itself is not behind the Cloudflare proxy; only the DNS resolution path is."
dataDe="Quell-IP und User-Agent zum Zeitpunkt der Auflösung."
dataEn="Source IP address and user-agent at resolution time."
locationDe="USA; globales Anycast-Netz von Cloudflare."
@@ -58,8 +58,8 @@ describe("<AdminShell>", () => {
// 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" });
@@ -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
+1 -1
View File
@@ -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;
+4 -1
View File
@@ -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: [
+3 -2
View File
@@ -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
+39 -38
View File
@@ -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 },