Commit Graph
71 Commits
Author SHA1 Message Date
Marc-André BombeckandClaude Opus 4.8 38ef581e7a test(security): inventory guard for the SSRF requirePublicHost pins
Source-grep guard asserting the openai/anthropic/geo outbound sites keep
the unconditional DNS-rebinding pin and the local AI client keeps its
conditional LAN escape hatch — so a future edit can't silently re-open
the SSRF surface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:23:43 +02:00
Marc-André Bombeck 93694cacd0 fix(idempotency): scrub hls_ share-link tokens from cached bodies
The body-content secret guard matched hlk_ / hlr_ / sk- but not the
new hls_ clinician share-link prefix, so a response echoing a freshly
minted share token could be persisted to the idempotency cache. Add
hls_ to SECRET_PATTERN alongside the existing token prefixes.
2026-06-04 02:34:33 +02:00
Marc-André Bombeck 6db3bef413 feat(measurements): add WHOOP source, native score types, and source-priority ladders
Add the WHOOP integration data layer (migration 0111): seven new
MeasurementType values (HRV_RMSSD, DAY_STRAIN, WORKOUT_STRAIN,
SLEEP_PERFORMANCE, SLEEP_EFFICIENCY, SLEEP_CONSISTENCY, SLEEP_NEED,
ENERGY_EXPENDITURE_KJ), a WHOOP MeasurementSource, the WhoopConnection
and WhoopOAuthState models, and the per-user encrypted BYO-key columns
on User. Day-strain takes DAY_STRAIN (not the COMPUTED STRAIN_SCORE) and
RMSSD takes HRV_RMSSD (not the SDNN HEART_RATE_VARIABILITY) so a native
value never shares a bucket with a derived proxy.

Carry the new types through the completeness walls (units, value ranges,
chart tokens, PR direction, categories, list-meta colours/icons/labels,
HealthKit-mapping exclusions) and feed the cross-source picker: recovery
ladder (WHOOP native above the COMPUTED proxy), WHOOP inserted into the
overlapping vital ladders and the workout ladder, plus skinTemperature
and respiratoryRate metric keys.
2026-06-04 00:49:31 +02:00
Marc-André Bombeck 48f20f4622 feat(api): cap request-body size on batch and export ingest routes
Apply safeJson's opt-in maxBytes ceiling to the body-accepting batch
and export routes that lacked one. The cap measures the raw text
before JSON.parse, so an over-limit payload returns a clean 413
without buffering onto the heap.

- workouts/batch: reuse the existing 5 MB ceiling so chunked uploads
  with no Content-Length are bounded too.
- measurements/batch: 4 MB — an order of magnitude above a 500-entry
  batch's few-hundred-KB worst case.
- export/health-record: 64 KB — the selection payload is small and
  bounded.

Each cap is a DoS ceiling sized well above any legitimate payload,
never a tight bound on a real batch.
2026-06-03 08:56:19 +02:00
Marc-André Bombeck 33b75361f3 feat(measurements): add a COMPUTED source and first-class score types
Add a server-owned COMPUTED measurement source for rows a server engine
derives rather than a device or a person reports, plus three first-class
0-100 score types — RECOVERY_SCORE, STRESS_SCORE, STRAIN_SCORE (unit
"score", plausibility range 0..100).

COMPUTED is read-only from a client's perspective: it joins the read /
decode enum so the native client can decode the rows it queries and
charts, but every client-facing write surface rejects it exactly like
WITHINGS and IMPORT — the batch ingest allowlist stays {APPLE_HEALTH,
MANUAL} and the single-entry POST 422s a COMPUTED source.

All three score types thread through every type-exhaustiveness site: the
unit + plausibility-range maps, the PR-direction resolver (scores carry
no record direction, like the device events), the category overlay (a
new "scores" cluster), the list-view label / icon / colour maps with
locale strings across all six bundles, and the insight chart-token
allowlist + orphan-enum stripper. They are not clinical vitals, so they
stay out of the doctor-report PDF.

Only the recovery score is computed today; the stress and strain types
are defined now so the engines that compute them later need no schema
change. Migration 0106 is purely additive (ADD VALUE IF NOT EXISTS).
2026-06-02 22:31:21 +02:00
Marc-André Bombeck 51fc48f93c feat(fhir): raise the MedicationAdministration cap to 5000 and make it configurable
Raise the per-export administration ceiling from 1000 to 5000 (~3.5 years
at four doses a day) and resolve it from FHIR_MAX_MEDICATION_ADMINISTRATIONS,
bounded 1..50000 with a fallback to 5000 on an unset or out-of-range value.
The windowed-then-capped newest-first behaviour and the honest-truncation
narrative are unchanged; this only raises the ceiling so it bites far less
often. Add the var to the compose environment whitelist and the production
env example so it reaches the container and check-env stays green.
2026-06-02 22:22:50 +02:00
Marc-André Bombeck 95064b405a test(healthkit): cover device-event ingest + the awareness surface
Add mapping tests for the five event classes (verdict resolution,
severity grading, the value-pinned-to-1 contract, unknown-codepoint
fallback, and that a continuous measurement never gets a classification),
the awareness-card gating + device-verdict framing + disclaimer presence,
and the events route narrowing + empty-state contract. Extend the enum,
chart-token and Coach-gate drift guards for the new event types.
2026-06-02 22:10:16 +02:00
Marc-André Bombeck 635e64ace7 feat(healthkit): ingest seven additive HealthKit signals end-to-end
Wire seven previously-deferred HealthKit quantity identifiers through
the full pipeline — enum, mapping, plausibility range, category, list-UI
metadata, FHIR coding, and the generic metric-status assessment:

  CARDIO_RECOVERY          heartRateRecoveryOneMinute   bpm
  WRIST_TEMPERATURE        appleSleepingWristTemperature celsius
  FALL_COUNT               numberOfTimesFallen           count
  SIX_MINUTE_WALK_DISTANCE sixMinuteWalkTestDistance     m
  STAIR_ASCENT_SPEED       stairAscentSpeed              m/s
  STAIR_DESCENT_SPEED      stairDescentSpeed             m/s
  BREATHING_DISTURBANCES   appleSleepingBreathingDisturbances count

Each value rides raw on the wire (identity conversion). Fall count is
cumulative (daily SUM); the stair speeds drain to a daily mean; the rest
store as latest per-workout / per-night spot readings. Wrist temperature
and breathing disturbances are flagged privacy-sensitive. The category
sleep-apnea and walking-steadiness EVENT identifiers stay deferred — they
are events, not quantities.

Each new metric gains an Insights sub-page on the shared scaffold with the
generic assessment card, localised across all six locales. Cardio fitness
(VO2 max) — ingested since v1.4.23 but assessment-less — gains its own
/insights/cardio-fitness page so it picks up the assessment instead of
riding as a bare chart-row on the pulse page.

Normal-range anchors are cited where defensible (six-minute walk 400-700 m,
ATS guideline + population reference); the rest defer to the user's own
baseline rather than fabricate a band.

