mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user