mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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>
156 lines
5.7 KiB
TypeScript
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;
|
|
}
|