mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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.
51 lines
2.7 KiB
SQL
51 lines
2.7 KiB
SQL
-- v1.4.25 W8d — PersonalRecord schema (no detection worker yet).
|
|
--
|
|
-- Schema lands now so the v1.5 iOS-Swift app can build its query path
|
|
-- against a real GET /api/personal-records route from day one. The
|
|
-- worker that populates the rows (sweep on insert / nightly job) is
|
|
-- explicitly out of v1.4.25 scope — see the deferred section in the
|
|
-- W8d phase report. v1.4.25 ships the bare list endpoint that returns
|
|
-- whatever rows the DB carries (initially empty for every user).
|
|
--
|
|
-- Direction enum (MAX | MIN) is stored on the row rather than derived
|
|
-- at read time so the future worker doesn't re-resolve a per-metric
|
|
-- lookup on every query. Some metric types deliberately carry NULL
|
|
-- direction in the helper (BP, glucose, weight, ambiguous-direction
|
|
-- metrics) — those never produce a PersonalRecord row at all, so the
|
|
-- column stays NOT NULL for the metrics where a record is actually
|
|
-- defined.
|
|
--
|
|
-- Composite uniqueness on (userId, metricType, metricSlot, achievedAt)
|
|
-- ensures the detection worker is idempotent — re-running it never
|
|
-- inserts a duplicate. A multi-source tie (e.g. iPhone + Apple Watch
|
|
-- both report 12_348 steps for the same day) is broken by including
|
|
-- `source` so each provenance retains its row; the display layer
|
|
-- runs the existing pickCanonicalSourceRows() helper to surface one.
|
|
|
|
CREATE TYPE "personal_record_direction" AS ENUM ('MAX', 'MIN');
|
|
|
|
CREATE TABLE "personal_records" (
|
|
"id" TEXT PRIMARY KEY,
|
|
"user_id" TEXT NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
|
|
"metric_type" "measurement_type" NOT NULL,
|
|
-- Sport-specific slot for workout-driven PRs (e.g. "running_5km_time").
|
|
-- NULL for measurement-driven PRs that don't need a sport dimension.
|
|
"metric_slot" TEXT,
|
|
"direction" "personal_record_direction" NOT NULL,
|
|
"value" DOUBLE PRECISION NOT NULL,
|
|
"unit" TEXT NOT NULL,
|
|
"achieved_at" TIMESTAMP(3) NOT NULL,
|
|
-- FK to the Measurement row that achieved the record. SET NULL on
|
|
-- delete so a PR survives the deletion of the originating
|
|
-- measurement (the PR is the historical fact, not a derived view).
|
|
"source_measurement_id" TEXT REFERENCES "measurements"("id") ON DELETE SET NULL,
|
|
"source" "measurement_source" NOT NULL DEFAULT 'MANUAL',
|
|
"external_id" TEXT,
|
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE UNIQUE INDEX "personal_records_dedup_key"
|
|
ON "personal_records" ("user_id", "metric_type", "metric_slot", "achieved_at");
|
|
CREATE INDEX "personal_records_user_metric_idx"
|
|
ON "personal_records" ("user_id", "metric_type", "value");
|