mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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.
This commit is contained in:
1 parent
0151f86f3f
commit
76dc03ae7f
3 files changed
+660
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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<string, HourlyDayAgg> {
|
||||
const byDay = new Map<string, HourlyDayAgg>();
|
||||
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<GeocodeResult[]> {
|
||||
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<DailyEnvironmentObservation[]> {
|
||||
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;
|
||||
}
|
||||
@@ -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<FetchAndStoreResult> {
|
||||
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<string, ResolvedLocation>();
|
||||
const groups = new Map<string, { loc: ResolvedLocation; days: string[] }>();
|
||||
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<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user