Files
HealthLog/src/lib/validations/measurement.ts
T
Marc-André BombeckandClaude Opus 4.7 8ee7aaac44 chore(release): v1.6.0
Medication editor overhaul, route of administration, today-tile read-flip.

- Route-of-administration column (ORAL | INJECTION | OTHER) decoupled from
  treatmentClass; injection-site picker surfaces for any injection.
  Migration 0088 backfills ORAL via a constant column default.
- One-time injection (oneShot + deliveryForm = INJECTION) editor path.
- Profile-photo upload card on Settings → Account.
- Today-tile read-flip: /api/medications/intake?scope=today and
  /api/dashboard/summary gate through the canonical recurrence engine,
  matching the reminder worker for bi-weekly, rolling, RRULE, and one-time
  cadences.
- Measurement note cap 25 → 200; dashboard range colors on semantic tokens;
  admin sidebar icons + shared-helper consolidation; API-tokens multi-language
  snippets restored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 00:10:12 +02:00

426 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { z } from "zod/v4";
export const measurementTypeEnum = z.enum([
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"PULSE",
"BODY_FAT",
"SLEEP_DURATION",
"ACTIVITY_STEPS",
"BLOOD_GLUCOSE",
"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",
// ── v1.4.25 W5d Withings full coverage ──
"FAT_FREE_MASS",
"FAT_MASS",
"MUSCLE_MASS",
"SKIN_TEMPERATURE",
"PULSE_WAVE_VELOCITY",
"VASCULAR_AGE",
"VISCERAL_FAT",
// ── v1.4.25 W8d Apple Health server-prep ──
"AUDIO_EXPOSURE_ENV",
"AUDIO_EXPOSURE_HEADPHONE",
"TIME_IN_DAYLIGHT",
// ── v1.4.30 R-F T1.4 + T1.5 ──
"WALKING_STEADINESS",
"AUDIO_EXPOSURE_EVENT",
// ── v1.5.5 iOS-coord — six previously-deferred HK identifiers wired end-to-end ──
"RESPIRATORY_RATE",
"BODY_MASS_INDEX",
"LEAN_BODY_MASS",
"WALKING_HEART_RATE_AVERAGE",
"WALKING_ASYMMETRY",
"WALKING_DOUBLE_SUPPORT",
// ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ──
// Both ship raw SI on the wire (metres / metres-per-second); see
// the convention block in `apple-health-mapping.ts`.
"WALKING_STEP_LENGTH",
"WALKING_SPEED",
]);
export const glucoseContextEnum = z.enum([
"FASTING",
"POSTPRANDIAL",
"RANDOM",
"BEDTIME",
]);
export const measurementSourceEnum = z.enum([
"MANUAL",
"WITHINGS",
"IMPORT",
"APPLE_HEALTH",
]);
const unitMap: Record<string, string> = {
WEIGHT: "kg",
BLOOD_PRESSURE_SYS: "mmHg",
BLOOD_PRESSURE_DIA: "mmHg",
PULSE: "bpm",
BODY_FAT: "%",
// 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",
// ── v1.4.25 W5d Withings full coverage ──
FAT_FREE_MASS: "kg",
FAT_MASS: "kg",
MUSCLE_MASS: "kg",
// Distinct from BODY_TEMPERATURE — surface temps run ~32 °C; sharing
// the bucket would corrupt analytics. Same canonical unit (°C).
SKIN_TEMPERATURE: "celsius",
PULSE_WAVE_VELOCITY: "m/s",
VASCULAR_AGE: "years",
// Withings reports visceral fat as a 1–12 rating, not a percent. The
// string mirrors what Withings prints in Health Mate.
VISCERAL_FAT: "rating",
// ── v1.4.25 W8d Apple Health server-prep ──
// Sound-pressure level — A-weighted decibels (dBA). HealthKit reports
// both audio-exposure metrics in dBASPL; we store the unweighted "dBA"
// label because the A-weighting is implicit (every HealthKit audio
// sample carries it). 30 dBA = quiet bedroom; 140 dBA = pain threshold.
AUDIO_EXPOSURE_ENV: "dBA",
AUDIO_EXPOSURE_HEADPHONE: "dBA",
// Daily-rollup pattern (one sample = one day's outdoor-light minutes).
// 0–1440 covers the 24-hour day; in practice indoor users sit near 0
// and outdoor athletes accumulate a few hours.
TIME_IN_DAYLIGHT: "minutes",
// ── v1.5.5 iOS-coord additions ──
// Respiratory rate breaths-per-minute — the HK identifier ships as
// `count/min`; we keep the more conventional clinical label.
RESPIRATORY_RATE: "breaths/min",
// BMI kg/m² — HealthKit ships the unitless ratio; the canonical
// display string mirrors clinical convention.
BODY_MASS_INDEX: "kg/m²",
// Lean body mass kg — body-composition partner to FAT_MASS.
LEAN_BODY_MASS: "kg",
// Walking heart rate average bpm — daily rollup; distinct from
// RESTING_HEART_RATE (sleep-window minimum) and spot PULSE.
WALKING_HEART_RATE_AVERAGE: "bpm",
// Walking gait percent (0-100 after server-side ×100 scaling).
// Same convention as WALKING_STEADINESS / BODY_FAT / OXYGEN_SATURATION
// — see the project convention block in `apple-health-mapping.ts`.
WALKING_ASYMMETRY: "%",
WALKING_DOUBLE_SUPPORT: "%",
// ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ──
// Step length is metres; speed is metres per second. Both flow
// raw on the wire — no server-side scaling. The unit strings
// match HealthKit's `m` / `m/s` defaults.
WALKING_STEP_LENGTH: "m",
WALKING_SPEED: "m/s",
};
export function getUnitForType(type: string): string {
return unitMap[type] ?? "unknown";
}
// Plausible ranges per measurement type
const VALUE_RANGES: Record<string, { min: number; max: number }> = {
WEIGHT: { min: 1, max: 500 },
BLOOD_PRESSURE_SYS: { min: 40, max: 300 },
BLOOD_PRESSURE_DIA: { min: 20, max: 200 },
PULSE: { min: 20, max: 300 },
BODY_FAT: { min: 1, max: 80 },
// 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
BONE_MASS: { min: 0.5, max: 8 }, // kg — adult plausibility (typical 2.5–4.5 kg)
// Pulse oximetry (SpO2). BTS Guideline 2017 + ATS clinical practice put the
// healthy resting range at 95–100%. We accept down to 50% so a faulty-sensor
// 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 },
// ── v1.4.25 W5d Withings full coverage ──
// Fat-free mass kg — adult plausibility, ~30 kg (small adult) to 120 kg
// (large lean athlete). Same bounds as weight minus a fat-mass floor.
FAT_FREE_MASS: { min: 10, max: 250 },
// Fat mass kg — pairs with FAT_FREE_MASS so totals reconcile to weight.
FAT_MASS: { min: 0, max: 250 },
// Muscle mass kg — sub-component of fat-free mass; widest sensible bound.
MUSCLE_MASS: { min: 5, max: 200 },
// Skin temperature °C — surface temps run cooler than core. ScanWatch's
// dermal sensor reports a relative offset that lands in the 25–40 °C
// band; treat anything outside as a sensor glitch.
SKIN_TEMPERATURE: { min: 20, max: 45 },
// Pulse-wave velocity m/s — clinical ranges sit ~4–15 m/s. Higher
// numbers indicate stiffer arteries (cardiovascular risk).
PULSE_WAVE_VELOCITY: { min: 1, max: 30 },
// Vascular age in years — Withings derives this from PWV + chronological
// age; the value is a "biological age" not a chronological one. Hard cap
// at 130 because that's the human longevity record.
VASCULAR_AGE: { min: 10, max: 130 },
// Visceral fat rating 1–12 (Withings' own scale; not a percent).
VISCERAL_FAT: { min: 0, max: 30 },
// ── v1.4.25 W8d Apple Health server-prep ──
// Audio exposure dBA — Apple's "loud audio" warning sits at 80 dBA;
// concerts run 100–115 dBA; 140 dBA is the pain threshold. The floor
// of 30 dBA covers a quiet bedroom; anything below is silence and
// almost certainly a sensor artefact.
AUDIO_EXPOSURE_ENV: { min: 30, max: 140 },
// Headphone audio caps slightly below the open-air upper edge because
// sealed-driver listening physically cannot reach a concert PA's SPL.
AUDIO_EXPOSURE_HEADPHONE: { min: 30, max: 140 },
// Time in daylight (minutes/day) — full 24-hour window. Outdoor work
// tops the range; sedentary indoor days sit near 0.
TIME_IN_DAYLIGHT: { min: 0, max: 1440 },
// ── v1.5.5 iOS-coord additions ──
// Respiratory rate breaths/min — adult range ~12-20 at rest; severe
// distress can spike past 40; bradypnea floor ~4. Generous bounds.
RESPIRATORY_RATE: { min: 3, max: 60 },
// BMI kg/m² — sub-12 is starvation-class, 70 covers extreme obesity.
// Outside the band is almost certainly a height-or-weight typo.
BODY_MASS_INDEX: { min: 8, max: 70 },
// Lean body mass kg — adult plausibility, ~25 kg (small adult) to
// 150 kg (extreme lean athlete). Same shape as FAT_FREE_MASS.
LEAN_BODY_MASS: { min: 10, max: 250 },
// Walking heart rate average bpm — endurance athletes can sit in
// the high 70s while walking; ailing or post-stimulant readings can
// touch 200. Same upper bound as the spot PULSE / RESTING_HEART_RATE.
WALKING_HEART_RATE_AVERAGE: { min: 30, max: 220 },
// Gait percent (0-100). Apple ships these as 0..1 fractions; after
// the ×100 server-side scaling the canonical band is 0..100.
WALKING_ASYMMETRY: { min: 0, max: 100 },
WALKING_DOUBLE_SUPPORT: { min: 0, max: 100 },
// ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ──
// Step length (metres) — adult walking sits around 0.5–0.8 m; the
// 0.1 floor captures shuffling gait and the 2.0 ceiling covers
// unusually tall sprinters without catching obvious sensor noise.
WALKING_STEP_LENGTH: { min: 0.1, max: 2.0 },
// Walking speed (m/s) — healthy adult casual gait ≈ 1.2–1.4 m/s;
// brisk walking tops out near 2.2 m/s before transitioning to a
// run. The 0.1 floor captures a very slow shuffle; 3.0 covers
// race-walking record territory.
WALKING_SPEED: { min: 0.1, max: 3.0 },
};
export function validateMeasurementRange(
type: string,
value: number,
): string | null {
const range = VALUE_RANGES[type];
if (range && (value < range.min || value > range.max)) {
return `Value must be between ${range.min} and ${range.max}`;
}
return null;
}
/**
* v1.6.0 — notes cap raised from 25 to 200. A 25-char limit could not
* hold a meaningful clinical note ("took after large meal, felt dizzy
* standing up"). The DB column is unbounded `String?`; this is the
* single source of truth the client char-counters import.
*/
export const MEASUREMENT_NOTES_MAX_LENGTH = 200;
export const createMeasurementSchema = z
.object({
type: measurementTypeEnum,
value: z.number(),
measuredAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
notes: z.string().max(MEASUREMENT_NOTES_MAX_LENGTH).optional(),
source: measurementSourceEnum.optional().default("MANUAL"),
// Only applies when type === BLOOD_GLUCOSE. Mirrored by a CHECK
// constraint in Postgres (see migration 0021).
glucoseContext: glucoseContextEnum.optional(),
// v1.4.25 W10 reconcile (code-review M4): the single-entry POST
// dropped `deviceType` silently because the column existed on
// `Measurement` and was accepted by the batch route, but never
// declared in this schema. iOS clients that POST one row at a
// time (and dashboards that backfill manual rows with device
// metadata) now persist the tag instead of seeing it disappear.
// Accepts `null` so a client can explicitly clear the column.
deviceType: z.string().min(1).max(32).nullable().optional(),
})
.refine((data) => validateMeasurementRange(data.type, data.value) === null, {
message: "Value out of plausible range",
})
.refine(
(data) =>
(data.type === "BLOOD_GLUCOSE" && data.glucoseContext !== undefined) ||
(data.type !== "BLOOD_GLUCOSE" && data.glucoseContext === undefined),
{
message:
"Blood glucose measurements require a context (fasting/postprandial/random/bedtime); other types must not set one.",
path: ["glucoseContext"],
},
);
export const updateMeasurementSchema = z.object({
value: z.number().min(0).max(500000).optional(),
measuredAt: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
notes: z
.string()
.max(
MEASUREMENT_NOTES_MAX_LENGTH,
`Note cannot exceed ${MEASUREMENT_NOTES_MAX_LENGTH} characters`,
)
.nullable()
.optional(),
});
export const listMeasurementsSchema = z
.object({
type: measurementTypeEnum.optional(),
from: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
to: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
// v1.4.28 FB-D2 — when the chart sends an explicit from/to window the
// payload is already bounded; lift the per-request ceiling to 5000
// (still cheaper than the legacy unbounded `while (true)` walk).
// Callers that omit from/to keep the historical 500-row cap.
limit: z.coerce.number().int().min(1).max(5000).optional().default(100),
offset: z.coerce.number().int().min(0).optional().default(0),
sortBy: z
.enum(["type", "value", "measuredAt", "source"])
.optional()
.default("measuredAt"),
sortDir: z.enum(["asc", "desc"]).optional().default("desc"),
// v1.4.28 FB-D2 — bucket hint for range-aware queries. When set the
// GET handler runs a server-side `date_trunc` aggregation and returns
// one row per bucket per type rather than the raw measurement rows.
// Omitting `aggregate` keeps the raw wire shape (iOS contract); the
// chart-data client must opt in explicitly.
aggregate: z.enum(["raw", "daily", "weekly", "monthly"]).optional(),
// v1.4.36 W1 — opt-in source switch for daily-aggregate reads. When
// `source=rollup` + `aggregate=daily`, the route reads from the
// persistent `measurement_rollups` DAY buckets instead of running a
// live `date_trunc` GROUP BY scan over the raw `measurements` table.
// The chart-data client opts in for the trends-row strip + every
// sub-page chart so the three parallel daily-aggregate requests stop
// burning a full table scan each. The route falls back to live SQL
// when the rollup bucket set is empty for the requested window so
// brand-new accounts still see correct data on their first chart.
source: z.enum(["rollup"]).optional(),
// v1.4.37 W7c — list-view "one row per day" mode for cumulative
// types (steps, active energy, distance, flights, daylight). When
// `groupBy=day` is set and `type` is a cumulative HK type, the route
// returns one synthesised row per user-TZ day with `value` = SUM
// and `sampleCount` = number of per-sample rows behind the bucket.
// Omitted = legacy per-sample list behaviour (iOS contract stable).
//
// v1.4.37 W10 — the route's groupBy=day branch hard-codes `offset:0`
// in the meta because the collapse runs after the per-sample scan;
// real pagination would require Postgres-side `date_trunc` grouping
// and a separate `prisma.count({ distinct: ["dayKey"] })`. Until
// that lands, reject any caller threading a non-zero offset so the
// pagination contract isn't silently dropped.
groupBy: z.enum(["day"]).optional(),
// v1.4.37 W7c — drill-down to per-sample rows for a single day in
// the user's IANA timezone. Format `YYYY-MM-DD`; the route resolves
// the day boundary against the user's `User.timezone`. Used by the
// expandable list row to reveal the chunks that contributed to the
// collapsed daily total.
//
// v1.4.37 W10 — same offset restriction as `groupBy=day`; the
// drill-down branch returns a single bounded page rather than a
// cursor.
dayKey: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "dayKey must be YYYY-MM-DD")
// v1.4.37 W10 — a `YYYY-MM-DD` string can satisfy the regex while
// still being an impossible calendar date (`2026-02-30`,
// `2026-13-01`). `new Date("2026-02-30T00:00:00Z")` silently
// overflows to March 2, so the drill-down would return rows from
// a different day than the user asked for. The same helper feeds
// the admin drain route, so a malformed CLI invocation has the
// same blast radius. Reject the impossible shapes at the
// validator instead.
.refine(
(s) => {
const parsed = new Date(`${s}T00:00:00Z`);
if (Number.isNaN(parsed.getTime())) return false;
return s === parsed.toISOString().slice(0, 10);
},
"dayKey must be a real calendar date (YYYY-MM-DD)",
)
.optional(),
})
.refine(
({ offset, groupBy, dayKey }) =>
!(offset > 0 && (groupBy === "day" || dayKey != null)),
{
message:
"offset is not supported with groupBy=day or dayKey; use a smaller window or omit offset",
path: ["offset"],
},
)
// v1.4.38 — surface the drill-down cap on the validator so a caller
// asking for more than 1000 rows on a per-day branch sees a 422
// instead of a silent `Math.min(limit, 1000)` clamp inside the
// route. The drill-down returns at most one phone-only stepCount
// day's worth of per-sample chunks; 1000 covers the pathological
// case with room to spare. Callers that want a wider window should
// omit `dayKey` and use `from` / `to` instead.
.refine(({ limit, dayKey }) => !(dayKey != null && limit > 1000), {
message:
"limit must be <= 1000 when dayKey is set; drill-down responses are capped at 1000 rows",
path: ["limit"],
});
export const createBatchMeasurementSchema = z.object({
measurements: z.array(createMeasurementSchema).min(1).max(5),
});
export type CreateMeasurementInput = z.infer<typeof createMeasurementSchema>;
export type CreateBatchMeasurementInput = z.infer<
typeof createBatchMeasurementSchema
>;
export type UpdateMeasurementInput = z.infer<typeof updateMeasurementSchema>;
export type ListMeasurementsInput = z.infer<typeof listMeasurementsSchema>;