mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(measurements): accept indoor CO2, temperature and humidity
Room climate from a home automation system gets three types of its own: INDOOR_CO2 (ppm), INDOOR_TEMPERATURE (celsius, shown in Fahrenheit for imperial readers) and INDOOR_HUMIDITY (percent). They arrive through the existing ingest token (measurements:write, source EXTERNAL), sit in the environment cluster beside daylight, and the daily rollup carries their mean, low and high like any spot reading. They never mix with the body temperatures or with the outdoor environment channels. The Home Assistant page documents the three types, a rest_command that sends a room as one request, a single-room automation, and an importable blueprint (docs/integrations/blueprints) that skips sensors reporting unknown or non-numeric states, converts Fahrenheit, and stamps readings to the minute so a retried push is refused rather than written twice.
This commit is contained in:
1 parent
b76826a5f8
commit
cc8ea3dfbb
32 files changed
+463
-9
No files matched your search
@@ -74,8 +74,8 @@ describe("report scope gating — structural guard", () => {
|
||||
it("reads a plausible catalogue", () => {
|
||||
// Sanity floor: a silently degraded catalogue would pass everything below
|
||||
// vacuously.
|
||||
expect(ALL_LEAF_IDS.length).toBe(101);
|
||||
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(83);
|
||||
expect(ALL_LEAF_IDS.length).toBe(104);
|
||||
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(86);
|
||||
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(18);
|
||||
});
|
||||
|
||||
|
||||
@@ -139,6 +139,9 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
|
||||
MINDFUL_MINUTES: "measurements.typeMindfulMinutes",
|
||||
STATE_OF_MIND_VALENCE: "measurements.typeStateOfMindValence",
|
||||
AFIB_BURDEN: "measurements.typeAfibBurden",
|
||||
INDOOR_CO2: "measurements.typeIndoorCo2",
|
||||
INDOOR_TEMPERATURE: "measurements.typeIndoorTemperature",
|
||||
INDOOR_HUMIDITY: "measurements.typeIndoorHumidity",
|
||||
};
|
||||
|
||||
export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
|
||||
@@ -282,6 +285,10 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
|
||||
MINDFUL_MINUTES: Wind,
|
||||
STATE_OF_MIND_VALENCE: Gauge,
|
||||
AFIB_BURDEN: HeartPulse,
|
||||
// Room climate: air, warmth, moisture.
|
||||
INDOOR_CO2: Wind,
|
||||
INDOOR_TEMPERATURE: Thermometer,
|
||||
INDOOR_HUMIDITY: Droplets,
|
||||
};
|
||||
|
||||
// The colour map lives in its own icon-free module so a surface that only
|
||||
|
||||
@@ -138,4 +138,8 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
|
||||
MINDFUL_MINUTES: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
STATE_OF_MIND_VALENCE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
AFIB_BURDEN: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
|
||||
// Room climate takes the environment family of daylight (chart-2).
|
||||
INDOOR_CO2: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
INDOOR_TEMPERATURE: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
INDOOR_HUMIDITY: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
};
|
||||
@@ -198,7 +198,7 @@ describe("<HealthRecordExportPanel> — the first run", () => {
|
||||
expect(groupChip(html, "activity")).toBe("0/11");
|
||||
expect(groupChip(html, "sleepRecovery")).toBe("0/16");
|
||||
expect(groupChip(html, "mobility")).toBe("0/11");
|
||||
expect(groupChip(html, "environment")).toBe("0/4");
|
||||
expect(groupChip(html, "environment")).toBe("0/7");
|
||||
});
|
||||
|
||||
it("disables Generate while the scope is empty, and says what to do", () => {
|
||||
|
||||
@@ -120,10 +120,14 @@ const EXPECTED_TYPES = [
|
||||
"MINDFUL_MINUTES",
|
||||
"STATE_OF_MIND_VALENCE",
|
||||
"AFIB_BURDEN",
|
||||
// ── v1.44 — room climate pushed through the ingest token ──
|
||||
"INDOOR_CO2",
|
||||
"INDOOR_TEMPERATURE",
|
||||
"INDOOR_HUMIDITY",
|
||||
] as const;
|
||||
|
||||
describe("measurementTypeEnum coverage", () => {
|
||||
it("exposes the 83 canonical measurement types", () => {
|
||||
it("exposes the 86 canonical measurement types", () => {
|
||||
expect([...measurementTypeEnum.options].sort()).toEqual(
|
||||
[...EXPECTED_TYPES].sort(),
|
||||
);
|
||||
|
||||
@@ -145,6 +145,10 @@ export const ALLOWED_CHART_TOKENS = [
|
||||
"metric:MINDFUL_MINUTES",
|
||||
"metric:STATE_OF_MIND_VALENCE",
|
||||
"metric:AFIB_BURDEN",
|
||||
// v1.44 — room climate from the ingest token.
|
||||
"metric:INDOOR_CO2",
|
||||
"metric:INDOOR_TEMPERATURE",
|
||||
"metric:INDOOR_HUMIDITY",
|
||||
"metric:GRIP_STRENGTH",
|
||||
"metric:PAIN_NRS",
|
||||
"metric:WAIST_CIRCUMFERENCE",
|
||||
|
||||
@@ -23,6 +23,10 @@ import type { MeasurementType } from "@/generated/prisma/client";
|
||||
* silently failing the assertion.
|
||||
*/
|
||||
const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set<MeasurementType>([
|
||||
// v1.44 — room climate arrives through the ingest token, not HealthKit.
|
||||
"INDOOR_CO2",
|
||||
"INDOOR_TEMPERATURE",
|
||||
"INDOOR_HUMIDITY",
|
||||
// Withings-only metrics (no HK identifier ships any of these as a
|
||||
// first-class quantity — Apple delegates body-composition tail to
|
||||
// third-party devices via the BodyMass/BodyFat pair).
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getUnitForType } from "@/lib/measurements/unit-map";
|
||||
import { getDisplayTransform } from "@/lib/measurements/display-transform";
|
||||
import { getMeasurementCategory } from "@/lib/measurements/categories";
|
||||
import { createMeasurementSchema } from "@/lib/validations/measurement";
|
||||
|
||||
/**
|
||||
* Room climate as a Home Assistant automation pushes it: the documented
|
||||
* three-reading array through the ingest token, no source, no unit.
|
||||
*/
|
||||
const at = new Date(Date.now() - 60_000).toISOString();
|
||||
|
||||
describe("indoor climate types", () => {
|
||||
it("accepts the documented push shape for all three", () => {
|
||||
for (const [type, value] of [
|
||||
["INDOOR_CO2", 812],
|
||||
["INDOOR_TEMPERATURE", 21.4],
|
||||
["INDOOR_HUMIDITY", 47],
|
||||
] as const) {
|
||||
expect(
|
||||
createMeasurementSchema.safeParse({ type, value, measuredAt: at })
|
||||
.success,
|
||||
type,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a reading no room produces", () => {
|
||||
for (const [type, value] of [
|
||||
["INDOOR_CO2", 12_000],
|
||||
["INDOOR_TEMPERATURE", 75],
|
||||
["INDOOR_HUMIDITY", 101],
|
||||
] as const) {
|
||||
expect(
|
||||
createMeasurementSchema.safeParse({ type, value, measuredAt: at })
|
||||
.success,
|
||||
type,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("stores canonical units and shows temperature in the person's scale", () => {
|
||||
expect(getUnitForType("INDOOR_CO2")).toBe("ppm");
|
||||
expect(getUnitForType("INDOOR_TEMPERATURE")).toBe("celsius");
|
||||
expect(getUnitForType("INDOOR_HUMIDITY")).toBe("%");
|
||||
expect(
|
||||
getDisplayTransform("INDOOR_TEMPERATURE", "imperial").displayUnit,
|
||||
).toBe("°F");
|
||||
});
|
||||
|
||||
it("files all three under the environment cluster", () => {
|
||||
for (const type of [
|
||||
"INDOOR_CO2",
|
||||
"INDOOR_TEMPERATURE",
|
||||
"INDOOR_HUMIDITY",
|
||||
] as const) {
|
||||
expect(getMeasurementCategory(type)).toBe("environment");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -67,6 +67,7 @@ const SHARED_SYMBOLS: ReadonlySet<string> = new Set([
|
||||
"°C",
|
||||
"°F",
|
||||
"dBA",
|
||||
"ppm",
|
||||
"mL/(kg·min)",
|
||||
"kg/m²",
|
||||
"mL",
|
||||
|
||||
@@ -230,6 +230,10 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap<
|
||||
["STATE_OF_MIND_VALENCE", "scores"],
|
||||
// AFib burden joins the rhythm notifications on the cardiovascular surface.
|
||||
["AFIB_BURDEN", "cardiovascular"],
|
||||
// ── v1.44 — room climate joins daylight in the environment cluster ──
|
||||
["INDOOR_CO2", "environment"],
|
||||
["INDOOR_TEMPERATURE", "environment"],
|
||||
["INDOOR_HUMIDITY", "environment"],
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -179,6 +179,8 @@ const TRANSFORMS: Partial<
|
||||
BODY_TEMPERATURE: TEMPERATURE_TRANSFORM,
|
||||
SKIN_TEMPERATURE: TEMPERATURE_TRANSFORM,
|
||||
WRIST_TEMPERATURE: TEMPERATURE_TRANSFORM,
|
||||
// v1.44 — room temperature reads in the person's own scale too.
|
||||
INDOOR_TEMPERATURE: TEMPERATURE_TRANSFORM,
|
||||
// A signed baseline DEVIATION (Δ°C) — the °F imperial branch scales by
|
||||
// 1.8 but carries NO +32 offset. A one-degree-Celsius deviation is a
|
||||
// 1.8-degree-Fahrenheit deviation, not a 33.8 °F reading.
|
||||
@@ -338,6 +340,8 @@ const INTEGER_DISPLAY_TYPES: ReadonlySet<string> = new Set([
|
||||
"STAND_MINUTES",
|
||||
"STAND_HOURS",
|
||||
"MINDFUL_MINUTES",
|
||||
// CO2 sensors report whole ppm.
|
||||
"INDOOR_CO2",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -110,6 +110,9 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<MeasurementType, string> = {
|
||||
MINDFUL_MINUTES: "measurements.typeMindfulMinutes",
|
||||
STATE_OF_MIND_VALENCE: "measurements.typeStateOfMindValence",
|
||||
AFIB_BURDEN: "measurements.typeAfibBurden",
|
||||
INDOOR_CO2: "measurements.typeIndoorCo2",
|
||||
INDOOR_TEMPERATURE: "measurements.typeIndoorTemperature",
|
||||
INDOOR_HUMIDITY: "measurements.typeIndoorHumidity",
|
||||
GRIP_STRENGTH: "measurements.typeGripStrength",
|
||||
PAIN_NRS: "measurements.typePainNrs",
|
||||
WAIST_CIRCUMFERENCE: "measurements.typeWaistCircumference",
|
||||
|
||||
@@ -166,6 +166,10 @@ const unitMap: Record<string, string> = {
|
||||
STATE_OF_MIND_VALENCE: "valence",
|
||||
// Percent of the week after the ×100 scaling.
|
||||
AFIB_BURDEN: "%",
|
||||
// ── v1.44 — room climate ──
|
||||
INDOOR_CO2: "ppm",
|
||||
INDOOR_TEMPERATURE: "celsius",
|
||||
INDOOR_HUMIDITY: "%",
|
||||
};
|
||||
|
||||
export function getUnitForType(type: string): string {
|
||||
|
||||
@@ -217,6 +217,10 @@ export function getPRDirection(
|
||||
// Neither is a goal axis a record could celebrate.
|
||||
case "AFIB_BURDEN":
|
||||
case "STATE_OF_MIND_VALENCE":
|
||||
// v1.44 — room climate describes a room, not a person's effort.
|
||||
case "INDOOR_CO2":
|
||||
case "INDOOR_TEMPERATURE":
|
||||
case "INDOOR_HUMIDITY":
|
||||
case "HIGH_HEART_RATE_EVENT":
|
||||
case "LOW_HEART_RATE_EVENT":
|
||||
case "WALKING_STEADINESS_EVENT":
|
||||
|
||||
@@ -53,9 +53,9 @@ function resolves(key: string): boolean {
|
||||
|
||||
describe("report selection catalogue", () => {
|
||||
it("carries every measurement type and every structured leaf", () => {
|
||||
expect(MEASUREMENT_LEAF_IDS).toHaveLength(83);
|
||||
expect(MEASUREMENT_LEAF_IDS).toHaveLength(86);
|
||||
expect(STRUCTURED_LEAF_IDS).toHaveLength(18);
|
||||
expect(ALL_LEAF_IDS).toHaveLength(101);
|
||||
expect(ALL_LEAF_IDS).toHaveLength(104);
|
||||
});
|
||||
|
||||
it("resolves a label for every leaf", () => {
|
||||
|
||||
@@ -184,6 +184,9 @@ export const MEASUREMENT_LEAF_GROUP: Record<MeasurementType, ReportGroupId> = {
|
||||
AUDIO_EXPOSURE_HEADPHONE: "environment",
|
||||
AUDIO_EXPOSURE_EVENT: "environment",
|
||||
TIME_IN_DAYLIGHT: "environment",
|
||||
INDOOR_CO2: "environment",
|
||||
INDOOR_TEMPERATURE: "environment",
|
||||
INDOOR_HUMIDITY: "environment",
|
||||
|
||||
// ── glucose ──
|
||||
BLOOD_GLUCOSE: "glucose",
|
||||
@@ -311,6 +314,9 @@ export const LEAF_MODULE: Partial<Record<ReportLeafId, ModuleKey>> = {
|
||||
AUDIO_EXPOSURE_HEADPHONE: "environment",
|
||||
AUDIO_EXPOSURE_EVENT: "environment",
|
||||
TIME_IN_DAYLIGHT: "environment",
|
||||
INDOOR_CO2: "environment",
|
||||
INDOOR_TEMPERATURE: "environment",
|
||||
INDOOR_HUMIDITY: "environment",
|
||||
// Glucose: the raw series and the derived panel.
|
||||
BLOOD_GLUCOSE: "glucose",
|
||||
GLUCOSE_PANEL: "glucose",
|
||||
|
||||
@@ -298,5 +298,23 @@
|
||||
"display": "Atrial fibrillation burden",
|
||||
"unit": "%",
|
||||
"category": "vital-signs"
|
||||
},
|
||||
"INDOOR_CO2": {
|
||||
"loinc": null,
|
||||
"display": "Indoor carbon dioxide",
|
||||
"unit": "[ppm]",
|
||||
"category": "activity"
|
||||
},
|
||||
"INDOOR_TEMPERATURE": {
|
||||
"loinc": null,
|
||||
"display": "Indoor air temperature",
|
||||
"unit": "Cel",
|
||||
"category": "activity"
|
||||
},
|
||||
"INDOOR_HUMIDITY": {
|
||||
"loinc": null,
|
||||
"display": "Indoor relative humidity",
|
||||
"unit": "%",
|
||||
"category": "activity"
|
||||
}
|
||||
}
|
||||
@@ -1914,6 +1914,78 @@ export const SIGNALS: Record<string, SignalDefinition> = {
|
||||
category: "vital-signs",
|
||||
},
|
||||
},
|
||||
// ── v1.44 — room climate pushed through the ingest token ──
|
||||
// Bands are the usual comfort guidance (CO2 under 1000 ppm as the long-
|
||||
// standing ventilation threshold, 18–24 °C, 40–60 % RH); placement only.
|
||||
INDOOR_CO2: {
|
||||
key: "INDOOR_CO2",
|
||||
kind: "measurement",
|
||||
source: { measurementType: "INDOOR_CO2" },
|
||||
displayName: "Indoor CO2",
|
||||
unit: "ppm",
|
||||
direction: "target-band",
|
||||
archetype: "environmental-exposure",
|
||||
normalRange: { low: 400, high: 1000 },
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: false,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
fhir: {
|
||||
// A room reading, not a body observation; no LOINC concept applies.
|
||||
loinc: null,
|
||||
display: "Indoor carbon dioxide",
|
||||
unit: "[ppm]",
|
||||
category: "activity",
|
||||
},
|
||||
},
|
||||
INDOOR_TEMPERATURE: {
|
||||
key: "INDOOR_TEMPERATURE",
|
||||
kind: "measurement",
|
||||
source: { measurementType: "INDOOR_TEMPERATURE" },
|
||||
displayName: "Indoor temperature",
|
||||
unit: "°C",
|
||||
direction: "target-band",
|
||||
archetype: "environmental-exposure",
|
||||
normalRange: { low: 18, high: 24 },
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: false,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
fhir: {
|
||||
// A room reading, not a body observation; no LOINC concept applies.
|
||||
loinc: null,
|
||||
display: "Indoor air temperature",
|
||||
unit: "Cel",
|
||||
category: "activity",
|
||||
},
|
||||
},
|
||||
INDOOR_HUMIDITY: {
|
||||
key: "INDOOR_HUMIDITY",
|
||||
kind: "measurement",
|
||||
source: { measurementType: "INDOOR_HUMIDITY" },
|
||||
displayName: "Indoor humidity",
|
||||
unit: "%",
|
||||
direction: "target-band",
|
||||
archetype: "environmental-exposure",
|
||||
normalRange: { low: 40, high: 60 },
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: false,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
fhir: {
|
||||
// A room reading, not a body observation; no LOINC concept applies.
|
||||
loinc: null,
|
||||
display: "Indoor relative humidity",
|
||||
unit: "%",
|
||||
category: "activity",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Type guard: narrow an arbitrary string to a registered signal key. */
|
||||
|
||||
@@ -153,6 +153,10 @@ export const measurementTypeEnum = z.enum([
|
||||
"MINDFUL_MINUTES",
|
||||
"STATE_OF_MIND_VALENCE",
|
||||
"AFIB_BURDEN",
|
||||
// ── v1.44 — room climate pushed through the ingest token ──
|
||||
"INDOOR_CO2",
|
||||
"INDOOR_TEMPERATURE",
|
||||
"INDOOR_HUMIDITY",
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -460,6 +464,13 @@ export const VALUE_RANGES: Record<string, { min: number; max: number }> = {
|
||||
// AFib burden as a percent of the week, after the ×100 scaling. A client
|
||||
// that already scaled sends 2 for 2 %, which lands at 200 and is refused.
|
||||
AFIB_BURDEN: { min: 0, max: 100 },
|
||||
// ── v1.44 — room climate. CO2: outdoor air sits near 420 ppm and a closed
|
||||
// bedroom can pass 3000; consumer sensors top out at 5000–10000 ppm.
|
||||
INDOOR_CO2: { min: 0, max: 10000 },
|
||||
// Indoor air °C: a cold cellar to an overheated attic.
|
||||
INDOOR_TEMPERATURE: { min: -20, max: 60 },
|
||||
// Relative humidity, percent.
|
||||
INDOOR_HUMIDITY: { min: 0, max: 100 },
|
||||
// Grip strength kg — a child can read in the single digits, an elite athlete
|
||||
// tops ~90 kg; 120 is a generous ceiling that still rejects sensor noise.
|
||||
GRIP_STRENGTH: { min: 0, max: 120 },
|
||||
|
||||
Reference in new issue
Block a user