Skip to content

Release v1.7.0 — health-record export, flexible schedules, first-paint snapshot, full HealthKit coverage - #219

Merged
MBombeck merged 72 commits into
mainfrom
release/v1.7.0
May 31, 2026
Merged

Release v1.7.0 — health-record export, flexible schedules, first-paint snapshot, full HealthKit coverage#219
MBombeck merged 72 commits into
mainfrom
release/v1.7.0

Conversation

@MBombeck

Copy link
Copy Markdown
Owner

A broad polishing release across medications, dashboard, export, and HealthKit.

Highlights

  • Flexible schedules — PRN (as-needed) and cyclic on/off-week dosing, a server-computed nextDueAt, and cadence-canonical compliance so non-daily plans report adherence correctly on both the dashboard and the detail page (the detail page previously still used the legacy weekday walker).
  • Health-record exportPOST /api/export/health-record produces a selectable export: an enriched clinical PDF, an HL7 FHIR R4 document bundle, or both as one zip. The bundle reuses the PDF aggregator (identical numbers) and matches the iOS client's LOINC/UCUM conventions for interchangeable records. Patient identity (full name / insurer / KVNR, encrypted at rest) lands on the profile.
  • First-paint — a unified GET /api/dashboard/snapshot assembles above-the-fold tiles in one round-trip, and a nightly job pre-generates the daily briefing so /insights never blocks on the model. Open dashboards refresh on a shared interval from a warm cache.
  • Full HealthKit coverage — every previously-unplotted metric type gets a chart; walking speed reads in km/h and distance in km via a render-time transform (stored/exported values stay canonical SI); a metric/imperial display preference; a daily-mean consolidation drain for high-frequency types.
  • Coach data clustering — the Coach accepts a chosen set of data clusters within a soft prompt budget; the default reproduces the prior domains.
  • Medication detail surface — restored history view (clock-with-counterclockwise-arrow icon), intake-only and newest-first, plus one redesigned advanced sheet grouping Data / Reminders / Lifecycle / danger zone.
  • iOS contract — per-medication reminder flags, a roaming delivery default + per-device override, the full 27-id dashboard-widget catalogue accepted on save, and a measurements delta feed (GET /api/sync/changes) with soft-delete tombstones and a stable refresh errorCode.

Schema

Migrations 00910096, all additive and defaulted: reminder flags + device delivery; schedule-type enum + cyclic columns; profile identity; unit preference; reminder-message time-of-day; measurement sync keyset index. No data loss; daysOfWeek retained for the legacy fallback.

Tests

5628 → 5823 unit; 1 skipped. docs/api/openapi.yaml regenerated and in sync. A six-dimension review (correctness, security, architecture, design/a11y, simplification, i18n) ran before this PR; every High and Medium finding was resolved in-branch.

Deferred to v1.7.1 (technical debt, not features)

  • Extract a shared base for the three consolidation drains (DRY refactor in working, tested code).
  • A project-wide roving-tabindex pass on radiogroups (existing house-pattern gap, not a regression).
  • Mood/intake sync tombstone+version columns (the first sync consumer is measurements-only by the iOS contract).

After merge

Tag v1.7.0 → docker-publish → deploy → verify /api/version reports 1.7.0 live → confirm /.well-known/apple-app-site-association is published on the default host.

MBombeck and others added 30 commits May 31, 2026 12:14
Add the EN keys for the v1.7.0 medication-detail rework — three-button
header labels, the full-history view (back link, subtitle, planned
suffix, drug-level disclosure) and the four advanced-settings group
headings plus the real CSV/JSON import block — and propagate them to
de/es/fr/it/pl. Drop the orphaned CSV-stub keys and the now-unused
intake "view full history" link copy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Detail header gains a three-button row — Edit (pencil, labelled),
History (clock, ghost) and Advanced (sliders, ghost). The History
button routes straight to the full intake-history view; the redundant
"view full history" footer link on the intake preview is removed. All
buttons keep a 44px touch target on mobile.

The history view is now intake-history only, defaulting to
scheduledFor-desc so the order reads today -> yesterday -> … and
skipped rows no longer float to the top; the date column falls back to
the scheduled time with a muted "(planned)" marker. The side-effect,
schedule and titration sections drop off the default surface; the
estimated drug-level curve stays as a default-closed disclosure for
GLP-1. Import is present but de-emphasised. Extract the shared editable
table + edit/delete/bulk machine into <IntakeHistoryEditable> so the
preview and the full view stay identical.

