mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
feat(schema): Apple Health measurement types + sleep stages + dedup index
Extend MeasurementType with the seven HealthKit-shaped values the iOS batch ingest needs (HRV, resting HR, active energy, flights climbed, distance, VO2 max, body temperature) and add APPLE_HEALTH to the MeasurementSource enum. Sleep duration unit shifts from hours to minutes so per-stage HKCategorySample rows persist without precision loss; the new SleepStage enum mirrors the existing GlucoseContext column shape and a CHECK constraint scopes it to SLEEP_DURATION rows. The composite unique index `(user_id, type, source, external_id)` gives the new batch endpoint an idempotent dedup key while the legacy `(user_id, type, measured_at, source)` index continues to back the manual UI's no-duplicate-at-the-same-wall-clock guard. NULL externalId rows stay distinct under Postgres semantics so manual entries never collide. Migration is strictly additive: new enum values append, new columns are nullable, no rows mutate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
8569a1879f
commit
95209ebf79
9 files changed
+256
-8
No files matched your search
@@ -24,6 +24,11 @@ import {
|
||||
Footprints,
|
||||
Bone,
|
||||
Wind,
|
||||
HeartPulse,
|
||||
Flame,
|
||||
TrendingUp,
|
||||
Thermometer,
|
||||
Gauge,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -39,6 +44,14 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
|
||||
TOTAL_BODY_WATER: "measurements.typeTotalBodyWater",
|
||||
BONE_MASS: "measurements.typeBoneMass",
|
||||
OXYGEN_SATURATION: "measurements.typeOxygenSaturation",
|
||||
// ── v1.4.23 Apple Health additions ──
|
||||
HEART_RATE_VARIABILITY: "measurements.typeHeartRateVariability",
|
||||
RESTING_HEART_RATE: "measurements.typeRestingHeartRate",
|
||||
ACTIVE_ENERGY_BURNED: "measurements.typeActiveEnergyBurned",
|
||||
FLIGHTS_CLIMBED: "measurements.typeFlightsClimbed",
|
||||
WALKING_RUNNING_DISTANCE: "measurements.typeWalkingRunningDistance",
|
||||
VO2_MAX: "measurements.typeVo2Max",
|
||||
BODY_TEMPERATURE: "measurements.typeBodyTemperature",
|
||||
};
|
||||
|
||||
export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
|
||||
@@ -53,6 +66,14 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
|
||||
TOTAL_BODY_WATER: Droplet,
|
||||
BONE_MASS: Bone,
|
||||
OXYGEN_SATURATION: Wind,
|
||||
// ── v1.4.23 Apple Health additions ──
|
||||
HEART_RATE_VARIABILITY: HeartPulse,
|
||||
RESTING_HEART_RATE: Heart,
|
||||
ACTIVE_ENERGY_BURNED: Flame,
|
||||
FLIGHTS_CLIMBED: TrendingUp,
|
||||
WALKING_RUNNING_DISTANCE: Footprints,
|
||||
VO2_MAX: Gauge,
|
||||
BODY_TEMPERATURE: Thermometer,
|
||||
};
|
||||
|
||||
export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
|
||||
@@ -67,4 +88,12 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
|
||||
TOTAL_BODY_WATER: "bg-chart-2/20 text-chart-2",
|
||||
BONE_MASS: "bg-chart-4/20 text-chart-4",
|
||||
OXYGEN_SATURATION: "bg-chart-5/20 text-chart-5",
|
||||
// ── v1.4.23 Apple Health additions ──
|
||||
HEART_RATE_VARIABILITY: "bg-chart-5/20 text-chart-5",
|
||||
RESTING_HEART_RATE: "bg-chart-3/20 text-chart-3",
|
||||
ACTIVE_ENERGY_BURNED: "bg-chart-4/20 text-chart-4",
|
||||
FLIGHTS_CLIMBED: "bg-chart-2/20 text-chart-2",
|
||||
WALKING_RUNNING_DISTANCE: "bg-chart-2/20 text-chart-2",
|
||||
VO2_MAX: "bg-chart-1/20 text-chart-1",
|
||||
BODY_TEMPERATURE: "bg-chart-4/20 text-chart-4",
|
||||
};
|
||||
@@ -26,10 +26,18 @@ const EXPECTED_TYPES = [
|
||||
"TOTAL_BODY_WATER",
|
||||
"BONE_MASS",
|
||||
"OXYGEN_SATURATION",
|
||||
// ── v1.4.23 Apple Health additions ──
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"RESTING_HEART_RATE",
|
||||
"ACTIVE_ENERGY_BURNED",
|
||||
"FLIGHTS_CLIMBED",
|
||||
"WALKING_RUNNING_DISTANCE",
|
||||
"VO2_MAX",
|
||||
"BODY_TEMPERATURE",
|
||||
] as const;
|
||||
|
||||
describe("measurementTypeEnum coverage", () => {
|
||||
it("exposes the 11 canonical measurement types", () => {
|
||||
it("exposes the 18 canonical measurement types", () => {
|
||||
expect([...measurementTypeEnum.options].sort()).toEqual(
|
||||
[...EXPECTED_TYPES].sort(),
|
||||
);
|
||||
@@ -39,12 +47,24 @@ describe("measurementTypeEnum coverage", () => {
|
||||
// - BLOOD_GLUCOSE renders through the per-context `glucoseStats` section
|
||||
// - SLEEP_DURATION + ACTIVITY_STEPS are intentionally omitted from the
|
||||
// clinical PDF (lifestyle, not a vital sign — see source comment).
|
||||
// - v1.4.23 Apple Health metrics (HRV, resting HR, active energy,
|
||||
// flights, distance, VO2 max, body temperature) are excluded from
|
||||
// the v1.4.23 release of the doctor PDF — they ship into the
|
||||
// clinical surface alongside the iOS app's first paying-customer
|
||||
// sync in v1.5 once layout + reference ranges are agreed.
|
||||
// Updates to this set MUST be paired with a comment in
|
||||
// doctor-report-pdf-core.ts so the rationale stays discoverable.
|
||||
const PDF_VITAL_EXCLUSIONS = new Set([
|
||||
"BLOOD_GLUCOSE",
|
||||
"SLEEP_DURATION",
|
||||
"ACTIVITY_STEPS",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"RESTING_HEART_RATE",
|
||||
"ACTIVE_ENERGY_BURNED",
|
||||
"FLIGHTS_CLIMBED",
|
||||
"WALKING_RUNNING_DISTANCE",
|
||||
"VO2_MAX",
|
||||
"BODY_TEMPERATURE",
|
||||
]);
|
||||
|
||||
it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => {
|
||||
|
||||
@@ -33,6 +33,16 @@ export const ALLOWED_CHART_TOKENS = [
|
||||
"metric:BONE_MASS",
|
||||
"metric:OXYGEN_SATURATION",
|
||||
"metric:MOOD",
|
||||
// v1.4.23 — Apple Health additions. The tokens are accepted by the
|
||||
// allowlist so the LLM can reference them; the corresponding chart
|
||||
// components ship in v1.5 alongside the iOS app surface.
|
||||
"metric:HEART_RATE_VARIABILITY",
|
||||
"metric:RESTING_HEART_RATE",
|
||||
"metric:ACTIVE_ENERGY_BURNED",
|
||||
"metric:FLIGHTS_CLIMBED",
|
||||
"metric:WALKING_RUNNING_DISTANCE",
|
||||
"metric:VO2_MAX",
|
||||
"metric:BODY_TEMPERATURE",
|
||||
] as const;
|
||||
|
||||
export type ChartToken = (typeof ALLOWED_CHART_TOKENS)[number];
|
||||
|
||||
@@ -13,11 +13,21 @@ describe("measurement validation", () => {
|
||||
expect(getUnitForType("BLOOD_PRESSURE_DIA")).toBe("mmHg");
|
||||
expect(getUnitForType("PULSE")).toBe("bpm");
|
||||
expect(getUnitForType("BODY_FAT")).toBe("%");
|
||||
expect(getUnitForType("SLEEP_DURATION")).toBe("hours");
|
||||
// v1.4.23 — sleep duration shifted from hours to minutes so per-stage
|
||||
// HealthKit category samples can be stored without precision loss.
|
||||
expect(getUnitForType("SLEEP_DURATION")).toBe("minutes");
|
||||
expect(getUnitForType("ACTIVITY_STEPS")).toBe("steps");
|
||||
expect(getUnitForType("BLOOD_GLUCOSE")).toBe("mg/dL");
|
||||
expect(getUnitForType("TOTAL_BODY_WATER")).toBe("kg");
|
||||
expect(getUnitForType("BONE_MASS")).toBe("kg");
|
||||
// ── v1.4.23 Apple Health canonical units ──
|
||||
expect(getUnitForType("HEART_RATE_VARIABILITY")).toBe("ms");
|
||||
expect(getUnitForType("RESTING_HEART_RATE")).toBe("bpm");
|
||||
expect(getUnitForType("ACTIVE_ENERGY_BURNED")).toBe("kcal");
|
||||
expect(getUnitForType("FLIGHTS_CLIMBED")).toBe("flights");
|
||||
expect(getUnitForType("WALKING_RUNNING_DISTANCE")).toBe("m");
|
||||
expect(getUnitForType("VO2_MAX")).toBe("mL/(kg·min)");
|
||||
expect(getUnitForType("BODY_TEMPERATURE")).toBe("celsius");
|
||||
});
|
||||
|
||||
it("returns 'unknown' for unrecognised types", () => {
|
||||
|
||||
@@ -12,6 +12,14 @@ export const measurementTypeEnum = z.enum([
|
||||
"TOTAL_BODY_WATER",
|
||||
"BONE_MASS",
|
||||
"OXYGEN_SATURATION",
|
||||
// ── v1.4.23 Apple Health additions ──
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"RESTING_HEART_RATE",
|
||||
"ACTIVE_ENERGY_BURNED",
|
||||
"FLIGHTS_CLIMBED",
|
||||
"WALKING_RUNNING_DISTANCE",
|
||||
"VO2_MAX",
|
||||
"BODY_TEMPERATURE",
|
||||
]);
|
||||
|
||||
export const glucoseContextEnum = z.enum([
|
||||
@@ -21,7 +29,21 @@ export const glucoseContextEnum = z.enum([
|
||||
"BEDTIME",
|
||||
]);
|
||||
|
||||
export const measurementSourceEnum = z.enum(["MANUAL", "WITHINGS", "IMPORT"]);
|
||||
export const sleepStageEnum = z.enum([
|
||||
"IN_BED",
|
||||
"AWAKE",
|
||||
"ASLEEP",
|
||||
"REM",
|
||||
"CORE",
|
||||
"DEEP",
|
||||
]);
|
||||
|
||||
export const measurementSourceEnum = z.enum([
|
||||
"MANUAL",
|
||||
"WITHINGS",
|
||||
"IMPORT",
|
||||
"APPLE_HEALTH",
|
||||
]);
|
||||
|
||||
const unitMap: Record<string, string> = {
|
||||
WEIGHT: "kg",
|
||||
@@ -29,12 +51,23 @@ const unitMap: Record<string, string> = {
|
||||
BLOOD_PRESSURE_DIA: "mmHg",
|
||||
PULSE: "bpm",
|
||||
BODY_FAT: "%",
|
||||
SLEEP_DURATION: "hours",
|
||||
// v1.4.23 — sleep duration shifted from hours to minutes so HealthKit
|
||||
// category-sample stages can be stored without precision loss. Older
|
||||
// surfaces that need hours convert at read time (`minutes / 60`).
|
||||
SLEEP_DURATION: "minutes",
|
||||
ACTIVITY_STEPS: "steps",
|
||||
BLOOD_GLUCOSE: "mg/dL",
|
||||
TOTAL_BODY_WATER: "kg",
|
||||
BONE_MASS: "kg",
|
||||
OXYGEN_SATURATION: "%",
|
||||
// ── v1.4.23 Apple Health canonical units ──
|
||||
HEART_RATE_VARIABILITY: "ms",
|
||||
RESTING_HEART_RATE: "bpm",
|
||||
ACTIVE_ENERGY_BURNED: "kcal",
|
||||
FLIGHTS_CLIMBED: "flights",
|
||||
WALKING_RUNNING_DISTANCE: "m",
|
||||
VO2_MAX: "mL/(kg·min)",
|
||||
BODY_TEMPERATURE: "celsius",
|
||||
};
|
||||
|
||||
export function getUnitForType(type: string): string {
|
||||
@@ -48,7 +81,10 @@ const VALUE_RANGES: Record<string, { min: number; max: number }> = {
|
||||
BLOOD_PRESSURE_DIA: { min: 20, max: 200 },
|
||||
PULSE: { min: 20, max: 300 },
|
||||
BODY_FAT: { min: 1, max: 80 },
|
||||
SLEEP_DURATION: { min: 0, max: 24 },
|
||||
// Minutes (v1.4.23 unit change). Per-stage rows can run from a few
|
||||
// seconds (a brief awakening) to 600+ minutes (a long IN_BED block),
|
||||
// so the upper bound covers a 24-hour day.
|
||||
SLEEP_DURATION: { min: 0, max: 1440 },
|
||||
ACTIVITY_STEPS: { min: 0, max: 200000 },
|
||||
BLOOD_GLUCOSE: { min: 20, max: 800 }, // mg/dL — covers severe hypo to severe hyperglycemia
|
||||
TOTAL_BODY_WATER: { min: 5, max: 100 }, // kg of water — adults typically 30–55 kg
|
||||
@@ -58,6 +94,26 @@ const VALUE_RANGES: Record<string, { min: number; max: number }> = {
|
||||
// critically-low reading still gets logged for the doctor to see; below 50%
|
||||
// is incompatible with sustained life and almost certainly a sensor glitch.
|
||||
OXYGEN_SATURATION: { min: 50, max: 100 },
|
||||
// ── v1.4.23 Apple Health ranges ──
|
||||
// HRV SDNN ms — Apple's own "high HRV" threshold sits ~80 ms; lows can
|
||||
// reach 5–10 ms in stressed/sick samples. 200 ms is a generous upper
|
||||
// bound for unusually relaxed athletic windows.
|
||||
HEART_RATE_VARIABILITY: { min: 1, max: 200 },
|
||||
// Resting HR bpm — endurance athletes can reach the high 30s; severe
|
||||
// tachycardia caps below 220.
|
||||
RESTING_HEART_RATE: { min: 25, max: 220 },
|
||||
// Active energy kcal/sample — a daily-rollup sample tops out around
|
||||
// 6–8000 kcal even for ultra-endurance days.
|
||||
ACTIVE_ENERGY_BURNED: { min: 0, max: 10000 },
|
||||
// Flights — vertical-feet/3 ≈ flights; cap generous.
|
||||
FLIGHTS_CLIMBED: { min: 0, max: 1000 },
|
||||
// Walking/running distance per sample (metres). 200 km covers an
|
||||
// ultra-marathon or a multi-stage day.
|
||||
WALKING_RUNNING_DISTANCE: { min: 0, max: 200000 },
|
||||
// VO2 max mL/(kg·min) — elite endurance hovers ~85; sedentary floor ~10.
|
||||
VO2_MAX: { min: 5, max: 100 },
|
||||
// Body temperature °C — survivable lows ~28; severe hyperthermia ~45.
|
||||
BODY_TEMPERATURE: { min: 28, max: 45 },
|
||||
};
|
||||
|
||||
export function validateMeasurementRange(
|
||||
|
||||
Reference in new issue
Block a user