Migration 0104 appends the enum values additively (IF NOT EXISTS, forward-only).
2026-06-02 22:02:50 +02:00
Marc-André Bombeck 170862fdf8 fix(targets): convert glucose targets back to mg/dL on save
The per-metric target editor receives a glucose range already projected
into the user's display unit (mmol/L) along with `unit="mmol/L"`, but it
seeded, validated and saved that value verbatim. HealthLog stores glucose
canonically in mg/dL, so a user on the mmol/L preference either had a
valid target (e.g. 3.9–5.5 mmol/L) rejected by the 40–400 mg/dL bounds,
or — when an override already existed — saw a mg/dL number rendered as if
it were mmol/L and wrote it straight back.

Add `mmolToMgdl` / `toCanonicalMgdl` (the inverse of `convertGlucose`) and
teach the edit sheet to treat the display unit at its boundary: seed the
persisted mg/dL override into the display unit, validate against bounds
expressed in the display unit, and convert the typed value back to
canonical mg/dL before the PUT. A no-op for every non-glucose metric and
for users on the mg/dL preference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 0cc0361058a85f32d7135e5f2fd425c98f62d885)
2026-06-02 17:01:14 +02:00
Marc-André Bombeck d880385635 refactor(targets): retire the standalone Targets page
The standalone /targets (Zielwerte) page is retired. Target editing
moved inline onto the Insights metric pages in v1.8.6 — each metric's
reference panel mounts the editor and writes the same
PUT /api/user/thresholds, reading the same /api/insights/targets
payload. The page carried a deprecation notice and had already dropped
out of the navigation, so removing it is a UI-only change: no data
model, API route, or inline editor moves.

Remove the page and its tests, the components reachable only from it
(target card, per-card coach button, summary header, target prompt /
scope helpers, coach-handoff hook), the now-dead /zielwerte→/targets
proxy redirect, and the orphaned targets.* i18n keys across all six
locales. Repoint the handful of code comments that pointed readers at
the page to the inline Insights panel, and prune the two coach
gate-inventory allowlists plus the queryKey factory guard root that
named the removed files.

The reference panel keeps the range bar, consistency strip, status
pill, edit sheet, and source-link helpers — those are the live editing
surface now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 7f842c5620087127e980fe22a56133135b7c0531)
2026-06-02 17:01:14 +02:00
Marc-André Bombeck 3f9fd9095f feat(medications): show the GLP-1 dose-strength curve instead of the empty therapy course
The GLP-1 detail disclosure carried a titration block that only ever
rendered the standard EMA *reference* ladder — a static schedule with no
values from the user's own therapy, blank whenever the medication's brand
fell outside the catalog. Replace it with a dose-strength curve over the
user's recorded dose-change history.

The MedicationDoseChange stream is the history of record for titration
(2.5 -> 5 -> 7.5 mg escalation steps). The new curve plots that series as
a step line, carrying the latest dose forward to now so the current
strength anchors the right edge. With a single dose change there is no
escalation to draw, so the surface states the current dose plainly with a
"no titration history yet" caption rather than a flat single-point chart;
with none it shows the empty state. The curve plots only logged facts, so
unlike the estimated-PK drug-level chart it needs no Research-Mode gate.

Drop the now-unused reference-ladder section, its read route, the ladder
helpers, the titration query key, and the orphaned i18n block.
2026-06-01 22:59:14 +02:00
Marc-André Bombeck aed83b7747 fix(report): keep modules together, label the chart time axis, fix glyph stretch
Four layout defects in the clinical PDF renderer:

- Stretched lines: the built-in Helvetica is WinAnsi-encoded, so glyphs
  outside it (the trend arrows the summary injects, the superscript-two in
  kg/m2) resolved to the .notdef box at a fallback advance width and stretched
  the surrounding words. Route every drawn string (direct text, wrapped
  paragraphs, and autoTable cells) through a central WinAnsi sanitiser that
  maps the offenders onto safe equivalents. Embedding a Unicode TTF so the
  arrows render as true glyphs is a documented follow-up.

- The sparkline dropped each reading's timestamp and plotted on an index
  axis, so a trend gave no sense of its time span. Plot points by their real
  timestamp and print the first/last dates under the baseline.

- No consistent bottom safe area: scattered y > 240 magic numbers and tables
  with no bottom margin let content run into the footer. Introduce a single
  contentMaxY derived from the page height, footer band, and bottom margin;
  every break check and every autoTable bottom margin refers to it.

- Modules tore mid-block. Add an ensureSpace guard called before each module
  heading and a per-line guard in every free-text loop; give the short
  titration and side-effect tables rowPageBreak: avoid.
2026-05-31 22:47:44 +02:00
Marc-André BombeckandClaude Opus 4.8 50e0a65228 chore(release): v1.7.0
v1.7.0 — health-record export (PDF + FHIR), flexible medication schedules, first-paint dashboard snapshot, full HealthKit coverage, offline sync delta feed, Coach data clustering. See CHANGELOG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 14:36:17 +02:00
Marc-André BombeckandClaude Opus 4.7 7f95a2a44f fix(measurements): wire walking step length and walking speed
The iOS team had gated both client-side waiting on the server map.
Both flow raw SI on the wire (metres and metres per second
respectively) — the server-side ×100 scaling the percent gait
metrics use does NOT apply here. The convention block at the top of
apple-health-mapping.ts now spells the split out so a future
gait-metric addition picks the right path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:38:21 +02:00
Marc-André BombeckandClaude Opus 4.7 ef21967d12 refactor(meds): lift IntakeImportDialog out of the list page and bundle the inline-compliance prefix
The list page was carrying two large dialogs (`IntakeImportDialog`
and `ApiEndpointDialog`) whose state slots had no list-card trigger
wired to them. The slots were only ever set from inside the dialogs
themselves, so they read as dead code on the list page even though
the dialog bodies themselves were healthy.

`IntakeImportDialog` lifts to `src/components/medications/intake-import-dialog.tsx`
as the canonical mount the detail-page intake-history header will
import. `ApiEndpointDialog` no longer mounts on the list page; its
final v1.5.5 home is the detail-page Settings sub-row (lands in a
later commit). The list page drops the two state slots, the inline
dialog bodies, and the now-unused imports.

`medicationDependentKeys` also gains the `compliance-chart-inline`
prefix. The per-medication inline compliance chart used to mount
under `queryKeys.medicationComplianceChart(id)` (which expands to
`["compliance-chart-inline", id]`) but the bundle did not match the
prefix, so an intake mutation left the chart up to 60 s stale. The
prefix now lands in the bundle so every detail-page mutation evicts
the tile in one tick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:34:08 +02:00
Marc-André BombeckandClaude Opus 4.7 ca1c52ffcb feat(avatar): self-hosted avatar storage replacing the Gravatar leak
The previous /me payload carried a gravatarUrl built from
SHA-256(email) and pointed every authenticated page-load at
www.gravatar.com — Automattic could correlate the digest against
its known-email table on each request. This release moves the
avatar in-house: the user uploads through POST /api/user/avatar
(multipart, JPEG/PNG/WebP, 2 MiB / 2048×2048 max), the bytes ride
on the User row alongside a content-type and updatedAt timestamp,
and GET /api/user/avatar/{id} serves them from same-origin with
the cache-busting timestamp on the profile response.

The image-header parsing is hand-rolled in src/lib/avatar.ts so
the standalone image does not gain a native dep just to read width
and height. Validation rejects oversize / wrong content-type at
the multipart parse boundary; DELETE clears the row. The bytes
ride pg_dump alongside the rest of the row so the existing weekly
encrypted backup path already covers them.

