From 76dc03ae7f7e66c0efc71b2bf83ff0fe3ed906e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 16:58:22 +0200 Subject: [PATCH] feat(environment): add Open-Meteo client, location resolution, and field vocabulary Add the keyless Open-Meteo client (historical archive + geocoding) routed through safeFetch, the per-day location-resolution + fetch/upsert service (travel override wins over home, then degrades silently), and the shared environmental-exposure field table the registry and correlation engine both read. The base URL is operator-overridable for a self-hosted instance; the fetch stays server-side so a private/LAN host is not blocked. --- src/lib/environment/fields.ts | 131 ++++++++++++++ src/lib/environment/open-meteo.ts | 290 ++++++++++++++++++++++++++++++ src/lib/environment/service.ts | 239 ++++++++++++++++++++++++ 3 files changed, 660 insertions(+) create mode 100644 src/lib/environment/fields.ts create mode 100644 src/lib/environment/open-meteo.ts create mode 100644 src/lib/environment/service.ts diff --git a/src/lib/environment/fields.ts b/src/lib/environment/fields.ts new file mode 100644 index 000000000..70e94d6cd --- /dev/null +++ b/src/lib/environment/fields.ts @@ -0,0 +1,131 @@ +/** + * v1.25 (W-ENV) — the canonical environmental-exposure field vocabulary. + * + * One declarative table that names each daily weather/daylight field HealthLog + * stores AND correlates. It is the single source the two surfaces speak: + * + * - the SIGNAL REGISTRY (`src/lib/signals/registry.ts`) registers one + * `kind:"environment"` signal per field here, so each gets a stable signal + * key + display metadata for free; and + * - the CORRELATION ENGINE reads {@link ENVIRONMENT_CHANNEL_KEYS} to fold the + * same fields in as lagged BEHAVIOUR (exposure) channels against mood / + * sleep / vitals. + * + * `key` doubles as the registry signal key AND the correlation channel key — + * the `ENV_` prefix keeps it from colliding with a `MeasurementType` and lets + * `metricFamily()` collapse all env channels into one `ENVIRONMENT` family (so + * the engine never lag-correlates two same-day weather fields against each + * other — that is a near-tautology, not an insight). + * + * `column` is the `EnvironmentContext` numeric column the field reads. Leaf + * module (no imports) so both the registry and the pure correlation engine can + * depend on it without a cycle. + */ + +/** The `EnvironmentContext` numeric columns a correlation field can read. */ +export type EnvironmentContextNumericColumn = + | "tempMean" + | "tempMin" + | "tempMax" + | "apparentMean" + | "sunshineSec" + | "daylightSec" + | "precipSum" + | "pressureMean" + | "pressureDelta" + | "humidityMean" + | "cloudMean"; + +export interface EnvironmentField { + /** Stable key — the registry signal key AND the correlation channel key. */ + key: string; + /** The `EnvironmentContext` column this field's daily value comes from. */ + column: EnvironmentContextNumericColumn; + /** Canonical unit (English, for prose + registry metadata). */ + unit: string; + /** Stable English display name (the UI localises via `i18nLabelKey`). */ + displayName: string; + /** Lower-cased phrase the correlation narration ("humanise") reads. */ + narrationLabel: string; + /** i18n label key resolved by the correlation / settings surfaces. */ + i18nLabelKey: string; +} + +/** + * The exposure fields, ranked by evidence in the W-ENV research review: + * temperature (sleep / BP / recovery), sunshine + daylight (mood), precipitation + * (activity), pressure mean + intraday swing (headache/symptom — honestly + * caveated, optional-strength). All are BEHAVIOUR (lag-source) channels. + */ +export const ENVIRONMENT_FIELDS: readonly EnvironmentField[] = [ + { + key: "ENV_TEMP_MEAN", + column: "tempMean", + unit: "°C", + displayName: "Daily temperature", + narrationLabel: "daily temperature", + i18nLabelKey: "environment.fields.tempMean", + }, + { + key: "ENV_TEMP_MIN", + column: "tempMin", + unit: "°C", + displayName: "Overnight low temperature", + narrationLabel: "overnight low temperature", + i18nLabelKey: "environment.fields.tempMin", + }, + { + key: "ENV_SUNSHINE", + column: "sunshineSec", + unit: "h", + displayName: "Sunshine duration", + narrationLabel: "sunshine", + i18nLabelKey: "environment.fields.sunshine", + }, + { + key: "ENV_DAYLIGHT", + column: "daylightSec", + unit: "h", + displayName: "Daylight length", + narrationLabel: "daylight", + i18nLabelKey: "environment.fields.daylight", + }, + { + key: "ENV_PRECIP", + column: "precipSum", + unit: "mm", + displayName: "Precipitation", + narrationLabel: "precipitation", + i18nLabelKey: "environment.fields.precip", + }, + { + key: "ENV_PRESSURE_MEAN", + column: "pressureMean", + unit: "hPa", + displayName: "Barometric pressure", + narrationLabel: "barometric pressure", + i18nLabelKey: "environment.fields.pressureMean", + }, + { + key: "ENV_PRESSURE_DELTA", + column: "pressureDelta", + unit: "hPa", + displayName: "Pressure swing", + narrationLabel: "intraday pressure swing", + i18nLabelKey: "environment.fields.pressureDelta", + }, +] as const; + +/** The correlation channel keys, in declaration order. */ +export const ENVIRONMENT_CHANNEL_KEYS: readonly string[] = + ENVIRONMENT_FIELDS.map((f) => f.key); + +/** True for a registered environmental channel key. */ +export function isEnvironmentChannelKey(key: string): boolean { + return ENVIRONMENT_CHANNEL_KEYS.includes(key); +} + +/** Resolve a field by its key, or null when unregistered. */ +export function getEnvironmentField(key: string): EnvironmentField | null { + return ENVIRONMENT_FIELDS.find((f) => f.key === key) ?? null; +} diff --git a/src/lib/environment/open-meteo.ts b/src/lib/environment/open-meteo.ts new file mode 100644 index 000000000..87ab829e6 --- /dev/null +++ b/src/lib/environment/open-meteo.ts @@ -0,0 +1,290 @@ +/** + * v1.25 (W-ENV) — Open-Meteo client (keyless historical weather + geocoding). + * + * The single outbound egress for the environmental-context module. Open-Meteo's + * Historical (Archive) API is keyless, free for non-commercial use, and offers + * a clean per-date global lat/lon endpoint — the right fit for a self-host- + * friendly, privacy-first exposure feed. Data is CC BY 4.0 ("Weather data by + * Open-Meteo.com" — credited on the settings surface). + * + * Egress goes through `safeFetch` (the documented wrapper: manual redirects + + * a request timeout). The base URL is operator config, not arbitrary user + * input, so `requirePublicHost` is deliberately NOT forced — a privacy- + * maximalist operator can point `OPENMETEO_BASE_URL` at a self-hosted Open-Meteo + * instance on a private/LAN host without it being blocked. The hosted default is + * public. + * + * Server-side only. The archive feed lags real time by a few days (reanalysis + * settling), so callers fetch with a lookback window rather than "yesterday + * only" — see the nightly job and the backfill route. + */ +import { safeFetch, SafeFetchError } from "@/lib/safe-fetch"; + +/** Hosted archive default; override with `OPENMETEO_BASE_URL` (self-host). */ +const ARCHIVE_BASE_URL = + process.env.OPENMETEO_BASE_URL?.replace(/\/$/, "") ?? + "https://archive-api.open-meteo.com"; + +/** Hosted geocoding default; override with `OPENMETEO_GEOCODING_URL`. */ +const GEOCODING_BASE_URL = + process.env.OPENMETEO_GEOCODING_URL?.replace(/\/$/, "") ?? + "https://geocoding-api.open-meteo.com"; + +const FETCH_TIMEOUT_MS = 15_000; + +/** CC BY 4.0 attribution required by Open-Meteo; surfaced on the settings UI. */ +export const OPEN_METEO_ATTRIBUTION = "Weather data by Open-Meteo.com"; + +/** One geocoding match — coarse city placement for the home-location picker. */ +export interface GeocodeResult { + /** Rounded coarse latitude (2 dp — ~1 km, the privacy floor). */ + lat: number; + /** Rounded coarse longitude (2 dp). */ + lon: number; + /** Human label, e.g. "Bochum, North Rhine-Westphalia, Germany". */ + label: string; + /** IANA timezone for the place (anchors the day-key of stored rows). */ + timezone: string; +} + +/** A single day's resolved weather observation, mapped to our column shape. */ +export interface DailyEnvironmentObservation { + /** YYYY-MM-DD in the requested timezone. */ + date: string; + tempMin: number | null; + tempMax: number | null; + tempMean: number | null; + apparentMean: number | null; + sunshineSec: number | null; + daylightSec: number | null; + precipSum: number | null; + pressureMean: number | null; + pressureDelta: number | null; + humidityMean: number | null; + cloudMean: number | null; + weatherCode: number | null; +} + +/** Round a coordinate to 2 decimals (~1 km) — the coarse-location privacy floor. */ +export function roundCoarse(value: number): number { + return Math.round(value * 100) / 100; +} + +function firstNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +/** Mean of the finite numbers in `xs`, or null when none. */ +function meanOf(xs: number[]): number | null { + if (xs.length === 0) return null; + const sum = xs.reduce((a, b) => a + b, 0); + return sum / xs.length; +} + +interface ArchiveDaily { + time?: string[]; + temperature_2m_min?: (number | null)[]; + temperature_2m_max?: (number | null)[]; + temperature_2m_mean?: (number | null)[]; + apparent_temperature_mean?: (number | null)[]; + sunshine_duration?: (number | null)[]; + daylight_duration?: (number | null)[]; + precipitation_sum?: (number | null)[]; + weather_code?: (number | null)[]; +} + +interface ArchiveHourly { + time?: string[]; + surface_pressure?: (number | null)[]; + relative_humidity_2m?: (number | null)[]; + cloud_cover?: (number | null)[]; +} + +interface ArchiveResponse { + daily?: ArchiveDaily; + hourly?: ArchiveHourly; +} + +const DAILY_FIELDS = [ + "temperature_2m_min", + "temperature_2m_max", + "temperature_2m_mean", + "apparent_temperature_mean", + "sunshine_duration", + "daylight_duration", + "precipitation_sum", + "weather_code", +].join(","); + +const HOURLY_FIELDS = [ + "surface_pressure", + "relative_humidity_2m", + "cloud_cover", +].join(","); + +/** Per-day aggregation accumulator for the hourly-only fields. */ +interface HourlyDayAgg { + pressures: number[]; + humidities: number[]; + clouds: number[]; +} + +/** + * Aggregate the hourly feed (pressure / humidity / cloud — not available as a + * daily aggregate from the archive) into per-day values: mean for each, plus the + * intraday pressure delta (max − min), the derived headache/symptom feature. + */ +function aggregateHourly(hourly: ArchiveHourly): Map { + const byDay = new Map(); + const times = hourly.time ?? []; + for (let i = 0; i < times.length; i++) { + const day = times[i]?.slice(0, 10); + if (!day) continue; + const agg = byDay.get(day) ?? { pressures: [], humidities: [], clouds: [] }; + const p = firstNumber(hourly.surface_pressure?.[i]); + const h = firstNumber(hourly.relative_humidity_2m?.[i]); + const c = firstNumber(hourly.cloud_cover?.[i]); + if (p !== null) agg.pressures.push(p); + if (h !== null) agg.humidities.push(h); + if (c !== null) agg.clouds.push(c); + byDay.set(day, agg); + } + return byDay; +} + +/** + * Forward-geocode a free-text place to coarse matches. Keyless. Returns at most + * `count` results; an empty array when nothing matched or the feed errored + * (the caller surfaces "no match" rather than failing the request). + */ +export async function geocodeLocation( + query: string, + count = 5, +): Promise { + const trimmed = query.trim(); + if (trimmed.length === 0) return []; + + const url = new URL(`${GEOCODING_BASE_URL}/v1/search`); + url.searchParams.set("name", trimmed); + url.searchParams.set("count", String(Math.min(Math.max(count, 1), 10))); + url.searchParams.set("format", "json"); + + let res: Response; + try { + res = await safeFetch( + url, + { headers: { accept: "application/json" } }, + { + timeoutMs: FETCH_TIMEOUT_MS, + }, + ); + } catch (err) { + if (err instanceof SafeFetchError) return []; + throw err; + } + if (!res.ok) return []; + + const body = (await res.json()) as { + results?: Array<{ + name?: string; + latitude?: number; + longitude?: number; + country?: string; + admin1?: string; + timezone?: string; + }>; + }; + + const out: GeocodeResult[] = []; + for (const r of body.results ?? []) { + if (typeof r.latitude !== "number" || typeof r.longitude !== "number") { + continue; + } + const parts = [r.name, r.admin1, r.country].filter( + (p): p is string => typeof p === "string" && p.length > 0, + ); + out.push({ + lat: roundCoarse(r.latitude), + lon: roundCoarse(r.longitude), + label: parts.join(", "), + timezone: + typeof r.timezone === "string" && r.timezone.length > 0 + ? r.timezone + : "auto", + }); + } + return out; +} + +/** + * Fetch the daily environment observations for a coarse location over a date + * range (inclusive YYYY-MM-DD). The hourly-only fields (pressure / humidity / + * cloud) are requested alongside and aggregated to per-day values here. Throws + * a {@link SafeFetchError} on egress failure so the caller can classify it. + */ +export async function fetchDailyEnvironment(args: { + lat: number; + lon: number; + timezone: string; + startDate: string; + endDate: string; +}): Promise { + const url = new URL(`${ARCHIVE_BASE_URL}/v1/archive`); + url.searchParams.set("latitude", String(args.lat)); + url.searchParams.set("longitude", String(args.lon)); + url.searchParams.set("start_date", args.startDate); + url.searchParams.set("end_date", args.endDate); + url.searchParams.set("daily", DAILY_FIELDS); + url.searchParams.set("hourly", HOURLY_FIELDS); + url.searchParams.set("timezone", args.timezone); + + const res = await safeFetch( + url, + { headers: { accept: "application/json" } }, + { + timeoutMs: FETCH_TIMEOUT_MS, + }, + ); + if (!res.ok) { + throw new SafeFetchError( + `open-meteo archive returned HTTP ${res.status}`, + "network", + ); + } + + const body = (await res.json()) as ArchiveResponse; + const daily = body.daily ?? {}; + const days = daily.time ?? []; + const hourlyByDay = aggregateHourly(body.hourly ?? {}); + + const observations: DailyEnvironmentObservation[] = []; + for (let i = 0; i < days.length; i++) { + const date = days[i]; + if (!date) continue; + const agg = hourlyByDay.get(date); + const pressures = agg?.pressures ?? []; + const pressureMean = meanOf(pressures); + const pressureDelta = + pressures.length > 0 + ? Math.max(...pressures) - Math.min(...pressures) + : null; + + observations.push({ + date, + tempMin: firstNumber(daily.temperature_2m_min?.[i]), + tempMax: firstNumber(daily.temperature_2m_max?.[i]), + tempMean: firstNumber(daily.temperature_2m_mean?.[i]), + apparentMean: firstNumber(daily.apparent_temperature_mean?.[i]), + sunshineSec: firstNumber(daily.sunshine_duration?.[i]), + daylightSec: firstNumber(daily.daylight_duration?.[i]), + precipSum: firstNumber(daily.precipitation_sum?.[i]), + pressureMean, + pressureDelta: + pressureDelta !== null ? Math.round(pressureDelta * 10) / 10 : null, + humidityMean: meanOf(agg?.humidities ?? []), + cloudMean: meanOf(agg?.clouds ?? []), + weatherCode: firstNumber(daily.weather_code?.[i]), + }); + } + return observations; +} diff --git a/src/lib/environment/service.ts b/src/lib/environment/service.ts new file mode 100644 index 000000000..59962bf73 --- /dev/null +++ b/src/lib/environment/service.ts @@ -0,0 +1,239 @@ +/** + * v1.25 (W-ENV) — 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): + * 1. a manual TRAVEL override whose [startDate, endDate] covers the day; + * 2. the user's HOME location. + * No home + no covering override ⇒ the day is skipped (the module degrades + * silently, matching the rollup "coverage miss → absent" posture). + * + * Coarse location only — the resolver never sees finer than the rounded city + * coordinates stored on the user / override. + */ +import { prisma } from "@/lib/db"; +import { + fetchDailyEnvironment, + type DailyEnvironmentObservation, +} from "@/lib/environment/open-meteo"; +import type { EnvironmentLocationSource } from "@/generated/prisma/client"; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; +/** 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. */ +interface ResolvedLocation { + lat: number; + lon: number; + label: string; + source: EnvironmentLocationSource; +} + +interface HomeLocation { + lat: number; + lon: number; + label: string; + timezone: string; +} + +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 { + 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[] = []; + const [sy, sm, sd] = start.split("-").map(Number); + const [ey, em, ed] = end.split("-").map(Number); + let cur = Date.UTC(sy, sm - 1, sd); + const last = Date.UTC(ey, em - 1, ed); + while (cur <= last) { + out.push(new Date(cur).toISOString().slice(0, 10)); + cur += MS_PER_DAY; + } + return out; +} + +/** + * Resolve the location for one day: a covering travel override wins, else home, + * else null. Pure — exported for unit tests. + */ +export function resolveLocationForDay( + day: string, + home: HomeLocation | null, + travels: readonly TravelOverride[], +): ResolvedLocation | null { + for (const t of travels) { + if (day >= t.startDate && day <= t.endDate) { + return { lat: t.lat, lon: t.lon, label: t.label, source: "TRAVEL" }; + } + } + if (home) { + return { lat: home.lat, lon: home.lon, label: home.label, source: "HOME" }; + } + return null; +} + +/** 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). */ + skipped: number; + /** Distinct upstream fetches made. */ + fetches: number; +} + +/** + * 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 { + const { userId, startDate, endDate } = args; + + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + homeLat: true, + homeLon: true, + homeLabel: true, + homeTimezone: true, + timezone: true, + }, + }); + if (!user) return { stored: 0, skipped: 0, fetches: 0 }; + + const home: HomeLocation | null = + user.homeLat != null && user.homeLon != null + ? { + lat: user.homeLat, + lon: user.homeLon, + label: user.homeLabel ?? "Home", + timezone: user.homeTimezone ?? user.timezone, + } + : null; + + const travelRows = await prisma.environmentTravelLocation.findMany({ + where: { userId }, + select: { + startDate: true, + endDate: true, + lat: true, + lon: true, + label: true, + }, + }); + + // 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 resolvedByDay = new Map(); + const groups = new Map(); + let skipped = 0; + for (const day of days) { + const loc = resolveLocationForDay(day, home, travelRows); + if (!loc) { + skipped += 1; + continue; + } + resolvedByDay.set(day, loc); + 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; + 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, + }); + } catch { + // 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; + + const obsByDate = new Map(observations.map((o) => [o.date, o])); + const wanted = new Set(groupDays); + for (const obs of observations) { + if (!wanted.has(obs.date)) continue; + await upsertObservation(userId, loc, obs); + stored += 1; + } + // Days the feed did not return (e.g. beyond the settling lag) stay absent. + void obsByDate; + } + + return { stored, skipped, fetches }; +} + +async function upsertObservation( + userId: string, + loc: ResolvedLocation, + obs: DailyEnvironmentObservation, +): Promise { + const data = { + lat: loc.lat, + lon: loc.lon, + locationLabel: loc.label, + source: loc.source, + tempMin: obs.tempMin, + tempMax: obs.tempMax, + tempMean: obs.tempMean, + apparentMean: obs.apparentMean, + 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: new Date(), + }; + await prisma.environmentContext.upsert({ + where: { userId_date: { userId, date: obs.date } }, + create: { userId, date: obs.date, ...data }, + update: data, + }); +}