The advanced-settings sheet widens to the 2xl token and regroups into
four labelled sections — Data (real CSV/JSON import + API endpoint),
Reminders (notifications + grace), Lifecycle (pause/end/phases) and
Danger zone (purge/delete) — with consistent outline / destructive
button styling. The CSV import is promoted from a stub to a real
button. Split the section bodies out of their card wrappers so the
groups can compose them directly.

Pin NULLS LAST on the takenAt sort in the intake list route so the
skipped-rows-first ordering is correct regardless of the client sort
key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…page

The active-chip positioner used Element.scrollIntoView(), which walks
every scrollable ancestor and adjusts both axes. On mobile this nudged
the whole document vertically on each route change, so tapping a
settings sub-page fired a dizzy page-level auto-scroll. Scroll the
strip's own scrollLeft instead, confining the motion to its horizontal
axis. Reduced-motion handling is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add fullName + insurerName (plaintext) and insuranceNumberEncrypted
(AES-256-GCM) to the User model (migration 0093), validate the German
KVNR mod-10 check digit, and surface all three optional fields through
the profile update path and the /api/auth/me + /api/user/profile reads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rofile

Use object-cover on the avatar image so a non-square upload is
centre-cropped instead of stretched. Add the optional full-name,
insurer, and insurance-number inputs to the profile form, and land the
i18n keys for the profile identity block, the enhanced report, and the
health-record export across all six locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Build a Bundle of type document (leading Composition + Patient +
Observations + MedicationStatements) from the same aggregator the PDF
consumes, so both describe identical numbers. Map measurement types to
LOINC/UCUM, emit a blood-pressure panel with sys/dia components, keep
mood opt-in, and carry the KVNR as a Patient identifier.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Print the new identity fields on the cover, add a deterministic
clinical-summary block (reading counts, per-vital trend arrows,
weighted adherence), embed jsPDF-native trend sparklines (no native
canvas module), set document metadata for a PDF/A-leaning file, and
add an opt-in, clearly-labelled AI-summary section that is off by
default and never part of the structured export.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add POST /api/export/health-record (apiHandler, requireAuth, shared
export rate bucket, audit health-record.export) driven by a strict Zod
selection schema with no userId field and multi-issue 422s. Emit PDF,
FHIR JSON, or a fflate zip package bundling both plus a README. Wire
the grouped selection panel into Settings and document the route in the
OpenAPI registry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add GET /api/dashboard/snapshot — one apiHandler-wrapped, requireAuth
round-trip that assembles every above-the-fold tile field via a single
Promise.all over the existing rollup / mood / widget helpers plus a
read-only lift of the pre-generated daily briefing.

Two-phase shape: tiles (slim summaries + mood + resolved layout + user
profile) always arrive together; extras (BD-in-target + per-context
glucose) ride the slowest reads and return null on a rollup-coverage
miss so the strip never waits on the live-SQL fallback. Coverage is
probed once up front and the thick phase only runs when warm.

The briefing is lifted read-only from User.insightsCachedText and
reports ready / preparing / disabled — no provider chain is reachable
from the builder. Body read-through caches.analytics keyed
${userId}|dashboard-snapshot, 60s TTL; per-sub-query timings surface
under meta.snapshot.sub_*_ms. OpenAPI .meta() registered under a new
Dashboard tag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…validators

Add queryKeys.dashboardSnapshot() to the centralised factory and a
dashboardSnapshotCacheKey helper so the cache key stays single-source.

Extend the per-user invalidators so a write drops the snapshot: the
measurement / mood / medication sweeps over the ${userId}| analytics
prefix already cover it, and the widget + insight paths — which do not
touch the analytics bucket — call invalidateUserDashboardSnapshot
explicitly. A fresh comprehensive-insight write now evicts the snapshot
so the next read re-embeds the new briefing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the insight-pregenerate pg-boss queue (registered in allQueues and
scheduled 04:30 Europe/Berlin in reminder-worker) so the comprehensive
insight — which carries the daily briefing — is pre-generated overnight
instead of lazily on the first /insights mount after the 24h cache
expires.

