mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +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
@@ -283,6 +283,13 @@
|
||||
"typeTotalBodyWater": "Gesamtkörperwasser",
|
||||
"typeBoneMass": "Knochenmasse",
|
||||
"typeOxygenSaturation": "Sauerstoffsättigung",
|
||||
"typeHeartRateVariability": "Herzfrequenzvariabilität",
|
||||
"typeRestingHeartRate": "Ruheherzfrequenz",
|
||||
"typeActiveEnergyBurned": "Aktive Energie",
|
||||
"typeFlightsClimbed": "Treppen",
|
||||
"typeWalkingRunningDistance": "Geh- und Laufstrecke",
|
||||
"typeVo2Max": "VO₂max",
|
||||
"typeBodyTemperature": "Körpertemperatur",
|
||||
"glucoseContext": "Kontext",
|
||||
"glucoseContextFasting": "Nüchtern (≥8 h ohne Nahrung)",
|
||||
"glucoseContextPostprandial": "Postprandial (2 h nach dem Essen)",
|
||||
|
||||
@@ -283,6 +283,13 @@
|
||||
"typeTotalBodyWater": "Total body water",
|
||||
"typeBoneMass": "Bone mass",
|
||||
"typeOxygenSaturation": "Oxygen saturation",
|
||||
"typeHeartRateVariability": "Heart-rate variability",
|
||||
"typeRestingHeartRate": "Resting heart rate",
|
||||
"typeActiveEnergyBurned": "Active energy",
|
||||
"typeFlightsClimbed": "Flights climbed",
|
||||
"typeWalkingRunningDistance": "Walking + running distance",
|
||||
"typeVo2Max": "VO₂ max",
|
||||
"typeBodyTemperature": "Body temperature",
|
||||
"glucoseContext": "Context",
|
||||
"glucoseContextFasting": "Fasting (≥8h without food)",
|
||||
"glucoseContextPostprandial": "Post-meal (2h after eating)",
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
-- v1.4.23 — Apple Health backend foundation
|
||||
--
|
||||
-- Adds the enum values, columns, and composite unique index the
|
||||
-- HealthKit batch-ingest endpoint needs. Strictly additive:
|
||||
-- * No existing rows are rewritten.
|
||||
-- * New columns are nullable.
|
||||
-- * New enum values append (no reordering).
|
||||
-- * The new composite unique index treats NULL externalId rows as
|
||||
-- distinct (Postgres semantics) so manual entries — which have no
|
||||
-- external id — don't collide.
|
||||
--
|
||||
-- Unit semantics change (advisory, no row mutation): `SLEEP_DURATION`
|
||||
-- shifts from hours to minutes so per-stage HealthKit category samples
|
||||
-- can be stored without precision loss. No production data exists for
|
||||
-- this enum value yet (per the v1.4.23 W1 research), so the change is
|
||||
-- effectively schema-only — the application enforces the new unit going
|
||||
-- forward via `getUnitForType()`.
|
||||
|
||||
-- ── MeasurementType — append 7 Apple-Health-shaped enum values ──────
|
||||
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'HEART_RATE_VARIABILITY';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'RESTING_HEART_RATE';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'ACTIVE_ENERGY_BURNED';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'FLIGHTS_CLIMBED';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WALKING_RUNNING_DISTANCE';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'VO2_MAX';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BODY_TEMPERATURE';
|
||||
|
||||
-- ── MeasurementSource — append APPLE_HEALTH ─────────────────────────
|
||||
|
||||
ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'APPLE_HEALTH';
|
||||
|
||||
-- ── SleepStage — new enum mirroring HKCategoryValueSleepAnalysis ────
|
||||
|
||||
CREATE TYPE "sleep_stage" AS ENUM (
|
||||
'IN_BED',
|
||||
'AWAKE',
|
||||
'ASLEEP',
|
||||
'REM',
|
||||
'CORE',
|
||||
'DEEP'
|
||||
);
|
||||
|
||||
-- ── Measurement — new nullable columns + composite unique index ─────
|
||||
|
||||
ALTER TABLE "measurements"
|
||||
ADD COLUMN "external_source_version" TEXT,
|
||||
ADD COLUMN "sleep_stage" "sleep_stage";
|
||||
|
||||
-- Enforce: sleep_stage is only set for SLEEP_DURATION rows. Mirrors the
|
||||
-- glucose_context CHECK constraint from migration 0021.
|
||||
ALTER TABLE "measurements"
|
||||
ADD CONSTRAINT "measurements_sleep_stage_requires_type"
|
||||
CHECK (
|
||||
sleep_stage IS NULL
|
||||
OR (sleep_stage IS NOT NULL AND type = 'SLEEP_DURATION')
|
||||
);
|
||||
|
||||
-- Composite unique index — dedup key for the Apple Health batch ingest
|
||||
-- endpoint. Postgres treats NULL as distinct in unique indexes, so
|
||||
-- manual entries (external_id = NULL) don't collide with each other or
|
||||
-- with imported rows. Withings ingest historically used externalId
|
||||
-- alongside the legacy `(userId, type, measuredAt, source)` unique
|
||||
-- index — both indexes coexist; the legacy one stays as the manual-UI
|
||||
-- "no duplicate at the same wall-clock" guard.
|
||||
CREATE UNIQUE INDEX "measurements_user_id_type_source_external_id_key"
|
||||
ON "measurements" ("user_id", "type", "source", "external_id");
|
||||
+45
-3
@@ -192,12 +192,20 @@ enum MeasurementType {
|
||||
BLOOD_PRESSURE_DIA
|
||||
PULSE
|
||||
BODY_FAT
|
||||
SLEEP_DURATION
|
||||
SLEEP_DURATION // v1.4.23: unit semantics shifted from hours to minutes so HealthKit category samples (per-stage rows) survive without precision loss.
|
||||
ACTIVITY_STEPS
|
||||
BLOOD_GLUCOSE // Canonical storage: mg/dL. Display unit (mg/dL or mmol/L) is user preference.
|
||||
TOTAL_BODY_WATER // Canonical storage: kilograms (kg of water). Matches Withings' hydration measure (type 77) and Health Connect's TotalBodyWaterRecord. Display can compute % from same-day weight if desired.
|
||||
BONE_MASS // Canonical storage: kilograms (kg). Matches Withings' bone mass measure (type 88).
|
||||
OXYGEN_SATURATION // Canonical storage: percent (0..100). Withings ScanWatch reports type 54. Health Connect's OxygenSaturationRecord uses Percentage (already 0..100). HealthKit uses HKQuantityTypeIdentifierOxygenSaturation as a fraction (0..1) — iOS DTO must multiply by 100 before submitting.
|
||||
// ── v1.4.23 Apple Health additions ──
|
||||
HEART_RATE_VARIABILITY // ms — HealthKit `heartRateVariabilitySDNN` (typically one sample per night).
|
||||
RESTING_HEART_RATE // bpm — HealthKit `restingHeartRate` (one sample per day; distinct from spot PULSE).
|
||||
ACTIVE_ENERGY_BURNED // kcal — HealthKit `activeEnergyBurned`, cumulative; daily-rollup pattern.
|
||||
FLIGHTS_CLIMBED // count — HealthKit `flightsClimbed`, cumulative.
|
||||
WALKING_RUNNING_DISTANCE // metres — HealthKit `distanceWalkingRunning`; SI metres in DB, UI converts per locale.
|
||||
VO2_MAX // mL/(kg·min) — HealthKit `vo2Max`; one sample every few days at most.
|
||||
BODY_TEMPERATURE // celsius — HealthKit `bodyTemperature`; iOS DTO already encodes this enum value.
|
||||
|
||||
@@map("measurement_type")
|
||||
}
|
||||
@@ -217,28 +225,62 @@ enum MeasurementSource {
|
||||
MANUAL
|
||||
WITHINGS
|
||||
IMPORT
|
||||
APPLE_HEALTH // v1.4.23 — HealthKit batch ingest. iOS DTO mirrors this exact spelling (not "HEALTHKIT").
|
||||
|
||||
@@map("measurement_source")
|
||||
}
|
||||
|
||||
/// Per-stage sleep label for HealthKit `HKCategoryValueSleepAnalysis`
|
||||
/// samples. Non-NULL only when `Measurement.type = SLEEP_DURATION`,
|
||||
/// mirroring the `GlucoseContext` pattern. A single night produces one
|
||||
/// row per stage (analytics sums them for `total_minutes`).
|
||||
enum SleepStage {
|
||||
IN_BED
|
||||
AWAKE
|
||||
ASLEEP // legacy iOS 15- `asleepUnspecified` — pre-iOS-16 sleep tracking with no stage breakdown.
|
||||
REM
|
||||
CORE
|
||||
DEEP
|
||||
|
||||
@@map("sleep_stage")
|
||||
}
|
||||
|
||||
model Measurement {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
type MeasurementType
|
||||
value Float
|
||||
unit String // kg, mmHg, bpm, %, hours, steps, mg/dL
|
||||
unit String // kg, mmHg, bpm, %, minutes (sleep), steps, mg/dL, kcal, m, ms, mL/(kg·min), celsius
|
||||
source MeasurementSource @default(MANUAL)
|
||||
measuredAt DateTime @map("measured_at")
|
||||
notes String? // encrypted at app level if sensitive
|
||||
externalId String? @map("external_id") // Withings grpid for dedup
|
||||
// External-system dedup key. Withings used `grpid` (numeric); v1.4.23
|
||||
// adds Apple Health which uses `HKSample.uuid` (string). NULL for
|
||||
// manual entries — Postgres treats NULLs as distinct in unique
|
||||
// indexes, so two manual rows with the same (user, type, source) are
|
||||
// still allowed.
|
||||
externalId String? @map("external_id")
|
||||
// v1.4.23 — opaque marker for the source's schema version (HealthKit
|
||||
// sample's `metadata.HKWasUserEntered`, Withings model code, …).
|
||||
// Stored as a string so the ingest path can replay it back unchanged.
|
||||
externalSourceVersion String? @map("external_source_version")
|
||||
// Only set for BLOOD_GLUCOSE measurements. NULL for all others.
|
||||
glucoseContext GlucoseContext? @map("glucose_context")
|
||||
// v1.4.23 — per-stage sleep label. NULL for non-sleep rows; non-NULL
|
||||
// only for `type = SLEEP_DURATION`. Mirrors the glucoseContext
|
||||
// pattern. A single night produces ~5 rows (one per stage).
|
||||
sleepStage SleepStage? @map("sleep_stage")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Original measuredAt-based dedup — kept for legacy Withings ingest
|
||||
// and the manual UI's "no duplicate at the same wall-clock" guard.
|
||||
@@unique([userId, type, measuredAt, source])
|
||||
// v1.4.23 — Apple Health batch dedup key. NULL externalId is distinct
|
||||
// (Postgres semantics) so manual entries don't collide here.
|
||||
@@unique([userId, type, source, externalId])
|
||||
@@index([userId, type, measuredAt])
|
||||
@@index([externalId])
|
||||
@@map("measurements")
|
||||
|
||||
@@ -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