feat(environment): air quality, pollen and UV per day, locations sealed at rest

Fetch the Open-Meteo air-quality feed (CAMS, domains=auto) with the weather:
17 hourly variables folded into local days by fixed rules (means and maxima
only with 18 of 24 hours, ozone as the highest 8-hour running mean, UV only
with the midday hours), a value the feed did not cover stored as null, never
as zero. Requests are cut into 90-day chunks, newest first, and chunks before
mid 2022 ask for the eight pollutant and index variables only. An air-quality
failure never costs the weather row; the day stays unfetched and the nightly
gap fill (up to 90 days per account, newest first) asks for it again.

Every Open-Meteo request asks an instance-wide budget kept in Postgres first
(500 a minute, 4000 an hour, 8000 a day, weighted by the hosted pricing
rule). A refused request is not sent and the run ends as done with
budget_blocked. OPENMETEO_AIR_QUALITY_URL and ENVIRONMENT_AIR_QUALITY_DISABLED
are honoured; the account switch lives on PATCH /api/environment/preferences.

The home, each dated location period and each stored day keep their coarse
location sealed as one value under healthlog/environment-location/v1. The
writers write the sealed value only, the readers open it and fall back to the
readable columns for rows the free-text encryption backfill has not reached,
and the backfill now seals all three and clears the readable columns. The
backup carries the location readable in a portable file and sealed in a
disaster-recovery file; a restore seals either under the receiving key.

