Commit Graph
6 Commits
Author SHA1 Message Date
Marc-André Bombeck dd487cfddc feat(measurements): add walking-steadiness and audio-exposure-event types
R-F T1.4 + T1.5 close-out. Two new MeasurementType enum values
unlock the iOS Tier-1 surfaces Apple's own Health app shows
prominently.

Schema (migration 0064, additive):
  - measurement_type gains WALKING_STEADINESS + AUDIO_EXPOSURE_EVENT

WALKING_STEADINESS — iOS 15+ Mobility daily rollup. Apple ships a
0..1 fraction; HealthLog stores 0..100 percent (same convention as
oxygen saturation + body fat).

AUDIO_EXPOSURE_EVENT — iOS 13+ category-type that fires when the
rolling 7-day average crosses the WHO 80-dBA loud-listening
threshold. Both the environmental + headphone events map to the
same MeasurementType so chart-card consumers pick them up uniformly;
the notes field carries the source token at iOS write time.

Updates ride the same release:
  - Zod measurementTypeEnum gains both values
  - apple-health-mapping.ts maps three HK identifiers
    (AppleWalkingSteadiness, EnvironmentalAudioExposureEvent,
    HeadphoneAudioExposureEvent) and removes the same identifiers
    from HK_QUANTITY_TYPE_DEFERRED
  - categories.ts assigns WALKING_STEADINESS to activity and
    AUDIO_EXPOSURE_EVENT to hearing
  - pr-direction.ts pins WALKING_STEADINESS to MAX (higher steadiness
    is the achievement) and AUDIO_EXPOSURE_EVENT to no-PR (threshold-
    cross flag, not goal-axis)

iOS contract: the two new enum values are net-new — iOS clients that
predate the codegen pass will not encounter them in read paths
because no source writes them yet. New iOS code paths can post the
new HK identifiers via the existing batch endpoint without further
server changes.
2026-05-16 12:16:39 +02:00
Marc-André Bombeck 5f5b8dfb49 chore(medications): delete unused research-mode-staleness module + add PR worker null-slot dup regression
`research-mode-staleness.ts` shipped in W19c-Safety as a sibling helper
for a future 90-day re-acknowledgment gate on the GLP-1 drug-level
chart. The wiring step never landed — the helper has zero production
importers (only its co-located test consumed it). The version-bump
re-prompt path in `/api/auth/me/research-mode/route.ts` already
covers the regulatory contract (a copy or EMA-EPAR ratchet forces
the dialog), so the calendar-staleness gate is intentionally deferred
to v1.4.26 and the unused module ships off-codebase rather than
sitting orphaned through the release.

Also pin the PR-detection worker's null-slot dedup contract against
a back-to-back invocation regression. The personal_records unique
index uses NULLS NOT DISTINCT on `(userId, metricType, metricSlot,
achievedAt)` — measurement-type PRs (slot = NULL) must coalesce
across consecutive worker invocations even when the candidate row
sits at the exact same achievedAt. A future refactor that drops the
NULLS-NOT-DISTINCT semantics would silently double-insert; this test
fails fast if that happens.

Fix-O / code-M5 + dead-H4 + senior-M3 + L2.
2026-05-14 22:43:53 +02:00
Marc-André Bombeck 6f3b901c3e fix(workout): reject endedAt <= startedAt + zero-duration PR guard
A workout with `endedAt <= startedAt` produced a non-positive
`durationSec` that downstream consumers treated as a real zero —
worst case a fastest-5km PR of zero seconds, which would lock in
until manual cleanup. Add a `.superRefine` clause on
`createWorkoutSchema` so the ingest gate refuses the row.

The PR detector stays defensive against any pre-gate row or future
code path that bypasses the schema: MIN-direction slots now always
filter `durationSec > 0` regardless of which field carries the PR
value. A MIN slot sorts ascending, so the smallest value wins — a
zero-duration row would always be picked first.