Extract the generation pipeline into comprehensive-generate so the
route and the cron share one path. Budget-gated: the master assistant
kill-switch short-circuits the run, a per-user rate-limit bucket
(1 / 20h, distinct from the route's bucket) caps re-generation, and a
per-run batch cap bounds the fan-out. The discovery query selects only
coach-enabled, stale-or-missing-cache users, oldest-first. Provider-less
accounts cost one cheap chain-resolve and no LLM call.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add useDashboardSnapshot (one un-gated client cell — server requireAuth
is the real gate, so firing it without waiting on isAuthenticated kills
the /api/auth/me round-trip from the cold critical path) and a
build-time NEXT_PUBLIC_DASHBOARD_SNAPSHOT flag.

Flag OFF (default) keeps the legacy slim / thick / mood / widget cells
byte-identical to today. Flag ON hydrates the same downstream data /
moodData / layoutData shapes from the single snapshot cell so no tile
rendering changes; the legacy cells go enabled:false. extras null on a
coverage miss leaves the BD-Zielbereich + glucose fields undefined so
those tiles shimmer while the rest of the strip paints together.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the ten-cluster data taxonomy (cardio, body composition, activity,
workouts, sleep, mood, glucose, medication, mobility & gait,
environment) and the cluster->source expansion + degradation-priority
helpers. Extend CoachScopeSource + the provenance metric union to cover
every clustered metric.

Persist selection in the existing User.coachPrefsJson as an optional
dataClusters array (no migration). undefined is the back-compat
sentinel: the snapshot builder expands DEFAULT_COACH_CLUSTERS (cardio +
body + mood + medication, reproducing the legacy five domains) when the
key is absent; an explicit empty array means everything-off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t cap

Expand the snapshot's source default from the hardcoded five to the
user's resolved clusters. Map and emit every clustered metric: the full
body-composition set, cardio composition/vascular series, activity,
mobility & gait, environmental exposure, per-context glucose, per-night
sleep with stage minutes, and the Workout model (capped recent list +
per-sport rollup, never raw rows).

Add the assembled-snapshot soft char cap (~24k) with progressive
degradation by reverse cluster priority — drop per-day detail, then
weekly buckets, then replace the block with a marker, lowest-signal
clusters first, so the clinical core survives truncation. Cap the
timeline window for additive clusters when many are active. Enabling
every cluster at allTime now stays inside the prompt budget.

New observability: coach.clusters.resolved, coach.snapshot.truncated,
coach.cluster.empty_skipped. Bump PROMPT_VERSION to 4.26.0 and teach the
system prompt that the snapshot is additive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the flat opt-out framing with clustered opt-in toggles in the
Coach settings sheet: each cluster row carries a label, a member-preview
hint, and a switch, with the per-metric exclude list kept below as the
advanced fine-tune. Persist dataClusters through the coach-prefs route
and surface clusterCount on the replied + put annotations. Derive the
provenance chip label from the metric token so the new taxonomy renders
without a hand-listed map.

Add cluster labels + hints and the ~26 new metric labels across all six
locales, and regenerate the OpenAPI contract for the dataClusters field.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the server FHIR document Bundle byte-identical to the iOS client's
LOINC/UCUM mapping so a Bundle from either side is interchangeable.

LOINC/UCUM corrections:
- SpO2 2708-6 -> 59408-5 (Pulse oximetry display)
- VO2max 84478-5 -> 96402-2 (Oxygen consumption maximum during exercise)
- Steps unit /d -> {steps}
- Sleep min -> h; emit value in hours (stored minutes / 60); PDF unaffected
- Body water 73704-9; bone mass 73708-0 (were local-text fallbacks)
- Resting HR display -> Heart rate --resting
- Glucose by context: random/bedtime 2339-0, fasting 1558-6,
  postprandial 1521-4 (was incorrectly 2339-0)
- BMI display -> Body mass index (BMI) [Ratio]; emitted once (computed
  block; stored BODY_MASS_INDEX series skipped)

New standard metrics: active energy 41981-2 kcal, walking speed 41957-2
m/s (FHIR stays m/s, no km/h), walking asymmetry 91557-1, step length
41955-6. Six HK-placeholder codes emit the HealthKit identifier string in
the LOINC slot (double support, env/headphone audio, flights, distance,
daylight).

Structural: DiagnosticReport (85353-1 vital-signs panel) as the last
entry routing every Observation into result[]; Composition sections
renamed to Vital signs + Medications.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ivery columns

Migration 0091 adds the per-medication iOS reminder flags
(live_activity_enabled, critical_alarm_enabled) plus a per-device
medication_delivery override column. Migration 0092 adds the
medication_schedule_type enum (SCHEDULED / PRN / CYCLIC) and the
cyclic on/off-week columns. All columns are additive with constant
defaults, so the ALTERs are non-blocking metadata operations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PRN short-circuits occurrencesBetween to zero slots and
nextOccurrenceAfter to null — as-needed schedules are loggable but
never projected, reminded, or counted in compliance expected. CYCLIC
gates whichever inner cadence (rrule / legacy / rolling / one-shot) by
an N-on / M-off week phase anchored to startsOn ?? createdAt, mirroring
the existing intervalWeeks phase math; the two phase computations stay
independent so cyclic composes with BYDAY and the legacy stride.

The projector select widens to carry the new columns; the worker reads
them through include: { schedules: true } already.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SB-SCHED-2. expandScheduleSlots now delegates to occurrencesBetween
when the caller threads a medication context and the schedule carries a
canonical recurrence (rrule / rolling), a non-SCHEDULED type, or the
medication is one-shot. This fixes the long-standing bug where an
rrule = "FREQ=WEEKLY;BYDAY=MO" schedule expanded to daily-every-day in
compliance because daysOfWeek = null read as "every day"; the
denominator now counts only the days the schedule actually emits.

calculateCompliance gains an optional medicationContext; when absent it
behaves exactly as before (the legacy weekday walker), so existing
callers and fixtures stay byte-stable. Fixing one expander fixes both
the compliance denominator and the detail-page cadence chart.

Golden-fixture parity tests pin the denominator per cadence type:
legacy parity, weekly BYDAY, bi-weekly, rolling, one-shot, PRN, cyclic,
and a DST spring-forward boundary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire the engine-routed denominator into all eight surfaces that surface
an adherence rate: the per-medication compliance endpoint, the two
insights routes (targets, comprehensive), the Health-Score feature
extractor + fast-path, the BP-status gate, and the medication-compliance
pillar. Each resolves the user timezone and the latest non-skipped
takenAt (rolling cadences re-anchor on it) via shared helpers, then
passes the canonical-engine context. The Health-Score fast-path select
widens to carry the canonical recurrence columns.

The per-medication compliance payload also gains the additive per-day
`due` / `expectedCount` fields (item 5): the day's expected count is the
engine's actual due-slot count, so iOS history renders a missed mark
only on days the schedule expected a dose — off-weeks, non-matching
weekdays, and PRN days no longer paint a false miss. `expected` stays
populated for existing web consumers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ted nextDueAt

Create / update medication accept liveActivityEnabled +
criticalAlarmEnabled (defaulted booleans, field-by-field passthrough)
and the per-schedule scheduleType (SCHEDULED / PRN / CYCLIC) plus the
cyclic on/off-week fields, with Zod refines: CYCLIC requires both week
counts, PRN forbids a cadence. The FREQ=DAILY recurring default no
longer applies to PRN.

GET list + detail expose a read-only nextDueAt computed from the
canonical engine's earliest nextOccurrenceAfter across schedules
(reusing the list's existing last-intake groupBy to avoid an N+1; a
60 s list-cache staleness is accepted as for todayEventCount).

The OpenAPI registry gains scheduleType / cyclic fields on the schedule
resource and liveActivityEnabled / criticalAlarmEnabled / nextDueAt on
the medication resource; the request schemas pick the new fields up from
the live Zod objects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The notificationPrefs medication sub-object grows a roaming
deliveryDefault ("server" / "client"); the resolver maps
deliveryDefault === "client" onto the established clientManaged cron
gate so the reminder worker keeps reading one boolean. The device
registration POST and a new PATCH /api/auth/me/devices/[id] store a
nullable per-device medicationDelivery override (null = inherit the
roaming default); resolveDeviceDelivery resolves device-over-user-over-
"server". The override is stored + echoed only — cron suppression stays
user-level in v1.7.0 (APNs fans out to all devices and iOS dedupes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SB-SCHED-4. The reminder worker keyed phase + dedup on the single
windowStart, so a schedule with timesOfDay = ["08:00","20:00"] reminded
once a day instead of twice. The dispatch loop now iterates every
first-class time-of-day, each with its own window (anchored at the time,
spanning the legacy windowEnd - windowStart duration), phase, RED-mint
instant, and dedup key.

Migration 0093 adds time_of_day to the reminder-message ledger and
re-keys its unique index to include it. A schedule with no first-class
timesOfDay emits one slot at windowStart with time_of_day = "", which
dedupes against pre-v1.7 rows (backfilled to "") byte-for-byte, so
single-window meds keep their exact prior behaviour. The client-managed
suppression check wraps each per-slot dispatch unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The widgets PUT rejected the entire layout blob with a 422 when any
widget id was unknown, surfacing the "Layout konnte nicht gespeichert
werden" toast whenever iOS shipped a tile ahead of a server release.
Filter unknown ids out before Zod — mirroring the read-side tolerance
that already drops unknown ids on GET — so the server persists only ids
it knows while iOS keeps its own local layout. The enum still validates
the surviving ids, so a genuinely malformed entry (missing order, etc.)
still 422s. Dropped ids are surfaced via a
dashboard.widgets.unknown-id-dropped annotation so the drift is
greppable without a 422 storm.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
0093 is taken by the profile-identity migration; renumber the
reminder-message time-of-day migration so the sequence is unambiguous.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduce a per-type display transform keyed by MeasurementType +
unit preference. Storage stays canonical SI; the transform applies
only at the render boundary. WALKING_SPEED renders km/h (factor 3.6)
and WALKING_RUNNING_DISTANCE renders km (factor 0.001) on the metric
preference, with additive mph/mi imperial branches.

HealthChart gains an optional valueScale prop (default 1) folded into
the raw value at the single read boundary so bucketing, y-domain,
baseline, trend, and tooltip all scale uniformly. Default 1 keeps
every existing chart byte-identical; Recharts is untouched. The chart
cache key trails with valueScale so a scaled chart never reads an
unscaled sibling out of the cache.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… metrics

Add HIGH_FREQUENCY_MEAN_TYPES (respiratory rate, walking speed/step
length, environmental + headphone audio exposure) and a reducer-aware
daily-mean drain modelled on the proven cumulative drain. Per user ×
type × completed day it UPSERTs one stats:<HK>:<day> row carrying the
mean and soft-deletes the per-sample rows (tombstone, audit-trail
preserving). Scoped to source=APPLE_HEALTH so manual + Withings spot
rows survive; 36-hour grace window keeps today's in-flight syncs raw.

The set is strictly disjoint from CUMULATIVE_HK_TYPES (asserted in a
test) so SUM and MEAN never collide on a type. PULSE is deliberately
excluded — correlation/scatter read raw PULSE rows; its display stays
daily-averaged via the read-path AVG.

Register the mean-consolidation pg-boss queue in allQueues with a
boot-time converging discovery + per-user handler, mirroring the
step-consolidation pattern. Source-grep guard asserts the registration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a per-user unit_preference column (additive migration 0094,
idempotent guard) mirroring the glucose_unit single-purpose display
precedent — there is no general settings JSON bag on users, so a
dedicated scalar is the right shape over abusing a domain-specific
JSON column. Default metric; canonical measurement storage stays SI.

Surface the preference on /api/auth/me and a new
PATCH /api/auth/me/unit-preference (Zod enum, 422 on bad input,
60/min rate limit, audit row). The display-transform registry reads
this preference to pick the km/h-vs-mph and km-vs-mi branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire a dedicated insights sub-page for all 24 ingested-but-unplotted
MeasurementType values (blood glucose, body-composition tail, audio
exposure, mobility/gait, respiratory rate, daylight, cardiovascular
risk markers). Each reuses the generic HealthKitMetricPage scaffold —
no new chart code; the existing daily-aggregating read path serves
them — plus a per-metric InsightMetric member, ChartOverlayKey slot,
sub-page slug, tab-strip pill, and six-locale page copy.

The new metric clusters (body, activity, cardiovascular, hearing,
environment, metabolic) each collapse behind a parent pill so the
strip stays scannable. WALKING_SPEED renders km/h (valueScale 3.6)
and WALKING_RUNNING_DISTANCE renders km (valueScale 0.001) via the
display-transform — raw storage stays SI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Real-Postgres test pins the drain contracts the unit mocks cannot:
per-sample rows collapse into one daily MEAN, originals soft-deleted,
manual/Withings rows untouched, PULSE never drained, idempotent
re-run. Confirms migration 0094 applies cleanly in sequence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MBombeck and others added 27 commits May 31, 2026 13:14
Both `use-dashboard-snapshot` and `use-analytics-query` hard-coded the
same `120_000` refetch interval. Extract a single named
`DASHBOARD_REFETCH_INTERVAL_MS` so the cadence has one source of truth
the server-side snapshot cache TTL can also key off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The dashboard-snapshot entry shared the analytics bucket's 60 s TTL
while the client polls every 120 s, so every scheduled refetch was a
guaranteed cache miss that re-ran the full builder. Add an optional
per-key TTL to the server cache and give the snapshot key a TTL longer
than the refetch interval, so an idle-tab poll lands on a warm entry.
The key still rides the analytics bucket, so the `${userId}|` eviction
sweep on a measurement / mood / medication write evicts it unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The doc claimed the insight-pregenerate cron calls invalidateUserInsights
directly. It does not — the cron evicts through generateComprehensiveInsight,
which invalidates after its cache write. The /api/insights/generate POST
remains a direct caller (its own pipeline). Reword to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GET /api/measurements/[id] and the PUT existence check used
findUnique without a deletedAt filter, so a soft-deleted (tombstoned)
row was still returned in full on a direct GET and could be
resurrect-edited via PUT. Switch both to findFirst with
deletedAt: null so a tombstoned measurement 404s, matching the
list / analytics / rollup read invariant. Pin the contract with a
real-Postgres GET/PUT 404 case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/api/sync/changes keyset-walks measurements filtered on user_id
ordered by (updated_at asc, id asc); the existing indexes cover
(user_id, type, measured_at) and (external_id) only, so Postgres
sorted the user's whole measurement set per page. The route allows
120 pulls/min/user, so a multi-device offline drain on a heavy
Apple-Health tenant is per-page full-sort pressure on the shared
pool. Add @@index([userId, updatedAt, id]) and ship the additive
migration 0096 (plain CREATE INDEX IF NOT EXISTS, no backfill).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The insurance number (German statutory `kvnr`) is encrypted at rest
and absent from every current wide-event excerpt, but the field name
belongs on the central redaction denylists as defence-in-depth. Add
insurance / insurer / kvnr to SENSITIVE_KEY_PATTERNS so a body routed
through redactSensitiveFields redacts the value, and extend the
query-string scrub in redactSecrets so a stray ?kvnr= / ?insuranceNumber=
leak is caught at the egress boundary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The unknown-id filter runs over the full incoming widgets array
before Zod's .max(20) applies, so a single large request could push
thousands of strings into one wide-event line. Slice the logged
dropped_ids to 20 entries and keep the full dropped_count. The
persisted layout was already bounded; only the log line was unbounded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
resolveCanonicalUnit issued a findFirst per day with no deletedAt
filter, so it could pick up a tombstoned row's unit (harmless today
since units are homogeneous per type, but an unnecessary query that
reads from soft-deleted rows). Carry unit in the PerSampleRow select
and read dayRows[0].unit instead; drop the helper. The scan already
filters deletedAt: null, so the unit can never come from a tombstone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ion window

The tombstone-cleanup job prunes on deletedAt and the changes feed
gates cursorExpired on updatedAt, but both derive from
TOMBSTONE_RETENTION_DAYS. Pin that a row whose deletedAt predates the
window is pruned while a recent tombstone survives and stays reachable
by a fresh cursor, so a deletion can't be pruned inside a client's
reachable cursor window. The assertion trips if either horizon is
re-keyed away from the shared retention constant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…check

The single-resource PUT existence check moved from findUnique to
findFirst (deletedAt: null filter); update the duplicate-timestamp
test's prisma mock to match so the 409 / 200 / re-throw cases keep
exercising the existence lookup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The unit-preference segmented control used min-h-9 (36px) with no mobile
bump, breaking the release-wide min-h-11 sm:min-h-9 touch-target rule. The
hand-rolled radio buttons also carried no visible focus indicator on their
bg-muted track. Bump to 44px on mobile and add the shadcn focus-visible ring
so keyboard focus is visible (WCAG 2.4.7).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "Medikamente" toggle was bound only to the compliance flag, so the
medication list shipped unconditionally even after unchecking it. Bind the
single control to both medList and compliance so unchecking it excludes all
medication data, matching the iOS export's five-section model
(vitals/charts/medications/adherence/mood).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The coach metric label used "Doppelstützzeit" while the chart page used
"Doppelstandphase" for the same HealthKit metric, so a user comparing the
two surfaces saw a mismatch. Align the coach label to the chart page's
clinically-standard "Doppelstandphase". German-only; the other locales were
already internally consistent for this metric.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nsolidation drains

The cumulative, daily-mean, and legacy-step passes each re-implemented the
same per-user-day skeleton: load the user set, resolve each user's
timezone, scan live source rows inside an optional grace window, bucket
them by local calendar day, reduce each day, then mint one canonical
daily stats row and drop the source rows. They duplicated the bucketing
helper, the user-load query, the cutoff calculation, and a separate
36-hour grace constant.

Pull the shared machinery into consolidation-base.ts and parameterise it
on the divergent parts: the type set, the reducer (sum vs mean), the
grace window, the source-scope filter, the canonical-unit resolution, the
mint source, the per-day write strategy (hard vs soft delete), and the
optional existing-total skip + unique-constraint step-over. Each drain is
now a thin call into runConsolidation that keeps its own public summary
shape and log lines.

No behaviour change: the cumulative drain stays sum + hard-delete over
every source, the daily-mean drain stays mean + soft-delete + APPLE_HEALTH
scope + PULSE-excluded, the legacy-step drain stays sum + soft-delete +
existing-total fold + P2002 conflict skip. The shared grace constant
seeds both DRAIN_CUMULATIVE_CUTOFF_HOURS and MEAN_CONSOLIDATION_CUTOFF_HOURS
so they track in lockstep. All existing unit and testcontainers
integration tests pass unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The hand-rolled radiogroups built from role="radio" buttons were each an
independent tab stop with no arrow-key handling, so keyboard users had to
Tab through every option and could not move selection with the arrow keys
an ARIA radiogroup is expected to honour.

Add a shared useRovingRadioGroup hook: the group becomes a single tab stop
(selected option tabindex=0, the rest tabindex=-1), Left/Up and Right/Down
move and wrap selection, Home/End jump to the first/last enabled option,
and focus follows the selection. Disabled options are skipped. Mouse and
touch behaviour is unchanged; the hook only layers the keyboard interaction
and per-item tabindex/ref wiring on top of the existing onClick.

Wire it into the unit-preference segmented control, the health-record
export format buttons, the mood-level pickers (entry + edit), and the
side-effect entry/severity chip groups.

The component test harness is SSR-only, so the navigation math is extracted
into a pure rovingRadioNextIndex function and unit-tested directly (arrows,
wrap, Home/End, disabled-skip, empty group); the tabindex/aria render shape
is pinned in the unit-preference SSR contract test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…alogue

Add two additive blocks to the dashboard snapshot so the native client can
adopt it as a cold-launch seed:

- metricStates: latest {value, measuredAt, unit} per chartable metric, keyed
  by the iOS MetricKind raw value (including the non-obvious raws such as
  oxygenSaturation, totalBodyWater, heartRateVariability, bodyMassIndex,
  walkingAsymmetryPercentage, walkingDoubleSupportPercentage,
  environmentalAudioExposure, headphoneAudioExposure, activeEnergyBurned).
  Derived in-process from the slim summaries slice already fetched for the
  tile strip, so it adds no query against the shared Prisma pool.
- layoutCatalogue: the full 27-id widget catalogue (16 server-known + 11
  iOS-only) with per-widget visibility + order, so the layout round-trips in
  one key. Server-known ids inherit the resolved layout; iOS-only ids append
  default-invisible. The writable widgets PUT enum is untouched.

Both blocks are additive — the web-consumed fields keep their exact shape.
OpenAPI .meta() updated for the new response fields.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…mig 0097)