The sidebar, the coach panel, the use-auth hook, and the disable-
coach card stop reading gravatarUrl; src/lib/gravatar.ts retires.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:39:20 +02:00
Marc-André BombeckandClaude Opus 4.7 befac0ec65 fix(measurements): wire the six deferred Apple Health identifiers
Adds RESPIRATORY_RATE, BODY_MASS_INDEX, and the four remaining
deferred quantity types to MeasurementType plus the Apple Health
mapping. The batch route already returns 200 with a per-entry skip
for unmappable identifiers; iOS reads that as success and advances
its sync anchor, so any sample with one of these identifiers used
to be dropped forever. The mapping now accepts them and the rows
land.

The walking-asymmetry and walking-double-support percent values
adopt the project's server-side scaling convention (raw HK reading
on the wire, server applies the ×100 at ingest), matching the
walking-steadiness path. iOS releases that still send the
pre-multiplied percent need a one-release shim and are tracked in
the iOS coord notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:32:18 +02:00
Marc-André BombeckandClaude Opus 4.7 b43b57f657 feat(security): pin resolved IP on outbound fetches to defeat DNS rebinding
Closes issue 217. The isPublicUrl input-time guard catches every
RFC1918 / CGNAT / loopback / link-local alt-notation, but a low-TTL
DNS record can flip between accept and dispatch. The new
pinnedPublicDispatcher resolves the hostname literally inside
undici.Agent's connect hook, refuses any address isPublicIp would
reject, and pins the connection to the first valid public address.

The five outbound paths that accept a user-supplied host route
through the dispatcher via safeFetch's requirePublicHost flag —
moodlog sync, moodlog reverse-push, the moodlog credentials probe,
the local AI client (already gated in commit 1), and the ntfy
sender. The flag now buys both the input-time check and the
connect-time pin in one call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:32:11 +02:00
Marc-André BombeckandClaude Opus 4.7 425503e095 feat(security): safeFetch wrapper with manual redirect and timeout defaults
Closes issue 218. Flips the convention from "remember to opt in to
redirect: manual" to "remember to opt out". Every outbound fetch that
doesn't pin a hard-coded host now routes through safeFetch and inherits
AbortSignal.timeout(15_000) so a tar-pit upstream can no longer pin a
worker indefinitely. The local AI client also gates on
requirePublicHost: true since its host is user-supplied; operators
running self-hosted Ollama / LM Studio on an RFC1918 address keep their
escape hatch via ALLOW_LOCAL_AI_PRIVATE_HOSTS=true (also enforced at
write-time in /api/user/ai-provider).

14 outbound call sites migrated. The four that already pinned manual
redirect (the historic templates) keep their semantics; the wrapper
preserves an explicit init.redirect. Adds undici as an explicit
dependency so the connect-time pin landing alongside this can rely on
the documented API rather than a transitive hop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:32:11 +02:00
Marc-André BombeckandClaude Opus 4.7 e686083bae i18n(meds): localise the modal-wizard surface
Populates messages/{de,en}.json with every key the new dialog reads
under medications.wizard.*, plus categoryDiabetes and
categoryAntibiotic on the cards' badge namespace; the other four
locales ship the English fallback per project convention. The
retired medications.create.wizard.* namespace is removed from all
six files so the locale-integrity guard surfaces every dangling key
the moment the old surface stops being reachable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:33:11 +02:00
Marc-André BombeckandClaude Opus 4.7 030e97906c i18n(meds): localise the v1.5 scheduling surface across six locales
Populates messages/{de,en}.json with native copy for every key the
new picker primitives, the wizard, the edit-form refactor, and the
plain-language summary consume. The remaining four locales ship the
English fallback for v1.5.0; native polish lands in a follow-up.

CadencePicker rolling-explainer key flipped to `kind.rollingExplainer`
so the i18n resolver can return both the rolling label and its
explainer (the original `kind.rolling.explainer` path collided with
the `kind.rolling` string leaf — JSON cannot host both shapes on the
same prefix). Adds the dose-unit SI tokens (mg / g / ml) to the
placeholder-allowlist in the locale-integrity guard since those
labels are intentionally identical across every locale.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:31:15 +02:00
Marc-André BombeckandClaude Opus 4.7 909c572bfd fix(auth): reject moodLog webhook secrets that fail envelope decryption
`readMoodLogSecret()` previously caught every decrypt failure and
returned the stored bytes verbatim, on the theory that legacy
plaintext rows should keep working through the migration grace
period. That fallback is correct for genuinely unprefixed legacy
values, but it also swallowed two failure modes that should never
silently succeed:

- An envelope-shaped row (`<keyId>.<payload>`) whose key id is no
  longer in the live `ENCRYPTION_KEYS` map. Returning the literal
  `v1.<base64-ciphertext>` as if it were the plaintext secret would
  cause every webhook attempt against that row to compare a known-bad
  literal — and worse, lock the user into a state where rotating the
  key out of the env quietly broke their integration.
- A row whose envelope bytes were tampered with directly in the
  column (SQL injection, restored-from-backup-with-tamper, leaked DB
  credentials). The catch-all returned the attacker-chosen value as
  the active secret; the webhook would then accept whatever the
  attacker sent over the wire.

The fix routes the envelope-shape check through `extractKeyId()` and
only catches the failure for the genuinely legacy path. Envelope
rows that fail to decrypt now throw — operators see the rotation gap
or the tampered row instead of running on a quietly-broken secret.

`isLegacyPlaintext()` simplifies to the same shape check: anything
with an envelope prefix is encrypted (even if currently undecryptable),
so it should not be re-rotated as if it were plaintext.

Two new test cases pin the contract for envelope-shape failures
(unknown key id; tampered ciphertext under a known key).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:08:56 +02:00
Marc-André Bombeck 19bc31b0bc fix(i18n): formatDateOrRelative dispatches to One/Other plural keys (v1.4.49.2)
Marc reported raw `insights.relativeHoursAgo` rendering verbatim across
medication cards, recent-achievements, admin sections, and the iOS
notification preview — anywhere `formatDateOrRelative` paints a
timestamp older than 60 s. The helper called `t("insights.relative
MinutesAgo", { count })` / `t("insights.relativeHoursAgo", { count })`
against a translation bundle that ships only the pluralised `*One` /
`*Other` variants; the i18n surface performs no auto-pluralisation so
the bare key bounced through `t()`'s identity fallback into the UI.

The fix mirrors the One/Other dispatch pattern that the v1.4.43 H6
sweep added to the canonical `src/lib/i18n/relative-time.ts:24-48`
helper. The format-twin in `src/lib/format.ts` was missed in that
sweep because the two helpers had drifted into byte-different siblings
since v1.4.20.

Audit confirmed the leak was isolated to these two keys: I cross-
checked every `t(…, { count })` call in the repo against
`messages/en.json`. The other 17 call sites (`insights.dayStreak`,
`medications.importDuplicatesSkipped`, `achievements.metricPercent`,
`passwordStrength.minLength`, `settings.integrationPill.*`,
`targets.*`, `admin.section.backups.uploadSuccess`,
`trendHints.remainingMany`, `dashboard.staleHint`, …) all use plain
non-pluralised keys that exist verbatim in the bundle.

Regression guard added to `format-date-or-relative.test.ts`: drives
every relative bucket through the helper, collects the `t()` keys it
emits, and asserts each one has a matching entry in `messages/en.json`.
Pre-fix this guard fails on the two bare keys; future twin-helper
divergence (e.g. someone adds a third date-formatting helper with a
copy-pasted body that lacks the pluralisation step) trips it before
it reaches prod.

