Files
HealthLog/src/lib/environment/service.ts
T
Marc-André Bombeck fea1d1e0b5 chore: strip planning tags from comments and enable the rule
Output of `node scripts/strip-planning-tags.mjs --write`: 3,157 comment
lines in 1,308 files lose the work-item identifier at their head. Release
versions and issue references stay; nothing outside comment text
changes, which the script checks per file against a comment-free
printout. healthlog/no-planning-tags is enabled at error level with this
commit, so the tree it lands on is clean.
2026-10-20 09:08:40 +02:00

604 lines
21 KiB
TypeScript

/**
* v1.25 — environmental-context resolution + fetch/upsert service.
*
* Owns the location-precedence logic and the per-day upsert into
* `EnvironmentContext`. Both the nightly job and the on-demand backfill route
* call {@link fetchAndStoreEnvironment} so the precedence, the day-keying, and
* the upsert live in exactly one place.
*
* Location precedence per day (first hit wins) — CONSERVATIVE:
* 1. an explicit dated LOCATION PERIOD (override) whose [startDate, endDate]
* covers the day (a trip, OR a corrected stretch of history);
* 2. DEVICE — reserved slot for a future client-supplied coarse per-day
* location (iOS v2); not produced server-side yet (see resolver comment);
* 3. the user's HOME location, but ONLY for days on/after `homeSince` (the
* instant the home became effective).
* A day before `homeSince`, or with no home and no covering period, is SKIPPED.
* The resolver never attributes a past day to the *current* home — that would
* fabricate weather for anyone who has moved/travelled. The deep past is filled
* by adding explicit dated location periods.
*
* 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,
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";
import { emitSignal } from "@/lib/logging/signal";
export { isAirQualityActive };
import { OpenMeteoBudgetExhaustedError } from "@/lib/environment/request-budget";
import type { EnvironmentLocationSource } from "@/generated/prisma/client";
import { shiftDateKey } from "@/lib/tz/format";
/** Default lookback window (days) for the nightly fetch — absorbs the archive
* settling lag and any days missed across worker reboots. */
export const ENVIRONMENT_LOOKBACK_DAYS = 7;
/** Hard cap on a single backfill span so one request can never fan out forever. */
export const ENVIRONMENT_MAX_BACKFILL_DAYS = 730;
/** A coarse resolved location for a given day. */
export interface ResolvedLocation {
lat: number;
lon: number;
label: string;
source: EnvironmentLocationSource;
}
export interface HomeLocation {
lat: number;
lon: number;
label: string;
timezone: string;
/** YYYY-MM-DD the home became effective; null ⇒ home resolves no day. */
since: string | null;
}
export interface TravelOverride {
startDate: string;
endDate: string;
lat: number;
lon: number;
label: string;
}
/** UTC YYYY-MM-DD for `date` (used to bound the default lookback window). */
export function utcDayKey(date: Date): string {
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the environment module keys homeSince and the backfill window on UTC days throughout, and the worker compares against the same keys
return date.toISOString().slice(0, 10);
}
/** Inclusive list of YYYY-MM-DD keys from `start` to `end` (date arithmetic). */
export function enumerateDays(start: string, end: string): string[] {
const out: string[] = [];
for (let cur = start; cur <= end; cur = shiftDateKey(cur, 1)) {
out.push(cur);
}
return out;
}
/**
* Resolve the location for one day (CONSERVATIVE, first hit wins). Pure —
* exported for unit tests.
*/
export function resolveLocationForDay(
day: string,
home: HomeLocation | null,
travels: readonly TravelOverride[],
): ResolvedLocation | null {
// 1. An explicit dated location period covering the day wins. This is also
// the mechanism a user/operator uses to correct history: an explicit
// period for a past range is honoured even before the home was effective.
for (const t of travels) {
if (day >= t.startDate && day <= t.endDate) {
return { lat: t.lat, lon: t.lon, label: t.label, source: "TRAVEL" };
}
}
// 2. DEVICE — reserved priority forward path (iOS v2). A future client supplies
// a coarse per-day device location that would rank HERE, between the explicit
// period and the home fallback. Nothing produces it server-side yet, but the
// resolver signature and the EnvironmentLocationSource enum already accept a
// "DEVICE" source, so wiring it in later needs no rework of this precedence.
//
// 3. Home — ONLY on/after the day it became effective (`homeSince`). Never
// fabricate past weather from the *current* home for days before it was set;
// those days are skipped and left to an explicit period above.
if (home && home.since != null && day >= home.since) {
return { lat: home.lat, lon: home.lon, label: home.label, source: "HOME" };
}
return null;
}
/**
* The conservative default backfill span for an account: from the day its home
* became effective (`homeSince`) through `today`. Days before `homeSince` are
* deliberately excluded — they belong to explicit location periods, not to the
* current home. Returns null when no home has been set.
*/
export function defaultBackfillRange(
homeSince: Date | null,
today: Date = new Date(),
): { startDate: string; endDate: string } | null {
if (!homeSince) return null;
return { startDate: utcDayKey(homeSince), endDate: utcDayKey(today) };
}
/** A location-keyed group of days (one upstream fetch per group). */
function groupKey(loc: ResolvedLocation): string {
return `${loc.lat},${loc.lon},${loc.source},${loc.label}`;
}
export interface FetchAndStoreResult {
/** Days that resolved to a location and were upserted. */
stored: number;
/**
* Days skipped because no location resolved (no home, no override), or
* because they fall in a period whose sealed location does not open.
*/
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. */
export const HOME_SELECT = {
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. Null
* when no home is set (or the sealed value does not open).
*/
export function resolveHome(user: {
homeLocationEncrypted: Uint8Array | null;
homeTimezone: string | null;
homeSince: Date | null;
timezone: string;
}): HomeLocation | null {
const location = readLocation(user.homeLocationEncrypted);
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,
};
}
/** A location period whose sealed location this host cannot open. */
export interface UnreadablePeriod {
startDate: string;
endDate: string;
}
/**
* Every dated location period of an account, opened, plus the periods whose
* sealed location did not open (a key missing from `ENCRYPTION_KEYS`, a
* damaged value). The second list exists so the caller can fail closed: a
* period it cannot read still says "this account was not at home on these
* days", so those days must not fall through to the home location.
*/
export async function readTravelOverrides(userId: string): Promise<{
overrides: TravelOverride[];
unreadable: UnreadablePeriod[];
}> {
const records = await prisma.environmentTravelLocation.findMany({
where: { userId },
select: {
startDate: true,
endDate: true,
locationEncrypted: true,
},
});
const overrides: TravelOverride[] = [];
const unreadable: UnreadablePeriod[] = [];
for (const row of records) {
const location = readLocation(row.locationEncrypted);
if (location) {
overrides.push({
startDate: row.startDate,
endDate: row.endDate,
...location,
});
} else if (row.locationEncrypted && row.locationEncrypted.byteLength > 0) {
// `readLocation` returns null for a present sealed value only when it
// does not open.
unreadable.push({ startDate: row.startDate, endDate: row.endDate });
}
}
return { overrides, unreadable };
}
/** True when `day` falls inside one of `periods`. */
export function inAnyPeriod(day: string, periods: readonly UnreadablePeriod[]) {
return periods.some((p) => day >= p.startDate && day <= p.endDate);
}
/**
* Resolve + fetch + upsert the environment rows for a user across a date range.
* Groups days by resolved location so each location is fetched once over its
* contiguous span. A day whose observation is absent from the feed is left
* un-stored (no fabricated row). Idempotent — re-running upserts the same rows.
*/
export async function fetchAndStoreEnvironment(args: {
userId: string;
startDate: string;
endDate: string;
}): Promise<FetchAndStoreResult> {
const { userId, startDate, endDate } = args;
const user = await prisma.user.findUnique({
where: { id: userId },
select: HOME_SELECT,
});
if (!user) {
return { stored: 0, skipped: 0, fetches: 0, budgetBlocked: false };
}
const home = resolveHome(user);
const travel = await readTravelOverrides(userId);
if (travel.unreadable.length > 0) {
// Fail closed: the days of a period this host cannot open are skipped,
// never resolved to the home location, so a stored travel day is not
// overwritten with the weather at home.
emitSignal({
action: "environment.travel_location.undecryptable",
level: "error",
meta: { periods: travel.unreadable.length },
});
}
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 groups = new Map<string, { loc: ResolvedLocation; days: string[] }>();
let skipped = 0;
for (const day of days) {
if (inAnyPeriod(day, travel.unreadable)) {
skipped += 1;
continue;
}
const loc = resolveLocationForDay(day, home, travel.overrides);
if (!loc) {
skipped += 1;
continue;
}
const gk = groupKey(loc);
const group = groups.get(gk) ?? { loc, days: [] };
group.days.push(day);
groups.set(gk, group);
}
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];
let observations: DailyEnvironmentObservation[];
try {
observations = await fetchDailyEnvironment(
{
lat: loc.lat,
lon: loc.lon,
timezone,
startDate: min,
endDate: max,
},
{ accountId: userId },
);
} 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,
},
{ accountId: userId },
);
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
// location group keeps the per-backfill DB chatter bounded while
// preserving the idempotent upsert semantics.
const ops = observations
.filter((obs) => wanted.has(obs.date))
.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, budgetBlocked };
}
/** The air-quality columns of a stored day, from one observation. */
export 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). */
export 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 = {
// The coarse location rides sealed only.
locationEncrypted: sealed,
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,
pressureMean: obs.pressureMean,
pressureDelta: obs.pressureDelta,
humidityMean: obs.humidityMean,
cloudMean: obs.cloudMean,
weatherCode: obs.weatherCode,
fetchedAt: air.now,
...airColumns,
};
return prisma.environmentContext.upsert({
where: { userId_date: { userId, date: obs.date } },
create: { userId, date: obs.date, ...data },
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 in Europe,
* before August 2022 elsewhere) 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,
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(row.locationEncrypted);
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],
},
{ accountId: userId },
);
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;
}