Extend the offline-sync reconciliation columns the measurement feed
already carries to MoodEntry and MedicationIntakeEvent so the
`/api/sync/changes` feed can cover both domains:

- MoodEntry gains `sync_version` (LWW counter, default 0) and
  `deleted_at` (soft-delete tombstone). It already had `updated_at`.
- MedicationIntakeEvent gains `updated_at` (it had none — the feed
  orders on it), `sync_version`, and `deleted_at`. An intake is an
  immutable fact, so its sync_version only bumps on the soft-delete
  write; a correction is tombstone + re-insert.

Each model gains a `(user_id, updated_at, id)` keyset index matching the
measurement feed's index from migration 0096. Every add is additive and
order-safe: sync_version defaults to a constant 0, deleted_at is
nullable, and the intake `updated_at` add carries a `DEFAULT now()` so
existing rows backfill without a table rewrite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…from reads

Convert the user-facing single-row DELETE on mood entries and medication
intake events from a hard `delete` to a `deletedAt` write (+ bump
`syncVersion`), mirroring the measurement soft-delete. A tombstoned row
stays in place so the `/api/sync/changes` feed can surface the deletion
to paired clients that were offline at delete time, keyed on the server
`id`.

Read-side: add `deletedAt: null` to every list / detail / today /
compliance / analytics / insights / coach / export / rollup read on both
domains so tombstoned rows never surface in normal reads. The rollup
aggregates (mood + medication-compliance), their not-exists prune, the
coverage probes, and the boot-backfill discovery queries all gain the
filter so the cached and live tiers agree. Write-path probes that guard
the `(userId, medicationId, scheduledFor, source)` unique slot
(today-backfill existence check, the RED missed-dose count) intentionally
keep counting tombstoned rows to avoid a P2002 collision; this is noted
inline at each site.

