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

@@ -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)",