feat(measurements): shared category map for the iOS picker and the web Insights nav

R-F §4 close-out. The flat 27-value MeasurementType enum stays as the
storage shape; this overlay is presentation-only and drives both
consumers:

  - iOS HealthKit permission picker — grouped by category so the user
    sees one consent screen per logical area (vitals / activity /
    sleep / hearing / environment / cardiovascular / metabolic / body)
    rather than a flat 27-line list
  - web Insights nav (post-v1.5) — the existing 7-page strip stays
    curated; an "All metrics" overview can group cards by category
    cheaply from this map
  - Coach evidence shelf — chip-grouping by category makes the prose
    more navigable than a flat list

The map is ReadonlyMap<MeasurementType, MeasurementCategory> so
consumers cannot mutate it accidentally. Adding a new MeasurementType
without a sibling entry is caught at test time by the completeness
wall.

iOS contract: presentation-only — no API surface change. The iOS
client mirrors this map verbatim once the swift-openapi-generator
wiring (R-E H-8) lands; new types stay invisible to clients that
predate the codegen pass.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 12:12:52 +02:00
1 parent 57abfbfb90
commit 479e0aed48
2 files changed
+215

No files matched your search

@@ -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<string>(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<string> = 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",
);
});
});
+132
View File
@@ -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<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"],
["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;
}