Files
HealthLog/src/lib/measurements/categories.ts
T
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

156 lines
5.7 KiB
TypeScript

/**
* v1.4.30 — UI-side categorisation overlay for `MeasurementType`.
*
* The flat 27-value enum is the correct storage shape (per R-F §4.1)
* — Postgres enums are cheap; a per-category sub-table would explode
* the read joins. The overlay below is presentation-only and drives:
*
* - The iOS HealthKit permission picker — grouped by `category` so
* the user sees one consent screen per logical area (vitals,
* activity, sleep, …) rather than a flat 27-line list. The iOS
* side mirrors this map verbatim via the swift-openapi-generator
* codegen path (R-E H-8); a new MeasurementType is one change in
* this file + the Prisma enum + the Zod validator + this overlay.
*
* - The web Insights nav (post-v1.5) — the sub-page strip stays
* curated at 7 pages, but the "All metrics" overview can group
* cards by category cheaply from this map.
*
* - The Coach evidence shelf — chip-grouping by category makes the
* prose more navigable than a flat list.
*
* Locked: see `.planning/research/v15-r-f-apple-health-depth.md` §4.
*/
import type { MeasurementType } from "@/generated/prisma/client";
/**
* Apple-Health-flavoured category set. Collapses Apple's
* Heart + Vitals into one `vitals` bucket and omits the ones we
* deliberately don't track (Cycle, Nutrition, Respiratory clinical,
* Symptoms).
*
* `cardiovascular` is distinct from `vitals` — vitals are point-in-
* time signals (BP, pulse, HRV, body temp, SpO2); cardiovascular
* holds derived risk markers that arrive less frequently (pulse-wave
* velocity, vascular age).
*/
export type MeasurementCategory =
| "vitals"
| "body"
| "activity"
| "sleep"
| "hearing"
| "environment"
| "cardiovascular"
| "metabolic";
/**
* Per-MeasurementType → category mapping. The map is exhaustive over
* the current enum surface — adding a new MeasurementType is a
* compile-time error in this file until a category is assigned.
*
* The map is intentionally `ReadonlyMap` rather than a plain object
* literal so consumers cannot mutate it accidentally; a flat
* `Record<MeasurementType, MeasurementCategory>` would be equally
* valid but provides no immutability guard.
*/
export const MEASUREMENT_CATEGORIES: ReadonlyMap<
MeasurementType,
MeasurementCategory
> = new Map<MeasurementType, MeasurementCategory>([
// ── Vitals (point-in-time signals) ──
["BLOOD_PRESSURE_SYS", "vitals"],
["BLOOD_PRESSURE_DIA", "vitals"],
["PULSE", "vitals"],
["OXYGEN_SATURATION", "vitals"],
["BODY_TEMPERATURE", "vitals"],
// v1.5.5 — respiratory rate sits alongside SpO2 + body temp as a
// point-in-time clinical vital.
["RESPIRATORY_RATE", "vitals"],
// ── Body composition ──
["WEIGHT", "body"],
["BODY_FAT", "body"],
["FAT_FREE_MASS", "body"],
["FAT_MASS", "body"],
["MUSCLE_MASS", "body"],
["TOTAL_BODY_WATER", "body"],
["BONE_MASS", "body"],
["VISCERAL_FAT", "body"],
// v1.5.5 — BMI + lean body mass round out the body-composition
// surface (FAT_MASS already covers the fat side).
["BODY_MASS_INDEX", "body"],
["LEAN_BODY_MASS", "body"],
// ── Activity (cumulative + fitness + mobility) ──
["ACTIVITY_STEPS", "activity"],
["ACTIVE_ENERGY_BURNED", "activity"],
["FLIGHTS_CLIMBED", "activity"],
["WALKING_RUNNING_DISTANCE", "activity"],
["VO2_MAX", "activity"],
["WALKING_STEADINESS", "activity"],
// v1.5.5 — gait + walking-HR-average sit alongside walking
// steadiness as Apple-Health Mobility-section signals.
["WALKING_ASYMMETRY", "activity"],
["WALKING_DOUBLE_SUPPORT", "activity"],
["WALKING_HEART_RATE_AVERAGE", "activity"],
// v1.5.5 follow-up — gait raw-SI pair join the same Mobility cluster.
["WALKING_STEP_LENGTH", "activity"],
["WALKING_SPEED", "activity"],
// ── Sleep ──
["SLEEP_DURATION", "sleep"],
// ── Hearing (audio exposure quantities + events) ──
["AUDIO_EXPOSURE_ENV", "hearing"],
["AUDIO_EXPOSURE_HEADPHONE", "hearing"],
["AUDIO_EXPOSURE_EVENT", "hearing"],
// ── Environment ──
["TIME_IN_DAYLIGHT", "environment"],
// ── Cardiovascular (derived risk markers + Apple-Health-grouped
// heart-rate variants). v1.4.32 — RESTING_HEART_RATE +
// HEART_RATE_VARIABILITY moved here from `vitals` to align with the
// iOS handoff brief's category table (RESTING_HR, HRV →
// cardiovascular). PULSE stays in `vitals` because spot-pulse
// samples are point-in-time signals; the resting + variability
// metrics are the derived cardiac-risk surface. ──
["RESTING_HEART_RATE", "cardiovascular"],
["HEART_RATE_VARIABILITY", "cardiovascular"],
["PULSE_WAVE_VELOCITY", "cardiovascular"],
["VASCULAR_AGE", "cardiovascular"],
// ── Metabolic ──
["BLOOD_GLUCOSE", "metabolic"],
["SKIN_TEMPERATURE", "metabolic"],
]);
/**
* Lookup the category for a `MeasurementType`. Returns `undefined`
* only if a new enum value lands in the schema without a sibling
* entry in the map — the completeness test in
* `__tests__/categories.test.ts` is the regression sentinel.
*/
export function getMeasurementCategory(
type: MeasurementType,
): MeasurementCategory | undefined {
return MEASUREMENT_CATEGORIES.get(type);
}
/**
* List every `MeasurementType` belonging to a given category. Useful
* for the iOS picker fan-out and the web Insights nav grouping. The
* return order is the insertion order in `MEASUREMENT_CATEGORIES`
* above — kept deterministic so the picker UX has a stable layout.
*/
export function measurementTypesForCategory(
category: MeasurementCategory,
): MeasurementType[] {
const out: MeasurementType[] = [];
for (const [type, cat] of MEASUREMENT_CATEGORIES) {
if (cat === category) out.push(type);
}
return out;
}