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 },