Bundles v1.4.49.1's analytics rollup-tier delegation that the Coolify
auto-deploy never picked up.

Tests: 5269 unit + integration pass, lint clean, typecheck clean.
2026-05-23 13:53:04 +02:00
Marc-André Bombeck f92e644154 chore(release): v1.4.49 — audit-backlog closure + server reminder suppression + diagnostic endpoint + analytics cold-path fix
Bundles the deferred v1.4.47 Mediums + Lows with the v1.4.48-deferred items
into a single release. Headline: server-side suppression of MEDICATION_REMINDER
APNs for iOS clients that own their local reminders (closes GH #206), a
push_attempts table backing /api/admin/notifications/diagnostic, and a sweep
of v1.4.48 QA forward-findings.

Performance (the user-visible win):
- /api/analytics cold-fallback path splits the heavy single-statement
  aggregate into two parallel queries. The linearly composable columns
  (count / min / max / mean) keep the full-partition scan; the windowed
  7 / 30 / 90-day avg / slope / r² columns take a 90-day outer cap so
  the planner does an index range scan on (user_id, type, measured_at)
  instead of a full-partition sequential scan. Output is bit-identical;
  cold slim slice drops from multi-second to sub-second on tenants with
  large measurement partitions.

Server-side notifications:
- GET + PATCH /api/auth/me/notification-prefs with deep-merge semantics.
  Initial category medication.clientManaged: boolean (default false). Cron
  medicationReminderJob skips dose-due APNs when the flag is true,
  emitting a medication_reminder.suppressed_client_managed wide-event
  annotation per skip.
- push_attempts table backs the diagnostic endpoint. Every APNS /
  Web-Push / Telegram / NTFY sender writes a fire-and-forget row.
  Daily 03:35 Europe/Berlin cleanup prunes rows older than 90 days.
- /api/admin/notifications/diagnostic — admin-only endpoint surfacing
  device APNs token presence (masked), channel config presence, and
  recent push attempts. Registered in OpenAPI.
- Per-channel APNs "Send test" button now fires with
  eventType: "MEDICATION_REMINDER" so the dispatcher's time-sensitive +
  priority-10 branch runs. A successful self-test now exercises the
  exact path production reminders take; an unsuccessful one points the
  user at iOS Settings → Time Sensitive Notifications rather than
  appearing to be a server-side bug.

Withings reliability:
- OAuth callback consumes the nonce atomically (delete-first with
  P2025 catch for the replay branch). The previous read-then-delete
  pattern allowed a race between two concurrent callbacks.
- ?reason=state redirect differentiated into csrf1 / replay / expired /
  cross_user so operators can tell mismatch types apart without DB
  shell access.
- connect endpoint rate-limited to 10/60s/user; row-create failures
  redirect to a consistent error surface.

Observability hardening:
- redactSensitiveFields(body) denylist helper covers password / token /
  secret / apiKey / authorization / csrfState / nonce, recursive,
  case-insensitive. Caught: /api/devices invalid_format issues were
  echoing apnsToken values verbatim into the audit log.
- sanitiseZodIssues(issues, { stripValuesFromMessage: true }) overload
  applied to 14 audit-log sites.

Migrations: 0079 (notification_prefs), 0080 (push_attempts).

Tests: 4815 → 5272 unit + integration. Workout-batch integration suite
that had been red on main since v1.4.45 (three release commits shipped
through it) now green again — updated to match v1.4.42 cross-source
dedup semantics.

Resolves PR #207.
2026-05-23 12:51:41 +02:00
Marc-André Bombeck 6c7fc7dc84 chore(release): v1.4.45 — analytics 9 s perf fix + audit-marathon closure + v1.4.44 hotfix merged
Audit-marathon close: 14 waves + W10 QA + reconcile. Perf fix (analytics 9 s -> 2-3 s), Zod multi-issue rollout (41 routes), Withings parked-state automation, mobile-UI WCAG 2.5.5 close, QoL i18n + offline banner + account delete, security PII drop + replay-injection scan. Migration 0075 required. Tests 4815 -> 5076.
2026-05-21 23:13:21 +02:00
Marc-André Bombeck 324d4bb411 chore(release): v1.4.44 — REG-11 dashboard summary fix (iOS Home tile sparkline + value)
iOS-operator-blocking hotfix for REG-11 — Home dashboard tile renders
neither chart nor latest value for BP / Puls / Körperfett when the
most recent reading is older than 7 days. Root cause was in
`/api/dashboard/summary` SQL gates; `latestIn7d` + `sparkBuckets` both
required `measured_at >= sevenDaysAgo`, which returned empty for
sparse accounts, leaving the iOS tile with `latestValue: null` +
`sparkline: []` to render from. Detail-screen
(`/api/measurements/series?kind=…`) escaped because of its broader
window.

Server-side changes:
- `latestIn7d` → `latestEver` (drops 7-day filter; DISTINCT ON returns
  latest reading EVER per type, bounded at N_metrics rows).
- `sparkBuckets` rewritten via ROW_NUMBER() OVER (PARTITION BY type
  ORDER BY bucket_start DESC) so the trailing N daily buckets per
  type render regardless of age, bounded at SPARK_DAYS × N_metrics.
- BP + Pulse metrics.push gated on `latest || allTimeCount > 0` like
  BodyFat already is — accounts with zero readings ever no longer get
  empty placeholder tiles.
- 4 new regression tests in summary/__tests__/route.test.ts cover
  sparse accounts, zero-data accounts, within-7d guards.

Other develop work bundled in this release:
- W4-QoL — copy nits.
- W5-WORKOUTS — user source priority threaded through write-time
  picker + batch route.
- W7-WITHINGS-RELIABILITY — response classifier + sync hardening.
- W8-OPS — ops phase docs.
- B11 — NEXT_PUBLIC_APP_VERSION build-arg routing so /api/version
  cannot drift from CI tag.
- env-check workflow — manifest / example lockstep enforcement.

Resolves PR #194 (squash-merge produced via local -X theirs strategy
because GitHub's UI couldn't auto-resolve develop's post-v1.4.42
contiguous work against the v1.4.42 squash on main; resulting tree is
byte-identical to develop's HEAD).
2026-05-21 22:07:32 +02:00
Marc-André Bombeck 9b6e237f5f chore(release): v1.4.42 — Knip enforcing, queryKey factory closed, iOS Workouts dedup, Withings off-response classification
Six touch-disjoint waves landed on develop before this release:

- W1-KNIP — exports/types tiers flipped to enforcing-mode on main;
  35 unused exports + 52 unused types triaged to 0/0; shadcn surface
  + zod-infer contract types scoped via knip.json ignore-block.
- W2-ZOD-MULTI-ISSUE — `returnAllZodIssues` shared helper +
  `/api/dashboard/widgets` 422 now returns every Zod issue + worker
  audit-ledger breadcrumb. 41 sibling routes documented for v1.4.43.
- W3-QUERYKEY-LONGTAIL — 40 settings/medications/admin/hooks files
  migrated to the queryKeys factory; 9 new factory entries; ESLint
  rule + test-guard scope extended in lockstep.
- W4-ARCH-HYGIENE — BERLIN_DAY_FORMATTER consolidated across nine
  sites (seven status helpers + bucket-series + bp-in-target);
  Suspense double-comment consolidated; doctor-report sanitiser regex
  rewritten to escape-form so diffs are reviewable; pr-detection-
  worker soft-delete filter; offhost-backup DR-intent comment.
- W5-IOS-WORKOUTS — `dedupeWorkoutBatch` write-time cross-source
  dedup helper for the v1.5 iOS HealthKit observer ingest; dashboard
  tile-strip placeholder parity with the live TrendCard chrome.
- W6-WITHINGS-OFF + ENV-CHECK — Withings off-response classifier
  (transient/reauth/persistent) + WithingsApiError typed throw + the
  `pnpm check-env` CLI. APNs `allOrNone: true` closes the AP-2
  silent-disable gap the wave was conceived to close.

No migration, no env-var change, no API contract break for iOS v0.5.4.
The new `/api/dashboard/widgets` 422 envelope is additively extended.

pnpm test --run green at 4815 passing / 1 skipped.
pnpm typecheck, pnpm lint, pnpm knip (no --include flag) green.
2026-05-21 19:33:07 +02:00
Marc-André Bombeck 67207d725e chore(release): v1.4.41 — iOS perf hotfix, soft-delete completeness, tree hygiene
Eight touch-disjoint waves landed on develop before this release:

- W-INSIGHTS-HOT + W-RECONCILE-INSIGHTS — extract the timeout-stub
  persist helper and wire all seven *-status routes through it, closing
  the recurring ~14 s warm response the iOS client sees on dashboard
  open once the AI provider has stalled once that day.
- W-DELETED-2 — close the remaining three soft-delete reader-tier gaps
  (export bundle, gamification achievements, doctor-report aggregator)
  with a five-route integration-test pin.
- W-ORG — types consolidation into src/types/analytics.ts and
  src/types/backups.ts; prompt directory unification.
- W-FRONTEND-FACTORY — queryKey factory expansion + ESLint rule lift.
- W-IOS-COORD — POST /api/auth/check-user four-branch onboarding
  discovery, identifier matched verbatim, per-IP rate-limited.
- W-SIMPLIFIER — today-intake projection helper extract; unused tx?
  params dropped; 13 knip-flagged dead exports trimmed.
- W-PERF-OPS — v1.4.39 W-SUM legacy-NULL UNION discovery arm retired.
- W-PROCESS-DOCS — pg.Pool sizing operator doc; ESLint rule
  formalisation; knip CI gate staged for v1.4.42 flip.

No migration, no env-var change, no API contract break for iOS v0.5.4.
The new check-user endpoint is an additive surface. AP-2 .p8 key is
installed; SB-5 time-sensitive APNs is live as of this deploy.

pnpm test --run green at 4732 passing / 1 skipped.
pnpm typecheck, pnpm lint, pnpm knip green.
2026-05-21 18:36:17 +02:00
Marc-André Bombeck 1114cf7e63 chore(release): v1.4.40 — comprehensive Critical+High architecture closure plus iOS PB30 enablement
Eleven touch-disjoint waves landed on develop plus a six-axis QA
reconcile round before tagging. See CHANGELOG.md for the user-facing
prose; this commit message documents the wave + report inventory for
release-management traceability.

Waves shipped (each with a phase report under .planning/):

- phase-W-POOL-v1440-report.md — p-limit(4) wrapper around the
  analytics 15-way fan-out + pg.Pool max raised 10 -> 20. Closes the
  v1.4.39 empirical-trace finding #1 (Prisma pool starvation).
- phase-W-DELETED-v1440-report.md — 11 reader-tier helpers + 3
  integration contracts close audit Critical Finding #3 (soft-delete
  invisibility) across dashboard, analytics, summaries, rollup tiers,
  and the Coach snapshot path.
