/** * 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) — 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. */ 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; /** YYYY-MM-DD the home became effective; null ⇒ home resolves no day. */ since: string | null; } 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 (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). */ 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, homeSince: 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, // Effective-from day-key; null ⇒ home resolves no day (conservative). since: user.homeSince ? utcDayKey(user.homeSince) : null, } : 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 wanted = new Set(groupDays); // 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, obs)); 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. } return { stored, skipped, fetches }; } /** Build the idempotent per-day upsert operation (un-awaited, for a batch). */ function buildUpsert( userId: string, loc: ResolvedLocation, obs: DailyEnvironmentObservation, ) { 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(), }; return prisma.environmentContext.upsert({ where: { userId_date: { userId, date: obs.date } }, create: { userId, date: obs.date, ...data }, update: data, }); }