diff --git a/src/lib/measurements/__tests__/categories.test.ts b/src/lib/measurements/__tests__/categories.test.ts new file mode 100644 index 000000000..f52fc5a58 --- /dev/null +++ b/src/lib/measurements/__tests__/categories.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; + +import { measurementTypeEnum } from "@/lib/validations/measurement"; + +import { + MEASUREMENT_CATEGORIES, + getMeasurementCategory, + measurementTypesForCategory, +} from "../categories"; + +describe("MEASUREMENT_CATEGORIES — completeness wall", () => { + it("assigns a category to every MeasurementType in the canonical enum", () => { + const enumValues = measurementTypeEnum.options; + for (const type of enumValues) { + expect( + MEASUREMENT_CATEGORIES.has(type), + `MeasurementType ${type} has no category assignment`, + ).toBe(true); + } + }); + + it("does not assign a category to a type the canonical enum does not know", () => { + const enumValues = new Set(measurementTypeEnum.options); + for (const type of MEASUREMENT_CATEGORIES.keys()) { + expect( + enumValues.has(type), + `MeasurementType ${type} is mapped but not in the canonical enum`, + ).toBe(true); + } + }); + + it("groups blood-pressure systolic and diastolic into the same category", () => { + expect(MEASUREMENT_CATEGORIES.get("BLOOD_PRESSURE_SYS")).toBe( + MEASUREMENT_CATEGORIES.get("BLOOD_PRESSURE_DIA"), + ); + }); +}); + +describe("getMeasurementCategory", () => { + it("returns the category for a known type", () => { + expect(getMeasurementCategory("WEIGHT")).toBe("body"); + expect(getMeasurementCategory("BLOOD_GLUCOSE")).toBe("metabolic"); + expect(getMeasurementCategory("ACTIVITY_STEPS")).toBe("activity"); + expect(getMeasurementCategory("AUDIO_EXPOSURE_HEADPHONE")).toBe("hearing"); + }); +}); + +describe("measurementTypesForCategory", () => { + it("lists every type assigned to a category", () => { + expect(measurementTypesForCategory("hearing").sort()).toEqual([ + "AUDIO_EXPOSURE_ENV", + "AUDIO_EXPOSURE_HEADPHONE", + ]); + }); + + it("returns an empty list for a category with no members today", () => { + // None of the current 27 enum values surface a mood-specific + // MeasurementType — mood lives in MoodEntry — so the category + // can be queried without surprise (mostly a sanity wall for the + // R-F §4.4 synthetic-token decision). + const allCategories: ReadonlyArray = Array.from( + new Set(Array.from(MEASUREMENT_CATEGORIES.values())), + ); + for (const cat of allCategories) { + const members = measurementTypesForCategory(cat as never); + expect(members.length, `${cat} should not be empty`).toBeGreaterThan(0); + } + }); + + it("includes the v1.4.25 W8d hearing + environment additions", () => { + // Regression sentinel — v1.4.25 W8d added AUDIO_EXPOSURE_ENV / + // AUDIO_EXPOSURE_HEADPHONE / TIME_IN_DAYLIGHT. The categorisation + // overlay must pick them up so the iOS picker surfaces them under + // Hearing + Environment rather than dropping them into the flat + // 27-line view that the overlay exists to avoid. + expect(measurementTypesForCategory("hearing")).toContain( + "AUDIO_EXPOSURE_ENV", + ); + expect(measurementTypesForCategory("environment")).toContain( + "TIME_IN_DAYLIGHT", + ); + }); +}); diff --git a/src/lib/measurements/categories.ts b/src/lib/measurements/categories.ts new file mode 100644 index 000000000..9c2d83c71 --- /dev/null +++ b/src/lib/measurements/categories.ts @@ -0,0 +1,132 @@ +/** + * 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` would be equally + * valid but provides no immutability guard. + */ +export const MEASUREMENT_CATEGORIES: ReadonlyMap< + MeasurementType, + MeasurementCategory +> = new Map([ + // ── Vitals (point-in-time signals) ── + ["BLOOD_PRESSURE_SYS", "vitals"], + ["BLOOD_PRESSURE_DIA", "vitals"], + ["PULSE", "vitals"], + ["RESTING_HEART_RATE", "vitals"], + ["HEART_RATE_VARIABILITY", "vitals"], + ["OXYGEN_SATURATION", "vitals"], + ["BODY_TEMPERATURE", "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"], + + // ── Activity (cumulative + fitness) ── + ["ACTIVITY_STEPS", "activity"], + ["ACTIVE_ENERGY_BURNED", "activity"], + ["FLIGHTS_CLIMBED", "activity"], + ["WALKING_RUNNING_DISTANCE", "activity"], + ["VO2_MAX", "activity"], + + // ── Sleep ── + ["SLEEP_DURATION", "sleep"], + + // ── Hearing (audio exposure quantities) ── + ["AUDIO_EXPOSURE_ENV", "hearing"], + ["AUDIO_EXPOSURE_HEADPHONE", "hearing"], + + // ── Environment ── + ["TIME_IN_DAYLIGHT", "environment"], + + // ── Cardiovascular (derived risk markers) ── + ["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; +}