mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
1 parent
aca497d9ac
commit
3c98d95a40
73 files changed
+6061
-385
No files matched your search
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user