From cc8ea3dfbb1cd5825dd48e2f67f3506490c8d256 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 20:26:15 +0200 Subject: [PATCH] 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. --- docs/api/openapi.yaml | 33 ++++++- .../blueprints/healthlog-room-climate.yaml | 93 +++++++++++++++++++ docs/integrations/home-assistant.md | 80 ++++++++++++++++ messages/de.json | 3 + messages/en.json | 3 + messages/es.json | 3 + messages/fr.json | 3 + messages/it.json | 3 + messages/ko.json | 3 + messages/nl.json | 3 + messages/pl.json | 3 + .../0433_indoor_climate_types/migration.sql | 11 +++ prisma/schema.prisma | 8 ++ ...doctor-report-control-gating-guard.test.ts | 4 +- .../measurements/measurement-list-meta.ts | 7 ++ .../measurements/measurement-type-colors.ts | 4 + .../__tests__/report-selection-panel.test.tsx | 2 +- .../measurement-type-enum-coverage.test.ts | 6 +- src/lib/insights/chart-tokens.ts | 4 + .../__tests__/apple-health-mapping.test.ts | 4 + .../__tests__/indoor-climate.test.ts | 61 ++++++++++++ .../measurements/__tests__/unit-label.test.ts | 1 + src/lib/measurements/categories.ts | 4 + src/lib/measurements/display-transform.ts | 4 + src/lib/measurements/type-label-keys.ts | 3 + src/lib/measurements/unit-map.ts | 4 + src/lib/personal-records/pr-direction.ts | 4 + .../__tests__/catalogue-guard.test.ts | 4 +- src/lib/report-selection/catalogue.ts | 6 ++ .../fixtures/expected-measurement-loinc.json | 18 ++++ src/lib/signals/registry.ts | 72 ++++++++++++++ src/lib/validations/measurement.ts | 11 +++ 32 files changed, 463 insertions(+), 9 deletions(-) create mode 100644 docs/integrations/blueprints/healthlog-room-climate.yaml create mode 100644 prisma/migrations/0433_indoor_climate_types/migration.sql create mode 100644 src/lib/measurements/__tests__/indoor-climate.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b390c93b3..35e6be480 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4321,6 +4321,9 @@ paths: - MINDFUL_MINUTES - STATE_OF_MIND_VALENCE - AFIB_BURDEN + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY - in: query name: from schema: @@ -26000,7 +26003,7 @@ components: type: number const: 2 leaves: - maxItems: 101 + maxItems: 104 type: array items: type: string @@ -26060,7 +26063,7 @@ components: type: number const: 2 leaves: - maxItems: 101 + maxItems: 104 type: array items: type: string @@ -26901,6 +26904,9 @@ components: - MINDFUL_MINUTES - STATE_OF_MIND_VALENCE - AFIB_BURDEN + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY value: type: number measuredAt: @@ -28588,6 +28594,9 @@ components: - MINDFUL_MINUTES - STATE_OF_MIND_VALENCE - AFIB_BURDEN + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY biomarkerId: type: string minLength: 1 @@ -29087,7 +29096,7 @@ components: type: number const: 2 leaves: - maxItems: 101 + maxItems: 104 type: array items: type: string @@ -30264,6 +30273,9 @@ components: - MINDFUL_MINUTES - STATE_OF_MIND_VALENCE - AFIB_BURDEN + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY description: DB-stored measurement category. v1.4.23 added 7 Apple Health values (HRV, resting HR, active energy, flights, walking/running distance, VO2 max, body temperature). InsightsSettingsPutRequest: @@ -39046,6 +39058,9 @@ components: - STAND_HOURS - MINDFUL_MINUTES - STATE_OF_MIND_VALENCE + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY - AFIB_BURDEN value: type: number @@ -39792,6 +39807,9 @@ components: - STAND_HOURS - MINDFUL_MINUTES - STATE_OF_MIND_VALENCE + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY - AFIB_BURDEN - type: "null" biomarkerName: @@ -42273,6 +42291,9 @@ components: - STAND_HOURS - MINDFUL_MINUTES - STATE_OF_MIND_VALENCE + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY - AFIB_BURDEN dateKey: type: string @@ -43457,6 +43478,9 @@ components: - STAND_HOURS - MINDFUL_MINUTES - STATE_OF_MIND_VALENCE + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY - AFIB_BURDEN - type: "null" intervalDays: @@ -43675,6 +43699,9 @@ components: - STAND_HOURS - MINDFUL_MINUTES - STATE_OF_MIND_VALENCE + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY - AFIB_BURDEN metricSlot: type: diff --git a/docs/integrations/blueprints/healthlog-room-climate.yaml b/docs/integrations/blueprints/healthlog-room-climate.yaml new file mode 100644 index 000000000..205ad0708 --- /dev/null +++ b/docs/integrations/blueprints/healthlog-room-climate.yaml @@ -0,0 +1,93 @@ +# Push a room's CO2, temperature and humidity to HealthLog every few minutes. +# +# Needs the `healthlog_room_climate` rest_command from +# docs/integrations/home-assistant.md ("Room climate") and a measurement +# ingest token. Every sensor is optional; a sensor that reports `unknown`, +# `unavailable` or a non-number is left out of that push, and a push with +# nothing left is skipped. Temperatures in °F are converted to °C. +blueprint: + name: HealthLog room climate + description: >- + Sends indoor CO2 (ppm), temperature (°C) and relative humidity (%) to + HealthLog through a measurement ingest token. HealthLog keeps each + reading and shows the day's mean, low and high. + domain: automation + source_url: https://github.com/MBombeck/HealthLog/blob/main/docs/integrations/blueprints/healthlog-room-climate.yaml + input: + co2_sensor: + name: CO2 sensor + description: A sensor reporting carbon dioxide in ppm. + default: "" + selector: + entity: + domain: sensor + device_class: carbon_dioxide + temperature_sensor: + name: Temperature sensor + description: A sensor reporting the room temperature (°C or °F). + default: "" + selector: + entity: + domain: sensor + device_class: temperature + humidity_sensor: + name: Humidity sensor + description: A sensor reporting relative humidity in %. + default: "" + selector: + entity: + domain: sensor + device_class: humidity + interval_minutes: + name: Interval + description: Minutes between pushes. 15 is plenty for a daily picture. + default: 15 + selector: + number: + min: 5 + max: 60 + step: 5 + unit_of_measurement: min + +mode: single + +variables: + co2_sensor: !input co2_sensor + temperature_sensor: !input temperature_sensor + humidity_sensor: !input humidity_sensor + interval_minutes: !input interval_minutes + # A list of readings (Home Assistant keeps a rendered list a list). + readings: >- + {% set at = now().replace(second=0, microsecond=0).isoformat() %} + {% set ns = namespace(items=[]) %} + {% for type, entity in [ + ('INDOOR_CO2', co2_sensor), + ('INDOOR_TEMPERATURE', temperature_sensor), + ('INDOOR_HUMIDITY', humidity_sensor) + ] %} + {% if entity and is_number(states(entity)) %} + {% set v = states(entity) | float %} + {% if type == 'INDOOR_TEMPERATURE' + and state_attr(entity, 'unit_of_measurement') == '°F' %} + {% set v = ((v - 32) * 5 / 9) | round(2) %} + {% endif %} + {% set ns.items = ns.items + + [{'type': type, 'value': v, 'measuredAt': at}] %} + {% endif %} + {% endfor %} + {{ ns.items }} + +trigger: + - platform: time_pattern + minutes: "/5" + +condition: + - condition: template + value_template: >- + {{ now().minute % (interval_minutes | int(15)) == 0 + and readings | count > 0 }} + +action: + - service: rest_command.healthlog_room_climate + data: + readings: "{{ readings }}" diff --git a/docs/integrations/home-assistant.md b/docs/integrations/home-assistant.md index 71d679ccc..d5423e650 100644 --- a/docs/integrations/home-assistant.md +++ b/docs/integrations/home-assistant.md @@ -149,6 +149,86 @@ Units are derived server-side from the type, so send the number in the unit above and do not send a `unit` field. The full enum is in `docs/api/openapi.yaml` under `MeasurementType`. +## Room climate + +Indoor CO₂, temperature and humidity are their own types, kept apart from the +body temperatures and from the outdoor weather the environment module pulls: + +| What the sensor reports | `type` | Unit expected | +| ----------------------- | -------------------- | ------------- | +| Carbon dioxide | `INDOOR_CO2` | ppm | +| Room temperature | `INDOOR_TEMPERATURE` | °C | +| Relative humidity | `INDOOR_HUMIDITY` | % | + +Each push is kept as a reading, and the day's mean, low and high are what the +charts and the daily views read, so a sensor sampled every 15 minutes gives a +clear picture without anything further on your side. Pushing every few seconds +adds rows, not detail. + +Add a second `rest_command` that takes a ready-made list of readings, so one +request carries all three: + +```yaml +rest_command: + healthlog_room_climate: + url: "https://healthlog.example.com/api/measurements" + method: POST + headers: + Authorization: !secret healthlog_token + Content-Type: "application/json" + payload: "{{ readings | to_json }}" +``` + +Then import the blueprint, either from +[`docs/integrations/blueprints/healthlog-room-climate.yaml`](./blueprints/healthlog-room-climate.yaml) +or by its raw GitHub URL under Settings → Automations & scenes → Blueprints → +Import blueprint, and create one automation per room. It asks for the three +sensors (each optional) and an interval. Every push: + +- leaves out a sensor that reports `unknown`, `unavailable` or anything that is + not a number, and skips the push when nothing is left; +- converts a temperature reported in °F to °C; +- stamps all readings with the current minute, so a retry of the same push is + refused with a 409 instead of written twice. + +If you would rather write the automation yourself, this is the same thing for a +single room without the blueprint: + +```yaml +automation: + - alias: "Push bedroom climate to HealthLog" + trigger: + - platform: time_pattern + minutes: "/15" + variables: + readings: >- + {% set at = now().replace(second=0, microsecond=0).isoformat() %} + {% set ns = namespace(items=[]) %} + {% for type, entity in [ + ('INDOOR_CO2', 'sensor.bedroom_co2'), + ('INDOOR_TEMPERATURE', 'sensor.bedroom_temperature'), + ('INDOOR_HUMIDITY', 'sensor.bedroom_humidity') + ] %} + {% if is_number(states(entity)) %} + {% set ns.items = ns.items + [{'type': type, + 'value': states(entity) | float, 'measuredAt': at}] %} + {% endif %} + {% endfor %} + {{ ns.items }} + condition: + - condition: template + value_template: "{{ readings | count > 0 }}" + action: + - service: rest_command.healthlog_room_climate + data: + readings: "{{ readings }}" +``` + +One request takes up to five readings, so a room is one call. Several rooms +are several automations: the readings carry no room name, and two rooms pushed +in the same minute collide on the 409 above. If you track more than one room, +pick the one you sleep in; that is the one worth reading next to your sleep. + ## Sending several readings at once A bare array writes up to five readings in one request — the shape to use for diff --git a/messages/de.json b/messages/de.json index 6b34c7498..9e4111523 100644 --- a/messages/de.json +++ b/messages/de.json @@ -917,6 +917,9 @@ "typeMindfulMinutes": "Achtsamkeitsminuten", "typeStateOfMindValence": "Gemütszustand", "typeAfibBurden": "VHF-Last", + "typeIndoorCo2": "CO₂ (innen)", + "typeIndoorTemperature": "Raumtemperatur", + "typeIndoorHumidity": "Luftfeuchtigkeit (innen)", "durationReadAs": "Gelesen als {duration}", "durationHoursMinutes": "{hours} Std. {minutes} Min.", "durationHoursOnly": "{hours} Std.", diff --git a/messages/en.json b/messages/en.json index 60fe5c6be..6b5b799b4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -917,6 +917,9 @@ "typeMindfulMinutes": "Mindful minutes", "typeStateOfMindValence": "State of mind", "typeAfibBurden": "AFib burden", + "typeIndoorCo2": "Indoor CO₂", + "typeIndoorTemperature": "Indoor temperature", + "typeIndoorHumidity": "Indoor humidity", "durationReadAs": "Read as {duration}", "durationHoursMinutes": "{hours} h {minutes} min", "durationHoursOnly": "{hours} h", diff --git a/messages/es.json b/messages/es.json index fcfada1b1..f107f380c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -917,6 +917,9 @@ "typeMindfulMinutes": "Minutos de atención plena", "typeStateOfMindValence": "Estado de ánimo", "typeAfibBurden": "Carga de FA", + "typeIndoorCo2": "CO₂ interior", + "typeIndoorTemperature": "Temperatura interior", + "typeIndoorHumidity": "Humedad interior", "durationReadAs": "Se lee como {duration}", "durationHoursMinutes": "{hours} h {minutes} min", "durationHoursOnly": "{hours} h", diff --git a/messages/fr.json b/messages/fr.json index 843d5ebf6..2226dbcc2 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -917,6 +917,9 @@ "typeMindfulMinutes": "Minutes de pleine conscience", "typeStateOfMindValence": "État d’esprit", "typeAfibBurden": "Charge de FA", + "typeIndoorCo2": "CO₂ intérieur", + "typeIndoorTemperature": "Température intérieure", + "typeIndoorHumidity": "Humidité intérieure", "durationReadAs": "Compris comme {duration}", "durationHoursMinutes": "{hours} h {minutes} min", "durationHoursOnly": "{hours} h", diff --git a/messages/it.json b/messages/it.json index 15d737a1c..570087490 100644 --- a/messages/it.json +++ b/messages/it.json @@ -917,6 +917,9 @@ "typeMindfulMinutes": "Minuti di consapevolezza", "typeStateOfMindValence": "Stato d’animo", "typeAfibBurden": "Carico di FA", + "typeIndoorCo2": "CO₂ interna", + "typeIndoorTemperature": "Temperatura interna", + "typeIndoorHumidity": "Umidità interna", "durationReadAs": "Letto come {duration}", "durationHoursMinutes": "{hours} h {minutes} min", "durationHoursOnly": "{hours} h", diff --git a/messages/ko.json b/messages/ko.json index f23096369..92402257f 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -917,6 +917,9 @@ "typeMindfulMinutes": "마음 챙기기 시간(분)", "typeStateOfMindValence": "마음 상태", "typeAfibBurden": "심방세동 부담", + "typeIndoorCo2": "실내 CO₂", + "typeIndoorTemperature": "실내 온도", + "typeIndoorHumidity": "실내 습도", "durationReadAs": "{duration}(으)로 읽었어요", "durationHoursMinutes": "{hours}시간 {minutes}분", "durationHoursOnly": "{hours}시간", diff --git a/messages/nl.json b/messages/nl.json index 3f6fabe35..af65f651a 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -917,6 +917,9 @@ "typeMindfulMinutes": "Mindfulnessminuten", "typeStateOfMindValence": "Gemoedstoestand", "typeAfibBurden": "AF-belasting", + "typeIndoorCo2": "CO₂ binnen", + "typeIndoorTemperature": "Binnentemperatuur", + "typeIndoorHumidity": "Luchtvochtigheid binnen", "durationReadAs": "Gelezen als {duration}", "durationHoursMinutes": "{hours} u {minutes} min", "durationHoursOnly": "{hours} u", diff --git a/messages/pl.json b/messages/pl.json index 66982c68e..3f0e4089d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -917,6 +917,9 @@ "typeMindfulMinutes": "Minuty uważności", "typeStateOfMindValence": "Stan umysłu", "typeAfibBurden": "Obciążenie AF", + "typeIndoorCo2": "CO₂ w pomieszczeniu", + "typeIndoorTemperature": "Temperatura w pomieszczeniu", + "typeIndoorHumidity": "Wilgotność w pomieszczeniu", "durationReadAs": "Odczytano jako {duration}", "durationHoursMinutes": "{hours} godz. {minutes} min", "durationHoursOnly": "{hours} godz.", diff --git a/prisma/migrations/0433_indoor_climate_types/migration.sql b/prisma/migrations/0433_indoor_climate_types/migration.sql new file mode 100644 index 000000000..c07e7c564 --- /dev/null +++ b/prisma/migrations/0433_indoor_climate_types/migration.sql @@ -0,0 +1,11 @@ +-- Extend `measurement_type` with room-climate readings pushed through the +-- ingest token (source EXTERNAL), for example by a Home Assistant +-- automation: indoor CO2 (ppm), indoor temperature (celsius) and indoor +-- relative humidity (percent). +-- +-- Purely additive. IF NOT EXISTS keeps a replay harmless; enum values cannot +-- be dropped in PostgreSQL, so there is no down path. + +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'INDOOR_CO2'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'INDOOR_TEMPERATURE'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'INDOOR_HUMIDITY'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 82129aac4..d6eeead54 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1718,6 +1718,14 @@ enum MeasurementType { STAND_HOURS // count — HealthKit `HKCategoryTypeIdentifierAppleStandHour`. One row per hour in which the person stood (`stood` = 1; an `idle` hour is not stored); cumulative, daily SUM gives the stand-ring hours. MINDFUL_MINUTES // minutes — HealthKit `HKCategoryTypeIdentifierMindfulSession`. Session length from the sample's own dates; cumulative, daily SUM. STATE_OF_MIND_VALENCE // valence (−1..1) — HealthKit `HKStateOfMind.valence` (iOS 18+). How pleasant the logged emotion or mood felt, from very unpleasant (−1) to very pleasant (1). A self-report, not a measurement. + // ── v1.44 — room climate from a home automation system ── + // Pushed through the ingest token (`measurements:write`, source EXTERNAL), + // e.g. by a Home Assistant automation. Spot readings: the daily rollup + // carries mean, min and max. Indoor, so never mixed with the outdoor + // environment channels. + INDOOR_CO2 // ppm — indoor carbon dioxide concentration. + INDOOR_TEMPERATURE // celsius — indoor air temperature. Distinct from every body temperature type. + INDOOR_HUMIDITY // percent (0–100) — indoor relative humidity. AFIB_BURDEN // percent (0–100, scaled ×100 from Apple's 0..1 fraction) — HealthKit `atrialFibrillationBurden` (iOS 16+, AFib History). The share of time the watch estimated in AFib over a week. AWARENESS ONLY — never a HealthLog diagnosis. @@map("measurement_type") diff --git a/src/__tests__/doctor-report-control-gating-guard.test.ts b/src/__tests__/doctor-report-control-gating-guard.test.ts index 1c06583ae..b0763a13f 100644 --- a/src/__tests__/doctor-report-control-gating-guard.test.ts +++ b/src/__tests__/doctor-report-control-gating-guard.test.ts @@ -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); }); diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts index 5be62c111..e0d6a8125 100644 --- a/src/components/measurements/measurement-list-meta.ts +++ b/src/components/measurements/measurement-list-meta.ts @@ -139,6 +139,9 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { 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 = { @@ -282,6 +285,10 @@ export const MEASUREMENT_TYPE_ICONS: Record = { 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 diff --git a/src/components/measurements/measurement-type-colors.ts b/src/components/measurements/measurement-type-colors.ts index 76a7d6803..98409b66f 100644 --- a/src/components/measurements/measurement-type-colors.ts +++ b/src/components/measurements/measurement-type-colors.ts @@ -138,4 +138,8 @@ export const MEASUREMENT_TYPE_COLORS: Record = { 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", }; diff --git a/src/components/settings/__tests__/report-selection-panel.test.tsx b/src/components/settings/__tests__/report-selection-panel.test.tsx index b3c162515..ca32f7ac3 100644 --- a/src/components/settings/__tests__/report-selection-panel.test.tsx +++ b/src/components/settings/__tests__/report-selection-panel.test.tsx @@ -198,7 +198,7 @@ describe(" — 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", () => { diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts index 5ef5738b7..79c4093c6 100644 --- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts +++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts @@ -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(), ); diff --git a/src/lib/insights/chart-tokens.ts b/src/lib/insights/chart-tokens.ts index 94df12acc..3b0fef932 100644 --- a/src/lib/insights/chart-tokens.ts +++ b/src/lib/insights/chart-tokens.ts @@ -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", diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts index 86afc5d32..b1ffdc978 100644 --- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -23,6 +23,10 @@ import type { MeasurementType } from "@/generated/prisma/client"; * silently failing the assertion. */ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set([ + // 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). diff --git a/src/lib/measurements/__tests__/indoor-climate.test.ts b/src/lib/measurements/__tests__/indoor-climate.test.ts new file mode 100644 index 000000000..a7ebc3d69 --- /dev/null +++ b/src/lib/measurements/__tests__/indoor-climate.test.ts @@ -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"); + } + }); +}); diff --git a/src/lib/measurements/__tests__/unit-label.test.ts b/src/lib/measurements/__tests__/unit-label.test.ts index 3c07ae748..dfd947eba 100644 --- a/src/lib/measurements/__tests__/unit-label.test.ts +++ b/src/lib/measurements/__tests__/unit-label.test.ts @@ -67,6 +67,7 @@ const SHARED_SYMBOLS: ReadonlySet = new Set([ "°C", "°F", "dBA", + "ppm", "mL/(kg·min)", "kg/m²", "mL", diff --git a/src/lib/measurements/categories.ts b/src/lib/measurements/categories.ts index 0bff7c895..0620110b2 100644 --- a/src/lib/measurements/categories.ts +++ b/src/lib/measurements/categories.ts @@ -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"], ]); /** diff --git a/src/lib/measurements/display-transform.ts b/src/lib/measurements/display-transform.ts index 03a4382cd..2dc71e322 100644 --- a/src/lib/measurements/display-transform.ts +++ b/src/lib/measurements/display-transform.ts @@ -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 = new Set([ "STAND_MINUTES", "STAND_HOURS", "MINDFUL_MINUTES", + // CO2 sensors report whole ppm. + "INDOOR_CO2", ]); /** diff --git a/src/lib/measurements/type-label-keys.ts b/src/lib/measurements/type-label-keys.ts index 46a61d0a4..12e3cf186 100644 --- a/src/lib/measurements/type-label-keys.ts +++ b/src/lib/measurements/type-label-keys.ts @@ -110,6 +110,9 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { 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", diff --git a/src/lib/measurements/unit-map.ts b/src/lib/measurements/unit-map.ts index 71761e35c..943a0fade 100644 --- a/src/lib/measurements/unit-map.ts +++ b/src/lib/measurements/unit-map.ts @@ -166,6 +166,10 @@ const unitMap: Record = { 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 { diff --git a/src/lib/personal-records/pr-direction.ts b/src/lib/personal-records/pr-direction.ts index dc8ecf4ad..f2ff28f9a 100644 --- a/src/lib/personal-records/pr-direction.ts +++ b/src/lib/personal-records/pr-direction.ts @@ -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": diff --git a/src/lib/report-selection/__tests__/catalogue-guard.test.ts b/src/lib/report-selection/__tests__/catalogue-guard.test.ts index 4e3ba4439..593bec93c 100644 --- a/src/lib/report-selection/__tests__/catalogue-guard.test.ts +++ b/src/lib/report-selection/__tests__/catalogue-guard.test.ts @@ -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", () => { diff --git a/src/lib/report-selection/catalogue.ts b/src/lib/report-selection/catalogue.ts index f53f1287a..de1683fef 100644 --- a/src/lib/report-selection/catalogue.ts +++ b/src/lib/report-selection/catalogue.ts @@ -184,6 +184,9 @@ export const MEASUREMENT_LEAF_GROUP: Record = { 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> = { 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", diff --git a/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json b/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json index 905fa945d..c55d1200c 100644 --- a/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json +++ b/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json @@ -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" } } diff --git a/src/lib/signals/registry.ts b/src/lib/signals/registry.ts index d889362a8..cf467c8be 100644 --- a/src/lib/signals/registry.ts +++ b/src/lib/signals/registry.ts @@ -1914,6 +1914,78 @@ export const SIGNALS: Record = { 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. */ diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 7134461ac..fa6a5692b 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -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 = { // 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 },