- phase-W-INSIGHTS-v1440-report.md — mood-rollup swap on the three
  /api/insights/{features,targets,cards} routes plus the
  compliance-rollup hook gap closure for bulk-projection mints; the
  post-QA reconcile patch closes the six remaining measurement.findMany
  sites in the cluster that still leaked tombstoned rows.
- phase-W-INFRA-v1440-report.md — rollup helper umbrella consolidation
  under src/lib/rollups/* with zero orphan re-exports; lint, typecheck,
  knip, and 93/93 rollup tests green post-restructure.
- phase-W-RSC-v1440-report.md — per-tile Suspense boundaries plus the
  queryKey factory-bypass walker test (extended to comparison/ in the
  reconcile pass).
- phase-W-PRIVACY-v1440-report.md — bilingual /privacy page with 17
  paired-section tests pinning the 9 SB-3 disclosure requirements.
- phase-W-AASA-v1440-report.md — /.well-known/apple-app-site-association
  endpoint pinned to S8WDX4W5KX.dev.healthlog.app with app-ID parity
  assertions guarding against split rotation.
- phase-W-APNS-NOTIFY-v1440-report.md — MEDICATION_REMINDER
  time-sensitive APNs payload (priority 10 + interruption-level)
  scoped via a parameterised test that pins all 6 other event-types
  do not bypass Focus. AP-2 .p8 install gates real delivery.
- phase-W-CONSENT-v1440-report.md — AI consent receipts CRUD with
  append-only invariant pinned in test; revoke-then-regrant leaves
  both rows; idempotent DELETE returns 200 + receipt: null.
- phase-W-WMY-WIRE-v1440-report.md — three previously-dead WMY readers
  (week/month/year) wired through summaries-slice and
  health-score-fast-path with a linear-composability parity test
  pinning the cross-consumer routing contract.
- phase-W-GHOSTS-v1440-report.md — ~1 177 lines of dead code removed
  across 9 atomic commits; startOfUtcDay + wallClockInTz consolidation
  eliminates a sibling-drift risk; knip whitelist trimmed to three
  files pending follow-up.

QA reconcile patches applied per the v1.4.40 QA round (six reports
under .planning/round-v1440-QA-*-findings.md):

- senior C1 — dashboard-suspense-boundaries regex aligned with the
  post-W-INFRA Thread 2 userTimezone-local lift.
- senior H1 — soft-delete filter added to the six remaining
  insights measurement.findMany sites.
- specialist H1 — Promise.allSettled around bulk-projection
  compliance recompute on both /api/medications/intake and
  /api/dashboard/summary.
- ux H1 — compare-toggle migrated to queryKeys.dashboardWidgets
  factory; walker test extended to src/components/comparison/.
- security M1 — consent artefact 64 KB cap enforced via
  Buffer.byteLength (UTF-8 bytes, not UTF-16 code units).

Quality gates green at tag: pnpm typecheck, pnpm lint, pnpm knip
--include files,dependencies,binaries,unlisted, pnpm test --run
(4726 passing / 1 skipped). Tests 4525 -> 4726 unit + 1 skipped over
the v1.4.39 -> v1.4.40 span.

User-facing prose: see CHANGELOG.md.
2026-05-21 12:48:02 +02:00
Marc-André Bombeck f9996552ed chore(release): v1.4.39.4 — dashboard symmetry for daily-schedule intake projection 2026-05-21 11:20:24 +02:00
Marc-André Bombeck a550031a84 chore(release): v1.4.38
Robustness sweep, perf hotspots, full localization, coach gate
cascade closure.

- /api/dashboard/summary cold-mount 4.6 s → ~500 ms via DAY-bucket
  sparkline + 60 s response cache.
- Coach API gate inventory test surfaced and closed two orphan
  routes (chat[id] GET+DELETE, chat-message feedback POST).
- Cross-tz fast-path runtime guard falls back to live for users
  more than ±3 h from UTC; proper bucket minting stays v1.5.
- es / fr / it / pl coverage 27 % → 63 % (~3 400 new strings) plus
  three medication-cluster placeholder restorations.
- IANA timezone validation closes the User.timezone self-DoS
  regression the new dashboard SQL would have surfaced.
- Unit suite 4520 → 4524.

See CHANGELOG.md for the full set.
2026-05-17 19:07:03 +02:00
Marc-André Bombeck 88028fa082 v1.4.37 — final web polish before iOS focus
Carries the v1.4.36 perf carry-over for the full /api/analytics
route (correlations + healthScore + bp_in_target on the rollup-
coverage probe, 111s cold worst-case down to ~1.5-3s on power-user
accounts), consolidates Apple Health step samples into a one-row-
per-day list view with chevron drill-down and a nightly drain
(~50x table compression once it converges), restores
IntakeHistoryListV2 to the pre-v1.4.28 contract, converges every
Coach affordance behind a single feature gate with an invariant
test pin, lifts the medication-detail Mounjaro card to byte-
equivalent parity with Ramipril, brings the Insights overview
hero row to height parity, hardens getClientIp behind a new
TRUST_CF_CONNECTING_IP env flag, ships the Arztbericht hero card
on Settings -> Export, adds the Medikamenteneinnahme quick-add
overlay to the dashboard, drops the timezone override toggle for
a silent browser-zone auto-seed, and fixes the v1.4.36 red CI
workflows. Documentation refreshed across all three repos with
five new architecture diagrams in the Dracula palette. Unit suite
4354 -> 4486 passing; typecheck + lint clean.
2026-05-17 15:36:38 +02:00
Marc-André Bombeck f5962497d6 v1.4.34.4 — audit-driven hotfix bundle (security, UX, code-quality, docs)
Consolidated hotfix landing the findings from a six-axis audit
covering mobile security, UX + accessibility, code quality + docs,
README + discoverability, web performance, and mobile-deep responsive
behaviour. None of the changes break the public API or the existing
data shape; every fix is additive and SAFE under the v1.4.34.x web
freeze. Full audit reports live in `.planning/round-audit-*.md`.

Security
  - Password rotation now wipes API tokens + refresh tokens in the
    same `$transaction` as session revocation. A stolen iOS Bearer no
    longer survives a user-initiated password change or an admin reset.
  - `/.well-known/` proxy bypass tightened to an explicit-allowlist
    `Set`. Stale `/api/auth/codex/callback` PUBLIC_PATHS entry dropped.
  - HSTS gains `preload`. Withings host removed from the global CSP
    and gated to the integration's own routes.
  - `getClientIp` warns once per process on `TRUST_PROXY_HOPS`/XFF
    mismatch + exposes a tagged helper for tighter universal buckets.
  - Service worker rejects off-origin notification-click URLs. Offline
    fallback body switched to a locale-neutral icon. Manifest gains
    `scope`, `id`, `display_override`.

UX + accessibility
  - i18n leaks closed on Dialog/Sheet close-X, ChartSkeleton's loading
    announcement, and the notifications-section breadcrumb.
  - Focus rings on bottom-nav, the More overflow button, and the
    mobile top-bar user menu.
  - Tap-target floor: Input / NativeSelect / Select / DateTimeInput
    switch to `h-11 sm:h-10`.
  - Admin chip-strip auto-scroll-into-view (matches v1.4.33 settings).
  - List-page subtitles visible on mobile.
  - Insights empty-state CTA vocabulary normalised so the link opens
    the right measurement form (was silently failing for
    BODY_TEMPERATURE / BLOOD_GLUCOSE / PULSE).

Mobile-deep
  - `viewport.themeColor` reacts to light/dark mode.
  - TopBar + Coach FAB reserve safe-area-inset-top / -bottom for
    notched iPhones.
  - Sonner toaster switches to `theme="system"`.

Code quality
  - `SESSION_SECRET` dropped — the env var was documented as required
    but never read by code. Three secrets, not four. Self-hosters
    upgrading can delete the existing line.
  - `toJson<T>()` helper consolidates seven Prisma JSON-cast sites.
  - `db-compat.ts` gains a header docstring naming it the
    schema-bootstrap path.
  - Passkey response types adopted from `@simplewebauthn/server`;
    `any` + eslint-disable pragmas drop out.
  - Zero-TODO CI gate added at `.github/workflows/no-todo-markers.yml`.

Documentation + discoverability
  - README rework: How-it-compares matrix, Apple Health + AI Coach
    promoted to standalone Key-Features bullets, status block, latest
    release + GHCR badges, OpenAPI pointer, three-secret Quick Start.
  - openGraph + twitter cards now carry `images` and a
    `summary_large_image` Twitter card.
  - GitHub repo metadata: description tightened from 290 to 156
    chars + keyword-front, `homepageUrl` flipped from a personal
    tenant to the demo site, six generic / PII-adjacent topics
    swapped for six high-intent ones.
  - Five new user-facing docs: `docs/self-hosting/getting-started.md`,
    `docs/self-hosting/reverse-proxy.md`,
    `docs/integrations/apple-health.md`,
    `docs/integrations/withings.md`,
    `docs/integrations/ai-providers.md`.
  - `docs/README.md` clarifies the docs tree as internal playbooks
    vs the user-facing `docs.healthlog.dev` site.
2026-05-17 00:39:58 +02:00
Marc-André Bombeck 7bef199039 v1.4.33 — polish and reliability
Quality-leap release between two HealthKit milestones. The headline is a P0 hotfix for the `/api/analytics` 500 that broke the Insights mother page for any account with more than a few thousand measurements: the six per-metric status helpers were spreading the entire numeric history into `Math.min` / `Math.max`, which trips a stack overflow once a single argument list exceeds the V8 spread cap. Both the surfaced symptom and the latent risk in five sibling helpers were folded onto a reduce-based min/max path.

Around that hotfix landed a deep polish pass: the Insights mother page now defers three below-the-fold blocks behind `next/dynamic` to trim the cold mount cost, the analytics endpoint gained a `?slice=summaries` slim slice that the per-metric sub-pages now ride, the Coach snapshot builder picked up a 60 s LRU keyed on `(userId, scope)`, the Settings shell consolidated three duplicate copies of the about / notifications / about-dropdown surfaces, the navigation strip dropped a redundant "Home" group label and a colliding "Notifications" sibling, every icon-only button on the medications and admin surfaces gained an accessible name, every Progress bar gained one too, three pages had their heading hierarchy repaired to a sequential h1 → h2 → h3, the mobile Coach FAB now hides while a chart tooltip is open so it cannot occlude the read-out, and the auth shell normalised every authenticated route to a single `max-w-screen-xl` container.

Also lands the Apple App Site Association handler at `/.well-known/apple-app-site-association` on every host that fronts the app, advertising the iOS bundle's App ID prefix (S8WDX4W5KX.dev.healthlog.app) under `webcredentials.apps` so the passkey ceremony shares cleanly between the web origin and the iOS app.

The release closes nineteen issues from a five-surface audit + a runtime bug-hunt pass, plus six follow-up tracks of polish across the affected surfaces.
2026-05-16 19:46:43 +02:00
Marc-André Bombeck c7a0ff214b chore(release): v1.4.32
v1.4.32 — HealthKit Tier 1 wave A: workouts API + list/detail pages + dashboard tile + 5 metric sub-pages; HRV/RESTING_HR moved to cardiovascular; latent workouts-list 500 fixed.
2026-05-16 16:04:21 +02:00
Marc-André Bombeck 3ece95f192 chore(release): v1.4.30
Server-side prep for the next iOS TestFlight build. Five surfaces land together so the iOS engineer can pick them up in one cut-over: a locked externalId shape for daily-aggregated cumulative HealthKit rows, the SyncMode foundation columns + handshake + bulk-backfill endpoints, a first-class MoodEntry.note column that replaces the legacy tag-based workaround, a cross-source workout dedup helper, and two new MeasurementType enums plus the shared category overlay that drives the iOS permission picker and the future Insights nav.
2026-05-16 12:37:55 +02:00
Marc-André Bombeck 232ea43d99 chore(release): v1.4.28.1
## [1.4.28.1] — 2026-05-16 — Dashboard-save hotfix

"Speichern" on the Dashboard works again for every account whose
layout was last persisted before v1.4.28. The v1.4.28 retire of the
`glp1` widget id left an orphan entry in `dashboardWidgetsJson` for
legacy users, which made the save validator reject the payload; the
resolver now filters unknown widget ids on read.

### Fixed

- **Dashboard save against legacy layouts.**
  `resolveDashboardLayout` in `src/lib/dashboard-layout.ts` drops
  widget ids that are no longer in the registry before returning
  the layout, so the `dashboardWidgetsJson` round-trip
  (`GET /api/me/dashboard` → user edit → `PUT /api/me/dashboard`)
  succeeds even when the persisted JSON still names `glp1` or any
  other retired widget.

### Tests

- `src/lib/__tests__/dashboard-layout.test.ts` gains a case for the
  retired-id filter (19/19 green).
2026-05-16 09:28:40 +02:00
Marc-André Bombeck 4f09c3f862 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.
2026-05-15 22:59:10 +02:00
Marc-André Bombeck 17b8d8d2c2 chore(release): v1.4.25
Largest feature delta in the v1.4.x line. Insights expands into seven dedicated metric sub-pages; GLP-1 medication tracking lands end-to-end (drug-level chart with Research Mode acknowledgment, EMA titration ladder, cadence + compliance, side-effect taxonomy, pen/vial inventory); cross-source priority becomes a two-axis resolver; per-user timezone threads through ten analytics surfaces; Withings activity + sleep v2 sync; onboarding rebuilt as a nested-route wizard; Personal Records ship end-to-end; Health Score provenance accordion; Coach runs native first-party prompts across six locales with a 1800-assertion refusal-probe matrix; OpenAPI drift gate hard-fails; multi-arch GHCR image. Migrations 0043 through 0060 are additive and forward-only. PROMPT_VERSION 4.24.0 → 4.25.0 with GROUND RULE 9 (refuses dose recommendations) and GROUND RULE 15 (refuses drug-level estimates with MDR + MDCG 2021-24 cites).

See CHANGELOG.md for the full per-section delta.
2026-05-14 23:49:32 +02:00
Marc-André Bombeck ca03c6099f chore(release): v1.4.24
Security + accessibility hardening release. Closes the highest-priority
findings from a focused security audit of the v1.4.23 release:

- Bearer auth fail-closed when a route declares no scope (api-handler)
- Public /api/send Umami proxy now rate-limits 120 req/min/IP
- Proxy nonce generation switched to btoa(...) for Edge portability
- Session-expiry delete race uses deleteMany + catch (no more 500s)
- POST /api/import returns the standard apiError envelope on 422
- Runtime Docker image pins Prisma 7.8 + worker deps to minors
- Notifications page renders a real load-error alert
- Accessibility: switch aria-labels, htmlFor pairing, mobile admin
  Overview link, deep-link anchors on notification settings
- Chart overlays: per-chart comparisonBaseline pref, BMI/bodyFat/
  sleep/steps overlay keys, TrendCard layout tolerates long labels

Verification: pnpm typecheck && pnpm lint && pnpm test (2244/2244)
&& pnpm format:check all green.
2026-05-12 22:33:35 +02:00
Marc-André BombeckandClaude Opus 4.7 95209ebf79 feat(schema): Apple Health measurement types + sleep stages + dedup index
Extend MeasurementType with the seven HealthKit-shaped values the iOS
batch ingest needs (HRV, resting HR, active energy, flights climbed,
distance, VO2 max, body temperature) and add APPLE_HEALTH to the
MeasurementSource enum. Sleep duration unit shifts from hours to
minutes so per-stage HKCategorySample rows persist without precision
loss; the new SleepStage enum mirrors the existing GlucoseContext
column shape and a CHECK constraint scopes it to SLEEP_DURATION rows.

The composite unique index `(user_id, type, source, external_id)`
gives the new batch endpoint an idempotent dedup key while the legacy
`(user_id, type, measured_at, source)` index continues to back the
manual UI's no-duplicate-at-the-same-wall-clock guard. NULL externalId
rows stay distinct under Postgres semantics so manual entries never
collide.

Migration is strictly additive: new enum values append, new columns
are nullable, no rows mutate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:15:56 +02:00
Marc-André BombeckandClaude Opus 4.7 363e73cbf2 chore: voice + filename hygiene + wave-4 state wrap
Maintainer-voice sweep across `src/`. ~190 references to the
maintainer by first name in code comments and JSDoc neutralised to
"the maintainer", "the live tenant", "users", or "the user" depending
on context. Three sed passes covering ~50 distinct patterns
(`Marc reported`, `Marc wants`, `Marc kept`, `Marc rolled back`, etc.).
Test fixtures (`displayName: "Marc B."`, `username: "marc"`,
`<HeroStrip userName="Marc">`) kept as opaque test data per the brief.

CHANGELOG normalisation. The v1.4.14 + v1.4.15 entries paired a
German bullet with its English translation by historical design — the
in-app copy is English-default and the user docs are English, so the
mixed-language style was a holdover from the early-marathon notes.
A one-off node script extracted the `_..._` English paragraph from
each bilingual bullet and rewrote the entry as that text. ~44 bullets
reduced; one Docs bullet that lost a leading word in the regex
fixed by hand.

CLAUDE.md → CONTRIBUTING-AI.md rename so the filename is no longer
AI-vendor-specific. `CONTRIBUTING.md` reference updated. `AGENTS.md`
stays in place for multi-agent compatibility (the conventions doc is
shared with Codex / Cursor / etc.).

Wave-4 state wrap. STATE.md now records C1–C5 + D + D2 as complete;
v1421-backlog.md grew a "Closed in v1.4.22" header listing the 20
items the marathon landed; `.planning/phase-W4-report.md` documents
the per-commit decisions, test deltas, and the 23 items deferred to
v1.4.23 with reasoning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:46:32 +02:00
Marc-André BombeckandClaude Opus 4.7 59a4a89b62 fix(insights): strip metric tokens in recommendation prose + DE component labels
Two related leak fixes traced to the same root: every other AI-prose
surface ran user-visible text through `stripChartTokens()`, but the
recommendation card's always-visible text was rendered directly. The
model embeds `metric:WEIGHT` and friends in the rec text expecting an
inline mini-chart, but the chart slot lives in the EXPANDED rationale
block — so the token had nowhere to be consumed and leaked verbatim
to the DOM.

  - `<RecommendationCard>` now wraps `norm.text` in
    `stripChartTokens()`, matching the contract every other
    rec / finding / summary surface already uses.
  - DE Health-Score component labels surfaced English nouns ("Mood",
    "Medis", "BD") instead of full German. Rename to "Blutdruck",
    "Stimmung", "Einnahmetreue", "Gewicht" so the four sub-bars read
    natively German. The "Mut" the maintainer reported on prod was
    voice-to-text rendering "Mood".
  - i18n-integrity test pins the four component labels so a copy-paste
    regression can't reintroduce the leak.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:54:21 +02:00
Marc-André BombeckandClaude Opus 4.7 ae2f671321 style: prettier on touched test + e2e files
Run prettier on the test, e2e, and component files this v1.4.19
A8 wave touched. The .planning/* and docs/audit/* prettier
warnings are pre-existing baseline noise — left alone to keep
this commit narrow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:58:30 +02:00
Marc-André BombeckandClaude Opus 4.7 7a6bc812c9 i18n(achievements): replace insulting badge titles
The over-intake and skipped-intake achievements shipped with the
literal titles "Idiot" / "Lazy Boy" in both EN and DE bundles —
the German file never translated them. For a personal health app
the user gets called an idiot any time they double-dose because
they forgot they already took it. Replaced with neutral,
descriptive titles ("Double take" / "Stepped back" — "Doppelt
genommen" / "Pause eingelegt"). Locked in by a regression suite
that scans every badge title against a banned-word list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:32:19 +02:00
Marc-André BombeckandClaude Opus 4.7 180f46c24e fix(medications): localise time-window range string
`formatTimeWindowRange` used to hard-code the German "bis ... Uhr"
suffix, so an English-locale user saw "Today, 19:00 bis 23:00 Uhr"
on the medication card and the medication form. Pass the active
locale through and emit "19:00 – 23:00" for English while keeping
the existing German output for DE users (and unchanged for legacy
call sites that still don't pass a locale).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:29:07 +02:00
Marc-André BombeckandClaude Opus 4.7 90a109d0b4 i18n(admin): translate Zielwerte status labels (Low/On Target/Stable/Moderate to DE)
Marc reported the Zielwerte page (`/targets`) still surfaces English
status pills even when the rest of the UI runs in German: "Low /
On Target / Stable / Moderate / Normal" stayed in source language
because the server emits stable English category strings and the
page rendered them verbatim. Card titles ("Resting pulse", "Mood
stability", "Medication compliance" …) had the same issue.

Add two i18n key blocks under `targets.*`:

- `targets.label.<TYPE>` — one entry per measurement type, used by
  `<TargetCard>` to localise the card title. The server keeps
  emitting English labels (so logs / external consumers don't
  churn); the page swaps them in via `t("targets.label." + target.type)`.
- `targets.status.<key>` — every classification category from
  `lib/analytics/classifications.ts`, `pulse-targets.ts`, and the
  inline maps in `targets/route.ts`. A small `STATUS_CATEGORY_KEY`
  map on the page normalises the human-readable English category
  to the i18n key.

Glucose targets keep their existing key path (the server already
emits `targets.glucoseFasting` etc. as the label, resolved at the
top of `<TargetsPage>` before cards mount).

Fall-through is graceful: if a future server adds a category we
have not translated yet, the verbatim English string still
renders — the page never blows up.

Also adds `targets.bmi`, `targets.label.BMI`, `targets.status.normal`,
`targets.status.optimal`, `targets.status.fitness` to the i18n
integrity test's PLACEHOLDER_ALLOWLIST: those terms are identical
in EN and DE (BMI is a global acronym; "Normal" / "Optimal" /
"Fitness" are technical terms German clinics use verbatim).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:21:36 +02:00
Marc-André BombeckandClaude Opus 4.7 d70a9b1ee9 feat(charts): persist per-chart overlay prefs (default off; clean line)
Hook the overlay-controls dropdown into a TanStack Query mutation so
each chart's three toggle states (7-day trend / Trend arrow / Target
range) save per user and survive reloads. The prefs piggy-back on the
existing User.dashboardWidgetsJson blob with a `chartOverlayPrefs`
keyed by chart id, mirroring the v1.4.16 phase B8 comparison-baseline
pattern — no Prisma migration needed.

Wires up:
  - GET /api/dashboard/widgets surfaces the resolved prefs in its
    response so a fresh dashboard load knows which overlays to paint.
  - PUT /api/dashboard/chart-overlay-prefs replaces a single chart's
    prefs without round-tripping the whole layout.
  - Existing PUT /api/dashboard/widgets preserves any chartOverlayPrefs
    the client doesn't send, so saving widget visibility doesn't wipe
    chart prefs.
  - useChartOverlayPrefs hook reads + optimistically updates the cache.
  - HealthChart, MoodChart, MedicationComplianceChart all consume the
    hook, render the controls dropdown next to the range tabs, and gate
    their three overlays behind the persisted toggles.

Default state for every chart, every flag: false. The clean line is
the new default; users opt into overlays they care about per chart.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:34:08 +02:00
Marc-André BombeckandClaude Opus 4.7 8a5b6de27e fix(dashboard): mount InsightsCardPreview on root page (wire B1b dashboard preview to live route)
The polished InsightsCardPreview component shipped in B1b commit 5
(d2cdf9d) was an orphan with zero non-test imports. The dashboard read
no advisor payload, so the top severity-ordered AI recommendation +
ring confidence meter + "View all" CTA was unreachable from any live
route. Wave-D design review flagged this as CRITICAL C2.

Wires the preview into the root dashboard page above the chart row,
sharing the advisor cache key with /insights so a regenerate on either
surface refreshes the other without a second LLM call. The preview
self-hides when the user has no provider configured (route 422 → null)
or the advisor returns zero recommendations.

Adds insightsPreview to DASHBOARD_WIDGET_IDS so the layout toggle
persists through Settings → Dashboard. Pins the widget into
DEFAULT_DASHBOARD_LAYOUT (visible: true, tileVisible: false) so users
discover the feature without hunting through settings, and adds a
contract test mirroring the A5 widget-enum-drift guard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 03:33:01 +02:00
Marc-André BombeckandClaude Opus 4.7 775df8cfe9 feat(comparison): comparison toggle persists baseline preference
Add a "Compare to" pulldown to the Settings → Dashboard section that
lets the user pick None / Last month / Last year. The preference rides
on the existing User.dashboardWidgetsJson blob — research §7 Q3 settled
on the JSON-pivot path so v1.4.16 ships migration-free.

The resolver defaults to "none" for legacy layouts and clamps unknown
values back to "none", so a stale client cannot poison the dashboard
with a baseline the renderer doesn't know how to draw. The PUT
endpoint accepts the new optional comparisonBaseline field; pre-B8
clients keep PUTing without it and see no behavioural change.

EN+DE i18n keys land for the toggle label, the three options, the
"unavailable" fallback, and the chart caption — Vormonat / Vorjahr per
Marc's brief.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 02:16:41 +02:00