Mutations bump `syncVersion`: mood update + soft-delete, intake status
toggle + per-event edit + soft-delete. Mood update/PUT and the intake
edit/status-toggle also refuse to resurrect a tombstoned row (the lookup
gains a `deletedAt: null` guard → 404). The one-shot lifecycle reconcile
treats a tombstoned intake as no-longer-logged, so deleting the single
dose reactivates the medication. Reminder + mood-nudge suppression no
longer count a deleted row as a logged action.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… cursor

Extend the delta feed beyond measurements. The opaque cursor becomes a
multi-domain keyset: it carries an independent `(updatedAt, id)`
watermark per domain (measurements / mood / intakes) so each table walks
at its own rate without one domain's high-water mark starving another.
The token stays fully opaque — `decodeCursor` returns null on garbage or
a wrong-version envelope, so a stale or old-format cursor re-inits
cleanly (no live consumer cursors exist yet, so the format change is
free).

Each domain block carries `upserts` + `tombstones`; tombstone identity
follows the iOS §7.3 table — measurements key on `externalId`, mood +
intakes on the server `id`. Tombstones precede upserts within each
domain. `syncVersion` is echoed per row. `hasMore` is true when any
served domain still has rows past its page; `cursorExpired` fires when
any domain watermark predates the shared retention horizon.

