From 4b535a34f469a06fbc43a1baa8a23df30a248609 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Tue, 25 Aug 2026 13:37:24 +0000 Subject: [PATCH] Per-tier Met Office fallback + use the new Forecast key for hourly/3-hourly Was: one Promise.all across all 3 Met Office endpoints, any single failure threw and discarded ALL of it, falling back to Open-Meteo entirely -- even though only the daily endpoint has ever been confirmed to work (only Spot key configured; hourly/three-hourly may need the separate Forecast subscription settings just gained a field for). So the display was very possibly running on Open-Meteo for everything despite a working Met Office key, silently. Now: hourly, three-hourly and daily are fetched independently (Promise.allSettled). hourly/three-hourly try met_office's new forecast_api_key first (falling back to api_key if no separate Forecast key is set); daily keeps using api_key (Spot), matching what's actually confirmed live. Only the tiers that fail fall back to Open-Meteo -- and Open-Meteo itself is now fetched lazily, once, only if at least one tier actually needs it. status.last_provider is now {hourly, threeHourly, daily} instead of one flat string, so the admin page shows exactly which source served which tier. Admin.tsx / types.ts updated to match. Co-Authored-By: Claude Sonnet 5 --- backend/src/lib/weather-fetch.js | 256 ++++++++++++++++++------------- frontend/src/pages/Admin.tsx | 6 +- frontend/src/types.ts | 14 +- 3 files changed, 164 insertions(+), 112 deletions(-) diff --git a/backend/src/lib/weather-fetch.js b/backend/src/lib/weather-fetch.js index a7b7561..156682e 100644 --- a/backend/src/lib/weather-fetch.js +++ b/backend/src/lib/weather-fetch.js @@ -4,21 +4,19 @@ // (hotel_location + met_office, via SETTINGS_URL/SETTINGS_SECRET) rather than asking // for its own copy of the credentials — see lib/weather.js in hvac. // -// Provider selection: Met Office DataHub Global Spot if an api_key is configured and -// the integration is enabled; otherwise, or if any Met Office call fails, Open-Meteo -// (no key required). Same fail-soft posture as hvac's getPeakTempC() — never throw -// out to the scheduler, just record the error in `status` and keep serving whatever -// was fetched last time. -// -// IMPORTANT: only the daily endpoint (sitespecific/v0/point/daily) has been exercised -// against a live Met Office key so far (hvac, 2026-08-25 — see project_hvac memory's -// "Live subscription mismatch" note). The hourly and three-hourly endpoint paths and -// field names below — including windDirectionFrom10m and sunrise/sunset — are taken -// from the published Met Office DataHub Site Specific Forecast API docs but NOT yet -// verified live — if any endpoint 403s or comes back with unexpected fields, it'll -// throw and this whole refresh degrades to Open-Meteo (see the catch in -// refreshForecast), so a doc mismatch here fails safe, it just won't use Met Office -// data until the field names are corrected against a real response. +// Provider selection is now PER TIER, not all-or-nothing: each of hourly, +// three-hourly and daily is fetched independently, and only the tiers that fail +// fall back to Open-Meteo — one Met Office hiccup no longer discards data the +// other endpoints could actually serve. This matters because Met Office DataHub +// sells "Global Spot" and "Forecast" as SEPARATE subscriptions with separate keys +// (settings' met_office integration: api_key = Spot, forecast_api_key = Forecast, +// optional) — only Spot (used here for daily) has ever been confirmed live (hvac, +// 2026-08-25); hourly/three-hourly try forecast_api_key first (falling back to +// api_key if no separate Forecast key is configured), since Spot alone may not +// actually be subscribed to those endpoints. Field names for hourly/three-hourly +// are taken from the published Met Office DataHub docs, still not verified live — +// a mismatch there just throws and that tier falls back to Open-Meteo, same as an +// outright subscription failure. import { pool, setStatus } from '../db.js' import { iconFromMetOffice, iconFromOpenMeteo } from './icons.js' @@ -42,8 +40,14 @@ function londonDateKey(isoTime) { function londonHour(isoTime) { return parseInt(new Date(isoTime).toLocaleString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', hour12: false })) } +// Pure date math, not derived from any provider response — "tomorrow"/"day after" +// are always just today+1/+2 regardless of source, which is what lets each tier +// below be fetched (and fall back) fully independently of the others. +function londonDateKeyPlusDays(n) { + return londonDateKey(new Date(Date.now() + n * 86_400_000).toISOString()) +} -// ── Met Office DataHub (Global Spot) ──────────────────────────────────────── +// ── Met Office DataHub ─────────────────────────────────────────────────────── async function fetchMetOfficeTimestep(timestep, lat, lon, apiKey) { const res = await fetch( `${MET_OFFICE_BASE}/${timestep}?latitude=${lat}&longitude=${lon}`, @@ -56,22 +60,12 @@ async function fetchMetOfficeTimestep(timestep, lat, lon, apiKey) { return series } -async function fetchFromMetOffice(lat, lon, apiKey) { - const [hourly, threeHourly, daily] = await Promise.all([ - fetchMetOfficeTimestep('hourly', lat, lon, apiKey), - fetchMetOfficeTimestep('three-hourly', lat, lon, apiKey), - fetchMetOfficeTimestep('daily', lat, lon, apiKey), - ]) - - const today = londonDateKey(new Date().toISOString()) - - // Store every hour the endpoint returns, not just today's — the "tap a Coming up - // card" hourly-detail modal needs this for tomorrow/day-after too (see - // routes/forecast.js's /forecast/hourly). Met Office's hourly product window - // isn't documented/verified here (unlike Open-Meteo, which we know covers the - // full forecast_days=8 range) — whatever it actually returns just becomes - // whatever range the modal can show; no assumption made about how far it goes. - const hourlyPoints = hourly +// Every hour the endpoint returns, not just today — the "tap a Coming up card" +// hourly-detail modal needs this for tomorrow/day-after (and, where available, +// days 4-7) too. +async function fetchMetOfficeHourly(lat, lon, apiKey) { + const series = await fetchMetOfficeTimestep('hourly', lat, lon, apiKey) + return series .map(p => ({ resolution: 'hourly', ts: p.time, @@ -85,10 +79,11 @@ async function fetchFromMetOffice(lat, lon, apiKey) { source: 'met_office', })) .filter(p => typeof p.temp_c === 'number') +} - // three-hourly covers today too; keep only the next two calendar days. - const dayKeys = [...new Set(threeHourly.map(p => londonDateKey(p.time)))].filter(d => d !== today).slice(0, 2) - const threeHourlyPoints = threeHourly +async function fetchMetOfficeThreeHourly(lat, lon, apiKey, dayKeys) { + const series = await fetchMetOfficeTimestep('three-hourly', lat, lon, apiKey) + return series .filter(p => dayKeys.includes(londonDateKey(p.time))) .map(p => ({ resolution: '3hourly', @@ -103,10 +98,15 @@ async function fetchFromMetOffice(lat, lon, apiKey) { source: 'met_office', })) .filter(p => typeof p.temp_c === 'number') +} - // daily covers ~7 days; skip today + the 2 days already covered by three-hourly, take next 4. - const skipKeys = new Set([today, ...dayKeys]) - const dailyPoints = daily +// Returns both the daily tier's points AND astro (sunrise/sunset) — the daily +// response is the only one with sunrise/sunset fields, so the two travel together; +// if this tier falls back to Open-Meteo, astro falls back with it (see +// openMeteoAstroPoints below). +async function fetchMetOfficeDaily(lat, lon, apiKey, skipKeys, astroKeys) { + const series = await fetchMetOfficeTimestep('daily', lat, lon, apiKey) + const dailyPoints = series .filter(p => !skipKeys.has(londonDateKey(p.time))) .slice(0, 4) .map(p => ({ @@ -122,20 +122,14 @@ async function fetchFromMetOffice(lat, lon, apiKey) { source: 'met_office', })) .filter(p => typeof p.temp_c === 'number') - - // Sunrise/sunset for today + the 2 next-2-days dates — pulled from the daily - // series we already fetched above (dailyPoints skips these dates, but the raw - // `daily` array still has them), so no extra API call needed. - const astroDateKeys = [today, ...dayKeys] - const astroPoints = daily - .filter(p => astroDateKeys.includes(londonDateKey(p.time))) + const astroPoints = series + .filter(p => astroKeys.includes(londonDateKey(p.time))) .map(p => ({ date: londonDateKey(p.time), sunrise: p.sunrise ?? null, sunset: p.sunset ?? null })) - - return { hourlyPoints, threeHourlyPoints, dailyPoints, astroPoints } + return { dailyPoints, astroPoints } } -// ── Open-Meteo fallback (no key required) ─────────────────────────────────── -async function fetchFromOpenMeteo(lat, lon) { +// ── Open-Meteo fallback (no key required) — one call covers every tier ───── +async function fetchOpenMeteoRaw(lat, lon) { const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}` + `&hourly=temperature_2m,apparent_temperature,precipitation_probability,weathercode,windspeed_10m,winddirection_10m` + `&daily=temperature_2m_max,temperature_2m_min,weathercode,sunrise,sunset` + @@ -145,7 +139,6 @@ async function fetchFromOpenMeteo(lat, lon) { const data = await res.json() if (!data?.hourly?.time || !data?.daily?.time) throw new Error('Open-Meteo response missing hourly/daily blocks') - const today = londonDateKey(new Date().toISOString()) const hourlyRows = data.hourly.time.map((t, i) => ({ time: t, dateKey: londonDateKey(t), @@ -157,28 +150,37 @@ async function fetchFromOpenMeteo(lat, lon) { wind: data.hourly.windspeed_10m?.[i] ?? null, windDir: data.hourly.winddirection_10m?.[i] ?? null, })) + const dailyRows = data.daily.time.map((t, i) => ({ + time: t, + dateKey: londonDateKey(`${t}T12:00:00`), + max: data.daily.temperature_2m_max[i], + min: data.daily.temperature_2m_min[i], + code: data.daily.weathercode[i], + sunrise: data.daily.sunrise?.[i] ?? null, + sunset: data.daily.sunset?.[i] ?? null, + })) + return { hourlyRows, dailyRows } +} - // Store every hour across the full forecast_days=8 window, not just today — the - // "tap a Coming up card" hourly-detail modal needs tomorrow/day-after (and, where - // available, days 4-7) too. This is free: the hourly= block already covers all 8 - // days in this one call, today's strip just used to discard everything past today. - const hourlyPoints = hourlyRows - .map(r => ({ - resolution: 'hourly', - ts: r.time, - temp_c: r.temp, - temp_min_c: null, - feels_like_c: r.feelsLike, - precip_prob: r.precipProb, - wind_speed_mph: r.wind != null ? r.wind * 0.621 : null, // km/h -> mph - wind_dir_deg: r.windDir, - icon: iconFromOpenMeteo(r.code, r.hour), - source: 'open_meteo', - })) - - const nextDayKeys = [...new Set(hourlyRows.map(r => r.dateKey))].filter(d => d !== today).slice(0, 2) - const threeHourlyPoints = hourlyRows - .filter(r => nextDayKeys.includes(r.dateKey) && r.hour % 3 === 0) +// Every hour across the full forecast_days=8 window, not just today — same +// reasoning as fetchMetOfficeHourly above. +function openMeteoHourlyPoints(hourlyRows) { + return hourlyRows.map(r => ({ + resolution: 'hourly', + ts: r.time, + temp_c: r.temp, + temp_min_c: null, + feels_like_c: r.feelsLike, + precip_prob: r.precipProb, + wind_speed_mph: r.wind != null ? r.wind * 0.621 : null, // km/h -> mph + wind_dir_deg: r.windDir, + icon: iconFromOpenMeteo(r.code, r.hour), + source: 'open_meteo', + })) +} +function openMeteoThreeHourlyPoints(hourlyRows, dayKeys) { + return hourlyRows + .filter(r => dayKeys.includes(r.dateKey) && r.hour % 3 === 0) .map(r => ({ resolution: '3hourly', ts: r.time, @@ -191,19 +193,9 @@ async function fetchFromOpenMeteo(lat, lon) { icon: iconFromOpenMeteo(r.code, r.hour), source: 'open_meteo', })) - - const skipKeys = new Set([today, ...nextDayKeys]) - const dailyRows = data.daily.time.map((t, i) => ({ - time: t, - dateKey: londonDateKey(`${t}T12:00:00`), - max: data.daily.temperature_2m_max[i], - min: data.daily.temperature_2m_min[i], - code: data.daily.weathercode[i], - sunrise: data.daily.sunrise?.[i] ?? null, - sunset: data.daily.sunset?.[i] ?? null, - })) - - const dailyPoints = dailyRows +} +function openMeteoDailyPoints(dailyRows, skipKeys) { + return dailyRows .filter(r => !skipKeys.has(r.dateKey)) .slice(0, 4) .map(r => ({ @@ -218,12 +210,11 @@ async function fetchFromOpenMeteo(lat, lon) { icon: iconFromOpenMeteo(r.code, 12), source: 'open_meteo', })) - - const astroPoints = dailyRows - .filter(r => skipKeys.has(r.dateKey)) +} +function openMeteoAstroPoints(dailyRows, astroKeys) { + return dailyRows + .filter(r => astroKeys.includes(r.dateKey)) .map(r => ({ date: r.dateKey, sunrise: r.sunrise, sunset: r.sunset })) - - return { hourlyPoints, threeHourlyPoints, dailyPoints, astroPoints } } // ── Storage ────────────────────────────────────────────────────────────── @@ -276,9 +267,11 @@ async function replaceAstro(rows) { } // refreshForecast() — the scheduler's hourly entrypoint. Never throws; records -// success/failure in `status` so the admin page can show it, same fail-safe posture -// as hvac's weather compensation (missing config or a dead provider just means the -// display keeps showing the last good fetch, never a blank/broken screen). +// success/failure per tier in `status` so the admin page can show exactly which of +// hourly/3-hourly/daily is actually coming from Met Office vs Open-Meteo, rather +// than one flat "which provider" flag. Same fail-safe posture throughout as hvac's +// weather compensation — a dead provider/tier just means that bit of the display +// keeps showing the last good fetch, never a blank/broken screen. export async function refreshForecast(log = console) { try { const location = await getIntegration('hotel_location') @@ -290,31 +283,76 @@ export async function refreshForecast(log = console) { return } + const today = londonDateKey(new Date().toISOString()) + const dayKeys = [londonDateKeyPlusDays(1), londonDateKeyPlusDays(2)] + const skipKeys = new Set([today, ...dayKeys]) + const astroKeys = [today, ...dayKeys] + const metOffice = await getIntegration('met_office') - let result, provider + const tierSource = { hourly: null, threeHourly: null, daily: null } + let hourlyPoints = [], threeHourlyPoints = [], dailyPoints = [], astroPoints = [] + if (metOffice?.api_key) { - try { - result = await fetchFromMetOffice(lat, lon, metOffice.api_key) - provider = 'met_office' - } catch (err) { - log.error?.(`[weather] Met Office fetch failed, falling back to Open-Meteo: ${err.message}`) - result = await fetchFromOpenMeteo(lat, lon) - provider = 'open_meteo' + const spotKey = metOffice.api_key + const forecastKey = metOffice.forecast_api_key || metOffice.api_key + + const [hourlyR, threeHourlyR, dailyR] = await Promise.allSettled([ + fetchMetOfficeHourly(lat, lon, forecastKey), + fetchMetOfficeThreeHourly(lat, lon, forecastKey, dayKeys), + fetchMetOfficeDaily(lat, lon, spotKey, skipKeys, astroKeys), + ]) + + if (hourlyR.status === 'fulfilled') { + hourlyPoints = hourlyR.value + tierSource.hourly = 'met_office' + } else { + log.error?.(`[weather] Met Office hourly failed, falling back to Open-Meteo: ${hourlyR.reason.message}`) + } + + if (threeHourlyR.status === 'fulfilled') { + threeHourlyPoints = threeHourlyR.value + tierSource.threeHourly = 'met_office' + } else { + log.error?.(`[weather] Met Office three-hourly failed, falling back to Open-Meteo: ${threeHourlyR.reason.message}`) + } + + if (dailyR.status === 'fulfilled') { + dailyPoints = dailyR.value.dailyPoints + astroPoints = dailyR.value.astroPoints + tierSource.daily = 'met_office' + } else { + log.error?.(`[weather] Met Office daily failed, falling back to Open-Meteo: ${dailyR.reason.message}`) } - } else { - result = await fetchFromOpenMeteo(lat, lon) - provider = 'open_meteo' } - await replaceResolution('hourly', result.hourlyPoints) - await replaceResolution('3hourly', result.threeHourlyPoints) - await replaceResolution('daily', result.dailyPoints) - await replaceAstro(result.astroPoints) + // Only hit Open-Meteo if something actually needs it — when Met Office covers + // every tier this stays a single API call per refresh, not two. + if (!tierSource.hourly || !tierSource.threeHourly || !tierSource.daily) { + const { hourlyRows, dailyRows } = await fetchOpenMeteoRaw(lat, lon) + if (!tierSource.hourly) { + hourlyPoints = openMeteoHourlyPoints(hourlyRows) + tierSource.hourly = 'open_meteo' + } + if (!tierSource.threeHourly) { + threeHourlyPoints = openMeteoThreeHourlyPoints(hourlyRows, dayKeys) + tierSource.threeHourly = 'open_meteo' + } + if (!tierSource.daily) { + dailyPoints = openMeteoDailyPoints(dailyRows, skipKeys) + astroPoints = openMeteoAstroPoints(dailyRows, astroKeys) + tierSource.daily = 'open_meteo' + } + } + + await replaceResolution('hourly', hourlyPoints) + await replaceResolution('3hourly', threeHourlyPoints) + await replaceResolution('daily', dailyPoints) + await replaceAstro(astroPoints) await setStatus('last_fetch_at', new Date().toISOString()) - await setStatus('last_provider', provider) + await setStatus('last_provider', tierSource) // now {hourly, threeHourly, daily}, not a flat string await setStatus('last_error', null) - log.info?.(`[weather] refreshed via ${provider}: ${result.hourlyPoints.length} hourly, ${result.threeHourlyPoints.length} 3-hourly, ${result.dailyPoints.length} daily, ${result.astroPoints.length} astro`) + log.info?.(`[weather] refreshed: hourly=${tierSource.hourly} (${hourlyPoints.length}), 3hourly=${tierSource.threeHourly} (${threeHourlyPoints.length}), daily=${tierSource.daily} (${dailyPoints.length}), astro=${astroPoints.length}`) } catch (err) { await setStatus('last_error', err.message) log.error?.(`[weather] refresh failed: ${err.message}`) diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index 2ddcf0e..01e58ee 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -120,7 +120,11 @@ export default function Admin() {
Provider - {status?.last_provider ?? '—'} + + {status?.last_provider + ? `hourly: ${status.last_provider.hourly ?? '—'} · 3-hourly: ${status.last_provider.threeHourly ?? '—'} · daily: ${status.last_provider.daily ?? '—'}` + : '—'} +
Status diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 6b2bcab..e3276aa 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -29,6 +29,16 @@ export interface Summary { umbrella: boolean // real rain chance now or later today } +export type Provider = 'met_office' | 'open_meteo' + +// Each tier is fetched (and can fall back to Open-Meteo) independently — see +// backend/src/lib/weather-fetch.js. Not a single flat provider anymore. +export interface TierProvider { + hourly: Provider | null + threeHourly: Provider | null + daily: Provider | null +} + export interface ForecastPayload { hourly: ForecastPoint[] // today, full day threeHourly: ForecastPoint[] // next 2 days @@ -37,7 +47,7 @@ export interface ForecastPayload { summary: Summary | null // null only if there's no hourly data yet meta: { fetched_at: string | null - provider: 'met_office' | 'open_meteo' | null + provider: TierProvider | null } } @@ -52,7 +62,7 @@ export interface DisplayConfig { export interface AdminStatus { last_fetch_at: string | null - last_provider: string | null + last_provider: TierProvider | null last_error: string | null point_counts: { hourly: number; threeHourly: number; daily: number } }