chore(release): v1.4.27

v1.4.27 — Mobile capability and maintainer-finding cleanup.

ResponsiveSheet primitive (bottom-sheet on narrow viewports, centred dialog on desktop)
applied to BD, Weight, Glucose, Mood, Activity forms. Maintainer-finding cleanups across
Coach, Insights, Reports, Privacy. GeoLite2 offline geocoding with resilience layer
(graceful fallback when EULA not yet propagated). E2E gap-fill coverage. 450-line
release notes attached to the GitHub Release.
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-15 22:59:10 +02:00
1 parent d98e85cd03
commit 4f09c3f862
314 files changed
+26801 -4419

No files matched your search

+160
View File
@@ -0,0 +1,160 @@
---
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
estimated_read_time: 15 min
last_updated: 2026-05-15
---
# App Store Connect Submission Checklist — HealthLog iOS
## TL;DR
Three submission gates that need Marc'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)
Plus pre-submit final checks.
## Section 1 — Regulated-Medical-Device declaration
**Where**: App Store Connect → App Information → "Health & Fitness" → "Medical device" toggle
**Decision**: HealthLog is **NOT a medical device** under EU MDR 2017/745 / FDA 21 CFR 880 / Apple's interpretation.
**Recommended wording in the declaration form**:
> HealthLog is a personal health-data logging and visualization app. It displays user-entered observations + integrates with Apple HealthKit, Withings, and manual entry. It does not diagnose, treat, prescribe, or recommend dosing. The AI Coach surfaces are explicit-refusal-gated (GROUND RULES 9 + 15) against any medical-device-class output. The drug-level estimation chart is display-only behind a versioned MDR-acknowledgment dialog citing EU 2017/745 + MDCG 2021-24. For medical advice, users must consult licensed clinicians.
**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
**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.
## Section 2 — Privacy Nutrition Labels
**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.
### Data categories to declare
For each category below: **Linked to user, used for App Functionality + Account Management**. Optional purposes: never enable Analytics, Tracking, Advertising, or Third-Party Tracking.
**Health & Fitness**
- Health: yes (all HKQuantityType + HKCategoryType the iOS app reads — see `/privacy` page §2)
- Fitness: yes (steps, distance, active energy, workouts)
- Purpose: App Functionality
- Linked to user: yes
**Contact Info**
- Email Address: yes (for account login + GDPR data-subject correspondence)
- Purpose: App Functionality + Account Management
- Linked to user: yes
**Sensitive Info**
- Blood pressure, glucose, medications, drug-related observations: yes
- Purpose: App Functionality
- Linked to user: yes
**User Content**
- Emails or Text Messages: NO (the AI Coach is in-app conversation, but iOS does not send emails)
- Photos or Videos: NO
- Audio Data: NO (HealthKit audio-exposure is environmental dB; not the user's voice)
- Customer Support: yes (open-source GitHub Issues; mention in policy)
- Other User Content: yes (Coach conversation context + medication notes + side-effect free-text)
- Purpose: App Functionality
- Linked to user: yes
**Identifiers**
- User ID: yes (server-side user UUID)
- Device ID: yes (X-Device-Id Keychain-stored UUID, APNs push token)
- Purpose: App Functionality + Account Management
- Linked to user: yes
**Other Data Types**
- Other Data Types: yes (general medical observations not fitting above)
- Purpose: App Functionality
- Linked to user: yes
### Data types to NOT declare (because not collected)
- Browsing History
- Search History
- 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.
- Advertising Data
- Other Usage Data
### Tracking declaration
**Tracking**: NO. HealthLog does not link user data with data from other companies' apps and websites for advertising or share data with data brokers. The Privacy Manifest must not declare any tracking-related purposes.
## Section 3 — Standard Metadata Checklist
### App Information
- [ ] Name: "HealthLog"
- [ ] 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
- [ ] Age rating: 17+ (because Sensitive Health Info content)
### Description
- [ ] Long-form description (4000 char max): cover Insights + Coach + GLP-1 + Withings + Apple HealthKit + open-source nature + EU GDPR compliance
- [ ] What's New (version-by-version notes): v0.3.0 = "Server-sync, comprehensive Apple HealthKit support across 19 sample types, smart Insights, AI Coach, Health Score, GLP-1 tracking with Research-Mode-gated drug-level chart, interactive charts with drill-down, push notifications, deep links, and account deletion."
### Screenshots
- [ ] 6.5" iPhone screenshots: cover Onboarding (welcome carousel + permissions), Dashboard (Health Score tile + tile-strip), Insights (sub-page with severity sort), Coach (refusal example to show MDR boundary), Medications (GLP-1 detail stack), Settings (Research Mode toggle + account deletion).
- [ ] 5.5" iPhone screenshots: same set (Apple's legacy size).
- [ ] 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)
- [ ] 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
- [ ] 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)
## Section 4 — Pre-Submit Final Check
Run through this list:
- [ ] Privacy Policy URL responds 200 (curl https://healthlog.bombeck.io/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
## 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
## Section 6 — Post-Submit / If Rejected
- Apple's most common rejection language for health apps: "Your app's marketing language and/or in-app content implies the diagnosis or treatment of conditions..."
- Response template: cite GROUND RULES 9 + 15 + /privacy §7 + the screenshot of the in-app MDR-boundary disclaimer. Apple usually accepts a clear, written delineation.
- If rejected, do NOT escalate to App Review Board immediately — first iterate on the in-app wording (move "GLP-1" / "drug-level" to back of UI, add more explicit "This is not medical advice" copy at every Coach surface). Most rejections resolve within 1-2 iterations.
---
**Source links**:
- Apple Health App Guidelines: https://developer.apple.com/app-store/review/guidelines/#health-and-health-research
- Apple App Privacy Details: https://developer.apple.com/app-store/app-privacy-details/
- EU MDR 2017/745: https://eur-lex.europa.eu/eli/reg/2017/745/oj
- MDCG 2021-24 (Software as a Medical Device): https://health.ec.europa.eu/system/files/2021-10/mdcg_2021-24_en_0.pdf
+463
View File
@@ -0,0 +1,463 @@
# HealthLog v1.4.25 — release summary
## Release brief
v1.4.25 is the largest feature delta in the v1.4.x line and the first time the
loud / polish / foundation three-beat ran through a multi-cluster
expansion on top of the v1.4.20-introduced develop-to-main release-merge model.
Insights expands from a single mother page into seven dedicated metric
sub-pages with a shared tab strip; GLP-1 medication tracking lands
end-to-end across the dashboard tile, the weight-chart marker overlay,
the therapy timeline, the plateau-detection rule in the daily briefing,
the dedicated doctor-report section, a Research-Mode drug-level chart
behind an MDR acknowledgment dialog, an EMA-sourced drug knowledge layer,
the EMA titration ladder, cadence visualisation, compliance chips, a
21-entry by 5-category side-effect taxonomy, and a pen-and-vial
inventory with a 30-day in-use clock; the cross-source priority resolver
becomes a two-axis metric × deviceType lookup with a per-user Settings
surface; per-user timezone threads through ten analytics and presentation
surfaces; Withings coverage roughly doubles with twelve new measurement
types, webhook-driven BP / temperature ingestion, plus the new Activity
sync (steps + distance + active-energy) and Sleep v2 sync (stage-level
segments); the onboarding flow rebuilds as a nested-route wizard with a
welcome carousel, goals chip-picker, source selection grid, baseline
form, and a welcome-back resume banner; Personal Records ship end-to-end
with a pg-boss detection worker, push opt-in, and a metric-trend badge;
Health Score gains a per-component provenance accordion; the Coach runs
on native first-party prompts across all six locales bound by a
1800-assertion refusal-probe matrix on every push; the OpenAPI drift gate
flips to hard-fail so the v1.5 iOS DTO codegen can rely on
`docs/api/openapi.yaml` being authoritative; and the GHCR image is now
multi-arch so Apple Silicon Macs and arm64 clouds pull native.
Migrations 0043–0060 are additive and forward-only. `PROMPT_VERSION`
4.24.0 → 4.25.0 carries GROUND RULE 9 (Coach refuses GLP-1 dose
recommendations) and GROUND RULE 15 (Coach refuses drug-level estimates
with explicit EU MDR 2017/745 + MDCG 2021-24 cites in the refusal copy).
## Live state
| Field | Value |
| ------------------ | ------------------------------------------------------------------------------- |
| URL | `https://healthlog.bombeck.io` |
| `/api/version` | `1.4.25` |
| GH release | https://github.com/MBombeck/HealthLog/releases/tag/v1.4.25 |
| Branch model | `develop` → `main` release-merge model, sixth tag through it (v1.4.20 onwards) |
| Tag commit | `17b8d8d2 chore(release): v1.4.25` |
| Predecessor | `v1.4.24` at `ca03c609` |
## What shipped
### Insights expansion — seven dedicated metric sub-pages
`/insights` becomes a navigation hub. `/insights/blutdruck`,
`/insights/gewicht`, `/insights/puls`, `/insights/stimmung`,
`/insights/medikamente`, `/insights/bmi` and `/insights/schlaf` each
render the metric's full chart, range bands, trend annotations and
correlation rows beneath the mother-page hero. Tab strip lifts above
each chart for cross-metric scanning. Targets (`/insights/zielwerte`)
redesigns around a new `<TargetCard>` primitive with a conditional
Coach-handoff card that hides cleanly in rule-based mode. Sleep
sub-page renders awake / REM / core / deep as a stacked column over
the last 7 / 14 / 30 nights, sourced from `Measurement.sleepStage`.
### GLP-1 medication tracking — end-to-end across ten surfaces
`Medication.treatmentClass` enum (`GLP1`, `STANDARD`),
`MedicationDoseChange` history table, an `InjectionSite` enum with
eight site values, and a body-map picker that proposes the next
rotation site from the last fourteen days. Dashboard tile carries
next-injection schedule plus week-over-week weight delta. Weight chart
gains vertical injection markers. Therapy timeline plots titration
alongside the weight trace. Plateau-detection in `/api/insights/briefing`
flags four-week stalls with referral framing. Doctor-report PDF gains
a dedicated GLP-1 section. Drug-level chart on the medication detail
page renders behind a Research-Mode gate; the acknowledgment dialog
cites EU MDR 2017/745 + MDCG 2021-24, EMA EPAR and Schneck / Urva 2024
before the user can opt in. EMA titration ladder helpers walk the
sourced schedule and frame as reference-not-advice. Cadence + compliance
helpers expand the schedule into expected slots, pair each with the
closest actual intake, and render a timeline + chip strip; honour
per-user timezone so cross-midnight doses bucket correctly regardless
of locale. Side-effect taxonomy (Migration 0059) covers 21 entries × 5
categories, severity Likert 1-5, with `category` derived server-side so
the taxonomy cannot drift between client and server. Pen-and-vial
inventory (Migration 0056) carries a SEALED → IN_USE → EXPIRED state
machine with a 30-day in-use clock from `markAsFirstUseAt`; a daily
03:00 cron flips expired in-use items in one `updateMany`. State
machine re-runs on every PATCH so a back-dated first-use immediately
moves a stale pen to EXPIRED.
### Cross-source priority — two-axis architecture
`User.sourcePriorityJson` (Migration 0048) stores the per-user resolver:
a single-axis ladder by metric type plus an optional per-device-type
override. `pickCanonicalSource()` walks the metric axis first; a
per-device override (e.g. "BP from Withings BPM Connect, weight from
Withings Body+") wins within that ladder. Defaults: cumulative + sleep +
HRV + RHR favour Apple Health → Withings → manual; point measurements
favour Withings → Apple Health → manual. The `__default__` sentinel is
retired in favour of a null bucket. Settings → Sources screen exposes
the ladder for drag-reorder per metric and per-device override picker.
### Per-user timezone — Option B threaded through ten surfaces
New `User.timezone` column defaults to `Europe/Berlin`. CSV exporter
emits ISO-8601 with offset; formatters honour the user's tz; Profile
picker covers all IANA zones; admin sets the default for new accounts;
signup detects the browser tz; doctor-report PDF dates use the user's
tz; chart x-axes take a `timezone` prop; Coach snapshot timestamps
land in the user's tz; `MoodEntry.tz` records local-day grouping for
weekday correlation (Migration 0044); the weight-weekday correlator
buckets in the user's tz. Cadence + compliance helpers honour the
same argument.
### Withings — expanded coverage + Activity + Sleep v2
Twelve new `MeasurementType` values (HRV, body temperature, SpO2 v2,
VO2 max, fat-free mass, fat mass, muscle mass, skin temperature,
pulse-wave velocity, vascular age, visceral fat — Migration 0049).
Webhook coverage extended to BP and temperature so the latency
collapses from ~1 h polling to seconds. OAuth scope upgraded to include
`user.activity` with an in-app reconnect banner. Activity sync calls
`getactivity`, ingests one row per day, anchors at noon UTC so
positive-offset users bucket cleanly into their local day. Sleep v2
sync calls `sleepv2_get` and writes per-night stage segments through
the new `sleepStage` composite (Migration 0055 widens the Measurement
unique index to include `sleep_stage` with `NULLS NOT DISTINCT`).
Backed by pg-boss `activity-sync` and `sleep-v2-sync` queues plus
webhook enqueue hooks on the new subscription channels.
### Onboarding rebuild — six-step nested-route wizard
`/onboarding/[step]` powered by a new `<OnboardingShell>` primitive:
value-prop carousel, goals chip-picker, source 4-card selection grid
(Apple Health marked coming-soon), baseline form, source-connect step,
done step. `User.onboardingStep` (Migration 0057, with Migration 0060
backfilling and flipping the column to `NOT NULL DEFAULT 0`) drives
resume-state. A welcome-back banner surfaces on the entry page when
an in-progress wizard is detected. `POST /api/onboarding/step`
conditions the update on `{ id, onboardingStep: current,
onboardingCompletedAt: null }` and returns 409 on concurrent advance
to close the read-then-write race.
### Personal Records — schema, worker, badge, push opt-in
Migration 0054 introduces the `PersonalRecord` table +
`PersonalRecordDirection` helper. `GET /api/personal-records` clamps
`?limit` (default twenty-five, max two hundred). A pg-boss worker
sweeps MAX / MIN per metric and the workout slots on every batch-ingest
plus a thirty-minute fallback cron (concurrency five; warmup gate so
the very first datapoint per metric does not promote itself).
Metric-trend tiles render a PR badge when the record landed in the
last thirty days, with WCAG-AA contrast in dark mode. A per-user push
opt-in toggle (default off) wires into the existing dispatcher cascade.
### Workouts — schema + typed batch-ingest endpoint
Migration 0053 introduces `Workout` + `WorkoutRoute` tables; the Zod
boundary in `src/lib/validations/workout.ts` covers a twenty-member
sport-type union and a `GeoJSON LineString` route column in JSONB.
`POST /api/workouts/batch` accepts up to five hundred rows per call,
wraps `withIdempotency()`, returns the same `inserted | duplicate |
skipped` envelope as the measurements batch, and reconciles inserted
vs duplicate counts correctly under contention. Rate-limited,
size-capped.
### Coach — native first-party prompts across six locales
Coach + insights system prompts move from EN body + `REPLY LANGUAGE`
footer to native per-locale bodies. The safety contract matrix lives
as YAML per locale (single source of truth across drafting + tests).
GROUND RULE 9 forbids GLP-1 dose recommendations; GROUND RULE 15
forbids drug-level estimates and cites EU MDR 2017/745 + MDCG 2021-24
in the refusal copy. The refusal-probe matrix in CI exercises 15
GROUND RULES across six locales with 20+ adversarial paraphrasings
each — 1800+ assertions on every push. Drift-guard tests pin the
YAML matrix in lockstep with the validator-derived enums.
### OpenAPI drift gate — hard-fail flip
`pnpm openapi:check` CI step now red-bars a PR on any drift between
the Zod registry and `docs/api/openapi.yaml`. The v1.4.23 warn-only
window closes; iOS DTO codegen can rely on the spec being
authoritative.
### Multi-arch GHCR image + Coolify auto-deploy toggle
GHCR publish workflow builds `linux/amd64` on `ubuntu-latest` plus
`linux/arm64` on `ubuntu-24.04-arm` and merges the manifest. Apple
Silicon Macs and arm64 clouds pull native. The Coolify auto-deploy
gate is now an explicit `vars.COOLIFY_AUTO_DEPLOY=true|false`
maintainer toggle in the deploy workflow instead of the implicit
secret-presence gate that silently no-op'd in v1.4.21-23.
### Security cluster
`WITHINGS_WEBHOOK_SECRET` no longer lands as a URL path-segment in
`http.path` on every Wide Event. The logging stack now carries a
parameterised `PATH_SECRET_PATHS` registry seeded with the
`/api/withings/webhook/[token]` shape; `WideEventBuilder.setHttp`
rewrites path + route segments to `[REDACTED]` before they reach
stdout / the in-memory ring buffer / Loki. Existing query-string
redaction is unaffected. `POST /api/medications/[id]/glp1` now parses
through bounded Zod schemas, enforces length caps on notes,
finite-number guards on dose value, bounded `effectiveFrom`, writes
an `auditLog` row on every change and inherits the 30-per-minute-per-
user rate limit from the rest of the medication surface.
Batch-ingest endpoints (`POST /api/measurements/batch`,
`POST /api/workouts/batch`, `DELETE /api/measurements/by-external-ids`)
carry a sixty-per-minute-per-user token-bucket rate limit. Source-
priority and doctor-report-prefs PUTs emit `AuditLog` rows.
## Migrations
| Migration | Purpose |
|---|---|
| 0051 `measurement_device_type` | Adds `deviceType` enum + column to `Measurement` for the two-axis source-priority resolver. |
| 0052 `apple_health_enum_extensions` | Adds `ENVIRONMENT_AUDIO_EXPOSURE`, `HEADPHONE_AUDIO_EXPOSURE` and `TIME_IN_DAYLIGHT` to `MeasurementType`. |
| 0053 `workout_and_route` | Introduces `Workout` and `WorkoutRoute` tables for the typed workout-batch ingest endpoint and the v1.5 iOS workout sync. |
| 0054 `personal_record` | `PersonalRecord` table + `PersonalRecordDirection` helper. Unique index is `NULLS DISTINCT` by convention; the detection worker carries the application-level idempotency guard. |
| 0055 `measurement_sleepstage_composite` | Widens the `Measurement` unique index to include `sleep_stage` with `NULLS NOT DISTINCT` (Postgres 16 native) so Withings sleep v2 segments dedup correctly. |
| 0056 `medication_inventory_item` | `MedicationInventoryItem` table for the GLP-1 pen/vial inventory with the SEALED → IN_USE → EXPIRED state machine. |
| 0057 `user_onboarding_step` | Adds `onboardingStep` nullable to `User` for the wizard resume-state. Originally nullable; superseded by Migration 0060. |
| 0058 `user_research_mode` | Adds `researchModeAck*` columns to `User` for the GLP-1 Research-Mode MDR acknowledgment. |
| 0059 `medication_side_effect` | `MedicationSideEffect` table for the 21-entry × 5-category taxonomy. |
| 0060 `onboarding_step_not_null` | Backfills existing `User.onboardingStep` rows to 0 and flips the column to `NOT NULL DEFAULT 0`. Closes the tri-state ambiguity left by 0057. |
All ten are additive and forward-only. Migration 0057's comment was
rewritten to match PG11+ fast-path `ADD COLUMN DEFAULT` semantics
during the reconcile pass.
## Test coverage delta
| Suite | v1.4.24 | v1.4.25 | Delta |
|---|---|---|---|
| Unit | 2 244 | 3 828 (across 344 files) | +1 584 |
| Integration | 140 | ~170 (across 11 files) | +30 |
The unit-test growth is dominated by the Coach refusal-probe matrix
(1800+ assertions across 15 GROUND RULES × 6 locales × 20+ paraphrasings)
plus the feature-cluster helper suites: GLP-1 plateau-detection text formatter;
two-axis source-priority resolution; source-priority Settings reducer;
medication-card GLP-1 variant + injection-site-picker; doctor-report
prefs PUT contract; personal-records pagination clamp; `berlinIsoWeekday`
timezone-aware suite; sleep-stage chart i18n drift-guard; Health-Score
provenance i18n drift-guard; api-handler private-field crash regression;
pure cadence + compliance helpers with TZ assertions across UTC+0,
UTC+9, UTC-8; side-effect taxonomy drift-guard; EMA titration ladder
helpers; pen-inventory state-machine + 30-day clock helpers; PR
detection worker (MAX / MIN per metric + workout slots + warmup gate
+ null-slot dup regression + zero-duration guard); Withings activity
TZ regression across Berlin / Los Angeles / Tokyo / Auckland; Withings
sleep v2 segment ingest; log-redaction path-segment rule + `setHttp`
rewrite; GLP-1 endpoint Zod + audit + rate-limit; onboarding step
concurrent-advance regression; inventory PATCH state-machine back-dated
first-use regression; bulk-update contract for the expire-stale cron;
brand-name guard + sentinel-preservation + safety-contract parity for
the YAML matrix; locale auto-discovery + fallback-chain runtime guard.
Integration: two-axis canonical-source resolution end-to-end;
`requireAuth()` narrow-scope on unscoped route; batch-ingest race
reconciliation under contention; workout-batch race + size-cap + ingest
end-to-end; PR-detection end-to-end; cross-source priority for
Withings Activity + Apple Health; Withings sleep-stage composite ingest;
per-user timezone end-to-end (Pacific/Auckland).
e2e green on the W2 CI fix (coach-prefs URL mock + Pixel-5 selector
hardening) plus the Fix-I hot-fix that re-anchored the dashboard
insight-card and the mobile x-axis tick locators class-independently.
Typecheck clean. Lint baseline carried (no new warnings). OpenAPI
drift-check clean against the regenerated spec.
## Code quality gates
The W21 review ran eight read-only reviewers in parallel: code,
security, design, senior-dev, simplifier, product-lead, dead-code,
i18n-runtime. Combined output was 103 findings (2 Critical, 20 High,
41 Medium, 40 Low) plus a strategic product-lead assessment.
**Critical (2 / 2 applied)**: sec-C1 (Withings webhook secret leaking
into `http.path` on every Wide Event) and product-lead-C1 (CHANGELOG
frozen at the initial release-prep surface, omitting the bulk of the
later feature work). Both closed before tag — sec-C1 via Fix-J
(`PATH_SECRET_PATHS` registry + `setHttp` redaction);
product-lead-C1 via the W22 release-redo editorial pass.
**High (20 / 20 applied)**: sec-H1 (GLP-1 endpoint missing Zod + audit
+ rate-limit) closed in Fix-K. Six design-Highs (onboarding tap targets,
range-bar palette, PR-badge contrast) closed in Fix-L. Two code-Highs
(inventory state machine bypass on PATCH, per-row expire loop) closed
in Fix-M. Seven simplifier-Highs (`readError` dup, `templateFill`,
`statusLabel` nested ternary, `ResearchModeStatus` shared type,
`findDrugIdByBrand`, `assertMedicationOwnership` across 11 routes,
`MedicationInventoryState` import) closed in Fix-N. Code-H2 (cadence +
compliance ignore per-user timezone) closed in Fix-O. Dead-H4
(research-mode-staleness module never wired) resolved by deletion in
Fix-O. One product-lead-H1 (test count footer stale) and
product-lead-H2 (Deferred-to-v1.4.26 section lists already-shipped
items) closed in W22.
**Medium (17 of 41 applied, 24 deferred to v1.4.26)**: 17 ship-now
Mediums landed across Fix-J through Fix-O; the deferred 24 split
between schema-shape decisions (`User.onboardingGoals` column;
`/api/audit-log` consumer decision), module-surface narrowing
(`glp1-pk.ts`, `glp1-knowledge.ts`, `scheduling/*` unused exports),
148 remaining dead i18n keys after the W15 380-key sweep, and seven
orphan endpoint go / no-go decisions that need maintainer call. Full
deferrals matrix lives in `.planning/phase-W21-reconcile-plan.md` lines
220-265.
**Low (40 / 40 deferred to v1.4.27)**: appendix in the reconcile plan
covers them — none are user-facing; one (i18n-Low-1, e2e
`healthlog_locale` vs `healthlog-locale` cookie typo) was
opportunistically picked up in Fix-P because the touch was a single
character.
Seven touch-disjoint Fix-J through Fix-P surfaces totalling 22 atomic
commits ran across `develop` between the W21 audit and the W22
release-prep. Three sequenced merge windows preserved touch-disjoint
diffs without conflict (Fix-O before Fix-N on the cadence route;
Fix-L before Fix-N on the onboarding components; Fix-M before Fix-O
on the PR worker test). No file had two agents editing the same lines.
## Strategic posture
### v1.5 iOS readiness
The iOS sprint has the contracts it needs to start Day 1. Locked
server contracts after v1.4.25:
- `POST /api/measurements/batch` — Apple Health bulk ingest with
`inserted | duplicate | skipped` per-entry envelope, idempotency
wrapper, rate-limited (v1.4.23, unchanged here).
- `DELETE /api/measurements/by-external-ids` — HealthKit deletion-sync
(new in v1.4.25).
- `POST /api/workouts/batch` — HKWorkout-aligned typed ingest with
20-sport-type union + GeoJSON LineString routes (new in v1.4.25).
- `POST /api/devices` — APNs token registration with paired
`apnsEnvironment` (v1.4.23, unchanged).
- `POST /api/onboarding/step` — wizard progression with rate limit +
audit + 409-on-concurrent (new in v1.4.25).
- `GET /api/personal-records?metricType=X` — paginated PR reads.
- `User.sourcePriorityJson` two-axis resolver with
`pickCanonicalSource()` — iOS sets Apple Health as the cumulative +
sleep + HRV + RHR favourite; Withings stays the point-measurement
default.
- `Measurement.deviceType` enum with iOS-17/18 HK long-tail mappings
ready to extend.
- `MedicationInventoryItem` — pen/vial 30-day clock available for an
iOS pen-scanner integration.
- `Workout` + `WorkoutRoute` tables (the table-side; endpoint signature
finalised during the iOS sprint).
- `docs/api/openapi.yaml` regenerates byte-identically from the Zod
registry; the drift gate is now hard-fail.
### MDR boundary preservation
The GLP-1 Research-Mode surface ships with three concentric defences:
chart gating (`User.researchModeAck*` columns require an active
acknowledgment before render), dialog acknowledgment (cites EU MDR
2017/745, MDCG 2021-24, EMA EPAR and Schneck / Urva 2024 before the
user can opt in; version-bump re-prompts), and Coach refusal at the
prompt layer (GROUND RULE 15 across all six locales, refusal-probe
matrix in CI). The titration ladder display is framed as
reference-not-advice and bound by the same GROUND RULE 15. Drug-level
chart-side 90-day staleness wiring stayed deferred for v1.4.26 (the
helper module shipped tested in W19c-Safety but the chart conditional
isn't wired — the unused helper was deleted during Fix-O after the
reconcile pass; the 90-day gate becomes a clean v1.4.26 surface when
the maintainer decides to wire it).
### Branch model
`develop` is the daily target; `main` is release-only and follows tags.
v1.4.25 is the sixth tag through the develop-to-main release-merge model
introduced in v1.4.20 F1. GHCR auto-deploy fires only on `main`; the
explicit `vars.COOLIFY_AUTO_DEPLOY=true|false` toggle closes the
silent-miss pattern that hit v1.4.21 / v1.4.22 / v1.4.23.
## 0-10 score per area
| Area | Score | Note |
|---|---|---|
| Insights | 9 | Seven-sub-page split lands cleanly; Targets redesign with conditional Coach-handoff is the tightest the layout has been; mother page slims to general status + Coach hero. Half-point off for the duplicate `StatusCard` regression that needed a Fix-I hotfix mid-release. |
| Dashboard | 9 | Top-tile polish (single-line headings, inline trend arrow, baseline alignment across tiles) closes the v1.4.24 reviewer note; comparison-overlay default removed in favour of per-chart preference; VO2 max secondary-metric tile lands as opt-in. |
| Coach | 9 | Native per-locale prompts replace the REPLY LANGUAGE footer across six locales; GROUND RULE 9 + GROUND RULE 15 close the MDR boundary at the prompt layer; 1800+ refusal-probe assertions in CI on every push. Half-point off for FR/ES/IT/PL prose still being LLM-drafted (structural matrix covers safety; the prose itself benefits from a native human pass — queued for v1.4.26). |
| Settings | 8 | Source-priority screen ships with drag-reorder + per-device override + audit-log; doctor-report per-section toggles + mood-default-off lands; Research-Mode toggle with version re-prompt banner lands. Single point off for the settings-cog vs per-message-controls debate (v1.4.23 carryover) still unresolved. |
| Admin | 8 | Login overview gains location + provider columns + CSV polish; coach-feedback header layout shift closed; notification-status-card icon parity restored; mobile admin nav gains Overview link. Two points off for the seven orphan endpoint go / no-go decisions still pending a maintainer call. |
| Mobile (web) | 8 | 44-px touch-target floor across onboarding wizard, top-bar, section strips and injection-site picker hits WCAG 2.5.5; dashboard tile baseline alignment regression-pinned. Two points off because the e2e workflow needed the Fix-I hot-fix re-anchor on dashboard insight-text + chart-tick locators (now class-independent). |
| Tests | 9 | 2 244 → 3 828 unit (+1 584); 140 → ~170 integration; new drift-guards across Apple Health mapping, side-effect taxonomy triangle, sleep-stage chart i18n, Health-Score provenance, safety-contract YAML matrix, plus per-user TZ regressions and the 1800-assertion refusal-probe matrix. Half-point off for the two pre-existing `coach-prefs.test.ts` `NextRequest` URL mock failures still carried from v1.4.23. |
| CI | 9 | OpenAPI drift gate flips to hard-fail; refusal-probe matrix runs on every push; multi-arch GHCR builds clean after the Fix-G/H lowercase fix-up; explicit `vars.COOLIFY_AUTO_DEPLOY` toggle closes the silent-miss pattern. Half-point off for the Fix-H lowercase regression that needed catching post-PR-open. |
| Docs | 9 | CHANGELOG expand in W22 brings the public artifact in lockstep with what shipped; per-section delta is complete; deferred-to-v1.4.26 / v1.5 sections are accurate. Half-point off because the W22 reconcile was the C-1 Critical of the release and only ran after the W21 product-lead caught it. |
| Repo hygiene | 9 | 380 dead i18n keys dropped; dead prompt constants removed; `<InsightsPageHero>` + `<IntakeTimeline>` + `<ComplianceCharts>` + orphan query keys + `/api/insights/general-status` orphan all gone; `MedicationDetailSection` wrapper + shared route-guards / read-error / dose-string / research-mode-types modules collapse 11+ duplications. Half-point off for the 148 remaining dead i18n keys queued for a v1.4.26 second-pass. |
| Demo readiness | 8 | Multi-arch image is live so the demo VPS pulls native arm64; W11b demo-deploy report covers the runbook; OpenAPI spec is authoritative for iOS DTO codegen. Two points off because the demo seeding still depends on the manual host-side fallback when Coolify auto-deploy is toggled off, and because the iOS Swift app itself is queued for v1.5 (the demo is web-only until then). |
## v1.4.26 backlog
24 deferred Mediums + 40 Lows + maintainer-decision items split across
the following themes:
- `User.onboardingGoals` schema column + server-side persistence
(today the goals chip-picker holds the choice client-side only;
the dashboard-widgets seed feature reads from it in v1.4.26).
- `advance()` hook extraction across the three onboarding step
components (simplifier surfaced; deferred to avoid collision with
the v1.4.25 touch-target pass).
- 148 dead i18n keys remaining after the W15 380-key sweep (need a
second-pass call-site verification).
- Drug-level chart-side 90-day staleness clock wiring (acknowledgment
dialog already re-prompts on version bump and Coach refuses at
GROUND RULE 15; the clock is defence-in-depth).
- FR / ES / IT / PL Coach + insights prose hand-review by native
speakers (structural refusal-probe matrix covers safety; the user-
facing prose benefits from a human pass).
- Sleep sub-page stacked-column visual polish (stage colours + legend
density).
- VO2 max chart-row card on `/insights/<metric>` (dashboard tile
shipped; chart card queued alongside the iOS body-composition page).
- Mood verbal-labels persistence behind a per-user toggle.
- Coach `lastYear` baseline + row-tap-to-prefill polish.
- Lazy-loaded locale JSON bundles (all six locales import
synchronously today, ~675 KB to every client).
- iOS-18 long-tail HK identifier mappings (sleep apnea, GAD-7 /
PHQ-9, running form, paddle / row / ski distances, pregnancy /
cycle, FHIR clinical).
- Seven orphan endpoint go / no-go decisions
(`/api/admin/ai-settings`, `/api/admin/backup/test`,
`/api/admin/status-overview`,
`/api/monitoring/{glitchtip,umami}/test`).
- `glp1-pk.ts` and `glp1-knowledge.ts` unused-export decisions
(internalise vs wire dashboard chip).
- Pearson incomplete-beta replacement (v1.4.23 carryover already
partially closed with the conservative MIN_PAIRED_N raise — the
rigorous replacement is the v1.4.26 candidate before correlation
auto-discovery widens in v1.5/v1.6).
## Carry-overs
- The two pre-existing `coach-prefs.test.ts` `NextRequest` URL mock
failures still carry through from v1.4.23. Tracked in
`.planning/v1426-backlog.md`; non-blocking for the release.
- Manual-workout route `findFirst` serial pattern stays in place
(no manual workout UI today; contract correctness only matters
once v1.5 UI lands).
- The Coolify auto-deploy MCP API path still fires
`force=true` and reports "finished" in ~18 seconds without a real
`docker pull`, but the new `vars.COOLIFY_AUTO_DEPLOY` toggle plus
the "Watch image registry for new digests" UI checkbox capture the
durable fix; the host-side retag fallback recipe lives in
`.planning/coolify-auto-deploy-howto.md` for the cases where the
toggle is off.
## Strategic next
The v1.5 sprint starts with iOS first launch (login + dashboard +
widget) against locked server contracts — pure Swift work, zero
server-side touches. P2 (Apple Health sync) calls the v1.4.23
`POST /api/measurements/batch` against the v1.4.25 device-type +
two-axis resolver. P3 (Coach extended for HRV / sleep / resting HR /
steps) ratchets `PROMPT_VERSION` 4.25.0 → 5.0.0 and copies the web
`<CoachDrawer>` UX to iOS. P4 (per-metric APNs alerts) leans on the
v1.4.23 APNs scaffolding for the send-side. P5 (workouts + GeoJSON
routes) calls the v1.4.25 `POST /api/workouts/batch` against the
shipped Workout + WorkoutRoute tables.
+109
View File
@@ -0,0 +1,109 @@
# v1.4.27 release summary
## Overview
v1.4.27 polishes the settings shell rhythm and lands the offline-first
geolocation pipeline that drives the `/admin/login-overview` table.
Two MaxMind GeoLite2 databases (`GeoLite2-City` + `GeoLite2-ASN`) are
now bundled into the runtime image; the resolver in
`src/lib/geo.ts` reads them with `mmdb-lib` and falls back to the
existing `ipwho.is` HTTPS path only when the local DB cannot resolve
the address.
## GeoLite2 attribution
This product includes GeoLite2 data created by MaxMind, available from
<https://www.maxmind.com>. The GeoLite2 databases (`GeoLite2-City`
and `GeoLite2-ASN`) are distributed under the
[Creative Commons Attribution-ShareAlike 4.0 International License][cc-by-sa-4].
The same attribution renders on the `/about` page so an end user can
discover it without inspecting the repository.
[cc-by-sa-4]: https://creativecommons.org/licenses/by-sa/4.0/
## Operating the offline databases
The MMDB files are too large to vendor in git. They are downloaded
outside the Docker build by `scripts/fetch-geolite2.sh`. The operator
runs the script before `docker build` with a free MaxMind license
key:
```
MAXMIND_LICENSE_KEY=xxxx ./scripts/fetch-geolite2.sh
```
The script writes `GeoLite2-City.mmdb` and `GeoLite2-ASN.mmdb` to
`assets/geolite2/`. The Dockerfile then copies the folder into
`/opt/geolite2/` so the runtime resolver finds the databases on disk.
If the script is skipped the image builds cleanly, and the resolver
falls back to the online `ipwho.is` provider — the v1.4.26 behaviour.
The offline tier is **optional** as of v1.4.27 R5. When
`MAXMIND_LICENSE_KEY` is unset the `Fetch GeoLite2 databases` step in
`.github/workflows/docker-publish.yml` emits a `::warning::`, drops an
`.empty` marker into `assets/geolite2/`, and continues — the build no
longer blocks on a missing secret. The runtime resolver detects the
marker on the first public-IP lookup, sends a one-shot admin
notification with the GitHub Actions secrets URL, and continues to
serve audits from the `ipwho.is` fallback. `/api/version` exposes
`offlineGeoEnabled: boolean` so the admin status page can render the
state at a glance.
MaxMind reissues the databases on the first Tuesday of each month;
re-run the script before each release to pick up new ASN allocations
and city renames.
## Schema delta
`AuditLog` grows two nullable columns:
- `asn Int?` — the autonomous-system number resolved from the
`GeoLite2-ASN` DB at audit-creation time.
- `carrier String?` — the matching `autonomous_system_organization`
string (e.g. `"Deutsche Telekom AG"`).
Both columns are additive and `IF NOT EXISTS`-guarded so the
migration is idempotent on the demo server which may already carry
hand-edited columns from a prior partial run.
## Backfill helper
`src/lib/jobs/geo-backfill.ts` walks rows where `location IS NULL`
and `createdAt > now() - 30 days`, capped at 5 000 rows per pass, and
re-resolves them through the offline DB. Useful after a fresh
GeoLite2 DB roll so the historical rows that landed during an
`ipwho.is` outage finally pick up a city + carrier label.
## Image-size budget
The two MMDB files add ~80 MB to the production image. The
`tzdata`-bearing Alpine runner stage already sits at ~330 MB
compressed; the v1.4.27 image lands well under the 90 MB image-size
delta budget set out in the fix plan.
## Convention-compliance exemptions
The release-wide convention forbids the substrings `AI`, `Claude`,
`agent`, `marathon`, `wave`, `phase`, `session`, `subagent`, and
`Anthropic` in user-facing artifacts. Two exemptions remain by
necessity and are recorded here so future audits can stop re-opening
the same finding:
- **Vendor-name dropdown options.** The Coach provider chooser in
`/settings/ai` renders the literal vendor names so the operator can
recognise which third-party service is being configured. The keys
`settings.ai.providerOptions.anthropic` and
`settings.ai.activeProviderOptions.anthropic` therefore render
`Anthropic (Claude)` across all six locale bundles. Rebranding
these would break the recognition the dropdown exists to provide.
The exemption applies to any vendor label whose product is the
setting being chosen, e.g. `OpenAI`, `Anthropic`, `Codex`.
- **GitHub repository URL on `/about`.** The source-link on the
public about page renders
`https://github.com/MBombeck/HealthLog`. The username segment is an
irreducible technical identifier for the URL and cannot be hidden
without breaking the link. The directive's spirit — never surface
the maintainer's full name in prose — is honoured: the `/about`
page never spells "Marc" or "Bombeck" in body copy, only inside
the URL.