Files
HealthLog/src/lib/environment/day-flags.ts
T
Marc-André Bombeck 3c98d95a40 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.
2026-10-08 10:02:39 +02:00

78 lines
2.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}