feat(environment): fill air quality for every past day with entries

A background job fetches air quality, pollen and UV for every earlier
local day with a reading, mood entry, dose, symptom or workout: after the
update (boot and nightly discovery), when the home is set and when air
quality is switched on. A day with a weather row gets its air quality; a
day without one gets both, at the covering location period or else at the
home, also before the home was set. Days the source does not reach (before
2013 in Europe, before August 2022 elsewhere) stay empty and are not
counted.

Pending days are grouped by coarse location and cut into ranges of up to
90 days, joining days at most 14 apart, newest first. A run holds itself to
300 of the account's 400 daily calls and half of each instance window,
stores its progress on the user (migration 0391) and sends a follow-up while
it makes progress; a budget stop waits for the next discovery. The
Location & weather card shows the progress.

The air-quality client now asks pollen and dust from 2021 and skips the
request outside Europe before CAMS global starts.
This commit is contained in:
Marc-André Bombeck committed 2026-10-09 09:43:01 +02:00
1 parent 7340cd3ab7
commit d81a6ddf25
30 files changed
+1747 -52

No files matched your search

+4
View File
@@ -18,6 +18,7 @@ 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";
import { enqueueAirQualityHistory } from "@/lib/jobs/environment-air-quality-history";
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -67,6 +68,9 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// Kick a lookback refresh so recent days populate promptly. No-ops cleanly
// when no worker is bound; the nightly cron still covers it.
await enqueueEnvironmentFetch({ userId: user.id });
// And the past: air quality for every earlier day with entries, in the
// background and inside the request budget. A no-op while it is off.
await enqueueAirQualityHistory(user.id);
annotate({ action: { name: "environment.home.set" } });
@@ -20,6 +20,7 @@ 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";
import { enqueueAirQualityHistory } from "@/lib/jobs/environment-air-quality-history";
export const PATCH = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -57,6 +58,8 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
before?.environmentAirQualityEnabled === false
) {
await enqueueEnvironmentFetch({ userId: user.id });
// Switched on: fill the past as well, every earlier day with entries.
await enqueueAirQualityHistory(user.id);
}
annotate({
+18 -1
View File
@@ -11,7 +11,8 @@
* (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`.
* `attributions`, and the progress of the air-quality history backfill
* (`airQuality.history`).
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
@@ -23,6 +24,7 @@ import { isAirQualityOperatorDisabled } from "@/lib/environment/open-meteo-air-q
import { environmentAttributionLines } from "@/lib/environment/air-quality-contract";
import { readLocation } from "@/lib/environment/location-cipher";
import { isAirQualityActive } from "@/lib/environment/service";
import { readAirQualityHistoryState } from "@/lib/environment/air-quality-history";
import { ENVIRONMENT_FETCH_QUEUE } from "@/lib/jobs/environment-fetch";
import { readQueueFailureForUser } from "@/lib/jobs/job-failures";
@@ -54,6 +56,7 @@ export const GET = apiHandler(async () => {
homeSince: true,
timezone: true,
environmentAirQualityEnabled: true,
environmentAqHistoryJson: true,
},
}),
prisma.environmentTravelLocation.findMany({
@@ -157,6 +160,19 @@ export const GET = apiHandler(async () => {
const accountEnabled = profile?.environmentAirQualityEnabled ?? true;
const operatorDisabled = isAirQualityOperatorDisabled();
const airActive = isAirQualityActive(accountEnabled);
// The history backfill's progress, while the part is on: what the last run
// counted, so the card can say how far the past has been filled.
const historyState = airActive
? readAirQualityHistoryState(profile?.environmentAqHistoryJson)
: null;
const history = historyState
? {
total: historyState.total,
done: historyState.done,
complete: historyState.complete,
checkedAt: historyState.checkedAt,
}
: null;
const latestYear = Number(
(latestAir?.date ?? latest?.date ?? new Date().toISOString()).slice(0, 4),
);
@@ -219,6 +235,7 @@ export const GET = apiHandler(async () => {
days: airDays,
latestDate: latestAir?.date ?? null,
domain: latestAir?.aqDomain ?? null,
history,
},
latestDay,
// null = the last background runs did not fail (or there is no queue to