`/api/sync/state` now reports the mood + intake summaries (same
lastUpdatedAt / liveCount / tombstonedCount shape as measurements). The
tombstone-cleanup job prunes mood + intake tombstones on the same
retention horizon as measurements. The OpenAPI registry gains the mood +
intake upsert/tombstone schemas and the multi-domain response/state
shapes (yaml regen batched centrally).

Integration coverage (testcontainers): mood + intake delta pagination,
tombstone surfacing after soft-delete (and absence from the list read),
multi-domain single-page round-trip, hasMore across domains, and the
garbage-cursor clean-init path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Widen the widgets layout to persist and return all 27 widget ids (16
web-known + 11 iOS-only) so the native client can drop its local
merge workarounds and seed its layout in one key.

- PUT enum widens to the 27-id catalogue; widgets cap raised 20 -> 30.
  Accept-and-ignore now drops only ids outside the catalogue (a retired
  tile or typo), capped + annotated; the 11 iOS-only ids survive and
  persist. Malformed surviving entries still 422.
- resolveDashboardLayout / serializeDashboardLayout retain the iOS-only
  ids in the stored and returned layout. The default layout stays the 16
  web tiles; iOS-only ids appear only once a client sends them and are
  never auto-seeded for a web-only account.
- Web render is robust to iOS-only ids: the dashboard looks up only its
  own 16 ids and skips the rest; the Settings tile list skips any id
  with no web component instead of painting a dead row; the first-paint
  skeleton counts web tiles only.