Fix-M / code-M2.
2026-05-14 22:27:32 +02:00
Marc-André Bombeck 223b8a9208 test(personal-records): detection worker + queue + UI badge coverage
Adds the integration suite for the detection worker against a real
Postgres testcontainer. Covers measurement-driven PRs (steps,
RHR, VO2 max), idempotency on re-run, silent-flag propagation,
and the drift guard that every PR-trackable metric type lands in
the scan loop.

Also patches a pre-existing schema gap: the
(userId, metricType, metricSlot, achievedAt) unique index on
personal_records is NULLS-DISTINCT in Postgres, so the index alone
does not dedup the measurement-driven rows (metricSlot = NULL).
The worker now does an application-level pre-flight findFirst on
the same composite key before each insert, keeping both the
workout path (index covers it) and the measurement path
(application-level guard) idempotent without a Migration.
2026-05-14 19:02:47 +02:00
Marc-André Bombeck 05c20c9ba6 feat(personal-records): detection worker — MAX/MIN per metric, workout slots, warmup gate
Scans Measurement and Workout history per user and writes
PersonalRecord rows for every all-time best. Direction comes from
the W8d helper (MAX = higher wins, MIN = lower wins, null = never
a PR). A seven-sample warm-up gate per metric keeps a one-off
out-of-range sensor reading from locking in as the baseline.

Workout slots cover longest run / longest distance / fastest 5 km
for running and cycling. Ties write a row but mark it as
push-suppressed so the user matched (not beat) their best. The
unique index plus skipDuplicates makes the handler idempotent.
2026-05-14 18:42:59 +02:00
Marc-André Bombeck 8f57f5e0d7 feat(schema): PersonalRecord + direction helper (Migration 0054)
Schema-only release of the personal-records feature. Lands now so the
v1.5 iOS-Swift app can build its query path against the GET
/api/personal-records contract from day one; the detection worker
that populates the rows is explicitly out of v1.4.25 scope and
will land in v1.4.26 or v1.5.

Schema (prisma/migrations/0054_personal_record):
  - PersonalRecordDirection enum (MAX | MIN) stored on the row so
    the future detection worker doesn't re-resolve direction at
    query time.
  - personal_records table: userId FK (cascade), metricType
    (MeasurementType enum), nullable metricSlot for sport-specific
    PRs, direction NOT NULL, value + unit + achievedAt, optional
    sourceMeasurementId (SET NULL on delete so PRs survive the
    underlying measurement), source provenance, externalId.
  - Composite unique (userId, metricType, metricSlot, achievedAt)
    for idempotent worker re-runs.
  - Index (userId, metricType, value) for the "current PR" read
    pattern (ORDER BY value DESC LIMIT 1 / ASC LIMIT 1).

Direction helper (src/lib/personal-records/pr-direction.ts):
  - getPRDirection(MeasurementType) -> MAX | MIN | null. Exhaustive
    switch covering all 28 canonical enum values:
      MAX — steps, active energy, flights, distance, VO2 max, HRV,
            total body water, bone mass, muscle mass, time in daylight.
      MIN — resting HR, body fat, fat mass, visceral fat, vascular age,
            pulse-wave velocity, audio exposure (env + headphone).
      null — BP, glucose, body / skin temperature, pulse, SpO2, weight,
             sleep duration, fat-free mass (homeostatic, clinical-edge,
             or direction-ambiguous metrics; the v1.4.26 worker can
             enrich weight from User.thresholdsJson when it ships).
  - isPRTrackable() convenience predicate for the future worker.

API surface (src/app/api/personal-records/route.ts):
  - GET returns { data: PersonalRecord[], error: null } (canonical
    apiSuccess envelope).
  - Optional ?metricType=X filter, validated against
    measurementTypeEnum; unknown values drop silently so a
    loose-typed caller never 400s.
  - No write endpoint, no detection worker — explicitly v1.4.26
    scope.

Tests (drift-guard + route):
  - pr-direction: every MeasurementType returns one of MAX / MIN /
    null; per-direction bucket membership pinned.
  - route: 401 unauth, empty response, valid filter passthrough,
    unknown-filter graceful drop, seeded-record round-trip.
2026-05-14 16:01:26 +02:00