Three correlation channels join the environment family (PM2.5, the ozone
8-hour high, the day's highest pollen count), read through the averaged
same-day window and left empty while air quality is off. The Coach and MCP
gain get_environment: module-gated, clamped to the lookback limit, no
coordinate or place name anywhere, with provenance and the attribution
lines. The settings section gets the switch, the coverage and the newest
day's values; the dashboard shows a quiet chip on a day with high pollen,
a warm night or very poor air. Copernicus and Open-Meteo are credited next
to the values.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 10:02:39 +02:00
1 parent aca497d9ac
commit 3c98d95a40
73 files changed
+6061 -385

No files matched your search

+168 -5
View File
@@ -21901,9 +21901,10 @@ paths:
tags:
- Environment
summary: Change the environment switches
description: Turns the air-quality part of the module on or off for this account (on by default). Off means the nightly
fetch skips the air-quality feed and the overview leaves the fields out; days already stored are kept.
Module-gated. Strict body.
description: "Turns the air-quality part of the module on or off for this account (on by default). Off means the nightly
fetch skips the air-quality feed, and the overview, the Coach, MCP and the correlations leave the air-quality
values out; days already stored keep theirs. Turning it on queues a refresh, and the nightly gap fill catches up
on older days. Module-gated. Strict body: an unknown key is a 422."
requestBody:
required: true
content:
@@ -21921,7 +21922,6 @@ paths:
"403": *a20
"422": *a4
"429": *a5
"501": *a9
/api/environment:
get:
tags:
@@ -53899,6 +53899,7 @@ components:
- get_cycle
- get_correlations
- get_metric_table
- get_environment
- show_result
- snapshot
labelKey:
@@ -54011,9 +54012,10 @@ components:
- illness
- cycle
- correlations
- environment
- snapshot
description: "The data domain a step, table or chip is about: a measurement-backed scope source, or a domain read as a
whole (labs, illness, cycle, correlations, the full snapshot)."
whole (labs, illness, cycle, correlations, the environment, the full snapshot)."
CoachMethod:
type: object
properties:
@@ -54097,6 +54099,7 @@ components:
- get_cycle
- get_correlations
- get_metric_table
- get_environment
domain:
$ref: "#/components/schemas/CoachStepDomain"
window:
@@ -54835,6 +54838,7 @@ components:
- get_cycle
- get_correlations
- get_metric_table
- get_environment
domain:
$ref: "#/components/schemas/CoachStepDomain"
window:
@@ -79093,12 +79097,171 @@ components:
attribution:
type: string
description: Upstream attribution string. Display it wherever the observations are shown.
airQuality:
type: object
properties:
enabled:
type: boolean
description: The account's switch (`PATCH /api/environment/preferences`).
operatorDisabled:
type: boolean
description: True when the operator turned air quality off for the instance; the account switch then has no effect.
days:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: Stored days the air-quality feed returned hourly values for.
latestDate:
anyOf:
- type: string
- type: "null"
description: Newest such day (YYYY-MM-DD).
domain:
anyOf:
- type: string
- type: "null"
description: "The model domain of that day: `cams_europe` (about 11 km, with pollen) or `cams_global` (about 45 km, no
pollen)."
required:
- enabled
- operatorDisabled
- days
- latestDate
- domain
additionalProperties: false
description: v1.42 — the air-quality part of the module.
latestDay:
anyOf:
- type: object
properties:
date:
type: string
description: YYYY-MM-DD of the newest stored day.
tempMin:
anyOf:
- type: number
- type: "null"
tempMax:
anyOf:
- type: number
- type: "null"
apparentMax:
anyOf:
- type: number
- type: "null"
description: Daily feels-like maximum, °C.
airQuality:
anyOf:
- type: object
properties:
pm25Mean:
anyOf:
- type: number
- type: "null"
description: PM2.5 daily mean, µg/m³.
pm10Mean:
anyOf:
- type: number
- type: "null"
description: PM10 daily mean, µg/m³.
no2Mean:
anyOf:
- type: number
- type: "null"
description: NO2 daily mean, µg/m³.
o3Max8h:
anyOf:
- type: number
- type: "null"
description: Ozone, highest 8-hour running mean, µg/m³.
eaqiMax:
anyOf:
- type: number
- type: "null"
description: European air-quality index, daily maximum.
uvIndexMax:
anyOf:
- type: number
- type: "null"
dustMax:
anyOf:
- type: number
- type: "null"
description: Dust, µg/m³.
pollen:
type: object
properties:
alder:
anyOf:
- type: number
- type: "null"
birch:
anyOf:
- type: number
- type: "null"
grass:
anyOf:
- type: number
- type: "null"
mugwort:
anyOf:
- type: number
- type: "null"
olive:
anyOf:
- type: number
- type: "null"
ragweed:
anyOf:
- type: number
- type: "null"
required:
- alder
- birch
- grass
- mugwort
- olive
- ragweed
additionalProperties: false
description: Daily maxima, grains/m³; null where not covered (outside Europe, before mid 2022).
required:
- pm25Mean
- pm10Mean
- no2Mean
- o3Max8h
- eaqiMax
- uvIndexMax
- dustMax
- pollen
additionalProperties: false
- type: "null"
description: Null while air quality is off for the account or the instance, and for a day whose air-quality part was not
fetched yet. Null values inside mean not covered, never zero.
required:
- date
- tempMin
- tempMax
- apparentMax
- airQuality
additionalProperties: false
- type: "null"
description: v1.42 — the newest stored day, usually yesterday or the day before (the archive settles late). No forecast.
Null when nothing is stored.
attributions:
type: array
items:
type: string
description: v1.42 — every attribution line, weather first; the air-quality lines (Open-Meteo, Copernicus) while air
quality is on. Display them wherever the values are shown.
required:
- home
- travel
- context
- lastFetchFailure
- attribution
- airQuality
- latestDay
- attributions
additionalProperties: false
description: "The module overview: home, travel overrides, a count of stored observations, and whether the background
fetch is healthy."
+110
View File
@@ -0,0 +1,110 @@
# Weather, air quality and pollen
The environment module stores, for each day, the weather and the outdoor air
at a coarse location: temperature, sunshine, daylight, rain, pressure,
humidity, and since v1.42 fine particles, ozone, nitrogen dioxide, the
European and US air-quality indices, UV, Saharan dust and six kinds of
pollen. Those days sit beside the person's own data: the correlations look
for patterns between them and sleep, heart-rate variability, blood pressure,
mood and symptoms, the Coach and connected MCP clients can read them, and the
dashboard shows a quiet note on a day with high pollen, a warm night or very
poor air.
The module is on by default. Nothing leaves the host until a person sets a
home location in Settings → Location & weather.
## What leaves the host
Three hosted Open-Meteo APIs, all keyless:
| Host | What for | Variable to self-host it |
| -------------------------------- | ------------------------------------ | --------------------------- |
| `archive-api.open-meteo.com` | daily weather (ERA5 reanalysis) | `OPENMETEO_BASE_URL` |
| `air-quality-api.open-meteo.com` | hourly air quality and pollen (CAMS) | `OPENMETEO_AIR_QUALITY_URL` |
| `geocoding-api.open-meteo.com` | the city search in settings | `OPENMETEO_GEOCODING_URL` |
A request carries the coordinates rounded to one decimal (about 11 km, the
size of a town), a date range and a timezone. No account id, no name, no
health value. Open-Meteo is open source; an operator who wants no egress at
all can run it and point the three variables at their own instance (a host
on the LAN is allowed).
## The data, and what it is not
- Weather comes from the ERA5 reanalysis on a grid of 9 to 25 kilometres. It
settles over a few days, so the newest stored day is usually yesterday or
the day before. Nothing here is a forecast.
- Air quality comes from the CAMS models: hourly on about 11 kilometres in
Europe, about 45 kilometres elsewhere. The feed starts in 2013; pollen, UV,
dust and aerosol depth only from about mid 2022, and pollen only in Europe.
A value the feed did not cover is stored as empty, never as zero.
- Hourly values become a day by fixed rules: means and maxima only for a day
with at least 18 of 24 hours, ozone as the highest 8-hour running mean,
UV only when the midday hours are all there.
- These are modelled outdoor conditions for an area, not anyone's personal
exposure. Every surface that shows them says so, and none of them says the
weather caused anything: a pattern is described as something that occurred
together.
## Switches
- **Per account:** Settings → Location & weather → Air quality and pollen.
On by default. Off stops the air-quality requests for that account and
leaves the air-quality values out of every surface; the days already
stored keep theirs.
- **For the whole instance:** `ENVIRONMENT_AIR_QUALITY_DISABLED=1` turns the
air-quality part off for every account (`1`, `true`, `yes` or `on`). The
weather keeps working. Settings then says the operator turned it off.
- The whole module is a normal module switch (Settings → Modules), and an
operator can make it unavailable like any other module.
All four variables are on the compose `environment:` whitelist.
## Request budget
The hosted APIs are free for non-commercial use up to 600 calls a minute,
5,000 an hour and 10,000 a day per instance, where a request for many
variables or many days counts as several calls. HealthLog keeps its own
counter in Postgres, shared by the web process and the worker, and stays
below 500 a minute, 4,000 an hour and 8,000 a day. A request the counter
refuses is not sent; the nightly run ends early and continues the next night.
For scale: a full two-year backfill of one account is about 150 calls, the
nightly refresh about 3 per account.
## Nightly fetch and catching up
The `environment-fetch` job runs at 02:10. For each account with a home it
refreshes the last seven days (weather and air quality together), then fills
up to 90 stored days that do not have air quality yet, newest first. An
instance that upgrades to v1.42 with two years of weather per account
catches up on air quality in about nine nights without any action. The
backfill button in settings fetches both for the range it is given.
## Locations are encrypted
Since v1.42 the home, each dated location period and each stored day keep
their coarse location sealed with the instance's encryption key (one value
per row, `{ lat, lon, label }`, under the label
`healthlog/environment-location/v1`). The boot-time encryption backfill seals
rows written by older releases and clears their readable columns; the
readable columns are dropped in a later release once the backfill reports
zero rows everywhere. Key rotation (`scripts/rotate-encryption-key.ts`, or the
in-app rotation) covers all three.
What the person sees is unchanged: the settings page and
`GET /api/environment` answer with the readable location, opened on the
server. The Coach and MCP read (`get_environment`) carry no coordinate and no
place name at all, only how many days were spent away from home.
Backups follow the usual split: a portable export carries the location
readable, a disaster-recovery backup carries it sealed, and a restore of
either seals it under the receiving host's key.
## Attribution
Open-Meteo data is CC BY 4.0, and CAMS data asks for the notice "Contains
modified Copernicus Atmosphere Monitoring Service information" (modified,
because the hourly values are folded into days). The settings page lists
both lines under the values, and the Coach and MCP read return them with
every result.
+20 -4
View File
@@ -5786,8 +5786,10 @@
"operatorDisabled": "Luftqualität ist auf diesem Server abgeschaltet.",
"coverage": "{count} Tage mit Luftqualitätsdaten",
"pollenEuropeOnly": "Pollendaten gibt es nur für Europa.",
"attribution": "Luftqualität: Copernicus-Atmosphärenüberwachungsdienst (CAMS), über Open-Meteo.",
"saveError": "Die Einstellung konnte nicht gespeichert werden."
"attribution": "Datenquellen und Lizenzen",
"saveError": "Die Einstellung konnte nicht gespeichert werden.",
"latest": "Jüngster Tag ({date})",
"modelNote": "Modellierte Außenwerte für deine Gegend, nicht deine persönliche Belastung."
}
},
"score": {
@@ -9972,7 +9974,8 @@
"illness": "Krankheit und Erholung",
"cycle": "Zyklus",
"correlations": "Zusammenhänge zwischen Messgrößen",
"snapshot": "Datenübersicht"
"snapshot": "Datenübersicht",
"environment": "Wetter und Luft"
},
"window": {
"last7days": "letzte 7 Tage",
@@ -10606,7 +10609,6 @@
"precip": "Niederschlag",
"pressureMean": "Luftdruck",
"pressureDelta": "Druckschwankung",
"apparentMax": "Gefühlte Höchsttemperatur",
"pm25": "Feinstaub (PM2,5)",
"pm10": "Feinstaub (PM10)",
"no2": "Stickstoffdioxid",
@@ -10615,6 +10617,20 @@
"uvIndexMax": "UV-Index (Höchstwert)",
"dustMax": "Saharastaub",
"pollenMax": "Pollen (höchster Wert)"
},
"chip": {
"day": "Wetter am {date}",
"pollenHigh": "Viel Pollen: {kinds}",
"hotNight": "Warme Nacht",
"veryPoorAir": "Sehr schlechte Luft"
},
"pollen": {
"alder": "Erle",
"birch": "Birke",
"grass": "Gräser",
"mugwort": "Beifuß",
"olive": "Olive",
"ragweed": "Ambrosia"
}
},
"documents": {
+20 -4
View File
@@ -5786,8 +5786,10 @@
"operatorDisabled": "Air quality is turned off on this server.",
"coverage": "{count} days with air-quality data",
"pollenEuropeOnly": "Pollen data covers Europe only.",
"attribution": "Air quality: Copernicus Atmosphere Monitoring Service (CAMS), via Open-Meteo.",
"saveError": "The setting could not be saved."
"attribution": "Data sources and licences",
"saveError": "The setting could not be saved.",
"latest": "Newest day ({date})",
"modelNote": "Modelled outdoor values for your area, not your personal exposure."
}
},
"score": {
@@ -9972,7 +9974,8 @@
"illness": "Illness and recovery",
"cycle": "Cycle",
"correlations": "Connections between metrics",
"snapshot": "Record summary"
"snapshot": "Record summary",
"environment": "Weather and air"
},
"window": {
"last7days": "last 7 days",
@@ -10606,7 +10609,6 @@
"precip": "Precipitation",
"pressureMean": "Barometric pressure",
"pressureDelta": "Pressure swing",
"apparentMax": "Feels-like high",
"pm25": "Fine particles (PM2.5)",
"pm10": "Particles (PM10)",
"no2": "Nitrogen dioxide",
@@ -10615,6 +10617,20 @@
"uvIndexMax": "UV index (high)",
"dustMax": "Saharan dust",
"pollenMax": "Pollen (highest)"
},
"chip": {
"day": "Weather on {date}",
"pollenHigh": "High pollen: {kinds}",
"hotNight": "Warm night",
"veryPoorAir": "Very poor air"
},
"pollen": {
"alder": "alder",
"birch": "birch",
"grass": "grass",
"mugwort": "mugwort",
"olive": "olive",
"ragweed": "ragweed"
}
},
"documents": {
+20 -4
View File
@@ -5786,8 +5786,10 @@
"operatorDisabled": "La calidad del aire está desactivada en este servidor.",
"coverage": "{count} días con datos de calidad del aire",
"pollenEuropeOnly": "Los datos de polen solo cubren Europa.",
"attribution": "Calidad del aire: Servicio de Vigilancia Atmosférica de Copernicus (CAMS), a través de Open-Meteo.",
"saveError": "No se pudo guardar el ajuste."
"attribution": "Fuentes de datos y licencias",
"saveError": "No se pudo guardar el ajuste.",
"latest": "Día más reciente ({date})",
"modelNote": "Valores exteriores modelados para tu zona, no tu exposición personal."
}
},
"score": {
@@ -9972,7 +9974,8 @@
"illness": "Enfermedad y recuperación",
"cycle": "Ciclo",
"correlations": "Relaciones entre métricas",
"snapshot": "Resumen de datos"
"snapshot": "Resumen de datos",
"environment": "Tiempo y aire"
},
"window": {
"last7days": "últimos 7 días",
@@ -10606,7 +10609,6 @@
"precip": "Precipitación",
"pressureMean": "Presión atmosférica",
"pressureDelta": "Variación de presión",
"apparentMax": "Máxima de sensación térmica",
"pm25": "Partículas finas (PM2,5)",
"pm10": "Partículas (PM10)",
"no2": "Dióxido de nitrógeno",
@@ -10615,6 +10617,20 @@
"uvIndexMax": "Índice UV (máximo)",
"dustMax": "Polvo del Sáhara",
"pollenMax": "Polen (valor más alto)"
},
"chip": {
"day": "Tiempo del {date}",
"pollenHigh": "Mucho polen: {kinds}",
"hotNight": "Noche cálida",
"veryPoorAir": "Aire muy malo"
},
"pollen": {
"alder": "aliso",
"birch": "abedul",
"grass": "gramíneas",
"mugwort": "artemisa",
"olive": "olivo",
"ragweed": "ambrosía"
}
},
"documents": {
+20 -4
View File
@@ -5786,8 +5786,10 @@
"operatorDisabled": "La qualité de l’air est désactivée sur ce serveur.",
"coverage": "{count} jours avec des données de qualité de l’air",
"pollenEuropeOnly": "Les données de pollen ne couvrent que l’Europe.",
"attribution": "Qualité de l’air : service de surveillance de l’atmosphère de Copernicus (CAMS), via Open-Meteo.",
"saveError": "Le réglage n’a pas pu être enregistré."
"attribution": "Sources de données et licences",
"saveError": "Le réglage n’a pas pu être enregistré.",
"latest": "Jour le plus récent ({date})",
"modelNote": "Valeurs extérieures modélisées pour ta zone, pas ton exposition personnelle."
}
},
"score": {
@@ -9972,7 +9974,8 @@
"illness": "Maladie et récupération",
"cycle": "Cycle",
"correlations": "Liens entre indicateurs",
"snapshot": "Résumé des données"
"snapshot": "Résumé des données",
"environment": "Météo et air"
},
"window": {
"last7days": "7 derniers jours",
@@ -10606,7 +10609,6 @@
"precip": "Précipitations",
"pressureMean": "Pression atmosphérique",
"pressureDelta": "Variation de pression",
"apparentMax": "Maximum ressenti",
"pm25": "Particules fines (PM2,5)",
"pm10": "Particules (PM10)",
"no2": "Dioxyde d’azote",
@@ -10615,6 +10617,20 @@
"uvIndexMax": "Indice UV (maximum)",
"dustMax": "Poussières du Sahara",
"pollenMax": "Pollen (valeur la plus élevée)"
},
"chip": {
"day": "Météo du {date}",
"pollenHigh": "Beaucoup de pollen : {kinds}",
"hotNight": "Nuit chaude",
"veryPoorAir": "Air très mauvais"
},
"pollen": {
"alder": "aulne",
"birch": "bouleau",
"grass": "graminées",
"mugwort": "armoise",
"olive": "olivier",
"ragweed": "ambroisie"
}
},
"documents": {
+20 -4
View File
@@ -5786,8 +5786,10 @@
"operatorDisabled": "La qualità dell’aria è disattivata su questo server.",
"coverage": "{count} giorni con dati sulla qualità dell’aria",
"pollenEuropeOnly": "I dati sui pollini coprono solo l’Europa.",
"attribution": "Qualità dell’aria: servizio di monitoraggio atmosferico di Copernicus (CAMS), tramite Open-Meteo.",
"saveError": "Impossibile salvare l’impostazione."
"attribution": "Fonti dei dati e licenze",
"saveError": "Impossibile salvare l’impostazione.",
"latest": "Giorno più recente ({date})",
"modelNote": "Valori esterni modellati per la tua zona, non la tua esposizione personale."
}
},
"score": {
@@ -9972,7 +9974,8 @@
"illness": "Malattia e recupero",
"cycle": "Ciclo",
"correlations": "Relazioni tra parametri",
"snapshot": "Riepilogo dei dati"
"snapshot": "Riepilogo dei dati",
"environment": "Meteo e aria"
},
"window": {
"last7days": "ultimi 7 giorni",
@@ -10606,7 +10609,6 @@
"precip": "Precipitazioni",
"pressureMean": "Pressione atmosferica",
"pressureDelta": "Variazione di pressione",
"apparentMax": "Massima percepita",
"pm25": "Particolato fine (PM2,5)",
"pm10": "Particolato (PM10)",
"no2": "Biossido di azoto",
@@ -10615,6 +10617,20 @@
"uvIndexMax": "Indice UV (massimo)",
"dustMax": "Polvere del Sahara",
"pollenMax": "Pollini (valore più alto)"
},
"chip": {
"day": "Meteo del {date}",
"pollenHigh": "Molti pollini: {kinds}",
"hotNight": "Notte calda",
"veryPoorAir": "Aria molto scadente"
},
"pollen": {
"alder": "ontano",
"birch": "betulla",
"grass": "graminacee",
"mugwort": "artemisia",
"olive": "olivo",
"ragweed": "ambrosia"
}
},
"documents": {
+20 -4
View File
@@ -5786,8 +5786,10 @@
"operatorDisabled": "이 서버에서는 대기질 기능이 꺼져 있습니다.",
"coverage": "대기질 데이터가 있는 날 {count}일",
"pollenEuropeOnly": "꽃가루 데이터는 유럽만 제공됩니다.",
"attribution": "대기질: 코페르니쿠스 대기 모니터링 서비스(CAMS), Open-Meteo 경유.",
"saveError": "설정을 저장하지 못했습니다."
"attribution": "데이터 출처와 라이선스",
"saveError": "설정을 저장하지 못했습니다.",
"latest": "가장 최근 날짜 ({date})",
"modelNote": "내 지역의 실외 모델값이며, 개인 노출량이 아닙니다."
}
},
"score": {
@@ -9972,7 +9974,8 @@
"illness": "질병과 회복",
"cycle": "생리 주기",
"correlations": "지표 간 연관성",
"snapshot": "기록 요약"
"snapshot": "기록 요약",
"environment": "날씨와 대기"
},
"window": {
"last7days": "최근 7일",
@@ -10606,7 +10609,6 @@
"precip": "강수량",
"pressureMean": "기압",
"pressureDelta": "기압 변화",
"apparentMax": "체감 최고 기온",
"pm25": "초미세먼지 (PM2.5)",
"pm10": "미세먼지 (PM10)",
"no2": "이산화질소",
@@ -10615,6 +10617,20 @@
"uvIndexMax": "자외선 지수 (최고)",
"dustMax": "사하라 먼지",
"pollenMax": "꽃가루 (최고)"
},
"chip": {
"day": "{date} 날씨",
"pollenHigh": "꽃가루 많음: {kinds}",
"hotNight": "더운 밤",
"veryPoorAir": "대기질 매우 나쁨"
},
"pollen": {
"alder": "오리나무",
"birch": "자작나무",
"grass": "잔디",
"mugwort": "쑥",
"olive": "올리브",
"ragweed": "돼지풀"
}
},
"documents": {
+20 -4
View File
@@ -5786,8 +5786,10 @@
"operatorDisabled": "Jakość powietrza jest wyłączona na tym serwerze.",
"coverage": "Dni z danymi o jakości powietrza: {count}",
"pollenEuropeOnly": "Dane o pyłkach obejmują tylko Europę.",
"attribution": "Jakość powietrza: serwis monitorowania atmosfery Copernicus (CAMS), przez Open-Meteo.",
"saveError": "Nie udało się zapisać ustawienia."
"attribution": "Źródła danych i licencje",
"saveError": "Nie udało się zapisać ustawienia.",
"latest": "Najnowszy dzień ({date})",
"modelNote": "Modelowane wartości zewnętrzne dla twojej okolicy, nie twoja osobista ekspozycja."
}
},
"score": {
@@ -9972,7 +9974,8 @@
"illness": "Choroba i powrót do zdrowia",
"cycle": "Cykl",
"correlations": "Związki między wskaźnikami",
"snapshot": "Podsumowanie danych"
"snapshot": "Podsumowanie danych",
"environment": "Pogoda i powietrze"
},
"window": {
"last7days": "ostatnie 7 dni",
@@ -10606,7 +10609,6 @@
"precip": "Opady",
"pressureMean": "Ciśnienie atmosferyczne",
"pressureDelta": "Wahanie ciśnienia",
"apparentMax": "Odczuwalne maksimum",
"pm25": "Pył drobny (PM2,5)",
"pm10": "Pył (PM10)",
"no2": "Dwutlenek azotu",
@@ -10615,6 +10617,20 @@
"uvIndexMax": "Indeks UV (maksimum)",
"dustMax": "Pył saharyjski",
"pollenMax": "Pyłki (najwyższa wartość)"
},
"chip": {
"day": "Pogoda {date}",
"pollenHigh": "Dużo pyłków: {kinds}",
"hotNight": "Ciepła noc",
"veryPoorAir": "Bardzo złe powietrze"
},
"pollen": {
"alder": "olcha",
"birch": "brzoza",
"grass": "trawy",
"mugwort": "bylica",
"olive": "oliwka",
"ragweed": "ambrozja"
}
},
"documents": {
+26 -26
View File
@@ -103,12 +103,12 @@ model User {
// from the current home). To fill the deep past the user adds an explicit
// dated location period instead. Backfill defaults its start to this instant.
homeSince DateTime? @map("home_since")
/// @pending-consumer(#615) v1.42 — sealed home location (lat, lon, label)
/// v1.42 — sealed home location (lat, lon, label)
/// under AAD `healthlog/environment-location/v1`. Replaces the plaintext
/// `homeLat` / `homeLon` / `homeLabel` once the free-text encryption backfill
/// reports zero remaining rows; the plaintext columns drop in v1.43.
homeLocationEncrypted Bytes? @map("home_location_encrypted")
/// @pending-consumer(#615) v1.42 — per-user switch for the air-quality and
/// v1.42 — per-user switch for the air-quality and
/// pollen part of the environment module. On by default; off means the
/// nightly fetch skips the air-quality feed and the surfaces leave the
/// fields out. The operator switch `ENVIRONMENT_AIR_QUALITY_DISABLED` wins.
@@ -1106,7 +1106,7 @@ model EnvironmentContext {
/// (`src/lib/export/environment-backup.ts`) with the rest of the row: a
/// restored reading has to keep saying where it was read.
locationLabel String? @map("location_label")
/// @pending-consumer(#615) v1.42 — sealed (lat, lon, label) for the day,
/// v1.42 — sealed (lat, lon, label) for the day,
/// AAD `healthlog/environment-location/v1`.
locationEncrypted Bytes? @map("location_encrypted")
source EnvironmentLocationSource
@@ -1140,49 +1140,49 @@ model EnvironmentContext {
// v1.42 (#615) — air quality, pollen and UV from the Open-Meteo
// air-quality feed (CAMS). All nullable: a partial or absent feed stores
// what it has, and a day the feed never covered stays NULL, never zero.
/// @pending-consumer(#615) Daily apparent ("feels-like") maximum (°C).
/// Daily apparent ("feels-like") maximum (°C).
apparentMax Float? @map("apparent_max")
/// @pending-consumer(#615) PM2.5 daily mean (µg/m³).
/// PM2.5 daily mean (µg/m³).
pm25Mean Float? @map("pm25_mean")
/// @pending-consumer(#615) PM2.5 hourly maximum over the local day (µg/m³).
/// PM2.5 hourly maximum over the local day (µg/m³).
pm25Max Float? @map("pm25_max")
/// @pending-consumer(#615) PM10 daily mean (µg/m³).
/// PM10 daily mean (µg/m³).
pm10Mean Float? @map("pm10_mean")
/// @pending-consumer(#615) Nitrogen dioxide daily mean (µg/m³).
/// Nitrogen dioxide daily mean (µg/m³).
no2Mean Float? @map("no2_mean")
/// @pending-consumer(#615) Sulphur dioxide daily mean (µg/m³).
/// Sulphur dioxide daily mean (µg/m³).
so2Mean Float? @map("so2_mean")
/// @pending-consumer(#615) Carbon monoxide daily mean (µg/m³).
/// Carbon monoxide daily mean (µg/m³).
coMean Float? @map("co_mean")
/// @pending-consumer(#615) Ozone, maximum 8-hour running mean of the day (µg/m³).
/// Ozone, maximum 8-hour running mean of the day (µg/m³).
o3Max8h Float? @map("o3_max_8h")
/// @pending-consumer(#615) European AQI, daily maximum.
/// European AQI, daily maximum.
eaqiMax Float? @map("eaqi_max")
/// @pending-consumer(#615) US AQI, daily maximum.
/// US AQI, daily maximum.
usaqiMax Float? @map("usaqi_max")
/// @pending-consumer(#615) UV index, daily maximum.
/// UV index, daily maximum.
uvIndexMax Float? @map("uv_index_max")
/// @pending-consumer(#615) Saharan dust, daily maximum (µg/m³).
/// Saharan dust, daily maximum (µg/m³).
dustMax Float? @map("dust_max")
/// @pending-consumer(#615) Aerosol optical depth, daily maximum.
/// Aerosol optical depth, daily maximum.
aodMax Float? @map("aod_max")
/// @pending-consumer(#615) Alder pollen, daily maximum (grains/m³, Europe only).
/// Alder pollen, daily maximum (grains/m³, Europe only).
pollenAlderMax Float? @map("pollen_alder_max")
/// @pending-consumer(#615) Birch pollen, daily maximum (grains/m³, Europe only).
/// Birch pollen, daily maximum (grains/m³, Europe only).
pollenBirchMax Float? @map("pollen_birch_max")
/// @pending-consumer(#615) Grass pollen, daily maximum (grains/m³, Europe only).
/// Grass pollen, daily maximum (grains/m³, Europe only).
pollenGrassMax Float? @map("pollen_grass_max")
/// @pending-consumer(#615) Mugwort pollen, daily maximum (grains/m³, Europe only).
/// Mugwort pollen, daily maximum (grains/m³, Europe only).
pollenMugwortMax Float? @map("pollen_mugwort_max")
/// @pending-consumer(#615) Olive pollen, daily maximum (grains/m³, Europe only).
/// Olive pollen, daily maximum (grains/m³, Europe only).
pollenOliveMax Float? @map("pollen_olive_max")
/// @pending-consumer(#615) Ragweed pollen, daily maximum (grains/m³, Europe only).
/// Ragweed pollen, daily maximum (grains/m³, Europe only).
pollenRagweedMax Float? @map("pollen_ragweed_max")
/// @pending-consumer(#615) Which air-quality model domain served the day (`cams_europe` / `cams_global`).
/// Which air-quality model domain served the day (`cams_europe` / `cams_global`).
aqDomain String? @map("aq_domain")
/// @pending-consumer(#615) Hourly air-quality samples the day was aggregated from (75 % rule).
/// Hourly air-quality samples the day was aggregated from (75 % rule).
aqHours Int? @map("aq_hours")
/// @pending-consumer(#615) When the air-quality part was last fetched; NULL = not yet fetched (gap index).
/// When the air-quality part was last fetched; NULL = not yet fetched (gap index).
aqFetchedAt DateTime? @map("aq_fetched_at")
/// When the row was last fetched/refreshed from the upstream feed.
@@ -1221,7 +1221,7 @@ model EnvironmentTravelLocation {
lat Float?
lon Float?
label String?
/// @pending-consumer(#615) v1.42 — sealed (lat, lon, label) for the
/// v1.42 — sealed (lat, lon, label) for the
/// period, AAD `healthlog/environment-location/v1`.
locationEncrypted Bytes? @map("location_encrypted")
@@ -0,0 +1,161 @@
/**
* v1.42 (#615) — pair guard binding the two environment models in
* `prisma/schema.prisma` to the backup that carries them
* (`src/lib/export/environment-backup.ts`), on the pattern of
* `measurement-backup-completeness.test.ts`.
*
* Twenty-two air-quality columns and three sealed locations arrived in one
* release, and nothing failed when a column was missing from the backup: the
* export kept building, the restore kept parsing (the section schema is
* `.passthrough()`), and the column came back NULL on every restored day. So
* both ends are compared as literals:
*
* - every scalar column of `EnvironmentContext` and
* `EnvironmentTravelLocation` is in the named backup select (or excluded
* here with a reason);
* - every selected `EnvironmentContext` column is written by the export
* (`row.<column>`, or the location helper for the location columns);
* - every selected column is written back by the restore (`<column>:` in
* `restoreEnvironmentData`, or listed in `AIR_QUALITY_COLUMNS`, which the
* restore maps over).
*
* Mutation check: delete `pm25Mean: true` from the context select and the
* first case names it; delete `"pm25Mean",` from `AIR_QUALITY_COLUMNS` and the
* restore case names it.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const ROOT = process.cwd();
const schema = readFileSync(join(ROOT, "prisma", "schema.prisma"), "utf8");
const source = readFileSync(
join(ROOT, "src", "lib", "export", "environment-backup.ts"),
"utf8",
);
/** Columns the backup is right to leave out, with the reason. */
const EXCLUDED: Record<string, string> = {
id: "nothing in the file or the database addresses one of these rows; a day is identified by its date",
userId:
"user-scoped backup; the owner is re-derived from the restoring account",
};
/** Columns written through the location helper rather than `row.<column>`. */
const LOCATION_COLUMNS = new Set([
"lat",
"lon",
"locationLabel",
"label",
"locationEncrypted",
]);
function block(text: string, start: string, end = "\n}"): string {
const from = text.indexOf(start);
expect(from, `${start} not found`).toBeGreaterThan(-1);
const to = text.indexOf(end, from);
expect(to).toBeGreaterThan(from);
return text.slice(from, to);
}
function scalarColumns(model: string): string[] {
const models = new Set(
[...schema.matchAll(/^model\s+([A-Za-z_]\w*)\s*\{/gm)].map((m) => m[1]),
);
const out: string[] = [];
for (const raw of block(schema, `model ${model} {`).split("\n").slice(1)) {
const line = raw.trim();
if (!line || line.startsWith("//") || line.startsWith("@@")) continue;
const m = /^([a-zA-Z_]\w*)\s+([A-Za-z_]\w*)(\[\])?/.exec(line);
if (!m || m[3] || models.has(m[2]) || line.includes("@relation")) continue;
out.push(m[1]);
}
return out;
}
function selectKeys(name: string): string[] {
return [
...block(source, `const ${name} = {`).matchAll(/^ {2}(\w+): true,$/gm),
].map((m) => m[1]);
}
const restoreBody = block(
source,
"export async function restoreEnvironmentData(",
"\n}\n",
);
const airQualityList = block(source, "const AIR_QUALITY_COLUMNS = [", "]");
describe.each([
["EnvironmentContext", "ENVIRONMENT_CONTEXT_BACKUP_SELECT"],
["EnvironmentTravelLocation", "ENVIRONMENT_TRAVEL_LOCATION_BACKUP_SELECT"],
])("%s backup completeness", (model, selectName) => {
const columns = scalarColumns(model);
const selected = selectKeys(selectName);
it("parses both ends into a plausible shape", () => {
expect(columns.length).toBeGreaterThan(8);
expect(columns).toContain("locationEncrypted");
expect(selected.length).toBeGreaterThan(5);
});
it("the backup select covers every column", () => {
const missing = columns.filter(
(c) => !selected.includes(c) && !(c in EXCLUDED),
);
expect(
missing,
`${selectName} omits these ${model} columns; a restore brings them back NULL`,
).toEqual([]);
});
it("the restore writes every selected column back", () => {
const unwritten = selected.filter(
(c) =>
!new RegExp(`\\b${c}:`).test(restoreBody) &&
!airQualityList.includes(`"${c}"`) &&
// The day key and the source are written under their own names.
!["date", "startDate", "endDate"].includes(c),
);
expect(unwritten, "selected but never restored").toEqual([]);
});
});
describe("EnvironmentContext export", () => {
it("writes every selected column into the entry", () => {
const selected = selectKeys("ENVIRONMENT_CONTEXT_BACKUP_SELECT");
const unwritten = selected.filter(
(c) =>
!LOCATION_COLUMNS.has(c) && !new RegExp(`row\\.${c}\\b`).test(source),
);
expect(unwritten, "selected but never exported").toEqual([]);
});
it("covers all twenty-two air-quality columns and the sealed location", () => {
const selected = selectKeys("ENVIRONMENT_CONTEXT_BACKUP_SELECT");
for (const column of [
"apparentMax",
"pm25Mean",
"o3Max8h",
"pollenRagweedMax",
"aqDomain",
"aqHours",
"aqFetchedAt",
"locationEncrypted",
]) {
expect(selected, column).toContain(column);
}
});
it("carries no stale exclusion", () => {
const all = new Set([
...scalarColumns("EnvironmentContext"),
...scalarColumns("EnvironmentTravelLocation"),
]);
for (const [column, reason] of Object.entries(EXCLUDED)) {
expect(all.has(column), column).toBe(true);
expect(reason.length).toBeGreaterThan(20);
}
});
});
@@ -83,14 +83,6 @@ const RELEASE_PENDING_PREFIXES: Record<string, string> = {
"v1.42 managed-profile handover dialog (#959)",
"settings.sections.export.import.healthConnect":
"v1.42 Health Connect import card (#972)",
"settings.sections.environment.airQuality":
"v1.42 environment settings, air quality (#615)",
"environment.fields.apparentMax": "v1.42 air-quality field label (#615)",
"environment.fields.pm10": "v1.42 air-quality field label (#615)",
"environment.fields.no2": "v1.42 air-quality field label (#615)",
"environment.fields.europeanAqi": "v1.42 air-quality field label (#615)",
"environment.fields.uvIndexMax": "v1.42 air-quality field label (#615)",
"environment.fields.dustMax": "v1.42 air-quality field label (#615)",
"settings.appleHealth.freshness":
"v1.42 per-type HealthKit freshness list (#1173)",
"insights.workouts.manual.editTitle": "v1.42 manual workout edit (#1162)",
+4 -2
View File
@@ -55,9 +55,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const profile = await prisma.user.findUnique({
where: { id: user.id },
select: { homeLat: true, homeSince: true },
select: { homeLat: true, homeLocationEncrypted: true, homeSince: true },
});
if (profile?.homeLat == null) {
// The sealed home (v1.42), or the readable one the encryption backfill has
// not cleared yet.
if (profile?.homeLocationEncrypted == null && profile?.homeLat == null) {
return apiError("Set a home location before backfilling.", 409, {
errorCode: "environment.no_home",
});
+19 -10
View File
@@ -16,6 +16,7 @@ import { requireModuleEnabled } from "@/lib/modules/gate";
import { prisma } from "@/lib/db";
import { homeLocationSchema } from "@/lib/validations/environment";
import { roundCoarse } from "@/lib/environment/open-meteo";
import { sealLocation } from "@/lib/environment/location-cipher";
import { enqueueEnvironmentFetch } from "@/lib/jobs/environment-fetch";
export const PUT = apiHandler(async (request: NextRequest) => {
@@ -46,14 +47,22 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// set/update re-stamps — the home is "effective from the moment it is set".
const homeSince = new Date();
const home = {
homeLat: roundCoarse(lat),
homeLon: roundCoarse(lon),
homeLabel: label,
homeTimezone: timezone,
homeSince,
lat: roundCoarse(lat),
lon: roundCoarse(lon),
label,
};
await prisma.user.update({ where: { id: user.id }, data: home });
await prisma.user.update({
where: { id: user.id },
data: {
homeLocationEncrypted: sealLocation(home),
homeLat: null,
homeLon: null,
homeLabel: null,
homeTimezone: timezone,
homeSince,
},
});
// Kick a lookback refresh so recent days populate promptly. No-ops cleanly
// when no worker is bound; the nightly cron still covers it.
@@ -63,10 +72,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
return apiSuccess({
home: {
lat: home.homeLat,
lon: home.homeLon,
label: home.homeLabel,
timezone: home.homeTimezone,
lat: home.lat,
lon: home.lon,
label: home.label,
timezone,
since: homeSince.toISOString(),
},
});
+58 -7
View File
@@ -1,17 +1,68 @@
/**
* `PATCH /api/environment/preferences` — the caller's environment switches,
* today `airQualityEnabled` (v1.42, #615). Module-gated like the rest of the
* environment surface.
* environment surface; `userId` is narrowed from auth.
*
* Contract stub: authenticates as the finished route will, then answers 501.
* Off: the nightly run stops asking the air-quality feed for this account,
* and the overview, the Coach, MCP and the correlations leave the air-quality
* values out. Days already stored keep theirs. On again: a lookback run is
* queued, and the nightly gap fill catches up on the days that never had any.
* The operator switch (`ENVIRONMENT_AIR_QUALITY_DISABLED`) wins either way,
* and the answer says when it does.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { NextRequest } from "next/server";
export const PATCH = apiHandler(async () => {
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { prisma } from "@/lib/db";
import { environmentPreferencesSchema } from "@/lib/validations/environment";
import { isAirQualityOperatorDisabled } from "@/lib/environment/open-meteo-air-quality";
import { enqueueEnvironmentFetch } from "@/lib/jobs/environment-fetch";
export const PATCH = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "environment");
if (!gate.enabled) return gate.response;
return notImplemented();
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 1024,
});
if (jsonError) return jsonError;
const parsed = environmentPreferencesSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "environment.invalid",
});
}
const before = await prisma.user.findUnique({
where: { id: user.id },
select: { environmentAirQualityEnabled: true },
});
const airQualityEnabled = parsed.data.airQualityEnabled;
await prisma.user.update({
where: { id: user.id },
data: { environmentAirQualityEnabled: airQualityEnabled },
});
const operatorDisabled = isAirQualityOperatorDisabled();
// Turned on: fetch the recent days now rather than at night. No-ops when
// no worker is bound; the nightly run covers it.
if (
airQualityEnabled &&
!operatorDisabled &&
before?.environmentAirQualityEnabled === false
) {
await enqueueEnvironmentFetch({ userId: user.id });
}
annotate({
action: { name: "environment.preferences.update" },
meta: { air_quality_enabled: airQualityEnabled },
});
return apiSuccess({ airQualityEnabled, operatorDisabled });
});
+174 -41
View File
@@ -5,6 +5,13 @@
* summary of stored daily observations (count + latest day), and the upstream
* attribution string. Module-gated: a 403 `module.disabled` envelope when the
* opt-in module is off. `userId` is narrowed from auth, never a body field.
*
* v1.42 (#615) — the locations are stored sealed and opened here, so the
* answer keeps its readable shape. Added beside it: the air-quality state
* (the account switch, the operator switch, how many days carry air-quality
* values), the newest stored day for the dashboard chips (with its air
* quality only while the part is on), and every attribution line in
* `attributions`.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
@@ -12,6 +19,10 @@ import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { prisma } from "@/lib/db";
import { OPEN_METEO_ATTRIBUTION } from "@/lib/environment/open-meteo";
import { isAirQualityOperatorDisabled } from "@/lib/environment/open-meteo-air-quality";
import { environmentAttributionLines } from "@/lib/environment/air-quality-contract";
import { readLocation } from "@/lib/environment/location-cipher";
import { isAirQualityActive } from "@/lib/environment/service";
import { ENVIRONMENT_FETCH_QUEUE } from "@/lib/jobs/environment-fetch";
import { readQueueFailureForUser } from "@/lib/jobs/job-failures";
@@ -23,50 +34,100 @@ export const GET = apiHandler(async () => {
const gate = await requireModuleEnabled(user.id, "environment");
if (!gate.enabled) return gate.response;
const [profile, travel, contextCount, latest, lastFetchFailure] =
await Promise.all([
prisma.user.findUnique({
where: { id: user.id },
select: {
homeLat: true,
homeLon: true,
homeLabel: true,
homeTimezone: true,
homeSince: true,
timezone: true,
},
}),
prisma.environmentTravelLocation.findMany({
where: { userId: user.id },
orderBy: { startDate: "desc" },
select: {
id: true,
startDate: true,
endDate: true,
lat: true,
lon: true,
label: true,
},
}),
prisma.environmentContext.count({ where: { userId: user.id } }),
prisma.environmentContext.findFirst({
where: { userId: user.id },
orderBy: { date: "desc" },
select: { date: true, fetchedAt: true },
}),
// A day count on its own cannot tell an empty module from a broken one —
// which is exactly how a job that failed on every run since the module
// shipped could render as "0 days recorded" and nothing else. The queue
// knows; the overview asks it.
readQueueFailureForUser(ENVIRONMENT_FETCH_QUEUE, user.id),
]);
const [
profile,
travelRows,
contextCount,
latest,
airDays,
latestAir,
lastFetchFailure,
] = await Promise.all([
prisma.user.findUnique({
where: { id: user.id },
select: {
homeLat: true,
homeLon: true,
homeLabel: true,
homeLocationEncrypted: true,
homeTimezone: true,
homeSince: true,
timezone: true,
environmentAirQualityEnabled: true,
},
}),
prisma.environmentTravelLocation.findMany({
where: { userId: user.id },
orderBy: { startDate: "desc" },
select: {
id: true,
startDate: true,
endDate: true,
lat: true,
lon: true,
label: true,
locationEncrypted: true,
},
}),
prisma.environmentContext.count({ where: { userId: user.id } }),
prisma.environmentContext.findFirst({
where: { userId: user.id },
orderBy: { date: "desc" },
select: {
date: true,
fetchedAt: true,
tempMin: true,
tempMax: true,
apparentMax: true,
pm25Mean: true,
pm10Mean: true,
no2Mean: true,
o3Max8h: true,
eaqiMax: true,
uvIndexMax: true,
dustMax: true,
pollenAlderMax: true,
pollenBirchMax: true,
pollenGrassMax: true,
pollenMugwortMax: true,
pollenOliveMax: true,
pollenRagweedMax: true,
aqFetchedAt: true,
},
}),
// A day "carries air quality" when the feed returned hourly values for
// it; a day fetched with nothing in it (outside the feed) does not count.
prisma.environmentContext.count({
where: { userId: user.id, aqHours: { gt: 0 } },
}),
prisma.environmentContext.findFirst({
where: { userId: user.id, aqHours: { gt: 0 } },
orderBy: { date: "desc" },
select: { date: true, aqDomain: true },
}),
// A day count on its own cannot tell an empty module from a broken one —
// which is exactly how a job that failed on every run since the module
// shipped could render as "0 days recorded" and nothing else. The queue
// knows; the overview asks it.
readQueueFailureForUser(ENVIRONMENT_FETCH_QUEUE, user.id),
]);
const homeLocation = profile
? readLocation({
sealed: profile.homeLocationEncrypted,
lat: profile.homeLat,
lon: profile.homeLon,
// The label may be absent on a pre-v1.42 home; the answer has
// always allowed a null label there.
label: profile.homeLabel ?? "",
})
: null;
const home =
profile?.homeLat != null && profile?.homeLon != null
profile && homeLocation
? {
lat: profile.homeLat,
lon: profile.homeLon,
label: profile.homeLabel,
lat: homeLocation.lat,
lon: homeLocation.lon,
label: homeLocation.label === "" ? null : homeLocation.label,
timezone: profile.homeTimezone ?? profile.timezone,
// Effective-from instant: the settings surface uses it to label the
// home and to prefill the backfill start (conservative default range).
@@ -74,12 +135,72 @@ export const GET = apiHandler(async () => {
}
: null;
const travel = travelRows.flatMap((row) => {
const location = readLocation({
sealed: row.locationEncrypted,
lat: row.lat,
lon: row.lon,
label: row.label,
});
return location
? [
{
id: row.id,
startDate: row.startDate,
endDate: row.endDate,
...location,
},
]
: [];
});
const accountEnabled = profile?.environmentAirQualityEnabled ?? true;
const operatorDisabled = isAirQualityOperatorDisabled();
const airActive = isAirQualityActive(accountEnabled);
const latestYear = Number(
(latestAir?.date ?? latest?.date ?? new Date().toISOString()).slice(0, 4),
);
const latestDay = latest
? {
date: latest.date,
tempMin: latest.tempMin,
tempMax: latest.tempMax,
apparentMax: latest.apparentMax,
// The air-quality part of the day only while the part is on, and
// only once it was fetched: an unfetched day says nothing, which is
// not the same as clean air.
airQuality:
airActive && latest.aqFetchedAt
? {
pm25Mean: latest.pm25Mean,
pm10Mean: latest.pm10Mean,
no2Mean: latest.no2Mean,
o3Max8h: latest.o3Max8h,
eaqiMax: latest.eaqiMax,
uvIndexMax: latest.uvIndexMax,
dustMax: latest.dustMax,
pollen: {
alder: latest.pollenAlderMax,
birch: latest.pollenBirchMax,
grass: latest.pollenGrassMax,
mugwort: latest.pollenMugwortMax,
olive: latest.pollenOliveMax,
ragweed: latest.pollenRagweedMax,
},
}
: null,
}
: null;
annotate({
action: { name: "environment.overview.read" },
meta: {
has_home: home !== null,
travel_count: travel.length,
context_days: contextCount,
air_quality_days: airDays,
air_quality_active: airActive,
last_fetch_failed: lastFetchFailure !== null,
},
});
@@ -92,10 +213,22 @@ export const GET = apiHandler(async () => {
latestDate: latest?.date ?? null,
latestFetchedAt: latest?.fetchedAt?.toISOString() ?? null,
},
airQuality: {
enabled: accountEnabled,
operatorDisabled,
days: airDays,
latestDate: latestAir?.date ?? null,
domain: latestAir?.aqDomain ?? null,
},
latestDay,
// null = the last background runs did not fail (or there is no queue to
// ask). Non-null names the failure so a zero day count is never left to
// stand alone. No error text: that message is written for an operator.
lastFetchFailure,
attribution: OPEN_METEO_ATTRIBUTION,
attributions: environmentAttributionLines({
airQuality: airActive,
year: latestYear,
}),
});
});
+17 -12
View File
@@ -6,6 +6,10 @@
* fallback). Coordinates are rounded to 1 decimal (about 11 km). Adding an override
* enqueues a lookback refresh so its days re-resolve. Module-gated; `userId` is
* narrowed from auth.
*
* v1.42 (#615) — the period's location is stored sealed
* (`locationEncrypted`); the readable columns are written empty and the
* answer carries the coarse location as before.
*/
import { NextRequest } from "next/server";
@@ -16,6 +20,7 @@ import { requireModuleEnabled } from "@/lib/modules/gate";
import { prisma } from "@/lib/db";
import { travelLocationSchema } from "@/lib/validations/environment";
import { roundCoarse } from "@/lib/environment/open-meteo";
import { sealLocation } from "@/lib/environment/location-cipher";
import { enqueueEnvironmentFetch } from "@/lib/jobs/environment-fetch";
export const POST = apiHandler(async (request: NextRequest) => {
@@ -41,24 +46,24 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
const entry = parsed.data;
const created = await prisma.environmentTravelLocation.create({
const location = {
lat: roundCoarse(entry.lat),
lon: roundCoarse(entry.lon),
label: entry.label,
};
const row = await prisma.environmentTravelLocation.create({
data: {
userId: user.id,
startDate: entry.startDate,
endDate: entry.endDate,
lat: roundCoarse(entry.lat),
lon: roundCoarse(entry.lon),
label: entry.label,
},
select: {
id: true,
startDate: true,
endDate: true,
lat: true,
lon: true,
label: true,
locationEncrypted: sealLocation(location),
lat: null,
lon: null,
label: null,
},
select: { id: true, startDate: true, endDate: true },
});
const created = { ...row, ...location };
// Re-resolve the affected window so the override's days pick up its weather.
await enqueueEnvironmentFetch({
+1
View File
@@ -112,6 +112,7 @@ const READ_TOOLS = [
"get_nutrients",
"get_intraday_pulse",
"get_ecg_recordings",
"get_environment",
// v1.38 — the bounded visit history.
"get_visits",
].sort();
+7
View File
@@ -69,6 +69,7 @@ import { summaryToTrend7Delta } from "@/lib/analytics/trend-delta";
import { GettingStartedChecklist } from "@/components/onboarding/getting-started-checklist";
import { RecentAchievementsCard } from "@/components/gamification/recent-achievements-card";
import { RecentWorkoutsTile } from "@/components/dashboard/recent-workouts-tile";
import { EnvironmentChip } from "@/components/dashboard/environment-chip";
import { SleepSourceDiscrepancyMarker } from "@/components/insights/sleep-source-discrepancy-marker";
import { VorsorgeDashboardCard } from "@/components/measurement-reminders/vorsorge-dashboard-card";
@@ -964,6 +965,12 @@ export default function DashboardPageClient({
) : null
}
{/* v1.42 (#615) — a quiet note about the newest stored environment
day (high pollen, a hot night, very poor air). Renders nothing
unless one of those applies, and nothing before mount (the module
map and the overview are client reads). Not a layout widget. */}
{mounted && <EnvironmentChip />}
{/* v1.18.6 — the spotlight tour launcher moved to the app-shell
* (`AuthShell`) so its overlay survives the cross-page
* `router.push`es the module tour makes. The dashboard no longer
@@ -0,0 +1,52 @@
/**
* v1.42 (#615) — the dashboard environment chip stays quiet: no flag, no
* chip; an unfetched air-quality part raises no air or pollen flag.
*/
import { describe, expect, it } from "vitest";
import { environmentChipFlags } from "../environment-chip";
const POLLEN = {
alder: 0,
birch: 0,
grass: 0,
mugwort: 0,
olive: 0,
ragweed: 0,
};
describe("environmentChipFlags", () => {
it("raises nothing on an ordinary day", () => {
expect(
environmentChipFlags({
date: "2026-10-07",
tempMin: 9,
airQuality: { eaqiMax: 35, pollen: POLLEN },
}),
).toEqual([]);
});
it("names the high pollen kinds, the warm night and the very poor air", () => {
expect(
environmentChipFlags({
date: "2026-07-20",
tempMin: 21,
airQuality: { eaqiMax: 84, pollen: { ...POLLEN, grass: 70 } },
}),
).toEqual([
{ kind: "pollen", kinds: ["grass"] },
{ kind: "hotNight" },
{ kind: "veryPoorAir" },
]);
});
it("raises no air flag for a day whose air quality was not fetched", () => {
expect(
environmentChipFlags({
date: "2026-07-20",
tempMin: 10,
airQuality: null,
}),
).toEqual([]);
});
});
@@ -0,0 +1,111 @@
"use client";
/**
* `<EnvironmentChip>` — the dashboard's quiet note about the newest stored
* environment day (v1.42, #615): high pollen, a hot night, very poor air.
*
* Quiet by construction: it renders nothing unless one of the three flags in
* `src/lib/environment/day-flags.ts` is set for that day, nothing while the
* environment module is off, and nothing for an air-quality flag the feed did
* not cover (an unfetched day is not clean air). It names the day it is about,
* because the archive behind it settles a few days late: the newest stored day
* is usually yesterday or the day before, never a forecast for today, and the
* chip never asks one.
*
* Not a dashboard widget: it has no widget id and no place in the layout
* contract the iOS client shares. It reads the module overview through the
* same query the settings section uses.
*/
import { useQuery } from "@tanstack/react-query";
import { CloudSun } from "lucide-react";
import { TagChip } from "@/components/ui/tag-chip";
import { useAuth } from "@/hooks/use-auth";
import { apiGet } from "@/lib/api/api-fetch";
import {
highPollenKinds,
isHotNight,
isVeryPoorAir,
type PollenByKind,
} from "@/lib/environment/day-flags";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
/** The slice of `GET /api/environment` the chip reads. */
export interface EnvironmentChipOverview {
latestDay: {
date: string;
tempMin: number | null;
airQuality: {
eaqiMax: number | null;
pollen: PollenByKind;
} | null;
} | null;
}
/** The flags a day raises, as i18n keys with their values. Pure. */
export function environmentChipFlags(
day: NonNullable<EnvironmentChipOverview["latestDay"]>,
): Array<
| { kind: "pollen"; kinds: string[] }
| { kind: "hotNight" }
| { kind: "veryPoorAir" }
> {
const flags: ReturnType<typeof environmentChipFlags> = [];
const air = day.airQuality;
if (air) {
const kinds = highPollenKinds(air.pollen);
if (kinds.length > 0) flags.push({ kind: "pollen", kinds });
}
if (isHotNight(day.tempMin)) {
flags.push({ kind: "hotNight" });
}
if (air && isVeryPoorAir(air.eaqiMax)) flags.push({ kind: "veryPoorAir" });
return flags;
}
export function EnvironmentChip() {
const { t } = useTranslations();
const fmt = useFormatters();
const { user } = useAuth();
const enabled = user?.modules?.environment === true;
const overview = useQuery({
queryKey: queryKeys.environment(),
enabled,
queryFn: () => apiGet<EnvironmentChipOverview>("/api/environment"),
staleTime: 30 * 60 * 1000,
});
const day = overview.data?.latestDay;
if (!enabled || !day) return null;
const flags = environmentChipFlags(day);
if (flags.length === 0) return null;
// A stated calendar day, read as that day wherever the person is.
const date = fmt.dateShortSmartCalendar(day.date);
return (
<div
data-slot="environment-chip"
className="flex flex-wrap items-center gap-2"
>
<CloudSun className="text-muted-foreground size-4" aria-hidden />
<span className="text-muted-foreground text-xs">
{t("environment.chip.day", { date })}
</span>
{flags.map((flag) => (
<TagChip key={flag.kind} data-flag={flag.kind}>
{flag.kind === "pollen"
? t("environment.chip.pollenHigh", {
kinds: flag.kinds
.map((kind) => t(`environment.pollen.${kind}`))
.join(", "),
})
: flag.kind === "hotNight"
? t("environment.chip.hotNight")
: t("environment.chip.veryPoorAir")}
</TagChip>
))}
</div>
);
}
@@ -40,6 +40,7 @@ import {
Brain,
ChartLine,
ChartScatter,
CloudSun,
ChevronDown,
CircleStop,
Database,
@@ -272,6 +273,8 @@ export function stepIcon(
return ChartLine;
case "get_correlations":
return ChartScatter;
case "get_environment":
return CloudSun;
case "get_metric_table":
return Table2;
default:
@@ -71,6 +71,10 @@ const CHANNEL_LABEL_KEY: Record<string, string> = {
ENV_PRECIP: "environment.fields.precip",
ENV_PRESSURE_MEAN: "environment.fields.pressureMean",
ENV_PRESSURE_DELTA: "environment.fields.pressureDelta",
// v1.42 (#615) — the air-quality channels.
ENV_PM25: "environment.fields.pm25",
ENV_OZONE_8H: "environment.fields.ozone8h",
ENV_POLLEN_MAX: "environment.fields.pollenMax",
};
/** Keep only the discovered pairs that involve the mood channel. */
+173 -6
View File
@@ -18,7 +18,7 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { CloudSun, MapPin, Plane, Trash2 } from "lucide-react";
import { CloudSun, MapPin, Plane, Trash2, Wind } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -26,11 +26,19 @@ import { Button } from "@/components/ui/button";
import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useAuth } from "@/hooks/use-auth";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import {
apiDelete,
apiGet,
apiPatch,
apiPost,
apiPut,
} from "@/lib/api/api-fetch";
import { pollenMax, type PollenByKind } from "@/lib/environment/day-flags";
import { queryKeys } from "@/lib/query-keys";
import {
DEFAULT_TIMEZONE,
@@ -76,6 +84,40 @@ interface EnvironmentOverview {
* "every run failed", so the count is never rendered on its own. */
lastFetchFailure: { lastFailedAt: string; failures: number } | null;
attribution: string;
/** v1.42 — the air-quality part: switches and coverage. */
airQuality: {
enabled: boolean;
operatorDisabled: boolean;
days: number;
latestDate: string | null;
domain: string | null;
};
/** v1.42 — the newest stored day; `airQuality` null while off/unfetched. */
latestDay: {
date: string;
tempMin: number | null;
tempMax: number | null;
apparentMax: number | null;
airQuality: {
pm25Mean: number | null;
pm10Mean: number | null;
no2Mean: number | null;
o3Max8h: number | null;
eaqiMax: number | null;
uvIndexMax: number | null;
dustMax: number | null;
pollen: PollenByKind;
} | null;
} | null;
/** v1.42 — every attribution line, weather first. */
attributions: string[];
}
/** One value of the newest day, or a dash when the feed did not cover it. */
function envValue(value: number | null, unit: string, digits = 0): string {
if (value == null) return "–";
const rounded = Number(value.toFixed(digits));
return unit ? `${rounded} ${unit}` : String(rounded);
}
export function EnvironmentSection() {
@@ -192,6 +234,16 @@ export function EnvironmentSection() {
onError: () => toast.error(t("settings.sections.environment.saveError")),
});
const setAirQuality = useMutation({
mutationKey: queryKeys.environment(),
mutationFn: (airQualityEnabled: boolean) =>
apiPatch("/api/environment/preferences", { airQualityEnabled }),
// The switch itself shows the new state; no toast on success.
onSuccess: () => invalidate(),
onError: () =>
toast.error(t("settings.sections.environment.airQuality.saveError")),
});
const backfill = useMutation({
mutationKey: queryKeys.environment(),
mutationFn: (startDate: string) =>
@@ -540,10 +592,125 @@ export function EnvironmentSection() {
)}
</SettingsCard>
{/* Attribution (CC BY 4.0, required) */}
<p className="text-muted-foreground pl-1 text-xs">
{data?.attribution ?? "Weather data by Open-Meteo.com"}
</p>
{/* Air quality, pollen and UV (v1.42) */}
{data && (
<SettingsCard data-testid="environment-air-quality">
<SettingsCardHeader
icon={Wind}
title={t("settings.sections.environment.airQuality.title")}
status={
<label className="flex min-h-11 items-center gap-3">
<Switch
checked={
data.airQuality.enabled && !data.airQuality.operatorDisabled
}
onCheckedChange={(next) => setAirQuality.mutate(next)}
disabled={
data.airQuality.operatorDisabled || setAirQuality.isPending
}
aria-label={t(
"settings.sections.environment.airQuality.toggle",
)}
/>
</label>
}
/>
<p className="text-sm leading-relaxed">
{t("settings.sections.environment.airQuality.description")}
</p>
{data.airQuality.operatorDisabled ? (
<p className="text-muted-foreground text-sm">
{t("settings.sections.environment.airQuality.operatorDisabled")}
</p>
) : data.airQuality.enabled ? (
<div className="space-y-3">
<p className="text-muted-foreground text-xs">
{t("settings.sections.environment.airQuality.coverage", {
count: data.airQuality.days,
})}
{data.airQuality.domain === "cams_global"
? ` ${t("settings.sections.environment.airQuality.pollenEuropeOnly")}`
: ""}
</p>
{data.latestDay?.airQuality && (
<div className="space-y-2">
<p className="text-sm font-medium">
{t("settings.sections.environment.airQuality.latest", {
date: data.latestDay.date,
})}
</p>
<dl className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
{(
[
[
"environment.fields.pm25",
envValue(data.latestDay.airQuality.pm25Mean, "µg/m³"),
],
[
"environment.fields.pm10",
envValue(data.latestDay.airQuality.pm10Mean, "µg/m³"),
],
[
"environment.fields.no2",
envValue(data.latestDay.airQuality.no2Mean, "µg/m³"),
],
[
"environment.fields.ozone8h",
envValue(data.latestDay.airQuality.o3Max8h, "µg/m³"),
],
[
"environment.fields.europeanAqi",
envValue(data.latestDay.airQuality.eaqiMax, ""),
],
[
"environment.fields.uvIndexMax",
envValue(data.latestDay.airQuality.uvIndexMax, "", 1),
],
[
"environment.fields.dustMax",
envValue(data.latestDay.airQuality.dustMax, "µg/m³"),
],
[
"environment.fields.pollenMax",
envValue(
pollenMax(data.latestDay.airQuality.pollen),
"/m³",
),
],
] as const
).map(([labelKey, value]) => (
<div key={labelKey} className="contents">
<dt className="text-muted-foreground text-xs">
{t(labelKey)}
</dt>
<dd className="tabular-nums">{value}</dd>
</div>
))}
</dl>
<p className="text-muted-foreground text-xs">
{t("settings.sections.environment.airQuality.modelNote")}
</p>
</div>
)}
</div>
) : null}
</SettingsCard>
)}
{/* Attribution (CC BY 4.0 for Open-Meteo; Copernicus notice for the
air-quality values), one line per source. */}
<ul
className="text-muted-foreground space-y-0.5 pl-1 text-xs"
aria-label={t("settings.sections.environment.airQuality.attribution")}
>
{(
data?.attributions ?? [
data?.attribution ?? "Weather data by Open-Meteo.com",
]
).map((line) => (
<li key={line}>{line}</li>
))}
</ul>
</div>
);
}
@@ -126,6 +126,15 @@ vi.mock("@/lib/ai/coach/tools/correlations-read", () => ({
},
}));
vi.mock("@/lib/ai/coach/tools/environment-read", () => ({
readEnvironmentForTool: async (args: { reach?: CoachHistoryReach }) => {
// The reader clamps its window to the reach it is handed; the guard
// checks that the executor hands it on.
record("readEnvironmentForTool", reachOk(args.reach), args.reach);
return { present: false, reason: "no_data" };
},
}));
vi.mock("@/lib/ai/coach/illness-snapshot", () => ({
buildIllnessScores: async (
_userId: string,
@@ -236,6 +245,7 @@ const CASES: Record<string, Array<Record<string, unknown>>> = {
get_workouts: [{ window: "allTime" }],
get_cycle: [{}],
get_correlations: [{}],
get_environment: [{ window: "allTime" }, {}],
get_metric_table: [
{ metric: "bp", window: "allTime" },
{ metric: "weight", window: "last30days", period: "yearAgo" },
@@ -17,6 +17,7 @@ const OWNER_BY_DOMAIN: ReadonlyMap<string, ModuleKey> = (() => {
const out = new Map<string, ModuleKey>([
["cycle", "cycle"],
["illness", "illness"],
["environment", "environment"],
]);
for (const [key, sources] of Object.entries(MODULE_SCOPED_SOURCES)) {
for (const source of sources ?? []) out.set(source, key as ModuleKey);
+2 -1
View File
@@ -49,12 +49,13 @@ export const coachStepDomainSchema = z
"illness",
"cycle",
"correlations",
"environment",
"snapshot",
])
.meta({
id: "CoachStepDomain",
description:
"The data domain a step, table or chip is about: a measurement-backed scope source, or a domain read as a whole (labs, illness, cycle, correlations, the full snapshot).",
"The data domain a step, table or chip is about: a measurement-backed scope source, or a domain read as a whole (labs, illness, cycle, correlations, the environment, the full snapshot).",
});
const coachResultGranularitySchema = z.enum(["day", "week", "month"]);
@@ -0,0 +1,242 @@
/**
* v1.42 (#615) — `get_environment` for the Coach and MCP.
*
* - the module switch answers `module_disabled` before a row is read;
* - no coordinate, no place name and no label leaves in any key, however
* the stored row is filled;
* - with the air-quality part off (by the account or the operator) every
* air-quality field is absent, not null-filled; on, an unfetched day says
* null, never zero;
* - the window is clamped to the lookback limit, and the summary counts with
* the shared day flags.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
moduleOn: true,
airEnabled: true,
rows: [] as Array<Record<string, unknown>>,
findMany: [] as Array<Record<string, unknown>>,
}));
vi.mock("@/lib/modules/gate", () => ({
isModuleEnabled: vi.fn(async () => state.moduleOn),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(async () => ({
environmentAirQualityEnabled: state.airEnabled,
})),
},
environmentContext: {
findMany: vi.fn(async (args: Record<string, unknown>) => {
state.findMany.push(args);
return state.rows;
}),
aggregate: vi.fn(async () => ({
_count: { _all: 0 },
_min: { date: null },
_max: { date: null },
})),
},
},
}));
import { prisma } from "@/lib/db";
import { readEnvironmentForTool } from "../environment-read";
import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach";
const NOW = new Date("2026-10-08T06:00:00Z");
function storedDay(date: string, extra: Record<string, unknown> = {}) {
return {
date,
source: "HOME",
// A row as the database might still hold it; none of this may leave.
lat: 51.5,
lon: 7.2,
locationLabel: "Bochum, Germany",
tempMin: 12,
tempMax: 21,
tempMean: 16.44,
apparentMax: 22,
precipSum: 0,
sunshineSec: 7200,
pressureMean: 1013.27,
pressureDelta: 2,
humidityMean: 70.12,
pm25Mean: 8,
pm25Max: 15,
pm10Mean: 12,
no2Mean: 9,
o3Max8h: 70,
eaqiMax: 35,
uvIndexMax: 4,
dustMax: 1,
pollenAlderMax: 0,
pollenBirchMax: 120,
pollenGrassMax: null,
pollenMugwortMax: 0,
pollenOliveMax: 0,
pollenRagweedMax: 0,
aqFetchedAt: new Date("2026-10-07T02:10:00Z"),
aqHours: 24,
...extra,
};
}
/** Every key anywhere in a value. */
function keysDeep(value: unknown, out = new Set<string>()): Set<string> {
if (Array.isArray(value)) value.forEach((v) => keysDeep(v, out));
else if (value && typeof value === "object") {
for (const [k, v] of Object.entries(value)) {
out.add(k);
keysDeep(v, out);
}
}
return out;
}
beforeEach(() => {
state.moduleOn = true;
state.airEnabled = true;
state.findMany = [];
state.rows = [
storedDay("2026-10-05"),
storedDay("2026-10-06", {
source: "TRAVEL",
tempMin: 21,
eaqiMax: 82,
pollenBirchMax: 3,
}),
storedDay("2026-10-07", { aqFetchedAt: null, aqHours: null }),
];
vi.clearAllMocks();
});
afterEach(() => vi.unstubAllEnvs());
describe("readEnvironmentForTool", () => {
it("answers module_disabled without reading a row", async () => {
state.moduleOn = false;
const result = await readEnvironmentForTool({
userId: "u1",
window: "last30days",
reach: UNBOUNDED_REACH,
now: NOW,
});
expect(result).toEqual({ present: false, reason: "module_disabled" });
expect(prisma.environmentContext.findMany).not.toHaveBeenCalled();
});
it("never carries a coordinate, a place name or a label", async () => {
const result = await readEnvironmentForTool({
userId: "u1",
window: "last30days",
reach: UNBOUNDED_REACH,
now: NOW,
});
expect(result.present).toBe(true);
const keys = keysDeep(result);
for (const forbidden of [
"lat",
"lon",
"label",
"locationLabel",
"locationEncrypted",
"homeLabel",
]) {
expect(keys.has(forbidden), forbidden).toBe(false);
}
expect(JSON.stringify(result)).not.toContain("Bochum");
// The read does not even select the location columns.
const select = state.findMany[0].select as Record<string, boolean>;
expect(select.lat).toBeUndefined();
expect(select.locationEncrypted).toBeUndefined();
});
it("counts with the shared flags and says an unfetched day is null", async () => {
const result = await readEnvironmentForTool({
userId: "u1",
window: "last30days",
reach: UNBOUNDED_REACH,
now: NOW,
});
if (!result.present) throw new Error("expected data");
expect(result.data.coverage).toEqual({
firstDate: "2026-10-05",
lastDate: "2026-10-07",
days: 3,
awayDays: 1,
airQualityDays: 2,
});
expect(result.data.summary).toEqual({
hotNights: 1,
veryPoorAirDays: 1,
highPollenDays: 1,
pollenPeak: 120,
});
expect(result.data.daily[2].airQuality).toBeNull();
expect(result.data.daily[0].airQuality?.pollen.grass).toBeNull();
expect(result.data.airQuality).toBe("on");
expect(result.data.attributions).toHaveLength(3);
expect(result.data.provenance.note).toMatch(
/not the person's own exposure/,
);
});
it("leaves every air-quality field out when the account turned it off", async () => {
state.airEnabled = false;
const result = await readEnvironmentForTool({
userId: "u1",
window: "last30days",
reach: UNBOUNDED_REACH,
now: NOW,
});
if (!result.present) throw new Error("expected data");
const keys = keysDeep(result.data.daily);
expect(keys.has("airQuality")).toBe(false);
expect(keys.has("pm25Mean")).toBe(false);
expect(result.data.summary).toEqual({ hotNights: 1 });
expect(result.data.coverage.airQualityDays).toBeUndefined();
expect(result.data.airQuality).toBe("off_account");
expect(result.data.attributions).toHaveLength(1);
});
it("says when the operator turned it off", async () => {
vi.stubEnv("ENVIRONMENT_AIR_QUALITY_DISABLED", "1");
const result = await readEnvironmentForTool({
userId: "u1",
window: "last30days",
reach: UNBOUNDED_REACH,
now: NOW,
});
if (!result.present) throw new Error("expected data");
expect(result.data.airQuality).toBe("off_operator");
expect(keysDeep(result.data.daily).has("pm25Mean")).toBe(false);
});
it("clamps the window to the lookback limit", async () => {
const result = await readEnvironmentForTool({
userId: "u1",
window: "allTime",
reach: { window: "last7days", days: 7 },
now: NOW,
});
if (!result.present) throw new Error("expected data");
expect(result.data.window).toBe("last7days");
const where = state.findMany[0].where as { date: { gte: string } };
expect(where.date.gte).toBe("2026-10-02");
});
it("answers no_data for an account with nothing stored", async () => {
state.rows = [];
const result = await readEnvironmentForTool({
userId: "u1",
window: "last30days",
reach: UNBOUNDED_REACH,
now: NOW,
});
expect(result).toEqual({ present: false, reason: "no_data" });
});
});
+3
View File
@@ -725,6 +725,9 @@ export function subjectForTool(
case "get_illness_recovery":
case "get_cycle":
case "get_correlations":
// v1.42 — the environment read probes its own table and reports its own
// reason (module_disabled / no_data / outside_window).
case "get_environment":
return null;
}
}
+30 -1
View File
@@ -23,6 +23,9 @@
* 10. get_metric_table — one row per day / week / month for a metric,
* the same numbers the app's charts draw
* (v1.39.4)
* 11. get_environment — the weather, air quality, pollen and UV of
* the stored days, without any location
* (v1.42, #615)
*
* Beside the catalogue, `show_result` (v1.39.4) shows a table an earlier reply
* of the same conversation already read. It reads no health data of its own,
@@ -48,6 +51,7 @@ export const COACH_TOOL_NAMES = [
"get_cycle",
"get_correlations",
"get_metric_table",
"get_environment",
] as const;
export type CoachToolName = (typeof COACH_TOOL_NAMES)[number];
@@ -127,6 +131,13 @@ export const getMetricTableArgsSchema = z
})
.strict();
/** v1.42 (#615) — the environment read takes a window only. */
export const getEnvironmentArgsSchema = z
.object({
window: coachScopeWindowSchema.optional(),
})
.strict();
/**
* v1.39.4 — `m<k>.r<n>`, a table of an earlier reply as the context names
* it. The shape only; whether it exists in this conversation is decided by
@@ -153,6 +164,7 @@ const COACH_TOOL_ARG_SCHEMAS: Record<CoachToolName, z.ZodType> = {
get_cycle: getCycleArgsSchema,
get_correlations: getCorrelationsArgsSchema,
get_metric_table: getMetricTableArgsSchema,
get_environment: getEnvironmentArgsSchema,
};
/**
@@ -345,7 +357,7 @@ export const COACH_TOOL_DEFS: AiToolDef[] = [
{
name: "get_correlations",
description:
"Fetch the user's DISCOVERED cross-metric patterns: statistically-vetted (FDR-controlled) day-to-next-day driver pairs between behaviours (daylight, mood, glucose, blood pressure, steps) and outcomes (sleep, HRV, resting HR, weight, mood), each with direction, lag, sample size and a descriptive — never causal — note. Also reports the coincident-deviation flag (whether two or more vitals are outside their usual band today). Use when a metric is off and you want to state the observed linkage. Returns { present: false } when too little paired data exists for any pattern to survive.",
"Fetch the user's DISCOVERED cross-metric patterns: statistically-vetted (FDR-controlled) driver pairs between behaviours (daylight, mood, glucose, blood pressure, steps, and with the environment module the weather, fine particles, ozone and pollen) and outcomes (sleep, HRV, resting HR, weight, mood, symptoms), each with direction, lag, sample size and a descriptive — never causal — note. Season and trend are removed from both series first. lagDays 1 pairs a day with the next day's outcome; lagDays 0 (the environment channels) pairs the mean of the day before and the day itself with that same day's outcome. Also reports the coincident-deviation flag (whether two or more vitals are outside their usual band today). Use when a metric is off and you want to state the observed linkage. Returns { present: false } when too little paired data exists for any pattern to survive.",
parameters: {
type: "object",
additionalProperties: false,
@@ -387,6 +399,23 @@ export const COACH_TOOL_DEFS: AiToolDef[] = [
},
},
},
{
name: "get_environment",
description:
"Fetch the weather, air quality, pollen and UV the user's stored days had: per day the temperature (min, max, mean, feels-like high), precipitation, sunshine hours, pressure and humidity, and while air quality is on, fine particles (PM2.5 mean and peak), PM10, NO2, SO2, CO, the ozone 8-hour high, the European and US AQI peaks, UV, dust, aerosol optical depth and the six pollen kinds; plus coverage (days, days away from home) and a summary (hot nights, very poor air days, high pollen days). These are MODELLED OUTDOOR conditions on a coarse grid of 9 to 45 kilometres, not the user's personal exposure: describe them as what the days were like, and say a pattern 'occurred together', never that the weather caused anything. A null value means the feed did not cover it, never zero. Carries no location. Returns { present: false } with a reason: module_disabled (the environment module is off), no_data, or outside_window.",
parameters: {
type: "object",
additionalProperties: false,
required: [],
properties: {
window: {
type: "string",
enum: WINDOW_ENUM,
description: WINDOW_ARG_DESCRIPTION,
},
},
},
},
{
name: SHOW_RESULT_TOOL_NAME,
description:
+341
View File
@@ -0,0 +1,341 @@
/**
* v1.42 (#615) — `get_environment`: the weather, air quality, pollen and UV
* the person's stored days had, for the Coach and for MCP clients.
*
* What it never carries: a coordinate or a place name. The stored days hold
* their coarse location sealed; this read does not open it. The only trace
* of where a day was is `coverage.awayDays`, the number of days that resolved
* to a dated location period rather than the home.
*
* What it says about itself: the values are modelled outdoor conditions on a
* 9 to 45 km grid (ERA5 reanalysis for the weather, CAMS for the air), which
* is not anyone's personal exposure, and every result carries that note and
* the attribution lines. Null is "the feed did not cover this", never zero.
*
* Gates: the `environment` module (off answers `module_disabled` without
* reading a row), and the air-quality part, off by the account or the
* operator, which leaves every air-quality field out of the result rather
* than null-filling it. The window is clamped to the Coach lookback limit
* like every Coach read.
*/
import { prisma } from "@/lib/db";
import { isModuleEnabled } from "@/lib/modules/gate";
import {
isAirQualityActive,
isAirQualityOperatorDisabled,
} from "@/lib/environment/open-meteo-air-quality";
import { environmentAttributionLines } from "@/lib/environment/air-quality-contract";
import {
highPollenKinds,
isHotNight,
isVeryPoorAir,
pollenMax,
type PollenByKind,
} from "@/lib/environment/day-flags";
import { windowToDays } from "@/lib/ai/coach/snapshot-series";
import {
clampWindow,
type CoachHistoryReach,
} from "@/lib/ai/coach/history-reach";
import type { CoachScopeWindow } from "@/lib/ai/coach/types";
import { shiftDateKey } from "@/lib/tz/format";
/** Days listed one by one; the summary covers the whole window. */
export const ENVIRONMENT_DAILY_LIMIT = 31;
/** The air-quality fields of one day; only present while the part is on. */
export interface EnvironmentToolAirQuality {
pm25Mean: number | null;
pm25Max: number | null;
pm10Mean: number | null;
no2Mean: number | null;
so2Mean: number | null;
coMean: number | null;
o3Max8h: number | null;
eaqiMax: number | null;
usaqiMax: number | null;
uvIndexMax: number | null;
dustMax: number | null;
aodMax: number | null;
pollen: PollenByKind;
}
/** One stored day as the tool returns it. No location, ever. */
export interface EnvironmentToolDay {
date: string;
tempMin: number | null;
tempMax: number | null;
tempMean: number | null;
apparentMax: number | null;
precipSum: number | null;
sunshineHours: number | null;
pressureMean: number | null;
pressureDelta: number | null;
humidityMean: number | null;
/** Absent while the air-quality part is off; null when not fetched yet. */
airQuality?: EnvironmentToolAirQuality | null;
}
export type EnvironmentAirQualityState = "on" | "off_account" | "off_operator";
export interface EnvironmentToolData {
window: CoachScopeWindow;
coverage: {
firstDate: string;
lastDate: string;
/** Stored days in the window. */
days: number;
/** Of those, days spent at a dated location period, not at home. */
awayDays: number;
/** Days with air-quality values; absent while the part is off. */
airQualityDays?: number;
};
/** The newest days of the window, oldest first, at most 31. */
daily: EnvironmentToolDay[];
summary: {
/** Nights whose minimum stayed at or above 20 °C. */
hotNights: number;
/** Days whose European AQI reached 80 (absent while the part is off). */
veryPoorAirDays?: number;
/** Days with a pollen kind at its high mark (absent while off). */
highPollenDays?: number;
/** Highest pollen count of the window, grains/m³ (absent while off). */
pollenPeak?: number | null;
};
airQuality: EnvironmentAirQualityState;
provenance: {
weather: string;
airQuality: string;
note: string;
};
attributions: string[];
}
export type EnvironmentToolResult =
| { present: true; data: EnvironmentToolData }
| {
present: false;
reason: "module_disabled" | "no_data" | "outside_window";
searchedWindow?: CoachScopeWindow;
available?: {
count: number;
firstDate: string;
lastDate: string;
reachableWithWindow: CoachScopeWindow | null;
};
};
const PROVENANCE = {
weather:
"ERA5 reanalysis via the Open-Meteo archive, on a grid of about 9 to 25 kilometres; recent days settle over a few days",
airQuality:
"CAMS air-quality models via Open-Meteo: hourly on a grid of about 11 kilometres in Europe and 45 kilometres elsewhere; pollen only in Europe; pollen, UV and dust only from about mid 2022",
note: "Modelled outdoor conditions at a coarse location, not the person's own exposure. A pattern beside their data is context, never a cause.",
} as const;
const WINDOWS_NARROWEST_FIRST: readonly CoachScopeWindow[] = [
"last7days",
"last30days",
"last90days",
"lastYear",
"allTime",
];
function round1(value: number | null): number | null {
return value == null ? null : Math.round(value * 10) / 10;
}
/** UTC day key of `date`. */
function dayKey(date: Date): string {
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the environment module keys its stored days and window bounds on UTC day keys (see service.ts); a window edge a day either way does not change the read
return date.toISOString().slice(0, 10);
}
/**
* Read the stored environment days for the tool. Never throws for "nothing
* there"; a database error propagates to the executor, which reports
* `retrieval_failed`.
*/
export async function readEnvironmentForTool(args: {
userId: string;
window: CoachScopeWindow;
reach: CoachHistoryReach;
now?: Date;
}): Promise<EnvironmentToolResult> {
const { userId } = args;
if (!(await isModuleEnabled(userId, "environment"))) {
return { present: false, reason: "module_disabled" };
}
const window = clampWindow(args.window, args.reach);
const today = dayKey(args.now ?? new Date());
const fromKey = shiftDateKey(today, -(windowToDays(window) - 1));
const [account, rows] = await Promise.all([
prisma.user.findUnique({
where: { id: userId },
select: { environmentAirQualityEnabled: true },
}),
prisma.environmentContext.findMany({
where: { userId, date: { gte: fromKey } },
orderBy: { date: "asc" },
take: 800,
select: {
date: true,
source: true,
tempMin: true,
tempMax: true,
tempMean: true,
apparentMax: true,
precipSum: true,
sunshineSec: true,
pressureMean: true,
pressureDelta: true,
humidityMean: true,
pm25Mean: true,
pm25Max: true,
pm10Mean: true,
no2Mean: true,
so2Mean: true,
coMean: true,
o3Max8h: true,
eaqiMax: true,
usaqiMax: true,
uvIndexMax: true,
dustMax: true,
aodMax: true,
pollenAlderMax: true,
pollenBirchMax: true,
pollenGrassMax: true,
pollenMugwortMax: true,
pollenOliveMax: true,
pollenRagweedMax: true,
aqFetchedAt: true,
aqHours: true,
},
}),
]);
if (rows.length === 0) {
const older = await prisma.environmentContext.aggregate({
where: { userId },
_count: { _all: true },
_min: { date: true },
_max: { date: true },
});
if (older._count._all === 0 || !older._min.date || !older._max.date) {
return { present: false, reason: "no_data" };
}
const lastDate = older._max.date;
return {
present: false,
reason: "outside_window",
searchedWindow: window,
available: {
count: older._count._all,
firstDate: older._min.date,
lastDate,
reachableWithWindow:
WINDOWS_NARROWEST_FIRST.find(
(w) =>
clampWindow(w, args.reach) === w &&
shiftDateKey(today, -(windowToDays(w) - 1)) <= lastDate,
) ?? null,
},
};
}
const accountEnabled = account?.environmentAirQualityEnabled ?? true;
const airActive = isAirQualityActive(accountEnabled);
const airState: EnvironmentAirQualityState = airActive
? "on"
: isAirQualityOperatorDisabled()
? "off_operator"
: "off_account";
let hotNights = 0;
let veryPoorAirDays = 0;
let highPollenDays = 0;
let airQualityDays = 0;
let pollenPeak: number | null = null;
const days: EnvironmentToolDay[] = [];
for (const row of rows) {
if (isHotNight(row.tempMin)) hotNights += 1;
const day: EnvironmentToolDay = {
date: row.date,
tempMin: row.tempMin,
tempMax: row.tempMax,
tempMean: round1(row.tempMean),
apparentMax: row.apparentMax,
precipSum: row.precipSum,
sunshineHours:
row.sunshineSec == null ? null : round1(row.sunshineSec / 3600),
pressureMean: round1(row.pressureMean),
pressureDelta: row.pressureDelta,
humidityMean: round1(row.humidityMean),
};
if (airActive) {
if (row.aqFetchedAt) {
const pollen: PollenByKind = {
alder: row.pollenAlderMax,
birch: row.pollenBirchMax,
grass: row.pollenGrassMax,
mugwort: row.pollenMugwortMax,
olive: row.pollenOliveMax,
ragweed: row.pollenRagweedMax,
};
day.airQuality = {
pm25Mean: row.pm25Mean,
pm25Max: row.pm25Max,
pm10Mean: row.pm10Mean,
no2Mean: row.no2Mean,
so2Mean: row.so2Mean,
coMean: row.coMean,
o3Max8h: row.o3Max8h,
eaqiMax: row.eaqiMax,
usaqiMax: row.usaqiMax,
uvIndexMax: row.uvIndexMax,
dustMax: row.dustMax,
aodMax: row.aodMax,
pollen,
};
if ((row.aqHours ?? 0) > 0) airQualityDays += 1;
if (isVeryPoorAir(row.eaqiMax)) veryPoorAirDays += 1;
if (highPollenKinds(pollen).length > 0) highPollenDays += 1;
const peak = pollenMax(pollen);
if (peak != null && (pollenPeak === null || peak > pollenPeak)) {
pollenPeak = peak;
}
} else {
day.airQuality = null;
}
}
days.push(day);
}
const firstDate = rows[0].date;
const lastDate = rows[rows.length - 1].date;
return {
present: true,
data: {
window,
coverage: {
firstDate,
lastDate,
days: rows.length,
awayDays: rows.filter((r) => r.source === "TRAVEL").length,
...(airActive ? { airQualityDays } : {}),
},
daily: days.slice(-ENVIRONMENT_DAILY_LIMIT),
summary: {
hotNights,
...(airActive ? { veryPoorAirDays, highPollenDays, pollenPeak } : {}),
},
airQuality: airState,
provenance: PROVENANCE,
attributions: environmentAttributionLines({
airQuality: airActive,
year: Number(lastDate.slice(0, 4)),
}),
},
};
}
+31
View File
@@ -106,6 +106,7 @@ import {
getCycleArgsSchema,
getCorrelationsArgsSchema,
getMetricTableArgsSchema,
getEnvironmentArgsSchema,
isCoachToolName,
showResultArgsSchema,
SHOW_RESULT_TOOL_NAME,
@@ -116,6 +117,7 @@ import {
METRIC_SERIES_EXCLUDED_SOURCES,
} from "./source-keys";
import { readCoachCorrelations } from "./correlations-read";
import { readEnvironmentForTool } from "./environment-read";
import { resolveLocaleForUser } from "@/lib/i18n/user-locale";
import {
resolveEmptyRead,
@@ -489,6 +491,8 @@ async function dispatchRead(
turn,
reach,
);
case "get_environment":
return getEnvironment(userId, rawArgs, fallbackWindow, reach);
}
}
@@ -1009,6 +1013,33 @@ async function getCorrelations(
};
}
/**
* v1.42 (#615) — the stored days' weather and air quality, without any
* location. Module-gated inside the reader (off answers `module_disabled`
* without reading a row) and clamped to the lookback limit.
*/
async function getEnvironment(
userId: string,
rawArgs: unknown,
fallbackWindow: CoachScopeWindow | undefined,
reach: CoachHistoryReach,
): Promise<CoachToolResult> {
const parsed = getEnvironmentArgsSchema.safeParse(rawArgs);
if (!parsed.success) return badArgs("get_environment", parsed.error);
const result = await readEnvironmentForTool({
userId,
window: parsed.data.window ?? fallbackWindow ?? "last30days",
reach,
});
if (result.present) return { present: true, data: result.data };
return {
present: false,
reason: result.reason,
...(result.searchedWindow ? { searchedWindow: result.searchedWindow } : {}),
...(result.available ? { available: result.available } : {}),
};
}
// ── v1.39.4: result tables ──────────────────────────────────────────────
/**
+39 -1
View File
@@ -17,6 +17,8 @@
*/
import { annotate } from "@/lib/logging/context";
import { isCycleAvailableForUser } from "@/lib/cycle/gate";
import { prisma } from "@/lib/db";
import { isModuleEnabled } from "@/lib/modules/gate";
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
import type {
CoachProvenanceMetric,
@@ -143,9 +145,10 @@ export async function buildCoachDataInventory(
? undefined
: clampWindow(scope.window, reach),
};
const [snapshot, cycleEnabled] = await Promise.all([
const [snapshot, cycleEnabled, environment] = await Promise.all([
buildCoachSnapshot(userId, probeScope, { reach }),
isCycleAvailableForUser(userId),
probeEnvironment(userId),
]);
const sections = snapshot.sections;
const counts = snapshot.provenance.counts ?? {};
@@ -234,6 +237,17 @@ export async function buildCoachDataInventory(
});
}
// v1.42 (#615) — the stored days' weather and air quality, advertised only
// while the environment module is on (off, the domain does not exist for
// the Coach, like the cycle above).
if (environment !== null) {
entries.push({
tool: "get_environment",
domain: "weather, air quality & pollen (modelled, not personal exposure)",
present: environment,
});
}
// Every row that came back absent gets checked against the record before the
// manifest says "absent". A domain whose whole history predates the window —
// an imported year of readings, a device that stopped syncing — is otherwise
@@ -258,6 +272,30 @@ export async function buildCoachDataInventory(
};
}
/**
* Whether the account's environment days exist: null when the module is off
* or the probe failed (the row is left out), else whether any day is stored.
* One indexed probe.
*/
async function probeEnvironment(userId: string): Promise<boolean | null> {
try {
if (!(await isModuleEnabled(userId, "environment"))) return null;
const row = await prisma.environmentContext.findFirst({
where: { userId },
select: { date: true },
});
return row !== null;
} catch (err) {
// The manifest is advisory: a failed probe leaves the row out (the tool
// itself still answers), and says so on the request's event.
annotate({
action: { name: "coach.inventory.environment_failed" },
meta: { reason: err instanceof Error ? err.name : "unknown" },
});
return null;
}
}
/**
* Fill `entry.availability` for every absent row the record actually holds rows
* for. One batched probe: a single grouped aggregate over `Measurement` for the
+6 -3
View File
@@ -26,7 +26,8 @@
* the user can tell you" is the right move for data the record does not hold,
* and exactly the wrong one for data it does. `outside_reach` is data
* the record holds beyond the lookback limit the person set: the model is told
* it exists, gets no figure from it, and points at the setting.
* it exists, gets no figure from it, and points at the setting. Rule 9
* (v1.42) frames the environment read: modelled outdoor values, never a cause.
*/
import type { Locale } from "@/lib/i18n/config";
@@ -47,7 +48,8 @@ You have read-only retrieval tools and a DATA INVENTORY listing what the user ha
5. If you can answer without data (a definition, a "what can you help with?"), just answer — do not call a tool.
6. Keep using the EVIDENCE (---KEYVALUES---) block exactly as before, citing only numbers you fetched this turn.
7. A present get_metric_series result always carries numbers. When it is "condensed", cite its "summary" (when it has one) and the points it kept. When the question needs more history or finer detail than a series carries (all time, every month, a table), call get_metric_table yourself in this same answer. Never ask the user whether you should fetch it, unless the CLARIFYING QUESTIONS rule applies, and never tell them the numbers were not given to you.
8. You have several rounds: read, look at what came back, and fetch what is still missing in the next one. Never repeat a call you already made this turn; its result is above. If a round has to be your last, you are told so: then answer from what you have.`;
8. You have several rounds: read, look at what came back, and fetch what is still missing in the next one. Never repeat a call you already made this turn; its result is above. If a round has to be your last, you are told so: then answer from what you have.
9. Environment (get_environment, and the weather, air and pollen patterns get_correlations reports): these are modelled outdoor conditions at a coarse location, not the user's own exposure. Say what the days were like and that something occurred together; never that the weather, the air or the pollen caused a reading, a symptom or a mood.`;
const DE = `TOOL-BASIERTE ABFRAGE (diese Unterhaltung)
@@ -66,7 +68,8 @@ Du hast schreibgeschützte Abfrage-Tools und ein DATA INVENTORY, das auflistet,
5. Wenn du ohne Daten antworten kannst (eine Definition, „Wobei kannst du helfen?"), antworte einfach — rufe kein Tool auf.
6. Nutze den EVIDENZ-Block (---KEYVALUES---) genau wie zuvor und zitiere nur Zahlen, die du in diesem Zug abgerufen hast.
7. Ein vorhandenes Ergebnis von get_metric_series enthält immer Zahlen. Ist es „condensed", nenne die Werte aus „summary" (falls vorhanden) und die Punkte, die es behalten hat. Braucht die Frage mehr Verlauf oder feinere Werte, als eine Serie trägt (gesamter Zeitraum, jeder Monat, eine Tabelle), rufe get_metric_table in derselben Antwort selbst auf. Frage den Nutzer nie, ob du das abrufen sollst, außer die Regel CLARIFYING QUESTIONS greift, und sage nie, die Zahlen seien dir nicht mitgegeben worden.
8. Du hast mehrere Runden: abrufen, ansehen, was zurückkam, und in der nächsten Runde holen, was noch fehlt. Wiederhole keinen Aufruf, den du in diesem Zug schon gemacht hast; sein Ergebnis steht oben. Muss eine Runde deine letzte sein, wird dir das gesagt: dann antworte mit dem, was du hast.`;
8. Du hast mehrere Runden: abrufen, ansehen, was zurückkam, und in der nächsten Runde holen, was noch fehlt. Wiederhole keinen Aufruf, den du in diesem Zug schon gemacht hast; sein Ergebnis steht oben. Muss eine Runde deine letzte sein, wird dir das gesagt: dann antworte mit dem, was du hast.
9. Umwelt (get_environment und die Wetter-, Luft- und Pollenmuster aus get_correlations): das sind modellierte Außenwerte für einen groben Ort, nicht die persönliche Belastung des Nutzers. Beschreibe, wie die Tage waren und dass etwas zusammen auftrat; sage nie, Wetter, Luft oder Pollen hätten einen Wert, ein Symptom oder eine Stimmung verursacht.`;
export function buildToolModeAddendum(locale: Locale): string {
return locale === "de" ? DE : EN;
@@ -44,6 +44,7 @@ const DOMAINS: CoachStepDomain[] = [
"illness",
"cycle",
"correlations",
"environment",
"snapshot",
];
/** Every call a turn can make: the catalogue and `show_result`. */
+2
View File
@@ -84,6 +84,7 @@ const TOOL_DOMAIN: Readonly<Record<CoachToolName, CoachStepDomain | null>> = {
get_cycle: "cycle",
get_correlations: "correlations",
get_metric_table: null,
get_environment: "environment",
};
/**
@@ -105,6 +106,7 @@ const TOOL_WINDOW: Readonly<
get_cycle: null,
get_correlations: null,
get_metric_table: "fallback",
get_environment: "fallback",
};
/**
+7 -1
View File
@@ -487,7 +487,13 @@ export type CoachStepStatus = "running" | "done" | "empty" | "failed";
* scope sources plus the domains that are read as a whole.
*/
export type CoachStepDomain =
CoachScopeSource | "labs" | "illness" | "cycle" | "correlations" | "snapshot";
| CoachScopeSource
| "labs"
| "illness"
| "cycle"
| "correlations"
| "environment"
| "snapshot";
/** Why a step or a method entry found nothing. */
export type CoachStepReason =
@@ -5,6 +5,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
* 11 km), not 2 (about 1 km). The geocoder is where a picked city's
* coordinates enter, so its results must already be coarse.
*/
// The instance-wide request budget lives in Postgres; these tests are about
// the request itself, so the budget admits every call.
vi.mock("@/lib/environment/request-budget", async (importOriginal) => ({
...(await importOriginal<
typeof import("@/lib/environment/request-budget")
>()),
reserveOpenMeteoCalls: vi.fn(async () => true),
}));
beforeEach(() => {
vi.resetModules();
});
@@ -0,0 +1,52 @@
/**
* v1.42 (#615) — the notable-day flags the chips and the Coach summary share.
* Null is "not covered" and never raises a flag; each mark is inclusive.
*/
import { describe, expect, it } from "vitest";
import {
highPollenKinds,
isHotNight,
isVeryPoorAir,
pollenMax,
} from "../day-flags";
const NONE = {
alder: null,
birch: null,
grass: null,
mugwort: null,
olive: null,
ragweed: null,
};
describe("day flags", () => {
it("marks a tropical night at 20 °C and not below, and never for null", () => {
expect(isHotNight(20)).toBe(true);
expect(isHotNight(19.9)).toBe(false);
expect(isHotNight(null)).toBe(false);
});
it("marks very poor air from the index's very-poor band", () => {
expect(isVeryPoorAir(80)).toBe(true);
expect(isVeryPoorAir(79)).toBe(false);
expect(isVeryPoorAir(null)).toBe(false);
});
it("marks pollen per kind at its own high mark", () => {
expect(highPollenKinds({ ...NONE, birch: 100, grass: 49 })).toEqual([
"birch",
]);
expect(highPollenKinds({ ...NONE, grass: 50, ragweed: 50 })).toEqual([
"grass",
"ragweed",
]);
expect(highPollenKinds(NONE)).toEqual([]);
});
it("takes the pollen high over covered kinds only", () => {
expect(pollenMax(NONE)).toBeNull();
expect(pollenMax({ ...NONE, alder: 0 })).toBe(0);
expect(pollenMax({ ...NONE, alder: 3, grass: 12 })).toBe(12);
});
});
@@ -0,0 +1,82 @@
/**
* v1.42 (#615) — the sealed environment location.
*
* Round trip, rounding to the privacy floor before sealing, the label that
* keeps a location from opening as any other sealed value, and the reader's
* order: the sealed value first, the readable columns only without one, and
* a value that does not open read as no location rather than replaced.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Buffer } from "node:buffer";
import { decryptBytes, encryptBytes } from "@/lib/crypto";
import { ENVIRONMENT_LOCATION_AAD } from "@/lib/crypto/encrypted-columns";
import { openLocation, readLocation, sealLocation } from "../location-cipher";
beforeEach(() => {
vi.stubEnv("ENCRYPTION_KEYS", "");
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
vi.stubEnv("ENCRYPTION_KEY", "b".repeat(64));
});
afterEach(() => vi.unstubAllEnvs());
describe("sealLocation / openLocation", () => {
it("round-trips a location and rounds it to one decimal first", () => {
const sealed = sealLocation({
lat: 51.48165,
lon: 7.21648,
label: "Bochum, Germany",
});
expect(openLocation(sealed)).toEqual({
lat: 51.5,
lon: 7.2,
label: "Bochum, Germany",
});
// The bytes are ciphertext, not the JSON.
expect(Buffer.from(sealed).toString("utf8")).not.toContain("Bochum");
});
it("is labelled: the value does not open under another label", () => {
const sealed = sealLocation({ lat: 1, lon: 2, label: "x" });
expect(() =>
decryptBytes(Buffer.from(sealed), "healthlog/workout-route-geometry/v1"),
).toThrow();
expect(() =>
decryptBytes(Buffer.from(sealed), ENVIRONMENT_LOCATION_AAD),
).not.toThrow();
});
it("refuses a sealed value that is not a location", () => {
const other = encryptBytes(
Buffer.from(JSON.stringify({ lat: "51" })),
ENVIRONMENT_LOCATION_AAD,
);
expect(() => openLocation(new Uint8Array(other))).toThrow();
});
});
describe("readLocation", () => {
it("prefers the sealed value over readable columns", () => {
const sealed = sealLocation({ lat: 48.1, lon: 11.6, label: "Munich" });
expect(
readLocation({ sealed, lat: 52.5, lon: 13.4, label: "Berlin" }),
).toEqual({ lat: 48.1, lon: 11.6, label: "Munich" });
});
it("falls back to the readable columns for a row the backfill has not reached", () => {
expect(
readLocation({ sealed: null, lat: 52.5, lon: 13.4, label: "Berlin" }),
).toEqual({ lat: 52.5, lon: 13.4, label: "Berlin" });
expect(
readLocation({ sealed: null, lat: 52.5, lon: null, label: "Berlin" }),
).toBeNull();
});
it("reads a value that does not open as no location, never as the readable columns", () => {
const sealed = sealLocation({ lat: 48.1, lon: 11.6, label: "Munich" });
vi.stubEnv("ENCRYPTION_KEY", "c".repeat(64));
expect(
readLocation({ sealed, lat: 52.5, lon: 13.4, label: "Berlin" }),
).toBeNull();
});
});
@@ -0,0 +1,297 @@
/**
* v1.42 (#615) — the air-quality client: how hourly values become a day, and
* what leaves the host.
*
* Fixtures only, no live call: a day is built hour by hour so each rule is
* pinned at its edge (18 of 24 hours, 6 of 8 in an ozone window, 13 of 17
* windows, the midday UV hours), a missing value stays null and is never read
* as zero, and the request carries the coarse coordinates, `domains=auto`
* and nothing else about the person.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const reserve = vi.hoisted(() => vi.fn(async (_weight: number) => true));
vi.mock("@/lib/environment/request-budget", async (importOriginal) => ({
...(await importOriginal<
typeof import("@/lib/environment/request-budget")
>()),
reserveOpenMeteoCalls: reserve,
}));
import {
AIR_QUALITY_VARIABLES,
aggregateAirQuality,
chunkDays,
coveredMax,
coveredMean,
fetchDailyAirQuality,
insideCamsEurope,
ozoneMax8h,
uvIndexMax,
variablesForChunk,
type AirQualityHourly,
} from "../open-meteo-air-quality";
/** 24 hourly stamps of one local day. */
function hoursOf(day: string): string[] {
return Array.from(
{ length: 24 },
(_, h) => `${day}T${String(h).padStart(2, "0")}:00`,
);
}
/** A full day with every variable at `value`, then `edit` applied. */
function dayFixture(
day: string,
value: number,
edit: (h: AirQualityHourly) => void = () => {},
): AirQualityHourly {
const hourly: AirQualityHourly = { time: hoursOf(day) };
for (const variable of AIR_QUALITY_VARIABLES) {
hourly[variable] = Array.from({ length: 24 }, () => value);
}
edit(hourly);
return hourly;
}
const BOCHUM = { lat: 51.5, lon: 7.2 };
beforeEach(() => {
reserve.mockReset();
reserve.mockResolvedValue(true);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
describe("daily rules", () => {
it("averages a day with 18 hours and refuses one with 17", () => {
const eighteen = [...Array(18).fill(10), ...Array(6).fill(null)];
const seventeen = [...Array(17).fill(10), ...Array(7).fill(null)];
expect(coveredMean(eighteen)).toBe(10);
expect(coveredMean(seventeen)).toBeNull();
expect(coveredMax(eighteen)).toBe(10);
expect(coveredMax(seventeen)).toBeNull();
});
it("never reads a missing hour as zero", () => {
// 18 hours at 20 and six missing: a zero-fill would give 15.
expect(coveredMean([...Array(18).fill(20), ...Array(6).fill(null)])).toBe(
20,
);
});
it("takes the highest 8-hour running mean inside the day", () => {
const byHour = Array.from({ length: 24 }, (_, h) =>
h >= 12 && h < 20 ? 120 : 40,
);
expect(ozoneMax8h(byHour)).toBe(120);
});
it("counts an ozone window with 6 of 8 hours and drops the day under 13 windows", () => {
// Hours 0–5 missing: windows starting 0..3 have fewer than 6 hours…
const sparse = Array.from({ length: 24 }, (_, h) => (h < 6 ? null : 50));
// windows 0,1 have 2/3 hours; 2,3 have 4/5; 4..16 have ≥6 → 13 windows.
expect(ozoneMax8h(sparse)).toBe(50);
const sparser = Array.from({ length: 24 }, (_, h) => (h < 7 ? null : 50));
expect(ozoneMax8h(sparser)).toBeNull();
});
it("gives a UV maximum only when the midday hours are all there", () => {
const full: (number | null)[] = Array.from({ length: 24 }, (_, h) =>
h === 13 ? 6.5 : 1,
);
expect(uvIndexMax(full)).toBe(6.5);
const gap = [...full];
gap[11] = null;
expect(uvIndexMax(gap)).toBeNull();
});
it("decides the model domain by the CAMS Europe box", () => {
expect(insideCamsEurope(51.5, 7.2)).toBe(true);
expect(insideCamsEurope(40.7, -74)).toBe(false);
expect(insideCamsEurope(-33.9, 151.2)).toBe(false);
});
});
describe("aggregateAirQuality", () => {
it("folds a full day into one observation", () => {
const [day] = aggregateAirQuality(
dayFixture("2025-05-01", 10, (h) => {
h.pm2_5![15] = 40;
h.european_aqi![15] = 85;
}),
BOCHUM,
);
expect(day).toMatchObject({
date: "2025-05-01",
pm25Mean: 11.3,
pm25Max: 40,
eaqiMax: 85,
pollenBirchMax: 10,
aqDomain: "cams_europe",
aqHours: 24,
});
});
it("keeps a variable the feed left empty as null (pollen outside Europe)", () => {
const [day] = aggregateAirQuality(
dayFixture("2023-06-07", 30, (h) => {
for (const kind of [
"alder_pollen",
"birch_pollen",
"grass_pollen",
"mugwort_pollen",
"olive_pollen",
"ragweed_pollen",
] as const) {
h[kind] = Array(24).fill(null);
}
}),
{ lat: 40.7, lon: -74 },
);
expect(day.pm25Mean).toBe(30);
expect(day.pollenGrassMax).toBeNull();
expect(day.pollenBirchMax).toBeNull();
expect(day.aqDomain).toBe("cams_global");
});
it("keeps a variable missing from the response as null (the pre-2022 era)", () => {
const hourly: AirQualityHourly = {
time: hoursOf("2016-03-01"),
pm2_5: Array(24).fill(12),
};
const [day] = aggregateAirQuality(hourly, BOCHUM);
expect(day.pm25Mean).toBe(12);
expect(day.uvIndexMax).toBeNull();
expect(day.pollenBirchMax).toBeNull();
expect(day.dustMax).toBeNull();
});
});
describe("requests", () => {
it("splits into 90-day chunks and asks the pre-2022 ones for the pollutants only", () => {
expect(chunkDays("2022-01-01", "2022-07-01")).toEqual([
{ startDate: "2022-01-01", endDate: "2022-03-31" },
{ startDate: "2022-04-01", endDate: "2022-06-29" },
{ startDate: "2022-06-30", endDate: "2022-07-01" },
]);
expect(variablesForChunk("2022-03-31")).toHaveLength(8);
expect(variablesForChunk("2022-06-29")).toHaveLength(17);
});
it("sends the coarse coordinates, the timezone and domains=auto, nothing else", async () => {
const fetchSpy = vi.fn(async (_url: unknown) =>
Response.json({ hourly: dayFixture("2025-05-01", 10) }),
);
vi.stubGlobal("fetch", fetchSpy);
const result = await fetchDailyAirQuality({
...BOCHUM,
timezone: "Europe/Berlin",
startDate: "2025-05-01",
endDate: "2025-05-01",
});
expect(result.stopped).toBeNull();
expect(result.days).toHaveLength(1);
const url = new URL(String(fetchSpy.mock.calls[0]![0]));
expect(url.origin + url.pathname).toBe(
"https://air-quality-api.open-meteo.com/v1/air-quality",
);
expect([...url.searchParams.keys()].sort()).toEqual(
[
"domains",
"end_date",
"hourly",
"latitude",
"longitude",
"start_date",
"timezone",
].sort(),
);
expect(url.searchParams.get("latitude")).toBe("51.5");
expect(url.searchParams.get("longitude")).toBe("7.2");
expect(url.searchParams.get("domains")).toBe("auto");
expect(url.searchParams.get("hourly")!.split(",")).toHaveLength(17);
});
it("asks the budget with the request's weight and sends nothing when it refuses", async () => {
reserve.mockResolvedValue(false);
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
const result = await fetchDailyAirQuality({
...BOCHUM,
timezone: "UTC",
startDate: "2025-01-01",
endDate: "2025-03-31",
});
expect(result).toEqual({ days: [], stopped: "budget" });
expect(fetchSpy).not.toHaveBeenCalled();
// 17 variables over 90 days: 1.7 × 90/14.
expect(reserve.mock.calls[0]![0]).toBeCloseTo(1.7 * (90 / 14), 5);
});
it("fetches newest first and keeps what it has when a chunk fails", async () => {
const fetchSpy = vi
.fn()
.mockResolvedValueOnce(
Response.json({ hourly: dayFixture("2025-06-30", 5) }),
)
.mockResolvedValueOnce(new Response("busy", { status: 503 }));
vi.stubGlobal("fetch", fetchSpy);
const result = await fetchDailyAirQuality({
...BOCHUM,
timezone: "UTC",
startDate: "2025-01-01",
endDate: "2025-06-30",
});
expect(result.stopped).toBe("error");
expect(result.days.map((d) => d.date)).toEqual(["2025-06-30"]);
const first = new URL(String(fetchSpy.mock.calls[0]![0]));
expect(first.searchParams.get("end_date")).toBe("2025-06-30");
});
it("answers days before 2013 as uncoverable without a request", async () => {
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
const result = await fetchDailyAirQuality({
...BOCHUM,
timezone: "UTC",
startDate: "2012-12-30",
endDate: "2012-12-31",
});
expect(fetchSpy).not.toHaveBeenCalled();
expect(result.days.map((d) => [d.date, d.aqHours, d.pm25Mean])).toEqual([
["2012-12-30", 0, null],
["2012-12-31", 0, null],
]);
});
it("honours a self-hosted endpoint", async () => {
vi.resetModules();
vi.stubEnv("OPENMETEO_AIR_QUALITY_URL", "https://aq.example.test/");
const fetchSpy = vi.fn(async (_url: unknown) =>
Response.json({ hourly: {} }),
);
vi.stubGlobal("fetch", fetchSpy);
const fresh = await import("../open-meteo-air-quality");
await fresh.fetchDailyAirQuality({
...BOCHUM,
timezone: "UTC",
startDate: "2025-05-01",
endDate: "2025-05-01",
});
expect(String(fetchSpy.mock.calls[0]![0])).toMatch(
/^https:\/\/aq\.example\.test\/v1\/air-quality\?/,
);
});
it("reads the operator switch", async () => {
const { isAirQualityActive } = await import("../open-meteo-air-quality");
expect(isAirQualityActive(true)).toBe(true);
expect(isAirQualityActive(false)).toBe(false);
vi.stubEnv("ENVIRONMENT_AIR_QUALITY_DISABLED", "true");
expect(isAirQualityActive(true)).toBe(false);
});
});
@@ -6,6 +6,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
* hosted defaults must still apply; before, the empty string was kept and
* every request died on `new URL("/v1/search")` with ERR_INVALID_URL.
*/
// The instance-wide request budget lives in Postgres; these tests are about
// the request itself, so the budget admits every call.
vi.mock("@/lib/environment/request-budget", async (importOriginal) => ({
...(await importOriginal<
typeof import("@/lib/environment/request-budget")
>()),
reserveOpenMeteoCalls: vi.fn(async () => true),
}));
beforeEach(() => {
vi.resetModules();
});
@@ -0,0 +1,115 @@
/**
* v1.42 (#615) — the instance-wide Open-Meteo request budget.
*
* The weight rule (the hosted pricing formula), the three windows checked
* together, a window that expired starting over, and the transaction that
* charges all three windows or none.
*/
import { describe, expect, it, vi } from "vitest";
const store = vi.hoisted(
() => new Map<string, { count: number; resetAt: Date }>(),
);
vi.mock("@/lib/db", () => {
const tx = {
$queryRaw: vi.fn(async () => [{ locked: 1 }]),
rateLimit: {
findMany: vi.fn(async (args: { where: { key: { in: string[] } } }) =>
args.where.key.in
.filter((key) => store.has(key))
.map((key) => ({ key, ...store.get(key)! })),
),
upsert: vi.fn(
async (args: {
where: { key: string };
create: { count: number; resetAt: Date };
}) => {
store.set(args.where.key, {
count: args.create.count,
resetAt: args.create.resetAt,
});
},
),
},
};
return {
prisma: {
$transaction: async (fn: (t: typeof tx) => unknown) => fn(tx),
},
};
});
import {
OPEN_METEO_BUDGET_WINDOWS,
admitWeight,
openMeteoCallWeight,
reserveOpenMeteoCalls,
type BudgetBucket,
} from "../request-budget";
const NOW = new Date("2026-10-08T02:10:00Z");
describe("call weight", () => {
it("follows the hosted rule: more than 10 variables or 14 days weighs more", () => {
expect(openMeteoCallWeight(3, 1)).toBe(1);
expect(openMeteoCallWeight(10, 14)).toBe(1);
expect(openMeteoCallWeight(15, 14)).toBe(1.5);
expect(openMeteoCallWeight(17, 90)).toBeCloseTo(10.93, 2);
expect(openMeteoCallWeight(12, 730)).toBeCloseTo(62.57, 2);
});
});
describe("admitWeight", () => {
it("admits into empty windows and stamps each window's reset", () => {
const decision = admitWeight(new Map(), 150, NOW);
expect(decision.admitted).toBe(true);
if (!decision.admitted) return;
for (const w of OPEN_METEO_BUDGET_WINDOWS) {
expect(decision.next.get(w.name)).toEqual({
count: 150,
resetAt: new Date(NOW.getTime() + w.windowMs),
});
}
});
it("refuses when the day is full even though the minute and hour are not", () => {
const later = new Date(NOW.getTime() + 3_600_000);
const buckets = new Map<"minute" | "hour" | "day", BudgetBucket>([
["day", { count: 8_000 * 100 - 50, resetAt: later }],
]);
expect(admitWeight(buckets, 49, NOW).admitted).toBe(true);
expect(admitWeight(buckets, 51, NOW)).toEqual({
admitted: false,
window: "day",
});
});
it("starts a window over once it has expired", () => {
const expired = new Date(NOW.getTime() - 1);
const buckets = new Map<"minute" | "hour" | "day", BudgetBucket>([
["minute", { count: 500 * 100, resetAt: expired }],
]);
const decision = admitWeight(buckets, 100, NOW);
expect(decision.admitted).toBe(true);
if (decision.admitted) {
expect(decision.next.get("minute")!.count).toBe(100);
}
});
});
describe("reserveOpenMeteoCalls", () => {
it("charges every window, and charges nothing once one is full", async () => {
store.clear();
expect(await reserveOpenMeteoCalls(1.7)).toBe(true);
expect(store.get("open-meteo-budget:day")!.count).toBe(170);
expect(store.get("open-meteo-budget:minute")!.count).toBe(170);
// Fill the minute to the brim: the next call is refused and the hour
// and the day are not charged for it.
const minute = store.get("open-meteo-budget:minute")!;
store.set("open-meteo-budget:minute", { ...minute, count: 500 * 100 });
const dayBefore = store.get("open-meteo-budget:day")!.count;
expect(await reserveOpenMeteoCalls(1)).toBe(false);
expect(store.get("open-meteo-budget:day")!.count).toBe(dayBefore);
});
});
@@ -0,0 +1,297 @@
/**
* v1.42 (#615) — the fetch-and-store run with air quality and sealed
* locations.
*
* - the weather row is stored even when the air-quality fetch fails, and
* its air-quality part is left unfetched for the gap fill;
* - with air quality on, the day's values and `aqFetchedAt` ride the same
* upsert; off by the account or by the operator, the feed is not asked;
* - the home is opened from its sealed copy and only its coarse
* coordinates go into a request; the row is written sealed, the readable
* location columns empty;
* - a refused budget stops the run without failing it;
* - the gap fill fetches the stored days newest first at the location each
* day was stored for.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const db = vi.hoisted(() => ({
user: null as Record<string, unknown> | null,
travel: [] as Array<Record<string, unknown>>,
days: [] as Array<Record<string, unknown>>,
upserts: [] as Array<{
create: Record<string, unknown>;
update: Record<string, unknown>;
}>,
updates: [] as Array<{
where: Record<string, unknown>;
data: Record<string, unknown>;
}>,
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn(async () => db.user) },
environmentTravelLocation: { findMany: vi.fn(async () => db.travel) },
environmentContext: {
upsert: vi.fn(
(args: {
create: Record<string, unknown>;
update: Record<string, unknown>;
}) => {
db.upserts.push(args);
return args;
},
),
findMany: vi.fn(async () => db.days),
updateMany: vi.fn(
(args: {
where: Record<string, unknown>;
data: Record<string, unknown>;
}) => {
db.updates.push(args);
return { count: 1 };
},
),
},
$transaction: vi.fn(async (ops: unknown[]) =>
ops.map((op) =>
op && typeof op === "object" && "count" in op ? op : op,
),
),
},
}));
const weather = vi.hoisted(() => vi.fn());
vi.mock("@/lib/environment/open-meteo", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/environment/open-meteo")>()),
fetchDailyEnvironment: weather,
}));
const air = vi.hoisted(() => vi.fn());
vi.mock("@/lib/environment/open-meteo-air-quality", async (importOriginal) => ({
...(await importOriginal<
typeof import("@/lib/environment/open-meteo-air-quality")
>()),
fetchDailyAirQuality: air,
}));
import { openLocation, sealLocation } from "../location-cipher";
import { OpenMeteoBudgetExhaustedError } from "../request-budget";
import { uncoverableDay } from "../open-meteo-air-quality";
import { fetchAndStoreEnvironment, fillAirQualityGaps } from "../service";
function obs(date: string) {
return {
date,
tempMin: 14,
tempMax: 24,
tempMean: 19,
apparentMean: 18,
apparentMax: 25,
sunshineSec: 3600,
daylightSec: 50_000,
precipSum: 0,
pressureMean: 1012,
pressureDelta: 3,
humidityMean: 60,
cloudMean: 40,
weatherCode: 1,
};
}
function airDay(date: string) {
return {
...uncoverableDay(date),
pm25Mean: 9.5,
aqHours: 24,
aqDomain: "cams_europe",
};
}
beforeEach(() => {
vi.stubEnv("ENCRYPTION_KEYS", "");
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
vi.stubEnv("ENCRYPTION_KEY", "d".repeat(64));
db.user = {
homeLat: null,
homeLon: null,
homeLabel: null,
homeLocationEncrypted: sealLocation({
lat: 51.48,
lon: 7.21,
label: "Bochum, Germany",
}),
homeTimezone: "Europe/Berlin",
homeSince: new Date("2026-01-01T00:00:00Z"),
timezone: "Europe/Berlin",
environmentAirQualityEnabled: true,
};
db.travel = [];
db.days = [];
db.upserts = [];
db.updates = [];
weather.mockReset();
air.mockReset();
weather.mockImplementation(async (args: { startDate: string }) => [
obs(args.startDate),
]);
});
afterEach(() => vi.unstubAllEnvs());
describe("fetchAndStoreEnvironment", () => {
it("asks both feeds with the coarse home only and writes the day sealed", async () => {
air.mockResolvedValue({ days: [airDay("2026-05-01")], stopped: null });
const result = await fetchAndStoreEnvironment({
userId: "u1",
startDate: "2026-05-01",
endDate: "2026-05-01",
});
expect(result).toMatchObject({ stored: 1, budgetBlocked: false });
for (const call of [weather.mock.calls[0][0], air.mock.calls[0][0]]) {
expect(Object.keys(call).sort()).toEqual(
["endDate", "lat", "lon", "startDate", "timezone"].sort(),
);
expect([call.lat, call.lon]).toEqual([51.5, 7.2]);
}
const { create } = db.upserts[0];
expect(create).toMatchObject({
lat: null,
lon: null,
locationLabel: null,
source: "HOME",
apparentMax: 25,
pm25Mean: 9.5,
aqHours: 24,
});
expect(create.aqFetchedAt).toBeInstanceOf(Date);
expect(openLocation(create.locationEncrypted as Uint8Array)).toEqual({
lat: 51.5,
lon: 7.2,
label: "Bochum, Germany",
});
});
it("stores the weather when the air-quality fetch fails, and leaves the air part unfetched", async () => {
air.mockResolvedValue({ days: [], stopped: "error" });
const result = await fetchAndStoreEnvironment({
userId: "u1",
startDate: "2026-05-01",
endDate: "2026-05-01",
});
expect(result.stored).toBe(1);
const { update } = db.upserts[0];
expect(update).toMatchObject({
tempMin: 14,
aqFetchedAt: null,
pm25Mean: null,
});
});
it("does not ask the air-quality feed when the account turned it off", async () => {
db.user!.environmentAirQualityEnabled = false;
await fetchAndStoreEnvironment({
userId: "u1",
startDate: "2026-05-01",
endDate: "2026-05-01",
});
expect(air).not.toHaveBeenCalled();
// The stored air part is left as it is: no air column in the write.
expect(Object.keys(db.upserts[0].update)).not.toContain("aqFetchedAt");
});
it("does not ask it when the operator turned it off", async () => {
vi.stubEnv("ENVIRONMENT_AIR_QUALITY_DISABLED", "1");
await fetchAndStoreEnvironment({
userId: "u1",
startDate: "2026-05-01",
endDate: "2026-05-01",
});
expect(air).not.toHaveBeenCalled();
});
it("stops without failing when the budget refuses", async () => {
weather.mockRejectedValue(new OpenMeteoBudgetExhaustedError());
const result = await fetchAndStoreEnvironment({
userId: "u1",
startDate: "2026-05-01",
endDate: "2026-05-01",
});
expect(result).toMatchObject({ stored: 0, budgetBlocked: true });
expect(air).not.toHaveBeenCalled();
});
it("resolves a period from its sealed copy", async () => {
db.travel = [
{
startDate: "2026-05-01",
endDate: "2026-05-02",
lat: null,
lon: null,
label: null,
locationEncrypted: sealLocation({
lat: 38.72,
lon: -9.14,
label: "Lisbon, Portugal",
}),
},
];
air.mockResolvedValue({ days: [], stopped: null });
await fetchAndStoreEnvironment({
userId: "u1",
startDate: "2026-05-01",
endDate: "2026-05-01",
});
expect([
weather.mock.calls[0][0].lat,
weather.mock.calls[0][0].lon,
]).toEqual([38.7, -9.1]);
expect(db.upserts[0].create.source).toBe("TRAVEL");
});
});
describe("fillAirQualityGaps", () => {
it("fetches each stored day at its own sealed location and marks it fetched", async () => {
const sealed = sealLocation({ lat: 51.5, lon: 7.2, label: "Home" });
db.days = [
{
date: "2026-05-03",
lat: null,
lon: null,
locationLabel: null,
locationEncrypted: sealed,
},
{
date: "2026-05-02",
lat: null,
lon: null,
locationLabel: null,
locationEncrypted: sealed,
},
];
air.mockResolvedValue({
days: [airDay("2026-05-02"), airDay("2026-05-03")],
stopped: null,
});
const result = await fillAirQualityGaps("u1");
expect(result).toEqual({ filled: 2, stopped: null });
expect(air.mock.calls[0][0]).toMatchObject({
lat: 51.5,
lon: 7.2,
startDate: "2026-05-02",
endDate: "2026-05-03",
});
expect(db.updates.map((u) => u.where)).toEqual([
{ userId: "u1", date: "2026-05-02", aqFetchedAt: null },
{ userId: "u1", date: "2026-05-03", aqFetchedAt: null },
]);
});
it("is a no-op while air quality is off for the account", async () => {
db.user!.environmentAirQualityEnabled = false;
expect(await fillAirQualityGaps("u1")).toEqual({
filled: 0,
stopped: null,
});
expect(air).not.toHaveBeenCalled();
});
});
@@ -60,3 +60,25 @@ export const AIR_QUALITY_ATTRIBUTIONS = [
url: "https://open-meteo.com/",
},
] as const;
/**
* The attribution lines, in display order, for wherever environment values
* are shown (settings, the Coach and MCP read, the docs). The weather line
* always; the two air-quality lines when air-quality values are part of what
* is shown. "Contains modified" because hourly values are folded into days
* here (the Copernicus licence asks for that wording on derived data); the
* year is the newest year the shown values come from.
*/
export function environmentAttributionLines(args: {
airQuality: boolean;
year: number;
}): string[] {
const lines = ["Weather data by Open-Meteo.com (CC BY 4.0)"];
if (args.airQuality) {
lines.push(
"Air quality data by Open-Meteo.com (CC BY 4.0)",
`Contains modified Copernicus Atmosphere Monitoring Service information ${args.year}`,
);
}
return lines;
}
+77
View File
@@ -0,0 +1,77 @@
/**
* The three notable-day flags of the environment module (v1.42, #615): a
* high pollen count, a hot night, and very poor air. The dashboard chips, the
* Coach and MCP read's summary all count with these, so a day is "notable"
* by one definition everywhere. Leaf module, safe on the client.
*
* Deliberately few and deliberately high: a flag is a quiet note about the
* weather a day had, shown beside the person's own data, and a chip that is
* on most summer days says nothing. None of them claims an effect on the
* person: they describe the modelled outdoor air at a coarse location, which
* is not anyone's personal exposure.
*
* - Hot night: the day's minimum temperature at or above 20 °C, the
* meteorological "tropical night".
* - Very poor air: the European air-quality index reaching 80 at some hour
* of the day, the index's "very poor" band (60–80 is "poor", which a
* summer ozone afternoon reaches routinely).
* - High pollen: any kind at or above a coarse "high" mark, 100 grains/m³
* for the tree pollens (alder, birch, olive), 50 for grass, mugwort and
* ragweed. Approximate bands of the kind European pollen services use;
* individual sensitivity varies far more than these marks do.
*/
/** Minimum temperature (°C) at or above which a night counts as hot. */
export const HOT_NIGHT_MIN_C = 20;
/** European AQI at or above which the air counts as very poor. */
export const VERY_POOR_AIR_EAQI = 80;
/** The pollen kinds, in display order. */
export const POLLEN_KINDS = [
"alder",
"birch",
"grass",
"mugwort",
"olive",
"ragweed",
] as const;
export type PollenKind = (typeof POLLEN_KINDS)[number];
/** The grains/m³ at or above which each kind counts as high. */
export const POLLEN_HIGH_MARK: Readonly<Record<PollenKind, number>> = {
alder: 100,
birch: 100,
olive: 100,
grass: 50,
mugwort: 50,
ragweed: 50,
};
/** Pollen maxima of one day by kind; null = not covered. */
export type PollenByKind = Readonly<Record<PollenKind, number | null>>;
/** The day's highest pollen count over all kinds, or null when none covered. */
export function pollenMax(pollen: PollenByKind): number | null {
let best: number | null = null;
for (const kind of POLLEN_KINDS) {
const value = pollen[kind];
if (value != null && (best === null || value > best)) best = value;
}
return best;
}
/** The kinds at or above their high mark, in display order. */
export function highPollenKinds(pollen: PollenByKind): PollenKind[] {
return POLLEN_KINDS.filter((kind) => {
const value = pollen[kind];
return value != null && value >= POLLEN_HIGH_MARK[kind];
});
}
export function isHotNight(tempMin: number | null): boolean {
return tempMin != null && tempMin >= HOT_NIGHT_MIN_C;
}
export function isVeryPoorAir(eaqiMax: number | null): boolean {
return eaqiMax != null && eaqiMax >= VERY_POOR_AIR_EAQI;
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Day-key arithmetic for the environment module. Leaf module (no imports) so
* the feed clients, the service and the job share one definition.
*/
const MS_PER_DAY = 86_400_000;
/** Inclusive number of days from `start` to `end` (`YYYY-MM-DD` keys). */
export function enumerateDayCount(start: string, end: string): number {
const [sy, sm, sd] = start.split("-").map(Number);
const [ey, em, ed] = end.split("-").map(Number);
return (
Math.round(
(Date.UTC(ey, em - 1, ed) - Date.UTC(sy, sm - 1, sd)) / MS_PER_DAY,
) + 1
);
}
+91 -2
View File
@@ -56,13 +56,39 @@ export type EnvironmentContextNumericColumn =
| "pressureMean"
| "pressureDelta"
| "humidityMean"
| "cloudMean";
| "cloudMean"
// v1.42 (#615) — air quality.
| "pm25Mean"
| "o3Max8h";
/**
* A channel value that is not one column: the day's highest pollen count over
* the six kinds (`pollenAlderMax` … `pollenRagweedMax`), null when no kind was
* covered. Read by {@link environmentFieldValue}.
*/
export type EnvironmentDerivedColumn = "pollenMax";
/** The six pollen columns `pollenMax` folds. */
export const POLLEN_COLUMNS = [
"pollenAlderMax",
"pollenBirchMax",
"pollenGrassMax",
"pollenMugwortMax",
"pollenOliveMax",
"pollenRagweedMax",
] as const;
export interface EnvironmentField {
/** Stable key — the registry signal key AND the correlation channel key. */
key: string;
/** The `EnvironmentContext` column this field's daily value comes from. */
column: EnvironmentContextNumericColumn;
column: EnvironmentContextNumericColumn | EnvironmentDerivedColumn;
/**
* v1.42 — the field comes from the air-quality feed, so it is read only
* while the account's air-quality part is on (and the operator's is not
* off). The weather fields do not set it.
*/
airQuality?: true;
/** Canonical unit (English, for prose + registry metadata). */
unit: string;
/** Stable English display name (the UI localises via `i18nLabelKey`). */
@@ -136,4 +162,67 @@ export const ENVIRONMENT_FIELDS: readonly EnvironmentField[] = [
narrationLabel: "intraday pressure swing",
i18nLabelKey: "environment.fields.pressureDelta",
},
// v1.42 (#615) — three air-quality channels, deliberately no more: every
// channel widens the false-discovery family for all the others. Fine
// particles and the ozone high carry the evidence for blood pressure, HRV
// and sleep; the pollen high is the one with a plausible personal signal,
// against symptoms. UV, NO2 and dust are shown and read by the Coach but
// not correlated (UV is almost pure season, NO2 moves with PM2.5). The
// night-time heat channel is the existing overnight low above.
{
key: "ENV_PM25",
column: "pm25Mean",
unit: "µg/m³",
displayName: "Fine particles (PM2.5)",
narrationLabel: "fine particles (PM2.5)",
i18nLabelKey: "environment.fields.pm25",
airQuality: true,
},
{
key: "ENV_OZONE_8H",
column: "o3Max8h",
unit: "µg/m³",
displayName: "Ozone (8-hour high)",
narrationLabel: "ozone",
i18nLabelKey: "environment.fields.ozone8h",
airQuality: true,
},
{
key: "ENV_POLLEN_MAX",
column: "pollenMax",
unit: "grains/m³",
displayName: "Pollen (highest)",
narrationLabel: "pollen",
i18nLabelKey: "environment.fields.pollenMax",
airQuality: true,
},
] as const;
/** The columns a day row must carry for {@link environmentFieldValue}. */
export type EnvironmentFieldRow = Readonly<
Record<EnvironmentContextNumericColumn, number | null> &
Record<(typeof POLLEN_COLUMNS)[number], number | null>
>;
/**
* One field's raw value on one stored day (before any unit conversion), or
* null when the day does not cover it. The pollen high is the maximum over
* the kinds that were covered, never a zero for the ones that were not.
*/
export function environmentFieldValue(
field: EnvironmentField,
row: EnvironmentFieldRow,
): number | null {
if (field.column === "pollenMax") {
let best: number | null = null;
for (const column of POLLEN_COLUMNS) {
const value = row[column];
if (value != null && Number.isFinite(value)) {
best = best === null ? value : Math.max(best, value);
}
}
return best;
}
const value = row[field.column];
return value != null && Number.isFinite(value) ? value : null;
}
+114
View File
@@ -0,0 +1,114 @@
/**
* The environment module's coarse locations, sealed at rest (v1.42, #615).
*
* Three places held a location in readable columns: the home on the user row
* (`homeLat` / `homeLon` / `homeLabel`), each dated location period
* (`EnvironmentTravelLocation.lat` / `lon` / `label`), and each stored day
* (`EnvironmentContext.lat` / `lon` / `locationLabel`). Coarse as they are
* (one decimal, about 11 km), a home city and a dated list of trips are still
* a movement profile. From v1.42 each row stores ONE sealed value instead, the
* JSON `{ lat, lon, label }` under AES-256-GCM with the associated-data label
* {@link ENVIRONMENT_LOCATION_AAD}: one decrypt per row, and the three parts
* can never drift apart.
*
* Writers seal through {@link sealLocation}; the readable columns are written
* as null. Readers go through {@link readLocation}: the sealed value when a
* row has one, the readable columns only for a row the boot-time encryption
* backfill (`free-text-encryption-backfill.ts`) has not reached yet. A sealed
* value that does not open reads as no location, with a warning on the
* request's event, and is never replaced by the readable columns.
*
* The coordinates are rounded to the privacy floor BEFORE sealing, so the
* sealed value never carries more precision than the readable column did,
* and nothing that leaves the host (the weather and air-quality requests) is
* ever finer than that.
*/
import { Buffer } from "node:buffer";
import { decryptBytes, encryptBytes } from "@/lib/crypto";
import { ENVIRONMENT_LOCATION_AAD } from "@/lib/crypto/encrypted-columns";
import { roundCoarse } from "@/lib/environment/open-meteo";
import { getEvent } from "@/lib/logging/context";
/** A coarse location as the module uses it. */
export interface CoarseLocation {
lat: number;
lon: number;
label: string;
}
/** Seal a location for one of the three `*Encrypted` location columns. */
export function sealLocation(
location: CoarseLocation,
): Uint8Array<ArrayBuffer> {
const sealed = encryptBytes(
Buffer.from(
JSON.stringify({
lat: roundCoarse(location.lat),
lon: roundCoarse(location.lon),
label: location.label,
}),
"utf8",
),
ENVIRONMENT_LOCATION_AAD,
);
const out = new Uint8Array(new ArrayBuffer(sealed.byteLength));
out.set(sealed);
return out;
}
/**
* Open a sealed location. Throws on a bad key id, a tampered value, or a
* payload that is not a location.
*/
export function openLocation(sealed: Uint8Array): CoarseLocation {
const plain = decryptBytes(Buffer.from(sealed), ENVIRONMENT_LOCATION_AAD);
const parsed = JSON.parse(plain.toString("utf8")) as unknown;
if (
!parsed ||
typeof parsed !== "object" ||
typeof (parsed as CoarseLocation).lat !== "number" ||
typeof (parsed as CoarseLocation).lon !== "number" ||
typeof (parsed as CoarseLocation).label !== "string"
) {
throw new Error("sealed environment location has an unexpected shape");
}
const { lat, lon, label } = parsed as CoarseLocation;
return { lat, lon, label };
}
/**
* The location a row holds: the sealed value first, the legacy readable
* columns only when there is no sealed value. Null when the row holds
* neither (or only part of the readable triple), and null with a warning when
* the sealed value does not open.
*
* `fallbackLabel` stands in for a missing readable label, which the home has
* always allowed (`homeLabel` was nullable from the start).
*/
export function readLocation(
row: {
sealed: Uint8Array | null | undefined;
lat: number | null | undefined;
lon: number | null | undefined;
label: string | null | undefined;
},
fallbackLabel?: string,
): CoarseLocation | null {
if (row.sealed && row.sealed.byteLength > 0) {
try {
return openLocation(row.sealed);
} catch (err) {
getEvent()?.addWarning(
`environment location decrypt failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
return null;
}
}
if (row.lat == null || row.lon == null) return null;
const label = row.label ?? fallbackLabel;
if (label == null) return null;
return { lat: row.lat, lon: row.lon, label };
}
@@ -0,0 +1,438 @@
/**
* v1.42 (#615) — Open-Meteo air-quality client: particles, gases, the two
* air-quality indices, UV, Saharan dust, aerosol depth and six pollen kinds
* per local day, from the CAMS models.
*
* ## The request
*
* `GET /v1/air-quality` with the 17 hourly variables below, the coarse
* coordinates (one decimal, the module's privacy floor), the day range and the
* same timezone the weather archive is asked in, so a stored day means the
* same local day for both. `domains=auto` is required: CAMS Europe (0.1°,
* about 11 km) alone never returns UV or aerosol depth, CAMS global (0.4°,
* about 45 km) alone never returns pollen; `auto` takes each variable from the
* model that has it. The feed has no daily aggregates (asking for one is an
* error), so it is read hourly and folded into days here.
*
* Egress goes through `safeFetch` like the archive client, without
* `requirePublicHost`: the base URL is operator configuration
* (`OPENMETEO_AIR_QUALITY_URL`), and an operator may point it at a
* self-hosted Open-Meteo on a private host. `ENVIRONMENT_AIR_QUALITY_DISABLED`
* turns the whole feed off for the instance; nothing is sent then.
*
* ## Coverage, and why a missing value is null and never zero
*
* The feed starts on 2013-01-01; pollen, UV, dust and aerosol depth only from
* about mid 2022, and pollen only in Europe. A value the feed did not cover is
* stored as null. A chunk that ends before {@link AIR_QUALITY_FULL_FEED_FROM}
* asks only for the eight pollutant and index variables, which the feed has
* for that era; that keeps the request weight at 1.0 instead of 1.7 per ten
* variables. A day before {@link AIR_QUALITY_EARLIEST_DAY} cannot be fetched
* at all and is returned as uncoverable (every value null, no hours), so the
* nightly gap fill does not ask for it again.
*
* ## Daily rules (a local day of hourly values)
*
* - PM2.5, PM10, NO2, SO2, CO: the mean, only when at least 18 of the day's
* hours have a value (the 75 % completeness rule air-quality reporting
* uses); PM2.5 also its hourly maximum under the same rule (smoke shows in
* the peak, not the mean).
* - Ozone: the highest 8-hour running mean whose window lies inside the day
* (windows starting 00:00 to 16:00, 17 of them), a window counting when 6
* of its 8 hours have a value; the day only when at least 13 windows
* count. A simplified form of the EU 8-hour ozone metric.
* - European and US AQI, dust, aerosol depth, each pollen kind: the day's
* maximum, under the 18-hour rule.
* - UV index: the day's maximum, only when 10:00 to 16:00 are all present,
* because a UV maximum with the midday hours missing is not a maximum.
* - `aqHours`: the number of hours with a PM2.5 value, the day's coverage.
* - `aqDomain`: `cams_europe` when the coordinates lie inside the CAMS
* Europe domain (30–72° N, 25° W–45° E), else `cams_global`. The response
* does not say which model `auto` used; the domain box is what decides it.
*
* ## Attribution
*
* CAMS data is free to use with the notice "Contains modified Copernicus
* Atmosphere Monitoring Service information" (modified, because hourly
* values are folded into days here), and Open-Meteo's terms ask for credit
* (CC BY 4.0). Both lines come from `airQualityAttributionLines` in the
* contract module and are shown wherever the values are.
*/
import { envFlag, envOr } from "@/lib/env";
import { safeFetch, SafeFetchError } from "@/lib/safe-fetch";
import { shiftDateKey } from "@/lib/tz/format";
import type { AirQualityDay } from "@/lib/environment/air-quality-contract";
import { enumerateDayCount } from "@/lib/environment/day-span";
import {
openMeteoCallWeight,
reserveOpenMeteoCalls,
} from "@/lib/environment/request-budget";
/** Hosted default; override with `OPENMETEO_AIR_QUALITY_URL` (self-host). */
const AIR_QUALITY_BASE_URL = envOr(
"OPENMETEO_AIR_QUALITY_URL",
"https://air-quality-api.open-meteo.com",
).replace(/\/$/, "");
const FETCH_TIMEOUT_MS = 15_000;
/** True when the operator turned the air-quality feed off for the instance. */
export function isAirQualityOperatorDisabled(): boolean {
return envFlag("ENVIRONMENT_AIR_QUALITY_DISABLED");
}
/**
* Whether the air-quality part runs for an account: the operator has not
* turned it off for the instance and the account has not turned it off
* (`User.environmentAirQualityEnabled`).
*/
export function isAirQualityActive(accountEnabled: boolean): boolean {
return accountEnabled && !isAirQualityOperatorDisabled();
}
/** The first day the feed serves. */
export const AIR_QUALITY_EARLIEST_DAY = "2013-01-01";
/**
* From about here the feed carries pollen, UV, dust and aerosol depth. A
* chunk ending before it asks for the pollutant and index variables only.
*/
export const AIR_QUALITY_FULL_FEED_FROM = "2022-06-01";
/** Days per request: bounds the response (about 300 KB) and the heap. */
export const AIR_QUALITY_CHUNK_DAYS = 90;
/** The eight pollutant and index variables, served from 2013. */
const POLLUTANT_VARIABLES = [
"pm2_5",
"pm10",
"ozone",
"nitrogen_dioxide",
"sulphur_dioxide",
"carbon_monoxide",
"european_aqi",
"us_aqi",
] as const;
/** The nine variables the feed fills only from about mid 2022. */
const RECENT_VARIABLES = [
"uv_index",
"dust",
"aerosol_optical_depth",
"alder_pollen",
"birch_pollen",
"grass_pollen",
"mugwort_pollen",
"olive_pollen",
"ragweed_pollen",
] as const;
type HourlyVariable =
(typeof POLLUTANT_VARIABLES)[number] | (typeof RECENT_VARIABLES)[number];
/** All 17 hourly variables, in request order. */
export const AIR_QUALITY_VARIABLES: readonly HourlyVariable[] = [
...POLLUTANT_VARIABLES,
...RECENT_VARIABLES,
];
/** One stored day's air-quality part, as the feed produced it. */
export type DailyAirQualityObservation = { date: string } & Omit<
AirQualityDay,
"apparentMax" | "aqFetchedAt"
>;
/** The hourly block of a response: local ISO times and one array per variable. */
export type AirQualityHourly = { time?: string[] } & Partial<
Record<HourlyVariable, (number | null)[]>
>;
/** Hours a day needs for a mean or a maximum (75 % of 24). */
const MIN_HOURS = 18;
/** Valid hours an 8-hour ozone window needs. */
const MIN_O3_WINDOW_HOURS = 6;
/** Valid 8-hour windows (of 17) an ozone day needs. */
const MIN_O3_WINDOWS = 13;
/** Local hours whose values a UV maximum needs. */
const UV_MIDDAY_HOURS = [10, 11, 12, 13, 14, 15, 16];
function finite(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function round1(value: number): number {
return Math.round(value * 10) / 10;
}
function present(values: readonly (number | null)[]): number[] {
return values.filter((v): v is number => v !== null);
}
/** Mean when the day is covered, else null. Rounded to 0.1. */
export function coveredMean(values: readonly (number | null)[]): number | null {
const xs = present(values);
if (xs.length < MIN_HOURS) return null;
return round1(xs.reduce((a, b) => a + b, 0) / xs.length);
}
/** Maximum when the day is covered, else null. */
export function coveredMax(values: readonly (number | null)[]): number | null {
const xs = present(values);
if (xs.length < MIN_HOURS) return null;
return Math.max(...xs);
}
/**
* Highest 8-hour running mean of the day, from values indexed by local hour
* (0–23, a missing hour null). Null when fewer than 13 of the 17 windows have
* 6 of their 8 hours.
*/
export function ozoneMax8h(byHour: readonly (number | null)[]): number | null {
let best: number | null = null;
let validWindows = 0;
for (let start = 0; start <= 16; start += 1) {
const window = present(byHour.slice(start, start + 8));
if (window.length < MIN_O3_WINDOW_HOURS) continue;
validWindows += 1;
const mean = window.reduce((a, b) => a + b, 0) / window.length;
if (best === null || mean > best) best = mean;
}
if (validWindows < MIN_O3_WINDOWS || best === null) return null;
return round1(best);
}
/** The day's UV maximum when the midday hours are all present, else null. */
export function uvIndexMax(byHour: readonly (number | null)[]): number | null {
if (UV_MIDDAY_HOURS.some((h) => byHour[h] == null)) return null;
const xs = present(byHour);
return xs.length === 0 ? null : Math.max(...xs);
}
/** Whether a coarse location lies inside the CAMS Europe model domain. */
export function insideCamsEurope(lat: number, lon: number): boolean {
return lat >= 30 && lat <= 72 && lon >= -25 && lon <= 45;
}
/**
* Fold an hourly response into one observation per local day present in it.
* Pure; exported for tests. Times are the feed's local `YYYY-MM-DDTHH:MM`;
* a repeated hour (the autumn clock change) keeps its first value.
*/
export function aggregateAirQuality(
hourly: AirQualityHourly,
location: { lat: number; lon: number },
): DailyAirQualityObservation[] {
const times = hourly.time ?? [];
// day → variable → value per local hour (24 slots, null when absent).
const byDay = new Map<string, Map<HourlyVariable, (number | null)[]>>();
for (let i = 0; i < times.length; i += 1) {
const stamp = times[i];
if (typeof stamp !== "string" || stamp.length < 13) continue;
const day = stamp.slice(0, 10);
const hour = Number(stamp.slice(11, 13));
if (!Number.isInteger(hour) || hour < 0 || hour > 23) continue;
let vars = byDay.get(day);
if (!vars) {
vars = new Map();
byDay.set(day, vars);
}
for (const variable of AIR_QUALITY_VARIABLES) {
let slots = vars.get(variable);
if (!slots) {
slots = new Array<number | null>(24).fill(null);
vars.set(variable, slots);
}
if (slots[hour] === null) {
slots[hour] = finite(hourly[variable]?.[i]);
}
}
}
const domain = insideCamsEurope(location.lat, location.lon)
? "cams_europe"
: "cams_global";
const out: DailyAirQualityObservation[] = [];
for (const day of [...byDay.keys()].sort()) {
const vars = byDay.get(day)!;
const v = (name: HourlyVariable) =>
vars.get(name) ?? new Array<number | null>(24).fill(null);
const pm25 = v("pm2_5");
out.push({
date: day,
pm25Mean: coveredMean(pm25),
pm25Max: coveredMax(pm25),
pm10Mean: coveredMean(v("pm10")),
no2Mean: coveredMean(v("nitrogen_dioxide")),
so2Mean: coveredMean(v("sulphur_dioxide")),
coMean: coveredMean(v("carbon_monoxide")),
o3Max8h: ozoneMax8h(v("ozone")),
eaqiMax: coveredMax(v("european_aqi")),
usaqiMax: coveredMax(v("us_aqi")),
uvIndexMax: uvIndexMax(v("uv_index")),
dustMax: coveredMax(v("dust")),
aodMax: coveredMax(v("aerosol_optical_depth")),
pollenAlderMax: coveredMax(v("alder_pollen")),
pollenBirchMax: coveredMax(v("birch_pollen")),
pollenGrassMax: coveredMax(v("grass_pollen")),
pollenMugwortMax: coveredMax(v("mugwort_pollen")),
pollenOliveMax: coveredMax(v("olive_pollen")),
pollenRagweedMax: coveredMax(v("ragweed_pollen")),
aqDomain: domain,
aqHours: present(pm25).length,
});
}
return out;
}
/** A day the feed cannot serve: every value null, no hours, no domain. */
export function uncoverableDay(date: string): DailyAirQualityObservation {
return {
date,
pm25Mean: null,
pm25Max: null,
pm10Mean: null,
no2Mean: null,
so2Mean: null,
coMean: null,
o3Max8h: null,
eaqiMax: null,
usaqiMax: null,
uvIndexMax: null,
dustMax: null,
aodMax: null,
pollenAlderMax: null,
pollenBirchMax: null,
pollenGrassMax: null,
pollenMugwortMax: null,
pollenOliveMax: null,
pollenRagweedMax: null,
aqDomain: null,
aqHours: 0,
};
}
/** Split an inclusive day range into chunks of at most `size` days. */
export function chunkDays(
startDate: string,
endDate: string,
size: number = AIR_QUALITY_CHUNK_DAYS,
): Array<{ startDate: string; endDate: string }> {
const chunks: Array<{ startDate: string; endDate: string }> = [];
let cursor = startDate;
while (cursor <= endDate) {
const last = shiftDateKey(cursor, size - 1);
const chunkEnd = last < endDate ? last : endDate;
chunks.push({ startDate: cursor, endDate: chunkEnd });
cursor = shiftDateKey(chunkEnd, 1);
}
return chunks;
}
/** The variables a chunk asks for: the pollutant set alone before the full era. */
export function variablesForChunk(endDate: string): readonly HourlyVariable[] {
return endDate < AIR_QUALITY_FULL_FEED_FROM
? POLLUTANT_VARIABLES
: AIR_QUALITY_VARIABLES;
}
/** Why a fetch stopped before its last chunk. */
export type AirQualityStop = "budget" | "error" | null;
export interface AirQualityFetchResult {
/** One observation per day the feed answered (and per uncoverable day). */
days: DailyAirQualityObservation[];
/** Null when every chunk was fetched. */
stopped: AirQualityStop;
}
/**
* Fetch and fold the air quality for a coarse location over an inclusive day
* range, newest chunk first, so a run the budget stops part-way has filled
* the recent days the correlation window reads. Never throws for a feed
* problem: the caller gets what was fetched and why it stopped, and the days
* not returned stay unfetched for the next run.
*/
export async function fetchDailyAirQuality(args: {
lat: number;
lon: number;
timezone: string;
startDate: string;
endDate: string;
}): Promise<AirQualityFetchResult> {
const days: DailyAirQualityObservation[] = [];
// Days before the feed's first day are answered here, without a request.
for (
let day = args.startDate;
day <= args.endDate && day < AIR_QUALITY_EARLIEST_DAY;
day = shiftDateKey(day, 1)
) {
days.push(uncoverableDay(day));
}
const start =
args.startDate < AIR_QUALITY_EARLIEST_DAY
? AIR_QUALITY_EARLIEST_DAY
: args.startDate;
if (start > args.endDate) return { days, stopped: null };
const chunks = chunkDays(start, args.endDate).reverse();
for (const chunk of chunks) {
const variables = variablesForChunk(chunk.endDate);
const weight = openMeteoCallWeight(
variables.length,
enumerateDayCount(chunk.startDate, chunk.endDate),
);
if (!(await reserveOpenMeteoCalls(weight))) {
return { days, stopped: "budget" };
}
try {
const hourly = await requestHourly({
lat: args.lat,
lon: args.lon,
timezone: args.timezone,
startDate: chunk.startDate,
endDate: chunk.endDate,
variables,
});
const wanted = (d: string) => d >= chunk.startDate && d <= chunk.endDate;
days.push(
...aggregateAirQuality(hourly, args).filter((d) => wanted(d.date)),
);
} catch {
// A refused, timed-out or unreadable response: stop here and keep what
// came back. The weather row is unaffected, and the days not returned
// stay unfetched for the gap fill.
return { days, stopped: "error" };
}
}
return { days, stopped: null };
}
async function requestHourly(args: {
lat: number;
lon: number;
timezone: string;
startDate: string;
endDate: string;
variables: readonly HourlyVariable[];
}): Promise<AirQualityHourly> {
const url = new URL(`${AIR_QUALITY_BASE_URL}/v1/air-quality`);
url.searchParams.set("latitude", String(args.lat));
url.searchParams.set("longitude", String(args.lon));
url.searchParams.set("hourly", args.variables.join(","));
url.searchParams.set("start_date", args.startDate);
url.searchParams.set("end_date", args.endDate);
url.searchParams.set("timezone", args.timezone);
url.searchParams.set("domains", "auto");
const res = await safeFetch(
url,
{ headers: { accept: "application/json" } },
{ timeoutMs: FETCH_TIMEOUT_MS },
);
if (!res.ok) {
throw new SafeFetchError(
`open-meteo air quality returned HTTP ${res.status}`,
"network",
);
}
const body = (await res.json()) as { hourly?: AirQualityHourly };
return body.hourly ?? {};
}
+39 -5
View File
@@ -19,7 +19,14 @@
* only" — see the nightly job and the backfill route.
*/
import { envOr } from "@/lib/env";
import { annotate } from "@/lib/logging/context";
import { safeFetch, SafeFetchError } from "@/lib/safe-fetch";
import { enumerateDayCount } from "@/lib/environment/day-span";
import {
OpenMeteoBudgetExhaustedError,
openMeteoCallWeight,
reserveOpenMeteoCalls,
} from "@/lib/environment/request-budget";
/** Hosted archive default; override with `OPENMETEO_BASE_URL` (self-host). */
const ARCHIVE_BASE_URL = envOr(
@@ -58,6 +65,8 @@ export interface DailyEnvironmentObservation {
tempMax: number | null;
tempMean: number | null;
apparentMean: number | null;
/** v1.42 — daily apparent ("feels-like") maximum, °C. */
apparentMax: number | null;
sunshineSec: number | null;
daylightSec: number | null;
precipSum: number | null;
@@ -97,6 +106,7 @@ interface ArchiveDaily {
temperature_2m_max?: (number | null)[];
temperature_2m_mean?: (number | null)[];
apparent_temperature_mean?: (number | null)[];
apparent_temperature_max?: (number | null)[];
sunshine_duration?: (number | null)[];
daylight_duration?: (number | null)[];
precipitation_sum?: (number | null)[];
@@ -115,22 +125,29 @@ interface ArchiveResponse {
hourly?: ArchiveHourly;
}
const DAILY_FIELDS = [
const DAILY_FIELD_LIST = [
"temperature_2m_min",
"temperature_2m_max",
"temperature_2m_mean",
"apparent_temperature_mean",
"apparent_temperature_max",
"sunshine_duration",
"daylight_duration",
"precipitation_sum",
"weather_code",
].join(",");
];
const DAILY_FIELDS = DAILY_FIELD_LIST.join(",");
const HOURLY_FIELDS = [
const HOURLY_FIELD_LIST = [
"surface_pressure",
"relative_humidity_2m",
"cloud_cover",
].join(",");
];
const HOURLY_FIELDS = HOURLY_FIELD_LIST.join(",");
/** Variables one archive request asks for, for the request budget. */
const ARCHIVE_VARIABLE_COUNT =
DAILY_FIELD_LIST.length + HOURLY_FIELD_LIST.length;
/** Per-day aggregation accumulator for the hourly-only fields. */
interface HourlyDayAgg {
@@ -174,6 +191,13 @@ export async function geocodeLocation(
const trimmed = query.trim();
if (trimmed.length === 0) return [];
// A geocoder call weighs one; a refused one answers "no match" rather than
// reaching the host, and says why on the request's event.
if (!(await reserveOpenMeteoCalls(1))) {
annotate({ action: { name: "environment.geocode.budget_exhausted" } });
return [];
}
const url = new URL(`${GEOCODING_BASE_URL}/v1/search`);
url.searchParams.set("name", trimmed);
url.searchParams.set("count", String(Math.min(Math.max(count, 1), 10)));
@@ -230,7 +254,9 @@ export async function geocodeLocation(
* Fetch the daily environment observations for a coarse location over a date
* range (inclusive YYYY-MM-DD). The hourly-only fields (pressure / humidity /
* cloud) are requested alongside and aggregated to per-day values here. Throws
* a {@link SafeFetchError} on egress failure so the caller can classify it.
* a {@link SafeFetchError} on egress failure so the caller can classify it,
* and an {@link OpenMeteoBudgetExhaustedError} when the instance-wide request
* budget refuses the call (nothing is sent then).
*/
export async function fetchDailyEnvironment(args: {
lat: number;
@@ -239,6 +265,13 @@ export async function fetchDailyEnvironment(args: {
startDate: string;
endDate: string;
}): Promise<DailyEnvironmentObservation[]> {
const weight = openMeteoCallWeight(
ARCHIVE_VARIABLE_COUNT,
enumerateDayCount(args.startDate, args.endDate),
);
if (!(await reserveOpenMeteoCalls(weight))) {
throw new OpenMeteoBudgetExhaustedError();
}
const url = new URL(`${ARCHIVE_BASE_URL}/v1/archive`);
url.searchParams.set("latitude", String(args.lat));
url.searchParams.set("longitude", String(args.lon));
@@ -285,6 +318,7 @@ export async function fetchDailyEnvironment(args: {
tempMax: firstNumber(daily.temperature_2m_max?.[i]),
tempMean: firstNumber(daily.temperature_2m_mean?.[i]),
apparentMean: firstNumber(daily.apparent_temperature_mean?.[i]),
apparentMax: firstNumber(daily.apparent_temperature_max?.[i]),
sunshineSec: firstNumber(daily.sunshine_duration?.[i]),
daylightSec: firstNumber(daily.daylight_duration?.[i]),
precipSum: firstNumber(daily.precipitation_sum?.[i]),
+146
View File
@@ -0,0 +1,146 @@
/**
* The instance-wide Open-Meteo request budget (v1.42, #615).
*
* The hosted Open-Meteo APIs are free for non-commercial use within 600 calls
* a minute, 5 000 an hour and 10 000 a day, counted per client address, so
* per instance. A "call" is weighted: a request for more than 10 variables
* or more than two weeks counts as several, `max(1, variables / 10) ×
* max(1, days / 14)` (open-meteo.com/en/pricing). One account's full
* backfill of weather and air quality is around 150 calls; a large instance
* filling hundreds of accounts in one night would run into the daily limit
* and every request after it would fail.
*
* So every request to the archive, the geocoder and the air-quality API asks
* this budget first, with its weight, and is not sent when the budget says
* no. The limits sit below the free ones (500 / 4 000 / 8 000) to leave room
* for clock skew between our windows and theirs. A refused request is not an
* error: the nightly job stops, reports `budget_blocked`, and the next night
* continues where it stopped, because what is missing is exactly what the
* gap fill looks for.
*
* The counter lives in Postgres (`rate_limits`, the table every rate limit
* here uses), so the web process and the worker share it, and the three
* windows are checked and charged together under one advisory lock: a
* request is admitted only when all three have room, and then charged to
* all three. Weights are stored in hundredths of a call, because the column
* is an integer and a 90-day air-quality chunk weighs about 10.9 calls.
*
* An operator running their own Open-Meteo instance (`OPENMETEO_*_URL`) is
* not bound by the hosted limits, but the budget still applies: it is cheap,
* and it keeps a misconfigured instance from being hammered.
*/
import { prisma } from "@/lib/db";
/** One budget window. */
export interface BudgetWindow {
/** Bucket key suffix, also the window's name. */
name: "minute" | "hour" | "day";
windowMs: number;
/** Calls admitted per window (whole calls). */
limit: number;
}
export const OPEN_METEO_BUDGET_WINDOWS: readonly BudgetWindow[] = [
{ name: "minute", windowMs: 60_000, limit: 500 },
{ name: "hour", windowMs: 3_600_000, limit: 4_000 },
{ name: "day", windowMs: 86_400_000, limit: 8_000 },
];
const BUCKET_PREFIX = "open-meteo-budget";
const LOCK_KEY = "open-meteo-budget";
/** Hundredths of a call: the unit the buckets count in. */
const CENTI = 100;
/**
* The weight of one request in calls, by the hosted pricing rule: more than
* 10 variables or more than 14 days counts proportionally more.
*/
export function openMeteoCallWeight(variables: number, days: number): number {
return Math.max(1, variables / 10) * Math.max(1, days / 14);
}
/** A bucket as stored: count in hundredths, and when its window resets. */
export interface BudgetBucket {
count: number;
resetAt: Date;
}
/**
* Pure admission decision: given the stored buckets (keyed by window name,
* absent when never written), whether `weightCenti` fits in every window, and
* the buckets as they must be written when it does. An expired bucket starts
* over at the request's weight. Exported for tests.
*/
export function admitWeight(
buckets: ReadonlyMap<BudgetWindow["name"], BudgetBucket>,
weightCenti: number,
now: Date,
windows: readonly BudgetWindow[] = OPEN_METEO_BUDGET_WINDOWS,
):
| { admitted: true; next: Map<BudgetWindow["name"], BudgetBucket> }
| { admitted: false; window: BudgetWindow["name"] } {
const next = new Map<BudgetWindow["name"], BudgetBucket>();
for (const w of windows) {
const current = buckets.get(w.name);
const live = current && current.resetAt.getTime() > now.getTime();
const count = (live ? current.count : 0) + weightCenti;
if (count > w.limit * CENTI) return { admitted: false, window: w.name };
next.set(w.name, {
count,
resetAt: live ? current.resetAt : new Date(now.getTime() + w.windowMs),
});
}
return { admitted: true, next };
}
/**
* Ask the budget for one request of `weight` calls. Returns true and charges
* every window when it fits, false (charging nothing) when any window is
* full.
*/
export async function reserveOpenMeteoCalls(weight: number): Promise<boolean> {
const weightCenti = Math.max(1, Math.ceil(weight * CENTI));
const keys = OPEN_METEO_BUDGET_WINDOWS.map(
(w) => `${BUCKET_PREFIX}:${w.name}`,
);
return prisma.$transaction(async (tx) => {
// `pg_advisory_xact_lock` returns void, which the client cannot
// deserialize as a column; selecting FROM it yields a plain row.
await tx.$queryRaw`
SELECT 1 AS locked
FROM pg_advisory_xact_lock(hashtextextended(${LOCK_KEY}, 0))
`;
const rows = await tx.rateLimit.findMany({
where: { key: { in: keys } },
select: { key: true, count: true, resetAt: true },
});
const buckets = new Map<BudgetWindow["name"], BudgetBucket>();
for (const row of rows) {
const name = row.key.slice(BUCKET_PREFIX.length + 1) as
BudgetWindow["name"] | string;
if (name === "minute" || name === "hour" || name === "day") {
buckets.set(name, { count: row.count, resetAt: row.resetAt });
}
}
const decision = admitWeight(buckets, weightCenti, new Date());
if (!decision.admitted) return false;
for (const [name, bucket] of decision.next) {
const key = `${BUCKET_PREFIX}:${name}`;
await tx.rateLimit.upsert({
where: { key },
create: { key, count: bucket.count, resetAt: bucket.resetAt },
update: { count: bucket.count, resetAt: bucket.resetAt },
});
}
return true;
});
}
/** Thrown by a client whose request the budget refused. Not a failure. */
export class OpenMeteoBudgetExhaustedError extends Error {
constructor() {
super("open-meteo request budget exhausted");
this.name = "OpenMeteoBudgetExhaustedError";
}
}
+335 -52
View File
@@ -20,13 +20,31 @@
*
* Coarse location only — the resolver never sees finer than the rounded city
* coordinates stored on the user / override.
*
* v1.42 (#615) — the locations are sealed at rest (`location-cipher.ts`): the
* home and the periods are opened here, the rows are written with the sealed
* copy only, and the requests still carry nothing but the coarse coordinates.
* The same run fetches the day's air quality, pollen and UV when the account
* and the operator allow it (`isAirQualityActive`). An air-quality failure
* never costs the weather row: the weather is stored, the air-quality part
* stays unfetched (`aqFetchedAt` null), and the nightly gap fill
* ({@link fillAirQualityGaps}) asks for it again.
*/
import { prisma } from "@/lib/db";
import {
fetchDailyEnvironment,
roundCoarse,
type DailyEnvironmentObservation,
} from "@/lib/environment/open-meteo";
import {
fetchDailyAirQuality,
isAirQualityActive,
type AirQualityStop,
type DailyAirQualityObservation,
} from "@/lib/environment/open-meteo-air-quality";
import { readLocation, sealLocation } from "@/lib/environment/location-cipher";
export { isAirQualityActive };
import { OpenMeteoBudgetExhaustedError } from "@/lib/environment/request-budget";
import type { EnvironmentLocationSource } from "@/generated/prisma/client";
import { shiftDateKey } from "@/lib/tz/format";
@@ -134,6 +152,81 @@ export interface FetchAndStoreResult {
skipped: number;
/** Distinct upstream fetches made. */
fetches: number;
/**
* True when the instance-wide request budget refused a request, so some
* days were left for the next run.
*/
budgetBlocked: boolean;
}
/** The user columns every environment read resolves the home from. */
const HOME_SELECT = {
homeLat: true,
homeLon: true,
homeLabel: true,
homeLocationEncrypted: true,
homeTimezone: true,
homeSince: true,
timezone: true,
environmentAirQualityEnabled: true,
} as const;
/**
* The account's home as the resolver uses it: the sealed copy opened, or the
* readable columns for a row the encryption backfill has not reached. Null
* when no home is set (or the sealed value does not open).
*/
export function resolveHome(user: {
homeLat: number | null;
homeLon: number | null;
homeLabel: string | null;
homeLocationEncrypted: Uint8Array | null;
homeTimezone: string | null;
homeSince: Date | null;
timezone: string;
}): HomeLocation | null {
const location = readLocation({
sealed: user.homeLocationEncrypted,
lat: user.homeLat,
lon: user.homeLon,
label: user.homeLabel ?? "",
});
if (!location) return null;
return {
...location,
// A home stored without a label (allowed since v1.25) carries an empty
// one; the stored day says "Home", as it always has.
label: location.label === "" ? "Home" : location.label,
timezone: user.homeTimezone ?? user.timezone,
// Effective-from day-key; null ⇒ home resolves no day (conservative).
since: user.homeSince ? utcDayKey(user.homeSince) : null,
};
}
/** Every dated location period of an account, opened. */
async function readTravelOverrides(userId: string): Promise<TravelOverride[]> {
const records = await prisma.environmentTravelLocation.findMany({
where: { userId },
select: {
startDate: true,
endDate: true,
lat: true,
lon: true,
label: true,
locationEncrypted: true,
},
});
return records.flatMap((row) => {
const location = readLocation({
sealed: row.locationEncrypted,
lat: row.lat,
lon: row.lon,
label: row.label,
});
return location
? [{ startDate: row.startDate, endDate: row.endDate, ...location }]
: [];
});
}
/**
@@ -151,55 +244,21 @@ export async function fetchAndStoreEnvironment(args: {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
homeLat: true,
homeLon: true,
homeLabel: true,
homeTimezone: true,
homeSince: true,
timezone: true,
},
select: HOME_SELECT,
});
if (!user) return { stored: 0, skipped: 0, fetches: 0 };
if (!user) {
return { stored: 0, skipped: 0, fetches: 0, budgetBlocked: false };
}
const home: HomeLocation | null =
user.homeLat != null && user.homeLon != null
? {
lat: user.homeLat,
lon: user.homeLon,
label: user.homeLabel ?? "Home",
timezone: user.homeTimezone ?? user.timezone,
// Effective-from day-key; null ⇒ home resolves no day (conservative).
since: user.homeSince ? utcDayKey(user.homeSince) : null,
}
: null;
const travelRecords = await prisma.environmentTravelLocation.findMany({
where: { userId },
select: {
startDate: true,
endDate: true,
lat: true,
lon: true,
label: true,
},
});
// Since v1.42 the plaintext location is nullable (migration 0382): a period
// whose plaintext the encryption backfill has already cleared resolves only
// through its sealed copy, which this reader does not open yet.
const travelRows: TravelOverride[] = travelRecords.flatMap((row) =>
row.lat != null && row.lon != null && row.label != null
? [{ ...row, lat: row.lat, lon: row.lon, label: row.label }]
: [],
);
const home = resolveHome(user);
const travelRows = await readTravelOverrides(userId);
const airQuality = isAirQualityActive(user.environmentAirQualityEnabled);
// The timezone used to enumerate AND fetch days — one tz keeps the stored
// `date` consistent with the user's day-keys elsewhere.
const timezone = home?.timezone ?? user.timezone;
const days = enumerateDays(startDate, endDate);
const resolvedByDay = new Map<string, ResolvedLocation>();
const groups = new Map<string, { loc: ResolvedLocation; days: string[] }>();
let skipped = 0;
for (const day of days) {
@@ -208,7 +267,6 @@ export async function fetchAndStoreEnvironment(args: {
skipped += 1;
continue;
}
resolvedByDay.set(day, loc);
const gk = groupKey(loc);
const group = groups.get(gk) ?? { loc, days: [] };
group.days.push(day);
@@ -217,6 +275,7 @@ export async function fetchAndStoreEnvironment(args: {
let stored = 0;
let fetches = 0;
let budgetBlocked = false;
for (const { loc, days: groupDays } of groups.values()) {
const min = groupDays[0];
const max = groupDays[groupDays.length - 1];
@@ -229,14 +288,38 @@ export async function fetchAndStoreEnvironment(args: {
startDate: min,
endDate: max,
});
} catch {
} catch (err) {
if (err instanceof OpenMeteoBudgetExhaustedError) {
// Nothing was sent. The remaining groups wait for the next run.
budgetBlocked = true;
break;
}
// A failed fetch for one location leaves its days un-stored; the nightly
// lookback re-attempts them. Other locations still get their rows.
continue;
}
fetches += 1;
// The air-quality part of the same days, at the same coarse location. A
// stop (budget or feed error) leaves the days it did not reach without
// air quality; the weather is stored regardless.
let airByDay: Map<string, DailyAirQualityObservation> | null = null;
if (airQuality) {
const result = await fetchDailyAirQuality({
lat: loc.lat,
lon: loc.lon,
timezone,
startDate: min,
endDate: max,
});
if (result.stopped === "budget") budgetBlocked = true;
airByDay = new Map(result.days.map((d) => [d.date, d]));
fetches += result.days.length > 0 ? 1 : 0;
}
const wanted = new Set(groupDays);
const sealed = sealLocation(loc);
const now = new Date();
// Batch the group's day upserts into a single transaction round-trip
// rather than awaiting one upsert per day in series (a 730-day backfill
// would otherwise issue 730 sequential statements). One transaction per
@@ -244,34 +327,111 @@ export async function fetchAndStoreEnvironment(args: {
// preserving the idempotent upsert semantics.
const ops = observations
.filter((obs) => wanted.has(obs.date))
.map((obs) => buildUpsert(userId, loc, obs));
.map((obs) =>
buildUpsert(userId, loc, sealed, obs, {
active: airQuality,
day: airByDay?.get(obs.date) ?? null,
now,
}),
);
if (ops.length > 0) {
await prisma.$transaction(ops);
stored += ops.length;
}
// Days the feed did not return (e.g. beyond the settling lag) stay absent.
if (budgetBlocked) break;
}
return { stored, skipped, fetches };
return { stored, skipped, fetches, budgetBlocked };
}
/** The air-quality columns of a stored day, from one observation. */
function airQualityColumns(day: DailyAirQualityObservation, fetchedAt: Date) {
return {
pm25Mean: day.pm25Mean,
pm25Max: day.pm25Max,
pm10Mean: day.pm10Mean,
no2Mean: day.no2Mean,
so2Mean: day.so2Mean,
coMean: day.coMean,
o3Max8h: day.o3Max8h,
eaqiMax: day.eaqiMax,
usaqiMax: day.usaqiMax,
uvIndexMax: day.uvIndexMax,
dustMax: day.dustMax,
aodMax: day.aodMax,
pollenAlderMax: day.pollenAlderMax,
pollenBirchMax: day.pollenBirchMax,
pollenGrassMax: day.pollenGrassMax,
pollenMugwortMax: day.pollenMugwortMax,
pollenOliveMax: day.pollenOliveMax,
pollenRagweedMax: day.pollenRagweedMax,
aqDomain: day.aqDomain,
aqHours: day.aqHours,
aqFetchedAt: fetchedAt,
};
}
/** The air-quality columns of a day whose air quality is not (yet) known. */
const AIR_QUALITY_UNFETCHED = {
pm25Mean: null,
pm25Max: null,
pm10Mean: null,
no2Mean: null,
so2Mean: null,
coMean: null,
o3Max8h: null,
eaqiMax: null,
usaqiMax: null,
uvIndexMax: null,
dustMax: null,
aodMax: null,
pollenAlderMax: null,
pollenBirchMax: null,
pollenGrassMax: null,
pollenMugwortMax: null,
pollenOliveMax: null,
pollenRagweedMax: null,
aqDomain: null,
aqHours: null,
aqFetchedAt: null,
} as const;
/** Build the idempotent per-day upsert operation (un-awaited, for a batch). */
function buildUpsert(
userId: string,
loc: ResolvedLocation,
sealed: Uint8Array<ArrayBuffer>,
obs: DailyEnvironmentObservation,
air: {
active: boolean;
day: DailyAirQualityObservation | null;
now: Date;
},
) {
// With air quality on, the row's air-quality part is rewritten with the
// weather: the fetched values, or nothing at all when the fetch did not
// reach the day, so a value measured for the location the day resolved to
// before can never stand beside the weather of the one it resolves to now.
// With it off, the stored part is left as it is.
const airColumns = !air.active
? {}
: air.day
? airQualityColumns(air.day, air.now)
: AIR_QUALITY_UNFETCHED;
const data = {
// Already coarse at the source; rounded again so a per-day row never holds
// more precision than the privacy floor, whatever resolved it.
lat: roundCoarse(loc.lat),
lon: roundCoarse(loc.lon),
locationLabel: loc.label,
// The coarse location rides sealed only; the readable columns are
// written empty (they drop in a later release).
locationEncrypted: sealed,
lat: null,
lon: null,
locationLabel: null,
source: loc.source,
tempMin: obs.tempMin,
tempMax: obs.tempMax,
tempMean: obs.tempMean,
apparentMean: obs.apparentMean,
apparentMax: obs.apparentMax,
sunshineSec: obs.sunshineSec != null ? Math.round(obs.sunshineSec) : null,
daylightSec: obs.daylightSec != null ? Math.round(obs.daylightSec) : null,
precipSum: obs.precipSum,
@@ -280,7 +440,8 @@ function buildUpsert(
humidityMean: obs.humidityMean,
cloudMean: obs.cloudMean,
weatherCode: obs.weatherCode,
fetchedAt: new Date(),
fetchedAt: air.now,
...airColumns,
};
return prisma.environmentContext.upsert({
where: { userId_date: { userId, date: obs.date } },
@@ -288,3 +449,125 @@ function buildUpsert(
update: data,
});
}
/** Days per gap-fill run: about 11 weighted calls per account per night. */
export const AIR_QUALITY_GAP_FILL_DAYS = 90;
/** Two gap days further apart than this are fetched as separate ranges. */
const GAP_RANGE_JOIN_DAYS = 7;
export interface AirQualityGapFillResult {
/** Stored days whose air-quality part was filled (or marked uncoverable). */
filled: number;
/** Why the run stopped early, or null. */
stopped: AirQualityStop;
}
/**
* The nightly gap fill: up to {@link AIR_QUALITY_GAP_FILL_DAYS} stored days
* of an account whose air-quality part was never fetched (`aqFetchedAt`
* null), newest first, so the correlation window fills first and an account
* with two years of weather catches up within about nine nights. Each day is
* fetched at the location it was stored for (opened from its sealed copy),
* never re-resolved. A day the feed cannot serve (before 2013) is marked
* fetched with every value null, so it is not asked for again. A no-op when
* the air-quality part is off for the account or the instance.
*/
export async function fillAirQualityGaps(
userId: string,
maxDays: number = AIR_QUALITY_GAP_FILL_DAYS,
): Promise<AirQualityGapFillResult> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: HOME_SELECT,
});
if (!user || !isAirQualityActive(user.environmentAirQualityEnabled)) {
return { filled: 0, stopped: null };
}
const timezone = user.homeTimezone ?? user.timezone;
const rows = await prisma.environmentContext.findMany({
where: { userId, aqFetchedAt: null },
orderBy: { date: "desc" },
take: maxDays,
select: {
date: true,
lat: true,
lon: true,
locationLabel: true,
locationEncrypted: true,
},
});
// Group the days by location, then cut each group into ranges of nearby
// days, so a sparse group does not fetch the empty stretch between.
const byLocation = new Map<
string,
{ lat: number; lon: number; days: string[] }
>();
for (const row of rows) {
const location = readLocation({
sealed: row.locationEncrypted,
lat: row.lat,
lon: row.lon,
label: row.locationLabel,
});
if (!location) continue;
const key = `${location.lat},${location.lon}`;
const group = byLocation.get(key) ?? {
lat: location.lat,
lon: location.lon,
days: [],
};
group.days.push(row.date);
byLocation.set(key, group);
}
let filled = 0;
for (const group of byLocation.values()) {
for (const range of nearbyRanges(group.days)) {
const result = await fetchDailyAirQuality({
lat: group.lat,
lon: group.lon,
timezone,
startDate: range[0],
endDate: range[range.length - 1],
});
const wanted = new Set(range);
const now = new Date();
const ops = result.days
.filter((day) => wanted.has(day.date))
.map((day) =>
prisma.environmentContext.updateMany({
// Still unfetched, so a weather rewrite that landed meanwhile
// (with its own air quality) is not overwritten.
where: { userId, date: day.date, aqFetchedAt: null },
data: airQualityColumns(day, now),
}),
);
if (ops.length > 0) {
const done = await prisma.$transaction(ops);
filled += done.reduce((sum, r) => sum + r.count, 0);
}
if (result.stopped) return { filled, stopped: result.stopped };
}
}
return { filled, stopped: null };
}
/** Sorted runs of day keys, a new run wherever two days lie far apart. */
export function nearbyRanges(days: readonly string[]): string[][] {
const sorted = [...new Set(days)].sort();
const ranges: string[][] = [];
for (const day of sorted) {
const current = ranges[ranges.length - 1];
if (
current &&
shiftDateKey(current[current.length - 1], GAP_RANGE_JOIN_DAYS) >= day
) {
current.push(day);
} else {
ranges.push([day]);
}
}
return ranges;
}
@@ -6,6 +6,7 @@
* a person instead of ciphertext. Every refusal here leaves the account's
* current value in place and names the column in the restore's skip report.
*/
import { Buffer } from "node:buffer";
import { afterEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
@@ -19,6 +20,7 @@ import {
buildAccountSettingsBackupSection,
} from "@/lib/export/account-settings-backup";
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
import { openLocation, sealLocation } from "@/lib/environment/location-cipher";
import { reasoningEffortFor } from "@/lib/ai/reasoning-effort";
import {
coachReasoningLevel,
@@ -324,3 +326,74 @@ describe("admitAccountSettings", () => {
expect(data).toEqual({ heightCm: 180 });
});
});
describe("the environment home (v1.42, sealed at rest)", () => {
it("carries the home readable in a portable file, opened from its sealed copy", async () => {
const { accountSettings } = await buildAccountSettingsBackupSection(
prismaReturning(
rowWith({
homeLocationEncrypted: sealLocation({
lat: 51.5,
lon: 7.2,
label: "Bochum, Germany",
}),
}),
),
"u1",
{ purpose: "portable-export" },
);
expect(accountSettings).toMatchObject({
homeLat: 51.5,
homeLon: 7.2,
homeLabel: "Bochum, Germany",
});
expect(accountSettings).not.toHaveProperty("homeLocationEncrypted");
});
it("carries the sealed copy verbatim in a disaster-recovery file", async () => {
const sealed = sealLocation({ lat: 51.5, lon: 7.2, label: "Bochum" });
const { accountSettings } = await buildAccountSettingsBackupSection(
prismaReturning(rowWith({ homeLocationEncrypted: sealed })),
"u1",
{ purpose: "disaster-recovery" },
);
expect(accountSettings!.homeLocationEncrypted).toBe(
Buffer.from(sealed).toString("base64"),
);
});
it("seals a readable home from a file and writes the readable columns empty", () => {
const { data, refused } = admitAccountSettings(
{ homeLat: 48.137, homeLon: 11.575, homeLabel: "Munich" },
ctx,
);
expect(refused).toEqual([]);
expect(data).toMatchObject({
homeLat: null,
homeLon: null,
homeLabel: null,
});
expect(openLocation(data.homeLocationEncrypted as Uint8Array)).toEqual({
lat: 48.1,
lon: 11.6,
label: "Munich",
});
});
it("takes a sealed home as it came, and clears the home when the file has none", () => {
const sealed = sealLocation({ lat: 1, lon: 2, label: "x" });
const restored = admitAccountSettings(
{ homeLocationEncrypted: Buffer.from(sealed).toString("base64") },
ctx,
).data.homeLocationEncrypted as Uint8Array;
expect(Buffer.from(restored).equals(Buffer.from(sealed))).toBe(true);
expect(
admitAccountSettings({ homeLat: null, homeLon: null }, ctx).data,
).toEqual({
homeLocationEncrypted: null,
homeLat: null,
homeLon: null,
homeLabel: null,
});
});
});
+97 -31
View File
@@ -32,8 +32,8 @@
*
* ── Encryption ─────────────────────────────────────────────────────────────
*
* One column is sealed, the insurance number, and it follows the split every
* other section uses: a disaster-recovery file carries the ciphertext
* Two values are sealed, the insurance number and (v1.42) the environment
* home, and both follow the split every other section uses: a disaster-recovery file carries the ciphertext
* verbatim (so the restore's key check sees it), a portable file carries the
* readable value and the restore seals it under this host's key.
*/
@@ -52,6 +52,7 @@ import {
readAvatarDimensions,
} from "@/lib/avatar";
import { decrypt, encrypt } from "@/lib/crypto";
import { readLocation, sealLocation } from "@/lib/environment/location-cipher";
import {
USER_COLUMN_BACKUP_CLASS,
type AccountSettingColumn,
@@ -382,36 +383,100 @@ const insuranceNumber: SettingCodec = {
};
/**
* v1.42 (#615) — the sealed home location. A disaster-recovery file carries
* the ciphertext verbatim, like the insurance number, because it is restored
* onto a host holding the same keys. A portable file leaves it out: the
* readable home rides `homeLat` / `homeLon` / `homeLabel` until those columns
* drop, and the receiving host's encryption backfill seals it again.
* v1.42 (#615) — the environment home, sealed at rest as one value
* (`homeLocationEncrypted`, see `src/lib/environment/location-cipher.ts`)
* beside the readable `homeLat` / `homeLon` / `homeLabel` that the
* encryption backfill empties. The four columns travel as one, the way the
* avatar's three do:
*
* - a portable file carries the home readable, opened from the sealed copy
* when there is one, and no sealed value;
* - a disaster-recovery file carries the sealed value verbatim (base64)
* beside whatever the readable columns still hold.
*
* The restore writes the sealed value as it came, or seals a readable home
* under this host's key, and writes the readable columns empty either way. A
* file whose home is null clears the account's home. A home without a label
* (allowed since v1.25) is sealed with an empty label, which every reader
* shows as no label.
*/
const homeLocationSealed: SettingCodec = {
const homeLocation: SettingCodec = {
write: (row, disasterRecovery) => {
if (!disasterRecovery) return {};
const sealed = row.homeLocationEncrypted;
return {
homeLocationEncrypted:
sealed instanceof Uint8Array
? Buffer.from(sealed).toString("base64")
: null,
};
const sealed =
row.homeLocationEncrypted instanceof Uint8Array
? row.homeLocationEncrypted
: null;
if (disasterRecovery) {
return {
homeLat: row.homeLat,
homeLon: row.homeLon,
homeLabel: row.homeLabel,
homeLocationEncrypted:
sealed && sealed.byteLength > 0
? Buffer.from(sealed).toString("base64")
: null,
};
}
const home = readLocation({
sealed,
lat: row.homeLat as number | null,
lon: row.homeLon as number | null,
label: (row.homeLabel as string | null) ?? "",
});
return home
? {
homeLat: home.lat,
homeLon: home.lon,
homeLabel: home.label === "" ? null : home.label,
}
: { homeLat: null, homeLon: null, homeLabel: null };
},
read: (entry) => {
if (!has(entry, "homeLocationEncrypted")) return undefined;
const value = entry.homeLocationEncrypted;
if (value === null) return null;
if (typeof value !== "string") return new Refused("homeLocationEncrypted");
const buffer = Buffer.from(value, "base64");
if (buffer.byteLength === 0) return new Refused("homeLocationEncrypted");
const bytes = new Uint8Array(new ArrayBuffer(buffer.byteLength));
bytes.set(buffer);
return bytes;
const cleared = {
homeLocationEncrypted: null,
homeLat: null,
homeLon: null,
homeLabel: null,
};
const sealedValue = entry.homeLocationEncrypted;
if (has(entry, "homeLocationEncrypted") && sealedValue !== null) {
if (typeof sealedValue !== "string") {
return new Refused("homeLocationEncrypted");
}
const buffer = Buffer.from(sealedValue, "base64");
if (buffer.byteLength === 0) return new Refused("homeLocationEncrypted");
const bytes = new Uint8Array(new ArrayBuffer(buffer.byteLength));
bytes.set(buffer);
return { ...cleared, homeLocationEncrypted: bytes };
}
if (!has(entry, "homeLat") && !has(entry, "homeLon")) {
// Neither form in the file: a sealed null alone still says "no home".
return has(entry, "homeLocationEncrypted") ? cleared : undefined;
}
const lat = entry.homeLat ?? null;
const lon = entry.homeLon ?? null;
const label = entry.homeLabel ?? null;
if (lat === null || lon === null) return cleared;
if (
typeof lat !== "number" ||
typeof lon !== "number" ||
(label !== null && typeof label !== "string")
) {
return new Refused("homeLat");
}
return {
...cleared,
homeLocationEncrypted: sealLocation({ lat, lon, label: label ?? "" }),
};
},
};
/** Carried by the home codec above. */
const ridesWithHome: SettingCodec = {
write: () => ({}),
read: () => undefined,
};
const ACCOUNT_SETTING_CODECS: Readonly<
Record<AccountSettingColumn, SettingCodec>
> = {
@@ -428,12 +493,12 @@ const ACCOUNT_SETTING_CODECS: Readonly<
avatarBytes: avatar,
avatarContentType: ridesWithAvatar,
avatarUpdatedAt: ridesWithAvatar,
homeLat: plain("homeLat"),
homeLon: plain("homeLon"),
homeLabel: plain("homeLabel"),
homeLat: ridesWithHome,
homeLon: ridesWithHome,
homeLabel: ridesWithHome,
homeTimezone: timeZone("homeTimezone", true),
homeSince: instant("homeSince"),
homeLocationEncrypted: homeLocationSealed,
homeLocationEncrypted: homeLocation,
environmentAirQualityEnabled: plain("environmentAirQualityEnabled"),
timezone: timeZone("timezone", false),
locale: oneOf("locale", locales, true),
@@ -560,8 +625,9 @@ export function admitAccountSettings(
refused.push(...value.refused);
continue;
}
if (column === "avatarBytes") {
// The avatar codec answers for all three of its columns at once.
if (column === "avatarBytes" || column === "homeLocationEncrypted") {
// The avatar codec answers for all three of its columns at once, the
// home codec for all four of its.
Object.assign(data, value);
continue;
}
+244 -41
View File
@@ -51,19 +51,73 @@
* settles over a few days after the fact, so a restore that wrote "now" would
* claim a provisional reading from two years ago had just been confirmed.
*
* ## One wire shape, both purposes
* ## Two purposes since v1.42
*
* Neither model has a tombstone column and neither holds ciphertext, so a
* portable export and a disaster-recovery payload carry byte-identical
* sections. This builder therefore takes no `purpose`: a parameter that
* changes nothing would advertise a distinction the file does not have.
* The coarse locations are sealed at rest from v1.42 (`locationEncrypted`,
* see `src/lib/environment/location-cipher.ts`), so the two purposes differ
* the way every other section with a sealed column does:
*
* - a portable export opens the sealed location and writes the readable
* `lat` / `lon` / label, and no `locationEncrypted` at all, so the file
* reads anywhere;
* - a disaster-recovery file carries `locationEncrypted` verbatim (base64)
* beside whatever readable columns the row still holds, for a host with
* the same keys.
*
* The restore takes either: a sealed value is written as it came, a readable
* one is sealed under this host's key, and the readable columns are written
* empty in both cases. A row that holds neither restores without a location;
* it is never invented.
*
* The air-quality columns (v1.42) are plain values and ride both purposes
* alike. Every one is optional on the way in: a file written before them
* says nothing about air quality, and the restored day then reads as never
* fetched (`aqFetchedAt` absent), so the nightly gap fill asks for it.
*/
import { Buffer } from "node:buffer";
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import type { EnvironmentLocationSource } from "@/generated/prisma/client";
import { roundCoarse } from "@/lib/environment/open-meteo";
import type { AirQualityDay } from "@/lib/environment/air-quality-contract";
import { readLocation, sealLocation } from "@/lib/environment/location-cipher";
export interface EnvironmentBackupOptions {
purpose?: "portable-export" | "disaster-recovery";
}
/** The air-quality columns of a stored day, as they ride the file. */
type AirQualityBackupColumns = Omit<AirQualityDay, "aqFetchedAt"> & {
/** When the air-quality part was fetched; null = not yet. Verbatim. */
aqFetchedAt: string | null;
};
/** The air-quality column names, in file order. */
const AIR_QUALITY_COLUMNS = [
"apparentMax",
"pm25Mean",
"pm25Max",
"pm10Mean",
"no2Mean",
"so2Mean",
"coMean",
"o3Max8h",
"eaqiMax",
"usaqiMax",
"uvIndexMax",
"dustMax",
"aodMax",
"pollenAlderMax",
"pollenBirchMax",
"pollenGrassMax",
"pollenMugwortMax",
"pollenOliveMax",
"pollenRagweedMax",
"aqDomain",
"aqHours",
] as const satisfies ReadonlyArray<keyof AirQualityBackupColumns>;
/** One day's environmental observation, at the location resolved for that day. */
export interface EnvironmentContextBackupEntry {
export interface EnvironmentContextBackupEntry extends AirQualityBackupColumns {
/** `YYYY-MM-DD`, anchored to the resolved location's timezone. */
date: string;
/**
@@ -73,6 +127,11 @@ export interface EnvironmentContextBackupEntry {
lat: number | null;
lon: number | null;
locationLabel: string | null;
/**
* The sealed location, base64. Disaster-recovery files only; a portable
* file carries the readable columns instead.
*/
locationEncrypted?: string | null;
/**
* Which precedence rule chose the location. Carried because it is the only
* record that this day was NOT the home city, and because a re-resolve
@@ -106,6 +165,8 @@ export interface EnvironmentTravelLocationBackupEntry {
lat: number | null;
lon: number | null;
label: string | null;
/** The sealed location, base64; disaster-recovery files only. */
locationEncrypted?: string | null;
createdAt: string;
updatedAt: string;
}
@@ -132,6 +193,7 @@ const ENVIRONMENT_CONTEXT_BACKUP_SELECT = {
lat: true,
lon: true,
locationLabel: true,
locationEncrypted: true,
source: true,
tempMin: true,
tempMax: true,
@@ -145,6 +207,28 @@ const ENVIRONMENT_CONTEXT_BACKUP_SELECT = {
humidityMean: true,
cloudMean: true,
weatherCode: true,
apparentMax: true,
pm25Mean: true,
pm25Max: true,
pm10Mean: true,
no2Mean: true,
so2Mean: true,
coMean: true,
o3Max8h: true,
eaqiMax: true,
usaqiMax: true,
uvIndexMax: true,
dustMax: true,
aodMax: true,
pollenAlderMax: true,
pollenBirchMax: true,
pollenGrassMax: true,
pollenMugwortMax: true,
pollenOliveMax: true,
pollenRagweedMax: true,
aqDomain: true,
aqHours: true,
aqFetchedAt: true,
fetchedAt: true,
createdAt: true,
updatedAt: true,
@@ -156,10 +240,53 @@ const ENVIRONMENT_TRAVEL_LOCATION_BACKUP_SELECT = {
lat: true,
lon: true,
label: true,
locationEncrypted: true,
createdAt: true,
updatedAt: true,
} as const satisfies Prisma.EnvironmentTravelLocationSelect;
/** A sealed column as it rides a disaster-recovery file. */
function toBase64(bytes: Uint8Array | null): string | null {
return bytes && bytes.byteLength > 0
? Buffer.from(bytes).toString("base64")
: null;
}
/**
* The location columns of one entry: opened and readable for a portable
* file, as stored plus the sealed value for a disaster-recovery file.
*/
function locationFields(
row: {
lat: number | null;
lon: number | null;
label: string | null;
locationEncrypted: Uint8Array | null;
},
disasterRecovery: boolean,
): {
lat: number | null;
lon: number | null;
label: string | null;
locationEncrypted?: string | null;
} {
if (disasterRecovery) {
return {
lat: row.lat,
lon: row.lon,
label: row.label,
locationEncrypted: toBase64(row.locationEncrypted),
};
}
const location = readLocation({
sealed: row.locationEncrypted,
lat: row.lat,
lon: row.lon,
label: row.label,
});
return location ?? { lat: null, lon: null, label: null };
}
/**
* Build the environment slice of a user's full backup.
*
@@ -172,7 +299,9 @@ export async function buildEnvironmentBackupSection(
"environmentContext" | "environmentTravelLocation"
>,
userId: string,
options: EnvironmentBackupOptions = {},
): Promise<EnvironmentBackupSection> {
const disasterRecovery = options.purpose === "disaster-recovery";
const [contextRows, travelRows] = await Promise.all([
prisma.environmentContext.findMany({
where: { userId },
@@ -187,34 +316,64 @@ export async function buildEnvironmentBackupSection(
]);
return {
environmentContexts: contextRows.map((row) => ({
date: row.date,
lat: row.lat,
lon: row.lon,
locationLabel: row.locationLabel,
source: row.source,
tempMin: row.tempMin,
tempMax: row.tempMax,
tempMean: row.tempMean,
apparentMean: row.apparentMean,
sunshineSec: row.sunshineSec,
daylightSec: row.daylightSec,
precipSum: row.precipSum,
pressureMean: row.pressureMean,
pressureDelta: row.pressureDelta,
humidityMean: row.humidityMean,
cloudMean: row.cloudMean,
weatherCode: row.weatherCode,
fetchedAt: row.fetchedAt.toISOString(),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
})),
environmentContexts: contextRows.map((row) => {
const { label, ...location } = locationFields(
{
lat: row.lat,
lon: row.lon,
label: row.locationLabel,
locationEncrypted: row.locationEncrypted,
},
disasterRecovery,
);
return {
date: row.date,
...location,
locationLabel: label,
source: row.source,
tempMin: row.tempMin,
tempMax: row.tempMax,
tempMean: row.tempMean,
apparentMean: row.apparentMean,
sunshineSec: row.sunshineSec,
daylightSec: row.daylightSec,
precipSum: row.precipSum,
pressureMean: row.pressureMean,
pressureDelta: row.pressureDelta,
humidityMean: row.humidityMean,
cloudMean: row.cloudMean,
weatherCode: row.weatherCode,
apparentMax: row.apparentMax,
pm25Mean: row.pm25Mean,
pm25Max: row.pm25Max,
pm10Mean: row.pm10Mean,
no2Mean: row.no2Mean,
so2Mean: row.so2Mean,
coMean: row.coMean,
o3Max8h: row.o3Max8h,
eaqiMax: row.eaqiMax,
usaqiMax: row.usaqiMax,
uvIndexMax: row.uvIndexMax,
dustMax: row.dustMax,
aodMax: row.aodMax,
pollenAlderMax: row.pollenAlderMax,
pollenBirchMax: row.pollenBirchMax,
pollenGrassMax: row.pollenGrassMax,
pollenMugwortMax: row.pollenMugwortMax,
pollenOliveMax: row.pollenOliveMax,
pollenRagweedMax: row.pollenRagweedMax,
aqDomain: row.aqDomain,
aqHours: row.aqHours,
aqFetchedAt: row.aqFetchedAt?.toISOString() ?? null,
fetchedAt: row.fetchedAt.toISOString(),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}),
environmentTravelLocations: travelRows.map((row) => ({
startDate: row.startDate,
endDate: row.endDate,
lat: row.lat,
lon: row.lon,
label: row.label,
...locationFields(row, disasterRecovery),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
})),
@@ -254,6 +413,9 @@ export type RestoredEnvironmentContext = Pick<
OptionalNullable<
Pick<
EnvironmentContextBackupEntry,
| "locationEncrypted"
| (typeof AIR_QUALITY_COLUMNS)[number]
| "aqFetchedAt"
| "tempMin"
| "tempMax"
| "tempMean"
@@ -277,9 +439,38 @@ export type RestoredEnvironmentTravelLocation = Pick<
"startDate" | "endDate" | "lat" | "lon" | "label"
> &
OptionalNullable<
Pick<EnvironmentTravelLocationBackupEntry, "createdAt" | "updatedAt">
Pick<
EnvironmentTravelLocationBackupEntry,
"locationEncrypted" | "createdAt" | "updatedAt"
>
>;
/**
* The sealed location a restored row is written with: the file's sealed
* value as it came, or the file's readable location sealed under this host's
* key (rounded to the privacy floor first, so a file written before v1.39.4
* with two decimals comes back coarse). Null when the file carries neither.
*/
function restoredLocation(entry: {
locationEncrypted?: string | null;
lat: number | null;
lon: number | null;
label: string | null;
}): Uint8Array<ArrayBuffer> | null {
if (typeof entry.locationEncrypted === "string") {
const buffer = Buffer.from(entry.locationEncrypted, "base64");
if (buffer.byteLength > 0) {
const bytes = new Uint8Array(new ArrayBuffer(buffer.byteLength));
bytes.set(buffer);
return bytes;
}
}
if (entry.lat == null || entry.lon == null || entry.label == null) {
return null;
}
return sealLocation({ lat: entry.lat, lon: entry.lon, label: entry.label });
}
export interface EnvironmentRestoreInput {
environmentContexts: RestoredEnvironmentContext[];
environmentTravelLocations: RestoredEnvironmentTravelLocation[];
@@ -318,11 +509,11 @@ export async function restoreEnvironmentData(
// neither bound goes near a `Date`.
startDate: entry.startDate,
endDate: entry.endDate,
// A file written before v1.39.4 carries 2-decimal coordinates; they
// come back at today's privacy floor.
lat: entry.lat == null ? null : roundCoarse(entry.lat),
lon: entry.lon == null ? null : roundCoarse(entry.lon),
label: entry.label,
// Sealed, never readable: see `restoredLocation`.
locationEncrypted: restoredLocation(entry),
lat: null,
lon: null,
label: null,
...(entry.createdAt ? { createdAt: new Date(entry.createdAt) } : {}),
...(entry.updatedAt ? { updatedAt: new Date(entry.updatedAt) } : {}),
})),
@@ -334,9 +525,15 @@ export async function restoreEnvironmentData(
data: payload.environmentContexts.map((entry) => ({
userId: ownerId,
date: entry.date,
lat: entry.lat == null ? null : roundCoarse(entry.lat),
lon: entry.lon == null ? null : roundCoarse(entry.lon),
locationLabel: entry.locationLabel,
locationEncrypted: restoredLocation({
locationEncrypted: entry.locationEncrypted,
lat: entry.lat,
lon: entry.lon,
label: entry.locationLabel,
}),
lat: null,
lon: null,
locationLabel: null,
source: entry.source,
tempMin: entry.tempMin ?? null,
tempMax: entry.tempMax ?? null,
@@ -350,6 +547,12 @@ export async function restoreEnvironmentData(
humidityMean: entry.humidityMean ?? null,
cloudMean: entry.cloudMean ?? null,
weatherCode: entry.weatherCode ?? null,
...Object.fromEntries(
AIR_QUALITY_COLUMNS.map((column) => [column, entry[column] ?? null]),
),
// Verbatim, like `fetchedAt`; absent means the air-quality part was
// never fetched, and the nightly gap fill will ask for it.
aqFetchedAt: entry.aqFetchedAt ? new Date(entry.aqFetchedAt) : null,
// Verbatim, not stamped: this says when the feed was read, not when
// the row was written back.
...(entry.fetchedAt ? { fetchedAt: new Date(entry.fetchedAt) } : {}),
+5 -3
View File
@@ -886,9 +886,11 @@ export async function buildFullBackupPayload(
buildAwardsBackupSection(prisma, userId),
// The per-day readings and the location periods that explain them, which
// is why one section carries both. Both ends live in
// `src/lib/export/environment-backup.ts`, and the purpose is absent for
// the same reason as the awards above.
buildEnvironmentBackupSection(prisma, userId),
// `src/lib/export/environment-backup.ts`. Since v1.42 the locations are
// sealed, so the purpose decides whether they ride opened or sealed.
buildEnvironmentBackupSection(prisma, userId, {
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
}),
// The needs-based setup answers. One small row, and the only copy of what
// the person told the flow. Both ends live in
// `src/lib/export/onboarding-backup.ts` beside each other.
@@ -5,8 +5,12 @@ const measurementFindMany = vi.fn();
const moodFindMany = vi.fn();
const customMetricFindMany = vi.fn();
const environmentFindMany = vi.fn();
const userFindUnique = vi.fn(async () => ({
environmentAirQualityEnabled: true,
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: () => userFindUnique() },
measurement: { findMany: (a: unknown) => measurementFindMany(a) },
moodEntry: { findMany: (a: unknown) => moodFindMany(a) },
customMetric: { findMany: (a: unknown) => customMetricFindMany(a) },
@@ -16,6 +20,7 @@ vi.mock("@/lib/db", () => ({
},
}));
import { ENVIRONMENT_FIELDS, POLLEN_COLUMNS } from "@/lib/environment/fields";
import {
averageWithPreviousDay,
fetchEnvironmentSeries,
@@ -399,3 +404,93 @@ describe("environment exposure: the averaged lag 0–1 window (v1.42)", () => {
expect(sun.points).toEqual([{ day: "2026-05-02", value: 1.5 }]);
});
});
describe("environment exposure: the air-quality channels (v1.42, #615)", () => {
beforeEach(() => {
environmentFindMany.mockReset();
userFindUnique.mockReset();
userFindUnique.mockResolvedValue({ environmentAirQualityEnabled: true });
vi.unstubAllEnvs();
});
it("selects every column a channel reads (an unselected column reads as never covered)", async () => {
environmentFindMany.mockResolvedValueOnce([]);
await fetchEnvironmentSeries("u1", new Date("2026-04-01T00:00:00Z"));
const select = (
environmentFindMany.mock.calls[0][0] as {
select: Record<string, boolean>;
}
).select;
const read = ENVIRONMENT_FIELDS.flatMap((f) =>
f.column === "pollenMax" ? [...POLLEN_COLUMNS] : [f.column],
);
for (const column of read) expect(select[column], column).toBe(true);
});
it("adds exactly three air-quality channels", () => {
expect(
ENVIRONMENT_FIELDS.filter((f) => f.airQuality).map((f) => f.key),
).toEqual(["ENV_PM25", "ENV_OZONE_8H", "ENV_POLLEN_MAX"]);
});
it("takes the pollen high over the covered kinds, never a zero for the uncovered", async () => {
environmentFindMany.mockResolvedValueOnce([
{
date: "2026-05-01",
pm25Mean: 10,
o3Max8h: 80,
pollenBirchMax: 40,
pollenGrassMax: null,
pollenAlderMax: null,
},
{
date: "2026-05-02",
pm25Mean: 14,
o3Max8h: null,
pollenBirchMax: null,
pollenGrassMax: 20,
pollenAlderMax: 2,
},
// A day the feed did not cover at all: no point, not a zero.
{ date: "2026-05-03", pm25Mean: null, o3Max8h: null },
]);
const series = await fetchEnvironmentSeries(
"u1",
new Date("2026-04-01T00:00:00Z"),
);
const by = (key: string) => series.find((s) => s.key === key)!.points;
expect(by("ENV_POLLEN_MAX")).toEqual([{ day: "2026-05-02", value: 30 }]);
expect(by("ENV_PM25")).toEqual([{ day: "2026-05-02", value: 12 }]);
// Ozone is missing on 05-02, so no averaged point anywhere.
expect(by("ENV_OZONE_8H")).toEqual([]);
});
it("leaves the air-quality channels empty when the account turned air quality off", async () => {
userFindUnique.mockResolvedValue({ environmentAirQualityEnabled: false });
environmentFindMany.mockResolvedValueOnce([
{ date: "2026-05-01", pm25Mean: 10, tempMin: 5 },
{ date: "2026-05-02", pm25Mean: 14, tempMin: 7 },
]);
const series = await fetchEnvironmentSeries(
"u1",
new Date("2026-04-01T00:00:00Z"),
);
expect(series.find((s) => s.key === "ENV_PM25")!.points).toEqual([]);
expect(series.find((s) => s.key === "ENV_TEMP_MIN")!.points).toEqual([
{ day: "2026-05-02", value: 6 },
]);
});
it("leaves them empty when the operator turned air quality off", async () => {
vi.stubEnv("ENVIRONMENT_AIR_QUALITY_DISABLED", "1");
environmentFindMany.mockResolvedValueOnce([
{ date: "2026-05-01", pm25Mean: 10 },
{ date: "2026-05-02", pm25Mean: 14 },
]);
const series = await fetchEnvironmentSeries(
"u1",
new Date("2026-04-01T00:00:00Z"),
);
expect(series.find((s) => s.key === "ENV_PM25")!.points).toEqual([]);
});
});
+58 -29
View File
@@ -37,7 +37,11 @@ import {
} from "@/lib/insights/correlation-discovery";
import { resolveLabFields } from "@/lib/labs/serialise";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import { ENVIRONMENT_FIELDS } from "@/lib/environment/fields";
import {
ENVIRONMENT_FIELDS,
environmentFieldValue,
} from "@/lib/environment/fields";
import { isAirQualityActive } from "@/lib/environment/open-meteo-air-quality";
import {
buildComplianceDailySeries,
buildSymptomEventDailySeries,
@@ -237,37 +241,62 @@ export async function fetchEnvironmentSeries(
): Promise<NamedSeries[]> {
// eslint-disable-next-line healthlog/no-utc-day-key -- baseline: lower bound on stored environment day keys; a day either way at the far edge does not change the series
const sinceKey = since.toISOString().slice(0, 10);
const rows = await prisma.environmentContext.findMany({
where: { userId, date: { gte: sinceKey } },
orderBy: { date: "asc" },
take: 1000,
select: {
date: true,
tempMean: true,
tempMin: true,
tempMax: true,
apparentMean: true,
sunshineSec: true,
daylightSec: true,
precipSum: true,
pressureMean: true,
pressureDelta: true,
humidityMean: true,
cloudMean: true,
},
});
const [rows, account] = await Promise.all([
prisma.environmentContext.findMany({
where: { userId, date: { gte: sinceKey } },
orderBy: { date: "asc" },
take: 1000,
select: {
date: true,
tempMean: true,
tempMin: true,
tempMax: true,
apparentMean: true,
sunshineSec: true,
daylightSec: true,
precipSum: true,
pressureMean: true,
pressureDelta: true,
humidityMean: true,
cloudMean: true,
// v1.42 (#615) — the air-quality channels' columns. The read is an
// explicit select, so a channel whose column is missing here reads
// as never covered rather than failing.
pm25Mean: true,
o3Max8h: true,
pollenAlderMax: true,
pollenBirchMax: true,
pollenGrassMax: true,
pollenMugwortMax: true,
pollenOliveMax: true,
pollenRagweedMax: true,
},
}),
prisma.user.findUnique({
where: { id: userId },
select: { environmentAirQualityEnabled: true },
}),
]);
// With the air-quality part off (by the account or the operator) its
// channels are empty: stored values from before the switch are kept, but
// not correlated against the person's wish.
const airQuality = isAirQualityActive(
account?.environmentAirQualityEnabled ?? true,
);
return ENVIRONMENT_FIELDS.map((field) => {
const daily: DailySeriesPoint[] = [];
for (const row of rows) {
const raw = row[field.column];
if (raw == null || !Number.isFinite(raw)) continue;
// Seconds → hours for the duration fields; pass through otherwise.
const value =
field.column === "sunshineSec" || field.column === "daylightSec"
? raw / 3600
: raw;
daily.push({ day: row.date, value });
if (!field.airQuality || airQuality) {
for (const row of rows) {
const raw = environmentFieldValue(field, row);
if (raw == null) continue;
// Seconds → hours for the duration fields; pass through otherwise.
const value =
field.column === "sunshineSec" || field.column === "daylightSec"
? raw / 3600
: raw;
daily.push({ day: row.date, value });
}
}
// v1.42 — one averaged-lag hypothesis per outcome: the exposure on day D
// is the mean of D−1 and D, paired with the outcome on D (`lagDays: 0`).
@@ -5,6 +5,7 @@ import {
decryptRouteGeometry,
encryptRouteGeometry,
} from "@/lib/workouts/route-geometry-cipher";
import { openLocation, sealLocation } from "@/lib/environment/location-cipher";
// ── in-memory store backing a minimal prisma mock ──────────────────────────
interface ConversationRow {
@@ -37,11 +38,36 @@ interface RouteRow {
geometryEncrypted: Uint8Array | null;
}
/** v1.42 — an account's environment home; `userId` is its own id. */
interface HomeRow {
id: string;
userId: string;
homeLat: number | null;
homeLon: number | null;
homeLabel: string | null;
homeLocationEncrypted: Uint8Array | null;
updatedAt?: Date;
}
/** v1.42 — a dated location period or a stored day (label column named). */
interface PlaceRow {
id: string;
userId: string;
lat: number | null;
lon: number | null;
label?: string | null;
locationLabel?: string | null;
locationEncrypted: Uint8Array | null;
updatedAt?: Date;
}
const store = vi.hoisted(() => ({
conversations: [] as ConversationRow[],
entries: [] as EntryRow[],
practitioners: [] as PractitionerRow[],
routes: [] as RouteRow[],
homes: [] as HomeRow[],
travels: [] as PlaceRow[],
days: [] as PlaceRow[],
}));
vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: () => null }));
@@ -56,10 +82,11 @@ vi.mock("@/lib/db", () => {
// The handler pages on the owner plus "a readable column is set": the
// user id directly, or through the workout for a route.
findMany: async (args: {
where: { userId?: string; workout?: { userId: string } };
where: { userId?: string; id?: string; workout?: { userId: string } };
take: number;
}) => {
const userId = args.where.userId ?? args.where.workout?.userId;
const userId =
args.where.userId ?? args.where.workout?.userId ?? args.where.id;
return rows()
.filter(
(r) => r.userId === userId && readable.some((k) => r[k] !== null),
@@ -117,6 +144,9 @@ vi.mock("@/lib/db", () => {
customMetricEntry: delegate(() => store.entries, ["note"]),
practitioner: delegate(() => store.practitioners, ["phone", "location"]),
workoutRoute: delegate(() => store.routes, ["geometry"]),
user: delegate(() => store.homes, ["homeLat"]),
environmentTravelLocation: delegate(() => store.travels, ["lat"]),
environmentContext: delegate(() => store.days, ["lat"]),
};
return {
prisma: {
@@ -224,6 +254,72 @@ beforeEach(() => {
},
{ id: "r3", userId: "u2", geometry: TRACK, geometryEncrypted: null },
];
const stamp = new Date("2026-02-01T08:00:00.000Z");
store.homes = [
{
id: "u1",
userId: "u1",
homeLat: 51.5,
homeLon: 7.2,
homeLabel: "Bochum, Germany",
homeLocationEncrypted: null,
updatedAt: stamp,
},
{
id: "u2",
userId: "u2",
homeLat: 48.1,
homeLon: 11.6,
homeLabel: "Munich, Germany",
homeLocationEncrypted: null,
updatedAt: stamp,
},
];
store.travels = [
{
id: "t1",
userId: "u1",
lat: 38.7,
lon: -9.1,
label: "Lisbon, Portugal",
locationEncrypted: null,
updatedAt: stamp,
},
// Already sealed by this release's writer.
{
id: "t2",
userId: "u1",
lat: null,
lon: null,
label: null,
locationEncrypted: sealLocation({
lat: 41.4,
lon: 2.2,
label: "Barcelona, Spain",
}),
updatedAt: stamp,
},
];
store.days = [
{
id: "d1",
userId: "u1",
lat: 38.7,
lon: -9.1,
locationLabel: "Lisbon, Portugal",
locationEncrypted: null,
updatedAt: stamp,
},
{
id: "d2",
userId: "u2",
lat: 48.1,
lon: 11.6,
locationLabel: "Munich, Germany",
locationEncrypted: null,
updatedAt: stamp,
},
];
});
afterEach(() => {
@@ -241,6 +337,9 @@ describe("runFreeTextEncryptionBackfillForUser", () => {
routeGeometriesMigrated: 1,
appointmentAddressesCleared: 0,
contactAuditRowsScrubbed: 0,
homeLocationsMigrated: 1,
travelLocationsMigrated: 1,
dayLocationsMigrated: 1,
});
const c1 = store.conversations.find((r) => r.id === "c1")!;
@@ -344,12 +443,64 @@ describe("runFreeTextEncryptionBackfillForUser", () => {
routeGeometriesMigrated: 0,
appointmentAddressesCleared: 0,
contactAuditRowsScrubbed: 0,
homeLocationsMigrated: 0,
travelLocationsMigrated: 0,
dayLocationsMigrated: 0,
});
expect(store.conversations.find((r) => r.id === "c1")!.titleEncrypted).toBe(
sealed,
);
});
it("seals the environment home, periods and days, clears the readable columns, keeps the stamps", async () => {
const t2Before = store.travels.find(
(r) => r.id === "t2",
)!.locationEncrypted;
await runFreeTextEncryptionBackfillForUser("u1");
const home = store.homes.find((r) => r.id === "u1")!;
expect(home).toMatchObject({
homeLat: null,
homeLon: null,
homeLabel: null,
});
expect(openLocation(home.homeLocationEncrypted!)).toEqual({
lat: 51.5,
lon: 7.2,
label: "Bochum, Germany",
});
expect(home.updatedAt).toEqual(new Date("2026-02-01T08:00:00.000Z"));
const t1 = store.travels.find((r) => r.id === "t1")!;
expect(t1).toMatchObject({ lat: null, lon: null, label: null });
expect(openLocation(t1.locationEncrypted!)).toEqual({
lat: 38.7,
lon: -9.1,
label: "Lisbon, Portugal",
});
expect(store.travels.find((r) => r.id === "t2")!.locationEncrypted).toBe(
t2Before,
);
const d1 = store.days.find((r) => r.id === "d1")!;
expect(d1).toMatchObject({ lat: null, lon: null, locationLabel: null });
expect(openLocation(d1.locationEncrypted!).label).toBe("Lisbon, Portugal");
// Another account's rows are untouched.
expect(store.homes.find((r) => r.id === "u2")!.homeLat).toBe(48.1);
expect(store.days.find((r) => r.id === "d2")!.lat).toBe(48.1);
});
it("seals a home stored without a label with an empty one", async () => {
store.homes[0].homeLabel = null;
await runFreeTextEncryptionBackfillForUser("u1");
expect(openLocation(store.homes[0].homeLocationEncrypted!)).toEqual({
lat: 51.5,
lon: 7.2,
label: "",
});
});
it("is fail-closed: without a key every readable row stays intact", async () => {
vi.stubEnv("ENCRYPTION_KEY", "");
vi.stubEnv("ENCRYPTION_KEYS", "");
@@ -358,6 +509,10 @@ describe("runFreeTextEncryptionBackfillForUser", () => {
title: "Why is my pressure up after the new tablets?",
titleEncrypted: null,
});
expect(store.days.find((r) => r.id === "d1")).toMatchObject({
lat: 38.7,
locationEncrypted: null,
});
});
});
+33 -12
View File
@@ -16,6 +16,14 @@
* home location, so a disabled account never triggers an outbound fetch. Egress
* runs through `safeFetch` inside the Open-Meteo client. Recurring → pg-boss
* (never a CLI script; the prod image strips `tsx`).
*
* v1.42 (#615) — the same per-user run fetches air quality, pollen and UV
* with the weather, and then fills up to 90 stored days whose air-quality
* part is still missing, newest first (`fillAirQualityGaps`). Every request
* asks the instance-wide Open-Meteo budget first; a refused request ends the
* run as done with `budget_blocked`, and the next night continues. The home
* is found by its sealed copy as well as by the readable columns the
* encryption backfill has not cleared yet.
*/
import { prisma } from "@/lib/db";
import type { PgBoss } from "pg-boss";
@@ -30,8 +38,10 @@ import {
ENVIRONMENT_LOOKBACK_DAYS,
ENVIRONMENT_MAX_BACKFILL_DAYS,
fetchAndStoreEnvironment,
fillAirQualityGaps,
utcDayKey,
} from "@/lib/environment/service";
import { enumerateDayCount } from "@/lib/environment/day-span";
import { recordError } from "@/lib/jobs/worker-status";
import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome";
import { workerLog } from "./reminder/shared";
@@ -110,7 +120,14 @@ export async function enqueueEnvironmentFetchDiscovery(
// consults the per-user preference map + the operator availability.
const [candidates, operatorAvailability] = await Promise.all([
prisma.user.findMany({
where: { homeLat: { not: null }, homeLon: { not: null } },
// The sealed home (v1.42), or the readable one a row the encryption
// backfill has not reached yet still carries.
where: {
OR: [
{ homeLocationEncrypted: { not: null } },
{ homeLat: { not: null }, homeLon: { not: null } },
],
},
select: { id: true, modulePreferencesJson: true },
}),
getOperatorModuleAvailability(),
@@ -174,7 +191,7 @@ export async function handleEnvironmentFetch(
const { startDate, endDate } = resolveRange(payload);
// Clamp the span so a crafted backfill can never fan out an unbounded range.
const span = enumerateSpanDays(startDate, endDate);
const span = enumerateDayCount(startDate, endDate);
if (span > ENVIRONMENT_MAX_BACKFILL_DAYS) {
workerLog(
"error",
@@ -193,14 +210,27 @@ export async function handleEnvironmentFetch(
startDate,
endDate,
});
// The gap fill runs on the nightly path only; an explicit backfill range
// already fetched the air quality of the days it stored. It is skipped
// when the budget already refused this run.
const explicitRange = payload.startDate != null || payload.endDate != null;
const gaps =
explicitRange || result.budgetBlocked
? { filled: 0, stopped: null }
: await fillAirQualityGaps(userId);
const budgetBlocked = result.budgetBlocked || gaps.stopped === "budget";
workerLog(
"info",
`[environment-fetch] user=${userId} ${startDate}..${endDate} stored=${result.stored} skipped=${result.skipped} fetches=${result.fetches}`,
`[environment-fetch] user=${userId} ${startDate}..${endDate} stored=${result.stored} skipped=${result.skipped} fetches=${result.fetches} aq_gap_days=${gaps.filled}${budgetBlocked ? " budget_blocked" : ""}`,
);
// A refused request is not a failure: nothing was sent, and the days it
// would have filled are exactly what the next night looks for.
return jobDone({
days_stored: result.stored,
days_skipped: result.skipped,
fetches: result.fetches,
rows_upserted: gaps.filled,
budget_blocked: budgetBlocked,
});
} catch (err) {
recordError();
@@ -208,12 +238,3 @@ export async function handleEnvironmentFetch(
throw err;
}
}
function enumerateSpanDays(start: string, end: string): number {
const [sy, sm, sd] = start.split("-").map(Number);
const [ey, em, ed] = end.split("-").map(Number);
const days = Math.round(
(Date.UTC(ey, em - 1, ed) - Date.UTC(sy, sm - 1, sd)) / MS_PER_DAY,
);
return days + 1;
}
+167 -2
View File
@@ -22,6 +22,16 @@
* column holds a JSON null rather than SQL NULL has nothing to seal; it is set
* to SQL NULL so discovery stops finding it.
*
* v1.42 (#615) adds the environment module's coarse locations: the home on
* the user row (`homeLat` / `homeLon` / `homeLabel` -> `homeLocationEncrypted`),
* each dated location period and each stored day (`lat` / `lon` / label ->
* `locationEncrypted`), each sealed as one `{ lat, lon, label }` value under
* the environment-location label (`location-cipher.ts`). A row whose readable
* triple is incomplete (a home without a label is sealed with an empty one;
* a day or period cannot be incomplete, those
* columns were NOT NULL until migration 0382) has its readable columns
* cleared all the same, so it leaves discovery.
*
* Modelled on `med-notes-encryption-backfill.ts`: a discovery query enqueues
* one job per user still holding an un-migrated row, the per-user handler walks
* that user's rows, and the pass is idempotent across reboots — once a row is
@@ -62,6 +72,7 @@ import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { encryptNote } from "@/lib/crypto/note-cipher";
import { encryptRouteGeometry } from "@/lib/workouts/route-geometry-cipher";
import { sealLocation } from "@/lib/environment/location-cipher";
export const FREE_TEXT_ENCRYPTION_BACKFILL_QUEUE =
"free-text-encryption-backfill";
@@ -89,6 +100,12 @@ export interface FreeTextEncryptionBackfillSummary {
appointmentAddressesCleared: number;
/** v1.39.4 — edit audit rows whose old phone number / address was removed. */
contactAuditRowsScrubbed: number;
/** v1.42 — the account's environment home sealed (0 or 1). */
homeLocationsMigrated: number;
/** v1.42 — dated environment location periods sealed. */
travelLocationsMigrated: number;
/** v1.42 — stored environment days whose location was sealed. */
dayLocationsMigrated: number;
}
/** The audit action whose older rows carried contact values. */
@@ -286,6 +303,107 @@ async function migrateRouteGeometry(id: string): Promise<boolean> {
});
}
/**
* Seal one account's readable environment home into `homeLocationEncrypted`
* and clear the readable columns, atomically and idempotently. The user row
* keeps its own `updatedAt`: moving a value into ciphertext is not an edit.
*/
async function migrateHomeLocation(id: string): Promise<boolean> {
return prisma.$transaction(async (tx) => {
const fresh = await tx.user.findUnique({
where: { id },
select: {
homeLat: true,
homeLon: true,
homeLabel: true,
updatedAt: true,
},
});
if (!fresh || fresh.homeLat === null) return false;
const { count } = await tx.user.updateMany({
where: { id, updatedAt: fresh.updatedAt },
data: {
updatedAt: fresh.updatedAt,
...(fresh.homeLon !== null
? {
homeLocationEncrypted: sealLocation({
lat: fresh.homeLat,
lon: fresh.homeLon,
// A home stored without a label seals an empty one, which
// every reader shows as no label.
label: fresh.homeLabel ?? "",
}),
}
: {}),
homeLat: null,
homeLon: null,
homeLabel: null,
},
});
return count === 1;
});
}
/** Seal one dated location period's readable location, as above. */
async function migrateTravelLocation(id: string): Promise<boolean> {
return prisma.$transaction(async (tx) => {
const fresh = await tx.environmentTravelLocation.findUnique({
where: { id },
select: { lat: true, lon: true, label: true, updatedAt: true },
});
if (!fresh || fresh.lat === null) return false;
const { count } = await tx.environmentTravelLocation.updateMany({
where: { id, updatedAt: fresh.updatedAt },
data: {
updatedAt: fresh.updatedAt,
...(fresh.lon !== null && fresh.label !== null
? {
locationEncrypted: sealLocation({
lat: fresh.lat,
lon: fresh.lon,
label: fresh.label,
}),
}
: {}),
lat: null,
lon: null,
label: null,
},
});
return count === 1;
});
}
/** Seal one stored day's readable location, as above. */
async function migrateDayLocation(id: string): Promise<boolean> {
return prisma.$transaction(async (tx) => {
const fresh = await tx.environmentContext.findUnique({
where: { id },
select: { lat: true, lon: true, locationLabel: true, updatedAt: true },
});
if (!fresh || fresh.lat === null) return false;
const { count } = await tx.environmentContext.updateMany({
where: { id, updatedAt: fresh.updatedAt },
data: {
updatedAt: fresh.updatedAt,
...(fresh.lon !== null && fresh.locationLabel !== null
? {
locationEncrypted: sealLocation({
lat: fresh.lat,
lon: fresh.lon,
label: fresh.locationLabel,
}),
}
: {}),
lat: null,
lon: null,
locationLabel: null,
},
});
return count === 1;
});
}
/**
* Walk one kind of row page by page, migrating each. `page` returns the ids
* still holding a readable value; each migration clears it, so a migrated row
@@ -393,6 +511,38 @@ export async function runFreeTextEncryptionBackfillForUser(
scrubContactAuditRow,
once,
);
// v1.42 — the environment locations. The 0382 partial indexes match these
// predicates.
const homeLocationsMigrated = await drain(
() =>
prisma.user.findMany({
where: { id: userId, homeLat: { not: null } },
select: { id: true },
take: PAGE_SIZE,
}),
migrateHomeLocation,
once,
);
const travelLocationsMigrated = await drain(
() =>
prisma.environmentTravelLocation.findMany({
where: { userId, lat: { not: null } },
select: { id: true },
take: PAGE_SIZE,
}),
migrateTravelLocation,
once,
);
const dayLocationsMigrated = await drain(
() =>
prisma.environmentContext.findMany({
where: { userId, lat: { not: null } },
select: { id: true },
take: PAGE_SIZE,
}),
migrateDayLocation,
once,
);
annotate({
action: {
@@ -404,6 +554,9 @@ export async function runFreeTextEncryptionBackfillForUser(
route_geometries_migrated: routeGeometriesMigrated,
appointment_addresses_cleared: appointmentAddressesCleared,
contact_audit_rows_scrubbed: contactAuditRowsScrubbed,
home_locations_migrated: homeLocationsMigrated,
travel_locations_migrated: travelLocationsMigrated,
day_locations_migrated: dayLocationsMigrated,
},
},
});
@@ -415,13 +568,16 @@ export async function runFreeTextEncryptionBackfillForUser(
routeGeometriesMigrated,
appointmentAddressesCleared,
contactAuditRowsScrubbed,
homeLocationsMigrated,
travelLocationsMigrated,
dayLocationsMigrated,
};
}
/**
* Discovery. Finds every user still holding a readable value in any of the
* columns above and enqueues one backfill job per account. The 0357 and 0361
* partial indexes match the predicates, so the scan is index-only and converges to empty.
* columns above and enqueues one backfill job per account. The 0357, 0361 and
* 0382 partial indexes match the predicates, so the scan is index-only and converges to empty.
* The appointment-address and audit predicates have no partial index: the
* first scans the reminder table, the second reads one action through the
* `(action, created_at)` index; both converge to empty the same way. pg-boss
@@ -469,6 +625,15 @@ export async function enqueueBootTimeFreeTextEncryptionBackfill(
WHERE action = ${CONTACT_AUDIT_ACTION}
AND user_id IS NOT NULL
AND (details LIKE '%"location":%' OR details LIKE '%"phone":%')`,
prisma.$queryRaw<{ user_id: string }[]>`
SELECT id AS user_id FROM users
WHERE home_lat IS NOT NULL`,
prisma.$queryRaw<{ user_id: string }[]>`
SELECT DISTINCT user_id FROM environment_travel_locations
WHERE lat IS NOT NULL`,
prisma.$queryRaw<{ user_id: string }[]>`
SELECT DISTINCT user_id FROM environment_contexts
WHERE lat IS NOT NULL`,
]);
const userIds = new Set<string>();
@@ -1,6 +1,6 @@
/**
* Every MCP read of medications, labs and conditions honours the module
* switch (v1.39.3 review).
* Every MCP read of medications, labs, conditions and (v1.42) the
* environment honours the module switch (v1.39.3 review).
*
* `search` and `fetch` gained the rule with the clinical-record kinds; the
* rest of the surface has to say the same thing, or the claim that a
@@ -143,11 +143,24 @@ describe("medications switched off", () => {
});
});
describe("environment switched off (v1.42)", () => {
beforeEach(() => disabled.add("environment"));
it("get_environment refuses without reading", async () => {
expect(await tool("get_environment").run(CTX, {})).toEqual(OFF);
expect(
await tool("get_environment").run(CTX, { window: "lastYear" }),
).toEqual(OFF);
expect(executeCoachTool).not.toHaveBeenCalled();
});
});
describe("modules on", () => {
it("the same reads still answer", async () => {
await tool("get_labs").run(CTX, {});
await tool("get_medication_compliance").run(CTX, {});
expect(executeCoachTool).toHaveBeenCalledTimes(2);
await tool("get_environment").run(CTX, {});
expect(executeCoachTool).toHaveBeenCalledTimes(3);
expect(await template("lab").list!(CTX)).toHaveLength(1);
expect(await template("medication").list!(CTX)).toHaveLength(1);
});
+2
View File
@@ -207,6 +207,8 @@ describe("MCP tool registry — surface", () => {
"get_intraday_pulse",
// v1.30 coverage review (G3) — ECG recording metadata.
"get_ecg_recordings",
// v1.42 — weather, air quality and pollen of the stored days.
"get_environment",
// v1.38 — the bounded visit history.
"get_visits",
].sort(),
+104 -2
View File
@@ -215,7 +215,7 @@ const MODULE_DISABLED = { present: false, reason: "module_disabled" } as const;
*/
async function whenModuleOn(
ctx: McpAuthContext,
key: "labs" | "medications",
key: "labs" | "medications" | "environment",
tool: string,
read: () => Promise<unknown>,
): Promise<unknown> {
@@ -876,6 +876,92 @@ const SEARCH_PAGE_SIZE = 50;
*/
const SEARCH_RESULT_SCAN_CAP = 2000;
/**
* v1.42 (#615) — `get_environment`. Declared field by field rather than as
* the opaque Coach `data`, so the advertised schema itself says what the read
* can carry: no coordinate and no place name appear anywhere in it, and a
* strict client refuses a result that grew one. Every value is nullable
* (null = the feed did not cover it); the air-quality fields are absent
* while that part is switched off.
*/
const envValue = z.number().nullable();
const envPollen = z.object({
alder: envValue,
birch: envValue,
grass: envValue,
mugwort: envValue,
olive: envValue,
ragweed: envValue,
});
const envAirQuality = z.object({
pm25Mean: envValue,
pm25Max: envValue,
pm10Mean: envValue,
no2Mean: envValue,
so2Mean: envValue,
coMean: envValue,
o3Max8h: envValue,
eaqiMax: envValue,
usaqiMax: envValue,
uvIndexMax: envValue,
dustMax: envValue,
aodMax: envValue,
pollen: envPollen,
});
const getEnvironmentOutput: z.ZodRawShape = {
present: z.boolean(),
reason: z.string().optional(),
searchedWindow: z.string().optional(),
available: z
.object({
count: z.number(),
firstDate: z.string(),
lastDate: z.string(),
reachableWithWindow: z.string().nullable(),
})
.optional(),
data: z
.object({
window: coachScopeWindowSchema,
coverage: z.object({
firstDate: z.string(),
lastDate: z.string(),
days: z.number(),
awayDays: z.number(),
airQualityDays: z.number().optional(),
}),
daily: z.array(
z.object({
date: z.string(),
tempMin: envValue,
tempMax: envValue,
tempMean: envValue,
apparentMax: envValue,
precipSum: envValue,
sunshineHours: envValue,
pressureMean: envValue,
pressureDelta: envValue,
humidityMean: envValue,
airQuality: envAirQuality.nullable().optional(),
}),
),
summary: z.object({
hotNights: z.number(),
veryPoorAirDays: z.number().optional(),
highPollenDays: z.number().optional(),
pollenPeak: envValue.optional(),
}),
airQuality: z.enum(["on", "off_account", "off_operator"]),
provenance: z.object({
weather: z.string(),
airQuality: z.string(),
note: z.string(),
}),
attributions: z.array(z.string()),
})
.optional(),
};
const getCorrelationOutput: z.ZodRawShape = {
present: z.boolean(),
reason: z.string().optional(),
@@ -1432,7 +1518,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
name: "get_correlations",
title: "Get discovered correlations",
description:
"Fetch the user's statistically-vetted (FDR-controlled) day-to-next-day driver pairs between behaviours (daylight, mood, glucose, blood pressure, steps) and outcomes (sleep, HRV, resting HR, weight), each with direction, lag, sample size, and a descriptive — never causal — note over a fixed trailing window. Returns { present: false } when too little paired data exists.",
"Fetch the user's statistically-vetted (FDR-controlled) driver pairs between behaviours (daylight, mood, glucose, blood pressure, steps, and with the environment module the weather, fine particles, ozone and pollen) and outcomes (sleep, HRV, resting HR, weight, mood, symptoms), each with direction, lag, sample size, and a descriptive — never causal — note over a fixed trailing window. Season and trend are removed from both series first. lagDays 1 pairs a day with the next day's outcome; lagDays 0 (the environment channels) pairs the mean of the day before and the day itself with that same day's outcome. Returns { present: false } when too little paired data exists.",
inputShape: {},
annotations: READ_ONLY_ANNOTATIONS,
outputShape: coachReadOutput,
@@ -1594,6 +1680,22 @@ export const MCP_TOOLS: McpToolDefinition[] = [
return runCoachTool(ctx, "get_cycle", args);
},
},
{
name: "get_environment",
title: "Get weather, air quality and pollen",
description:
"Fetch the weather, air quality, pollen and UV the user's stored days had (environment module): per day the temperature (min, max, mean, feels-like high), precipitation, sunshine hours, pressure and humidity, and while air quality is on, PM2.5 (mean and peak), PM10, NO2, the ozone 8-hour high, the European AQI peak, UV, dust and six pollen kinds; plus coverage and a summary (hot nights, very poor air days, high pollen days). These are modelled outdoor conditions at a coarse location (ERA5 and CAMS, 9 to 45 km grid), not the user's personal exposure; describe co-occurrence, never a cause. Null means not covered, never zero. Carries no coordinates and no place names. Each result lists the attributions to show with it (Open-Meteo, CC BY 4.0; Copernicus Atmosphere Monitoring Service). Returns { present: false, reason: \"module_disabled\" } when the environment module is off, and no_data / outside_window when nothing is stored in the window.",
inputShape: {
window: coachScopeWindowSchema.optional(),
},
annotations: READ_ONLY_ANNOTATIONS,
outputShape: getEnvironmentOutput,
run(ctx, args) {
return whenModuleOn(ctx, "environment", "get_environment", () =>
runCoachTool(ctx, "get_environment", args),
);
},
},
// ── Multi-metric fan-out (catalogue: query expressiveness) ──────────
{
name: "get_metrics",
+85 -3
View File
@@ -30,7 +30,6 @@ import {
dataEnvelope,
errorEnvelope,
moduleDisabledResponse,
notImplementedResponse,
stdResponses,
} from "./shared";
@@ -136,6 +135,90 @@ const environmentOverview = z
.describe(
"Upstream attribution string. Display it wherever the observations are shown.",
),
airQuality: z
.object({
enabled: z
.boolean()
.describe(
"The account's switch (`PATCH /api/environment/preferences`).",
),
operatorDisabled: z
.boolean()
.describe(
"True when the operator turned air quality off for the instance; the account switch then has no effect.",
),
days: z
.number()
.int()
.describe(
"Stored days the air-quality feed returned hourly values for.",
),
latestDate: z
.string()
.nullable()
.describe("Newest such day (YYYY-MM-DD)."),
domain: z
.string()
.nullable()
.describe(
"The model domain of that day: `cams_europe` (about 11 km, with pollen) or `cams_global` (about 45 km, no pollen).",
),
})
.describe("v1.42 — the air-quality part of the module."),
latestDay: z
.object({
date: z.string().describe("YYYY-MM-DD of the newest stored day."),
tempMin: z.number().nullable(),
tempMax: z.number().nullable(),
apparentMax: z
.number()
.nullable()
.describe("Daily feels-like maximum, °C."),
airQuality: z
.object({
pm25Mean: z
.number()
.nullable()
.describe("PM2.5 daily mean, µg/m³."),
pm10Mean: z.number().nullable().describe("PM10 daily mean, µg/m³."),
no2Mean: z.number().nullable().describe("NO2 daily mean, µg/m³."),
o3Max8h: z
.number()
.nullable()
.describe("Ozone, highest 8-hour running mean, µg/m³."),
eaqiMax: z
.number()
.nullable()
.describe("European air-quality index, daily maximum."),
uvIndexMax: z.number().nullable(),
dustMax: z.number().nullable().describe("Dust, µg/m³."),
pollen: z
.object({
alder: z.number().nullable(),
birch: z.number().nullable(),
grass: z.number().nullable(),
mugwort: z.number().nullable(),
olive: z.number().nullable(),
ragweed: z.number().nullable(),
})
.describe(
"Daily maxima, grains/m³; null where not covered (outside Europe, before mid 2022).",
),
})
.nullable()
.describe(
"Null while air quality is off for the account or the instance, and for a day whose air-quality part was not fetched yet. Null values inside mean not covered, never zero.",
),
})
.nullable()
.describe(
"v1.42 — the newest stored day, usually yesterday or the day before (the archive settles late). No forecast. Null when nothing is stored.",
),
attributions: z
.array(z.string())
.describe(
"v1.42 — every attribution line, weather first; the air-quality lines (Open-Meteo, Copernicus) while air quality is on. Display them wherever the values are shown.",
),
})
.meta({
id: "EnvironmentOverview",
@@ -187,7 +270,7 @@ export const environmentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Environment"],
summary: "Change the environment switches",
description:
"Turns the air-quality part of the module on or off for this account (on by default). Off means the nightly fetch skips the air-quality feed and the overview leaves the fields out; days already stored are kept. Module-gated. Strict body.",
"Turns the air-quality part of the module on or off for this account (on by default). Off means the nightly fetch skips the air-quality feed, and the overview, the Coach, MCP and the correlations leave the air-quality values out; days already stored keep theirs. Turning it on queues a refresh, and the nightly gap fill catches up on older days. Module-gated. Strict body: an unknown key is a 422.",
requestBody: {
required: true,
content: {
@@ -208,7 +291,6 @@ export const environmentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
...moduleDisabledResponse,
...stdResponses,
...notImplementedResponse,
},
},
},
+14
View File
@@ -84,3 +84,17 @@ export type EnvironmentBackfillInput = z.infer<
export const geocodeQuerySchema = z
.object({ q: z.string().trim().min(1).max(120) })
.strict();
/**
* v1.42 (#615) — the account's environment switches. Today one: whether the
* air-quality, pollen and UV part is fetched and shown. Strict.
*/
export const environmentPreferencesSchema = z
.object({
airQualityEnabled: z.boolean(),
})
.strict();
export type EnvironmentPreferencesInput = z.infer<
typeof environmentPreferencesSchema
>;
+67 -42
View File
@@ -53,6 +53,7 @@ process.env.ENCRYPTION_KEY ??=
import type { PrismaClient } from "@/generated/prisma/client";
import { decrypt, decryptBytes, encrypt, encryptBytes } from "@/lib/crypto";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { openLocation, sealLocation } from "@/lib/environment/location-cipher";
import { readNote } from "@/lib/crypto/note-cipher";
import {
decryptFactData,
@@ -1386,14 +1387,18 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
// it. The assertion after the restore reads the two back TOGETHER. The
// coordinates are at 1 decimal, the precision the app stores since v1.39.4;
// a restore rounds anything finer, so finer values would not round-trip.
// Since v1.42 the location is stored sealed (`locationEncrypted`), the way
// the writers store it; the readable columns stay empty.
await prisma.environmentTravelLocation.create({
data: {
userId: OWNER_ID,
startDate: "2026-06-10",
endDate: "2026-06-20",
lat: 41.4,
lon: 2.2,
label: "Barcelona",
locationEncrypted: sealLocation({
lat: 41.4,
lon: 2.2,
label: "Barcelona",
}),
},
});
await prisma.environmentContext.createMany({
@@ -1401,9 +1406,11 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
{
userId: OWNER_ID,
date: "2026-06-15",
lat: 41.4,
lon: 2.2,
locationLabel: "Barcelona",
locationEncrypted: sealLocation({
lat: 41.4,
lon: 2.2,
label: "Barcelona",
}),
source: "TRAVEL",
tempMin: 19.4,
tempMax: 28.1,
@@ -1422,9 +1429,11 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
{
userId: OWNER_ID,
date: "2026-07-01",
lat: 52.5,
lon: 13.4,
locationLabel: "Berlin",
locationEncrypted: sealLocation({
lat: 52.5,
lon: 13.4,
label: "Berlin",
}),
source: "HOME",
tempMin: 13.1,
tempMax: 22.7,
@@ -2659,9 +2668,10 @@ describe("every model the plan claims two-ended survives a real restore", () =>
expect(
readings.map((reading) => ({
date: reading.date,
lat: reading.lat,
lon: reading.lon,
locationLabel: reading.locationLabel,
// The sealed location, opened (v1.42).
...(({ lat, lon, label }) => ({ lat, lon, locationLabel: label }))(
openLocation(reading.locationEncrypted!),
),
source: reading.source,
tempMean: reading.tempMean,
pressureDelta: reading.pressureDelta,
@@ -2710,15 +2720,17 @@ describe("every model the plan claims two-ended survives a real restore", () =>
where: { userId: OWNER_ID },
});
const travelDay = readings.find((reading) => reading.source === "TRAVEL")!;
const tripPlace = openLocation(trip.locationEncrypted!);
const dayPlace = openLocation(travelDay.locationEncrypted!);
expect(
{
startDate: trip.startDate,
endDate: trip.endDate,
label: trip.label,
label: tripPlace.label,
covers:
travelDay.date >= trip.startDate && travelDay.date <= trip.endDate,
sameLat: trip.lat === travelDay.lat,
sameLon: trip.lon === travelDay.lon,
sameLat: tripPlace.lat === dayPlace.lat,
sameLon: tripPlace.lon === dayPlace.lon,
},
"the period must still explain the reading it was exported beside",
).toEqual({
@@ -3500,6 +3512,22 @@ const COLUMN_EXCLUSIONS: Readonly<Record<string, string>> = {
"insert stamp of a symptom link; no reader, the day log's own timestamps travel",
"IllnessSymptomLink.createdAt":
"insert stamp of a symptom link; no reader, the illness day log's own timestamps travel",
// v1.42 (#615) — the readable copies of the sealed environment locations.
// The restore seals whatever location a file carries into
// `locationEncrypted` and writes these empty; the location itself is
// compared through the sealed column. They drop in v1.43.
"EnvironmentContext.lat":
"readable copy of the sealed day location; the restore seals it and writes this empty",
"EnvironmentContext.lon":
"readable copy of the sealed day location; the restore seals it and writes this empty",
"EnvironmentContext.locationLabel":
"readable copy of the sealed day location; the restore seals it and writes this empty",
"EnvironmentTravelLocation.lat":
"readable copy of the sealed period location; the restore seals it and writes this empty",
"EnvironmentTravelLocation.lon":
"readable copy of the sealed period location; the restore seals it and writes this empty",
"EnvironmentTravelLocation.label":
"readable copy of the sealed period location; the restore seals it and writes this empty",
};
/**
@@ -3510,35 +3538,9 @@ const COLUMN_EXCLUSIONS: Readonly<Record<string, string>> = {
* starts coming back, so the entry has to go in the change that carries it.
* The list is empty again before the release is tagged.
*/
const ENVIRONMENT_V142 =
"v1.42 (#615): the environment section carries the air-quality columns and the sealed location";
const LABS_V142 =
"v1.42 (#1095): the lab section carries the conversion provenance";
const RELEASE_PENDING_COLUMNS: Readonly<Record<string, string>> = {
"EnvironmentContext.aodMax": ENVIRONMENT_V142,
"EnvironmentContext.apparentMax": ENVIRONMENT_V142,
"EnvironmentContext.aqDomain": ENVIRONMENT_V142,
"EnvironmentContext.aqFetchedAt": ENVIRONMENT_V142,
"EnvironmentContext.aqHours": ENVIRONMENT_V142,
"EnvironmentContext.coMean": ENVIRONMENT_V142,
"EnvironmentContext.dustMax": ENVIRONMENT_V142,
"EnvironmentContext.eaqiMax": ENVIRONMENT_V142,
"EnvironmentContext.no2Mean": ENVIRONMENT_V142,
"EnvironmentContext.o3Max8h": ENVIRONMENT_V142,
"EnvironmentContext.pm10Mean": ENVIRONMENT_V142,
"EnvironmentContext.pm25Max": ENVIRONMENT_V142,
"EnvironmentContext.pm25Mean": ENVIRONMENT_V142,
"EnvironmentContext.pollenAlderMax": ENVIRONMENT_V142,
"EnvironmentContext.pollenBirchMax": ENVIRONMENT_V142,
"EnvironmentContext.pollenGrassMax": ENVIRONMENT_V142,
"EnvironmentContext.pollenMugwortMax": ENVIRONMENT_V142,
"EnvironmentContext.pollenOliveMax": ENVIRONMENT_V142,
"EnvironmentContext.pollenRagweedMax": ENVIRONMENT_V142,
"EnvironmentContext.so2Mean": ENVIRONMENT_V142,
"EnvironmentContext.usaqiMax": ENVIRONMENT_V142,
"EnvironmentContext.uvIndexMax": ENVIRONMENT_V142,
"EnvironmentContext.locationEncrypted": ENVIRONMENT_V142,
"EnvironmentTravelLocation.locationEncrypted": ENVIRONMENT_V142,
"Biomarker.analyteKey": LABS_V142,
"LabResult.sourceValue": LABS_V142,
"LabResult.sourceUnit": LABS_V142,
@@ -3704,6 +3706,8 @@ const BYTE_OPENERS: ReadonlyArray<(value: Uint8Array) => string> = [
decryptFromBytes,
decryptNoteFromBytes,
decryptContextFromBytes,
// v1.42 — the sealed environment locations, under their own label.
(value) => JSON.stringify(openLocation(value)),
(value) => decryptBytes(Buffer.from(value)).toString("base64"),
];
@@ -4045,6 +4049,20 @@ const USER_FILL_OVERRIDES: Readonly<Record<string, unknown>> = {
return bytes;
},
"User.avatarContentType": "image/png",
// v1.42 — a sealed home the way the writers seal it, so it opens.
"User.homeLocationEncrypted": () =>
sealLocation({ lat: 2.5, lon: 2.5, label: "round-trip home" }),
};
/**
* v1.42 (#615) — readable setting columns the restore seals into another
* column and writes empty. The value they held is compared through the sealed
* column (`homeLocationEncrypted`).
*/
const SEALED_ON_RESTORE: Readonly<Record<string, string>> = {
homeLat: "sealed into homeLocationEncrypted",
homeLon: "sealed into homeLocationEncrypted",
homeLabel: "sealed into homeLocationEncrypted",
};
/**
@@ -4183,8 +4201,15 @@ describe("the account's own settings survive a real restore", () => {
})) as unknown as Record<string, unknown>;
const lost = [...settings].filter(
(name) => opened(after[name]) !== opened(source[name]),
(name) =>
!Object.hasOwn(SEALED_ON_RESTORE, name) &&
opened(after[name]) !== opened(source[name]),
);
// The readable home columns come back empty by design; the home itself
// came back sealed, compared above through `homeLocationEncrypted`.
for (const name of Object.keys(SEALED_ON_RESTORE)) {
expect(after[name], name).toBeNull();
}
expect(
lost,
"these settings left with a value and came back without it",
@@ -8,6 +8,10 @@
* coarse;
* - the rounding block of migration 0361 coarsens rows written before it,
* and running it twice changes nothing more.
*
* v1.42 (#615): the locations are stored sealed, so the coarse value is
* asserted on the opened sealed copy, and the readable columns are empty. The
* rounding still happens before sealing; nothing finer is ever stored.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
@@ -19,6 +23,7 @@ process.env.ENCRYPTION_KEY ??=
import { PUT as putHome } from "@/app/api/environment/home/route";
import { POST as postTravel } from "@/app/api/environment/travel/route";
import { restoreEnvironmentData } from "@/lib/export/environment-backup";
import { openLocation } from "@/lib/environment/location-cipher";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
@@ -95,7 +100,16 @@ describe("environment coordinates at one decimal", () => {
const user = await getPrismaClient().user.findUniqueOrThrow({
where: { id: OWNER_ID },
});
expect([user.homeLat, user.homeLon]).toEqual([52.5, 13.4]);
expect([user.homeLat, user.homeLon, user.homeLabel]).toEqual([
null,
null,
null,
]);
expect(openLocation(user.homeLocationEncrypted!)).toEqual({
lat: 52.5,
lon: 13.4,
label: "Berlin, Germany",
});
const travel = await postTravel(
jsonRequest("http://localhost/api/environment/travel", "POST", {
@@ -107,10 +121,16 @@ describe("environment coordinates at one decimal", () => {
}),
);
expect(travel.status).toBe(201);
expect((await travel.json()).data).toMatchObject({ lat: 48.1, lon: 11.6 });
const stored = await getPrismaClient().environmentTravelLocation.findMany({
where: { userId: OWNER_ID },
});
expect(stored.map((t) => [t.lat, t.lon])).toEqual([[48.1, 11.6]]);
expect(stored.map((t) => [t.lat, t.lon, t.label])).toEqual([
[null, null, null],
]);
expect(stored.map((t) => openLocation(t.locationEncrypted!))).toEqual([
{ lat: 48.1, lon: 11.6, label: "Munich, Germany" },
]);
});
it("restores a file written with two-decimal coordinates at one decimal", async () => {
@@ -143,8 +163,15 @@ describe("environment coordinates at one decimal", () => {
const days = await prisma.environmentContext.findMany({
where: { userId: OWNER_ID },
});
expect(travel.map((t) => [t.lat, t.lon])).toEqual([[48.1, 11.6]]);
expect(days.map((d) => [d.lat, d.lon])).toEqual([[48.1, 11.6]]);
expect(travel.map((t) => openLocation(t.locationEncrypted!))).toEqual([
{ lat: 48.1, lon: 11.6, label: "Munich, Germany" },
]);
expect(days.map((d) => openLocation(d.locationEncrypted!))).toEqual([
{ lat: 48.1, lon: 11.6, label: "Munich, Germany" },
]);
expect(days.map((d) => [d.lat, d.lon, d.locationLabel])).toEqual([
[null, null, null],
]);
});
it("coarsens rows written before migration 0361, idempotently", async () => {
@@ -0,0 +1,492 @@
/**
* v1.42 (#615) — the environment locations sealed at rest, against real
* Postgres with the migrated schema (0381, 0382).
*
* - the writers (home, period) store only the sealed copy, the overview
* answers readable, and the air-quality switch round-trips through its
* route;
* - the boot-time backfill finds the accounts still holding a readable
* home, period or day, seals each, clears the readable columns, and
* converges (a second pass and a second discovery find nothing);
* - the backup carries the location readable in a portable file and sealed
* in a disaster-recovery file, and both restore to a location that opens
* to the same coarse place, with the air-quality values of the day;
* - the data wipe clears the sealed home with the rest.
*/
import { Buffer } from "node:buffer";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PUT as putHome } from "@/app/api/environment/home/route";
import { POST as postTravel } from "@/app/api/environment/travel/route";
import { PATCH as patchPreferences } from "@/app/api/environment/preferences/route";
import { GET as getOverview } from "@/app/api/environment/route";
import { setGlobalBoss } from "@/lib/jobs/boss-instance";
import {
enqueueBootTimeFreeTextEncryptionBackfill,
runFreeTextEncryptionBackfillForUser,
} from "@/lib/jobs/free-text-encryption-backfill";
import { openLocation, sealLocation } from "@/lib/environment/location-cipher";
import {
buildEnvironmentBackupSection,
restoreEnvironmentData,
type EnvironmentRestoreInput,
} from "@/lib/export/environment-backup";
/** A section as a file carries it: through JSON and back. */
function throughFile(section: unknown): EnvironmentRestoreInput {
return JSON.parse(JSON.stringify(section)) as EnvironmentRestoreInput;
}
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => cookieJar.set(name, value),
delete: (name: string) => cookieJar.delete(name),
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const OWNER = "env-sealed-owner";
function jsonRequest(url: string, method: string, body: unknown) {
return new Request(url, {
method,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}) as never;
}
async function seedUser(id: string) {
await getPrismaClient().user.create({
data: {
id,
username: id,
email: `${id}@example.test`,
timezone: "Europe/Berlin",
},
});
}
beforeEach(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
cookieJar.clear();
headerJar.clear();
await seedUser(OWNER);
const session = await prisma.session.create({
data: {
userId: OWNER,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
mfaVerifiedAt: new Date(),
},
});
cookieJar.set("healthlog_session", session.id);
});
afterEach(() => {
setGlobalBoss(null as never);
});
describe("environment writers and the overview", () => {
it("store the sealed copy only and answer readable", async () => {
const prisma = getPrismaClient();
expect(
(
await putHome(
jsonRequest("http://localhost/api/environment/home", "PUT", {
lat: 51.4818,
lon: 7.2162,
label: "Bochum, Germany",
timezone: "Europe/Berlin",
}),
)
).status,
).toBe(200);
expect(
(
await postTravel(
jsonRequest("http://localhost/api/environment/travel", "POST", {
startDate: "2026-08-01",
endDate: "2026-08-10",
lat: 38.7223,
lon: -9.1393,
label: "Lisbon, Portugal",
}),
)
).status,
).toBe(201);
const user = await prisma.user.findUniqueOrThrow({ where: { id: OWNER } });
expect([user.homeLat, user.homeLon, user.homeLabel]).toEqual([
null,
null,
null,
]);
const travel = await prisma.environmentTravelLocation.findFirstOrThrow({
where: { userId: OWNER },
});
expect([travel.lat, travel.lon, travel.label]).toEqual([null, null, null]);
// No readable trace of either place anywhere in the two rows.
expect(
JSON.stringify({ ...user, homeLocationEncrypted: null }),
).not.toMatch(/Bochum/);
const overview = (await (await getOverview()).json()).data;
expect(overview.home).toMatchObject({
lat: 51.5,
lon: 7.2,
label: "Bochum, Germany",
});
expect(overview.travel).toEqual([
expect.objectContaining({
startDate: "2026-08-01",
lat: 38.7,
lon: -9.1,
label: "Lisbon, Portugal",
}),
]);
expect(overview.airQuality).toMatchObject({
enabled: true,
operatorDisabled: false,
days: 0,
});
expect(overview.attributions.length).toBe(3);
});
it("turns the air-quality part off and on through its route", async () => {
const prisma = getPrismaClient();
const off = await patchPreferences(
jsonRequest("http://localhost/api/environment/preferences", "PATCH", {
airQualityEnabled: false,
}),
);
expect(off.status).toBe(200);
expect((await off.json()).data).toEqual({
airQualityEnabled: false,
operatorDisabled: false,
});
expect(
(await prisma.user.findUniqueOrThrow({ where: { id: OWNER } }))
.environmentAirQualityEnabled,
).toBe(false);
const bad = await patchPreferences(
jsonRequest("http://localhost/api/environment/preferences", "PATCH", {
airQualityEnabled: "yes",
userId: "someone-else",
}),
);
expect(bad.status).toBe(422);
});
it("leaves the air-quality values of a stored day out of the overview while off", async () => {
const prisma = getPrismaClient();
await prisma.environmentContext.create({
data: {
userId: OWNER,
date: "2026-10-06",
source: "HOME",
locationEncrypted: sealLocation({ lat: 51.5, lon: 7.2, label: "Home" }),
tempMin: 21,
pm25Mean: 12,
eaqiMax: 85,
pollenGrassMax: 70,
aqHours: 24,
aqDomain: "cams_europe",
aqFetchedAt: new Date(),
},
});
const on = (await (await getOverview()).json()).data;
expect(on.latestDay).toMatchObject({
date: "2026-10-06",
tempMin: 21,
airQuality: { pm25Mean: 12, eaqiMax: 85, pollen: { grass: 70 } },
});
expect(on.airQuality).toMatchObject({ days: 1, domain: "cams_europe" });
await prisma.user.update({
where: { id: OWNER },
data: { environmentAirQualityEnabled: false },
});
const off = (await (await getOverview()).json()).data;
expect(off.latestDay.airQuality).toBeNull();
expect(off.attributions.length).toBe(1);
});
});
describe("the encryption backfill (real Postgres)", () => {
it("discovers, seals, clears and converges", async () => {
const prisma = getPrismaClient();
await seedUser("env-other");
const stamp = new Date("2026-03-01T10:00:00.000Z");
await prisma.user.update({
where: { id: OWNER },
data: { homeLat: 51.5, homeLon: 7.2, homeLabel: "Bochum, Germany" },
});
await prisma.environmentTravelLocation.create({
data: {
userId: OWNER,
startDate: "2026-08-01",
endDate: "2026-08-10",
lat: 38.7,
lon: -9.1,
label: "Lisbon, Portugal",
updatedAt: stamp,
},
});
await prisma.environmentContext.create({
data: {
userId: OWNER,
date: "2026-08-02",
lat: 38.7,
lon: -9.1,
locationLabel: "Lisbon, Portugal",
source: "TRAVEL",
},
});
const sent: string[] = [];
setGlobalBoss({
send: async (_q: string, payload: { userId?: string }) => {
if (payload.userId) sent.push(payload.userId);
return "job";
},
} as never);
const discovery = await enqueueBootTimeFreeTextEncryptionBackfill();
expect(discovery.error).toBeNull();
expect(sent).toEqual([OWNER]);
const summary = await runFreeTextEncryptionBackfillForUser(OWNER);
expect(summary).toMatchObject({
homeLocationsMigrated: 1,
travelLocationsMigrated: 1,
dayLocationsMigrated: 1,
});
const user = await prisma.user.findUniqueOrThrow({ where: { id: OWNER } });
expect(user.homeLat).toBeNull();
expect(openLocation(user.homeLocationEncrypted!)).toEqual({
lat: 51.5,
lon: 7.2,
label: "Bochum, Germany",
});
const travel = await prisma.environmentTravelLocation.findFirstOrThrow({
where: { userId: OWNER },
});
expect(travel.lat).toBeNull();
expect(travel.updatedAt).toEqual(stamp);
expect(openLocation(travel.locationEncrypted!).label).toBe(
"Lisbon, Portugal",
);
const day = await prisma.environmentContext.findFirstOrThrow({
where: { userId: OWNER },
});
expect([day.lat, day.lon, day.locationLabel]).toEqual([null, null, null]);
expect(openLocation(day.locationEncrypted!).lat).toBe(38.7);
// Converged: nothing readable is left to find.
expect(await runFreeTextEncryptionBackfillForUser(OWNER)).toMatchObject({
homeLocationsMigrated: 0,
travelLocationsMigrated: 0,
dayLocationsMigrated: 0,
});
sent.length = 0;
await enqueueBootTimeFreeTextEncryptionBackfill();
expect(sent).toEqual([]);
const remaining = await prisma.$queryRaw<{ n: bigint }[]>`
SELECT (SELECT count(*) FROM users WHERE home_lat IS NOT NULL)
+ (SELECT count(*) FROM environment_travel_locations WHERE lat IS NOT NULL)
+ (SELECT count(*) FROM environment_contexts WHERE lat IS NOT NULL) AS n`;
expect(Number(remaining[0].n)).toBe(0);
});
});
describe("the environment backup with sealed locations", () => {
async function seedDay() {
const prisma = getPrismaClient();
await prisma.environmentTravelLocation.create({
data: {
userId: OWNER,
startDate: "2026-08-01",
endDate: "2026-08-10",
locationEncrypted: sealLocation({
lat: 38.7,
lon: -9.1,
label: "Lisbon, Portugal",
}),
},
});
await prisma.environmentContext.create({
data: {
userId: OWNER,
date: "2026-08-02",
source: "TRAVEL",
locationEncrypted: sealLocation({
lat: 38.7,
lon: -9.1,
label: "Lisbon, Portugal",
}),
tempMin: 20.5,
pm25Mean: 7.5,
o3Max8h: 96,
pollenOliveMax: null,
pollenGrassMax: 4,
aqDomain: "cams_europe",
aqHours: 24,
aqFetchedAt: new Date("2026-08-05T02:10:00.000Z"),
},
});
}
it("writes the location readable in a portable file and restores it sealed", async () => {
const prisma = getPrismaClient();
await seedDay();
const section = await buildEnvironmentBackupSection(prisma, OWNER, {
purpose: "portable-export",
});
const [day] = section.environmentContexts;
expect(day).toMatchObject({
lat: 38.7,
lon: -9.1,
locationLabel: "Lisbon, Portugal",
pm25Mean: 7.5,
pollenOliveMax: null,
aqFetchedAt: "2026-08-05T02:10:00.000Z",
});
expect(day).not.toHaveProperty("locationEncrypted");
expect(section.environmentTravelLocations[0]).toMatchObject({
lat: 38.7,
label: "Lisbon, Portugal",
});
const parsed = throughFile(section);
await prisma.$transaction((tx) =>
restoreEnvironmentData(tx, OWNER, parsed),
);
const restored = await prisma.environmentContext.findFirstOrThrow({
where: { userId: OWNER },
});
expect([restored.lat, restored.locationLabel]).toEqual([null, null]);
expect(openLocation(restored.locationEncrypted!)).toEqual({
lat: 38.7,
lon: -9.1,
label: "Lisbon, Portugal",
});
expect(restored).toMatchObject({
pm25Mean: 7.5,
o3Max8h: 96,
pollenGrassMax: 4,
aqHours: 24,
aqDomain: "cams_europe",
});
expect(restored.aqFetchedAt).toEqual(new Date("2026-08-05T02:10:00.000Z"));
});
it("carries the sealed value verbatim in a disaster-recovery file", async () => {
const prisma = getPrismaClient();
await seedDay();
const before = await prisma.environmentContext.findFirstOrThrow({
where: { userId: OWNER },
});
const section = await buildEnvironmentBackupSection(prisma, OWNER, {
purpose: "disaster-recovery",
});
const [day] = section.environmentContexts;
expect(day.lat).toBeNull();
expect(day.locationEncrypted).toBe(
Buffer.from(before.locationEncrypted!).toString("base64"),
);
const parsed = throughFile(section);
await prisma.$transaction((tx) =>
restoreEnvironmentData(tx, OWNER, parsed),
);
const after = await prisma.environmentContext.findFirstOrThrow({
where: { userId: OWNER },
});
expect(
Buffer.from(after.locationEncrypted!).equals(
Buffer.from(before.locationEncrypted!),
),
).toBe(true);
});
it("restores a file from before air quality as a day never fetched", async () => {
const prisma = getPrismaClient();
await prisma.$transaction((tx) =>
restoreEnvironmentData(tx, OWNER, {
environmentTravelLocations: [],
environmentContexts: [
{
date: "2026-01-02",
lat: 51.5,
lon: 7.2,
locationLabel: "Bochum, Germany",
source: "HOME",
tempMin: 1,
},
],
}),
);
const day = await prisma.environmentContext.findFirstOrThrow({
where: { userId: OWNER },
});
expect(day.aqFetchedAt).toBeNull();
expect(day.pm25Mean).toBeNull();
expect(openLocation(day.locationEncrypted!).label).toBe("Bochum, Germany");
});
});
describe("the data wipe", () => {
it("clears the sealed home, the periods and the days, and resets the switch", async () => {
const prisma = getPrismaClient();
await prisma.user.update({
where: { id: OWNER },
data: {
homeLocationEncrypted: sealLocation({
lat: 51.5,
lon: 7.2,
label: "Bochum, Germany",
}),
environmentAirQualityEnabled: false,
},
});
await prisma.environmentContext.create({
data: {
userId: OWNER,
date: "2026-08-02",
source: "HOME",
locationEncrypted: sealLocation({ lat: 51.5, lon: 7.2, label: "x" }),
},
});
const { DELETE } = await import("@/app/api/settings/data/route");
const response = await DELETE(
new Request("http://localhost/api/settings/data", {
method: "DELETE",
body: JSON.stringify({ confirm: "DELETE" }),
}) as never,
);
expect(response.status).toBe(200);
const user = await prisma.user.findUniqueOrThrow({ where: { id: OWNER } });
expect(user.homeLocationEncrypted).toBeNull();
expect(user.environmentAirQualityEnabled).toBe(true);
expect(
await prisma.environmentContext.count({ where: { userId: OWNER } }),
).toBe(0);
});
});
@@ -0,0 +1,154 @@
/**
* v1.42 (#615) — key rotation covers the three sealed environment locations
* (the home, the dated periods, the stored days), against real Postgres.
*
* Rows are sealed under the old key, the registry walk (shared by the in-app
* rotation and `scripts/rotate-encryption-key.ts`) re-seals them under the new
* one with the environment-location label, and they still open once the old
* key is gone. A walk that re-sealed them without the label, or with the
* string codec, would leave values no reader can open.
*/
import { beforeEach, describe, expect, it } from "vitest";
process.env.ENCRYPTION_KEYS = JSON.stringify({
v1: "1".repeat(64),
v2: "2".repeat(64),
});
process.env.ENCRYPTION_ACTIVE_KEY_ID = "v1";
delete process.env.ENCRYPTION_KEY;
import { _resetCryptoCacheForTests, extractKeyIdFromBytes } from "@/lib/crypto";
import {
ENCRYPTED_COLUMNS,
type EncryptedColumn,
} from "@/lib/crypto/encrypted-columns";
import {
rotateColumn,
type CorpusClient,
} from "@/lib/crypto/encryption-corpus";
import { openLocation, sealLocation } from "@/lib/environment/location-cipher";
import { getPrismaClient, truncateAllTables } from "./setup";
const USER_ID = "user-rotation-environment";
function column(model: string, field: string): EncryptedColumn {
const col = ENCRYPTED_COLUMNS.find(
(c) => c.model === model && c.field === field,
);
if (!col) throw new Error(`${model}.${field} is not registered`);
return col;
}
function activate(keyId: string) {
process.env.ENCRYPTION_ACTIVE_KEY_ID = keyId;
_resetCryptoCacheForTests();
}
beforeEach(async () => {
process.env.ENCRYPTION_KEYS = JSON.stringify({
v1: "1".repeat(64),
v2: "2".repeat(64),
});
activate("v1");
await truncateAllTables(getPrismaClient());
});
describe("key rotation over the environment locations", () => {
it("re-seals the home, a period and a day under the active key", async () => {
const prisma = getPrismaClient();
const stamp = new Date("2026-05-04T10:00:00.000Z");
await prisma.user.create({
data: {
id: USER_ID,
username: "rotation-environment",
email: "rotation-environment@example.test",
timezone: "Europe/Berlin",
homeLocationEncrypted: sealLocation({
lat: 51.5,
lon: 7.2,
label: "Bochum, Germany",
}),
},
});
await prisma.environmentTravelLocation.create({
data: {
id: "et-1",
userId: USER_ID,
startDate: "2026-08-01",
endDate: "2026-08-10",
locationEncrypted: sealLocation({
lat: 38.7,
lon: -9.1,
label: "Lisbon, Portugal",
}),
updatedAt: stamp,
},
});
await prisma.environmentContext.create({
data: {
id: "ec-1",
userId: USER_ID,
date: "2026-08-02",
source: "TRAVEL",
locationEncrypted: sealLocation({
lat: 38.7,
lon: -9.1,
label: "Lisbon, Portugal",
}),
},
});
activate("v2");
const client = {
user: prisma.user,
environmentTravelLocation: prisma.environmentTravelLocation,
environmentContext: prisma.environmentContext,
} as unknown as CorpusClient;
for (const [model, field] of [
["User", "homeLocationEncrypted"],
["EnvironmentTravelLocation", "locationEncrypted"],
["EnvironmentContext", "locationEncrypted"],
] as const) {
const result = await rotateColumn(client, column(model, field));
expect(result, `${model}.${field}`).toMatchObject({
scanned: 1,
rotated: 1,
errors: 0,
});
expect((await rotateColumn(client, column(model, field))).rotated).toBe(
0,
);
}
// The old key is gone; every location still opens.
process.env.ENCRYPTION_KEYS = JSON.stringify({ v2: "2".repeat(64) });
_resetCryptoCacheForTests();
const user = await prisma.user.findUniqueOrThrow({
where: { id: USER_ID },
});
const travel = await prisma.environmentTravelLocation.findUniqueOrThrow({
where: { id: "et-1" },
});
const day = await prisma.environmentContext.findUniqueOrThrow({
where: { id: "ec-1" },
});
for (const sealed of [
user.homeLocationEncrypted!,
travel.locationEncrypted!,
day.locationEncrypted!,
]) {
expect(extractKeyIdFromBytes(Buffer.from(sealed))).toBe("v2");
}
expect(openLocation(user.homeLocationEncrypted!).label).toBe(
"Bochum, Germany",
);
expect(openLocation(travel.locationEncrypted!)).toEqual({
lat: 38.7,
lon: -9.1,
label: "Lisbon, Portugal",
});
expect(openLocation(day.locationEncrypted!).lat).toBe(38.7);
// Re-sealing is not an edit: the period keeps its stamp.
expect(travel.updatedAt).toEqual(stamp);
});
});
@@ -133,6 +133,9 @@ describe("free-text encryption backfill (real Postgres)", () => {
routeGeometriesMigrated: 0,
appointmentAddressesCleared: 0,
contactAuditRowsScrubbed: 0,
homeLocationsMigrated: 0,
travelLocationsMigrated: 0,
dayLocationsMigrated: 0,
});
expect(await runFreeTextEncryptionBackfillForUser("ft-b")).toEqual({
conversationTitlesMigrated: 0,
@@ -141,6 +144,9 @@ describe("free-text encryption backfill (real Postgres)", () => {
routeGeometriesMigrated: 0,
appointmentAddressesCleared: 0,
contactAuditRowsScrubbed: 0,
homeLocationsMigrated: 0,
travelLocationsMigrated: 0,
dayLocationsMigrated: 0,
});
const conversations = await prisma.coachConversation.findMany({
@@ -189,6 +195,9 @@ describe("free-text encryption backfill (real Postgres)", () => {
routeGeometriesMigrated: 0,
appointmentAddressesCleared: 0,
contactAuditRowsScrubbed: 0,
homeLocationsMigrated: 0,
travelLocationsMigrated: 0,
dayLocationsMigrated: 0,
});
});
@@ -273,6 +282,9 @@ describe("free-text encryption backfill (real Postgres)", () => {
routeGeometriesMigrated: 0,
appointmentAddressesCleared: 0,
contactAuditRowsScrubbed: 0,
homeLocationsMigrated: 0,
travelLocationsMigrated: 0,
dayLocationsMigrated: 0,
});
expect(await runFreeTextEncryptionBackfillForUser("pc-b")).toEqual({
conversationTitlesMigrated: 0,
@@ -281,6 +293,9 @@ describe("free-text encryption backfill (real Postgres)", () => {
routeGeometriesMigrated: 1,
appointmentAddressesCleared: 0,
contactAuditRowsScrubbed: 0,
homeLocationsMigrated: 0,
travelLocationsMigrated: 0,
dayLocationsMigrated: 0,
});
const practitioners = await prisma.practitioner.findMany({
@@ -341,6 +356,9 @@ describe("free-text encryption backfill (real Postgres)", () => {
routeGeometriesMigrated: 0,
appointmentAddressesCleared: 0,
contactAuditRowsScrubbed: 0,
homeLocationsMigrated: 0,
travelLocationsMigrated: 0,
dayLocationsMigrated: 0,
});
});
@@ -438,6 +456,9 @@ describe("free-text encryption backfill (real Postgres)", () => {
expect(summaryA).toMatchObject({
appointmentAddressesCleared: 1,
contactAuditRowsScrubbed: 1,
homeLocationsMigrated: 0,
travelLocationsMigrated: 0,
dayLocationsMigrated: 0,
});
await runFreeTextEncryptionBackfillForUser("ad-b");
@@ -19,6 +19,10 @@
* schema exactly as it was advertised. A tool the case table does not cover
* fails the run, so a new tool cannot ship without joining it.
*
* v1.42: `get_environment` declares its result field by field, so the
* strict validator here is also what proves it carries no coordinate and no
* place name: an undeclared key would fail the run.
*
* It also pins the live-rows rule on the two presence probes: a measurement
* type whose readings were all deleted is not listed as present.
*/
@@ -37,6 +41,7 @@ import {
} from "@/lib/mcp/rich-reads";
import type { McpAuthContext } from "@/lib/mcp/auth";
import { sealLocation } from "@/lib/environment/location-cipher";
import { getPrismaClient, truncateAllTables } from "./setup";
const DAY = 86_400_000;
@@ -241,6 +246,42 @@ async function seedRecord(): Promise<string> {
},
});
}
// v1.42 — environment days with weather and air quality, one of them not
// yet fetched, so get_environment answers its full shape (and a null
// air-quality part) under the strict validator. The location is sealed.
for (let i = 2; i <= 12; i += 1) {
await prisma.environmentContext.create({
data: {
userId: id,
date: isoDay(daysAgo(i)),
source: i === 5 ? "TRAVEL" : "HOME",
locationEncrypted: sealLocation({
lat: 51.5,
lon: 7.2,
label: "Somewhere",
}),
tempMin: 10 + (i % 4),
tempMax: 20,
tempMean: 15,
apparentMax: 21,
sunshineSec: 7200,
...(i === 3
? {}
: {
pm25Mean: 8,
pm25Max: 16,
o3Max8h: 70,
eaqiMax: 40,
uvIndexMax: 3,
pollenGrassMax: 12,
pollenBirchMax: null,
aqDomain: "cams_europe",
aqHours: 24,
aqFetchedAt: daysAgo(1),
}),
},
});
}
return id;
}
@@ -297,6 +338,7 @@ const CASES: Record<string, Array<Record<string, unknown>>> = {
get_workouts: [{ window: "last30days" }],
get_illness_recovery: [{}],
get_cycle: [{}],
get_environment: [{}, { window: "last7days" }],
get_metrics: [
{ metrics: ["weight", "bp", "steps"], window: "last30days" },
{ metrics: ["hrv", "vo2_max"] },
@@ -439,6 +481,27 @@ describe("MCP tool outputs against their advertised schemas", () => {
}
});
it("get_environment answers with data and no location anywhere", async () => {
const { client, close } = await connect(userId);
try {
const result = await client.callTool({
name: "get_environment",
arguments: {},
});
const out = result.structuredContent as {
present: boolean;
data?: { daily: Array<{ airQuality?: unknown }> };
};
expect(out.present).toBe(true);
expect(out.data!.daily.some((d) => d.airQuality === null)).toBe(true);
const text = JSON.stringify(out);
expect(text).not.toMatch(/"(lat|lon|label|locationLabel)"/);
expect(text).not.toContain("Somewhere");
} finally {
await close();
}
});
it("list_metrics carries availability, and it is declared", async () => {
const { client, close } = await connect(userId);
try {