- GET returns the full persisted layout, up to 27 ids.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Update the status, tech-stack, and roadmap version references from v1.5 to
the current v1.7 line, and bump the local-dev Node prerequisite to 22 to
match the Dockerfile base image. Add the headline v1.7.0 capabilities:
health-record export (PDF + HL7 FHIR R4), the full recurrence-engine
medication schedules with cadence-canonical compliance, route of
administration, full HealthKit metric coverage with a metric/imperial
display preference, the first-paint dashboard snapshot with nightly
briefing pre-generation, Coach data clustering, and the offline sync
delta feed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MOOD_REMINDER delivery was gated by a double opt-in: the visible
moodReminderEnabled card plus a per-event preference that defaulted off.
With no preference row the dispatcher hit the no-row default-off branch
and silently dropped every mood-reminder push, so a user who enabled the
card still received nothing.

Make the card the single source of truth. For MOOD_REMINDER, derive the
no-row default from User.moodReminderEnabled, so enabling the card alone
delivers. A genuine explicit per-event opt-out (a preference row with
enabled=false) is still honoured; only the no-row default changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a settable local-time hour (0-23) for the daily mood reminder,
replacing the hardcoded 22:00 window so iOS and web share one source
of truth instead of the native client running its own schedule.

The hour persists in the existing notification-prefs JSON blob under a
new mood.reminderHour key (no migration). Default 22 keeps an unset
value identical to today. Expose it on GET/PATCH of the prefs route,
Zod-validated as an int 0..23, merged field-by-field. The mood-reminder
cron resolves the hour per user, keeping the 15-minute tick, the
already-logged-today skip, and the ledger-after-delivery idempotency.
Surface an hour picker in the mood-reminder settings card wired through
the prefs mutation across the six locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The mood-reminder hour is now per-user, so drop the literal 22:00 from
description and statusOn across all six locales in favour of the user's
chosen time. Values only; keys unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@MBombeck
MBombeck marked this pull request as ready for review May 31, 2026 12:36
@MBombeck
MBombeck merged commit 50e0a65 into main May 31, 2026
13 checks passed
@MBombeck
MBombeck deleted the release/v1.7.0 branch June 4, 2026 20:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant