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 <noreply@anthropic.com>
This commit is contained in:
parent
13fb30d9a3
commit
4b535a34f4
3 changed files with 164 additions and 112 deletions
|
|
@ -4,21 +4,19 @@
|
||||||
// (hotel_location + met_office, via SETTINGS_URL/SETTINGS_SECRET) rather than asking
|
// (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.
|
// 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
|
// Provider selection is now PER TIER, not all-or-nothing: each of hourly,
|
||||||
// the integration is enabled; otherwise, or if any Met Office call fails, Open-Meteo
|
// three-hourly and daily is fetched independently, and only the tiers that fail
|
||||||
// (no key required). Same fail-soft posture as hvac's getPeakTempC() — never throw
|
// fall back to Open-Meteo — one Met Office hiccup no longer discards data the
|
||||||
// out to the scheduler, just record the error in `status` and keep serving whatever
|
// other endpoints could actually serve. This matters because Met Office DataHub
|
||||||
// was fetched last time.
|
// sells "Global Spot" and "Forecast" as SEPARATE subscriptions with separate keys
|
||||||
//
|
// (settings' met_office integration: api_key = Spot, forecast_api_key = Forecast,
|
||||||
// IMPORTANT: only the daily endpoint (sitespecific/v0/point/daily) has been exercised
|
// optional) — only Spot (used here for daily) has ever been confirmed live (hvac,
|
||||||
// against a live Met Office key so far (hvac, 2026-08-25 — see project_hvac memory's
|
// 2026-08-25); hourly/three-hourly try forecast_api_key first (falling back to
|
||||||
// "Live subscription mismatch" note). The hourly and three-hourly endpoint paths and
|
// api_key if no separate Forecast key is configured), since Spot alone may not
|
||||||
// field names below — including windDirectionFrom10m and sunrise/sunset — are taken
|
// actually be subscribed to those endpoints. Field names for hourly/three-hourly
|
||||||
// from the published Met Office DataHub Site Specific Forecast API docs but NOT yet
|
// are taken from the published Met Office DataHub docs, still not verified live —
|
||||||
// verified live — if any endpoint 403s or comes back with unexpected fields, it'll
|
// a mismatch there just throws and that tier falls back to Open-Meteo, same as an
|
||||||
// throw and this whole refresh degrades to Open-Meteo (see the catch in
|
// outright subscription failure.
|
||||||
// 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.
|
|
||||||
import { pool, setStatus } from '../db.js'
|
import { pool, setStatus } from '../db.js'
|
||||||
import { iconFromMetOffice, iconFromOpenMeteo } from './icons.js'
|
import { iconFromMetOffice, iconFromOpenMeteo } from './icons.js'
|
||||||
|
|
||||||
|
|
@ -42,8 +40,14 @@ function londonDateKey(isoTime) {
|
||||||
function londonHour(isoTime) {
|
function londonHour(isoTime) {
|
||||||
return parseInt(new Date(isoTime).toLocaleString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', hour12: false }))
|
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) {
|
async function fetchMetOfficeTimestep(timestep, lat, lon, apiKey) {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${MET_OFFICE_BASE}/${timestep}?latitude=${lat}&longitude=${lon}`,
|
`${MET_OFFICE_BASE}/${timestep}?latitude=${lat}&longitude=${lon}`,
|
||||||
|
|
@ -56,22 +60,12 @@ async function fetchMetOfficeTimestep(timestep, lat, lon, apiKey) {
|
||||||
return series
|
return series
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchFromMetOffice(lat, lon, apiKey) {
|
// Every hour the endpoint returns, not just today — the "tap a Coming up card"
|
||||||
const [hourly, threeHourly, daily] = await Promise.all([
|
// hourly-detail modal needs this for tomorrow/day-after (and, where available,
|
||||||
fetchMetOfficeTimestep('hourly', lat, lon, apiKey),
|
// days 4-7) too.
|
||||||
fetchMetOfficeTimestep('three-hourly', lat, lon, apiKey),
|
async function fetchMetOfficeHourly(lat, lon, apiKey) {
|
||||||
fetchMetOfficeTimestep('daily', lat, lon, apiKey),
|
const series = await fetchMetOfficeTimestep('hourly', lat, lon, apiKey)
|
||||||
])
|
return series
|
||||||
|
|
||||||
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
|
|
||||||
.map(p => ({
|
.map(p => ({
|
||||||
resolution: 'hourly',
|
resolution: 'hourly',
|
||||||
ts: p.time,
|
ts: p.time,
|
||||||
|
|
@ -85,10 +79,11 @@ async function fetchFromMetOffice(lat, lon, apiKey) {
|
||||||
source: 'met_office',
|
source: 'met_office',
|
||||||
}))
|
}))
|
||||||
.filter(p => typeof p.temp_c === 'number')
|
.filter(p => typeof p.temp_c === 'number')
|
||||||
|
}
|
||||||
|
|
||||||
// three-hourly covers today too; keep only the next two calendar days.
|
async function fetchMetOfficeThreeHourly(lat, lon, apiKey, dayKeys) {
|
||||||
const dayKeys = [...new Set(threeHourly.map(p => londonDateKey(p.time)))].filter(d => d !== today).slice(0, 2)
|
const series = await fetchMetOfficeTimestep('three-hourly', lat, lon, apiKey)
|
||||||
const threeHourlyPoints = threeHourly
|
return series
|
||||||
.filter(p => dayKeys.includes(londonDateKey(p.time)))
|
.filter(p => dayKeys.includes(londonDateKey(p.time)))
|
||||||
.map(p => ({
|
.map(p => ({
|
||||||
resolution: '3hourly',
|
resolution: '3hourly',
|
||||||
|
|
@ -103,10 +98,15 @@ async function fetchFromMetOffice(lat, lon, apiKey) {
|
||||||
source: 'met_office',
|
source: 'met_office',
|
||||||
}))
|
}))
|
||||||
.filter(p => typeof p.temp_c === 'number')
|
.filter(p => typeof p.temp_c === 'number')
|
||||||
|
}
|
||||||
|
|
||||||
// daily covers ~7 days; skip today + the 2 days already covered by three-hourly, take next 4.
|
// Returns both the daily tier's points AND astro (sunrise/sunset) — the daily
|
||||||
const skipKeys = new Set([today, ...dayKeys])
|
// response is the only one with sunrise/sunset fields, so the two travel together;
|
||||||
const dailyPoints = daily
|
// 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)))
|
.filter(p => !skipKeys.has(londonDateKey(p.time)))
|
||||||
.slice(0, 4)
|
.slice(0, 4)
|
||||||
.map(p => ({
|
.map(p => ({
|
||||||
|
|
@ -122,20 +122,14 @@ async function fetchFromMetOffice(lat, lon, apiKey) {
|
||||||
source: 'met_office',
|
source: 'met_office',
|
||||||
}))
|
}))
|
||||||
.filter(p => typeof p.temp_c === 'number')
|
.filter(p => typeof p.temp_c === 'number')
|
||||||
|
const astroPoints = series
|
||||||
// Sunrise/sunset for today + the 2 next-2-days dates — pulled from the daily
|
.filter(p => astroKeys.includes(londonDateKey(p.time)))
|
||||||
// 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)))
|
|
||||||
.map(p => ({ date: londonDateKey(p.time), sunrise: p.sunrise ?? null, sunset: p.sunset ?? null }))
|
.map(p => ({ date: londonDateKey(p.time), sunrise: p.sunrise ?? null, sunset: p.sunset ?? null }))
|
||||||
|
return { dailyPoints, astroPoints }
|
||||||
return { hourlyPoints, threeHourlyPoints, dailyPoints, astroPoints }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Open-Meteo fallback (no key required) ───────────────────────────────────
|
// ── Open-Meteo fallback (no key required) — one call covers every tier ─────
|
||||||
async function fetchFromOpenMeteo(lat, lon) {
|
async function fetchOpenMeteoRaw(lat, lon) {
|
||||||
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${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` +
|
`&hourly=temperature_2m,apparent_temperature,precipitation_probability,weathercode,windspeed_10m,winddirection_10m` +
|
||||||
`&daily=temperature_2m_max,temperature_2m_min,weathercode,sunrise,sunset` +
|
`&daily=temperature_2m_max,temperature_2m_min,weathercode,sunrise,sunset` +
|
||||||
|
|
@ -145,7 +139,6 @@ async function fetchFromOpenMeteo(lat, lon) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!data?.hourly?.time || !data?.daily?.time) throw new Error('Open-Meteo response missing hourly/daily blocks')
|
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) => ({
|
const hourlyRows = data.hourly.time.map((t, i) => ({
|
||||||
time: t,
|
time: t,
|
||||||
dateKey: londonDateKey(t),
|
dateKey: londonDateKey(t),
|
||||||
|
|
@ -157,13 +150,22 @@ async function fetchFromOpenMeteo(lat, lon) {
|
||||||
wind: data.hourly.windspeed_10m?.[i] ?? null,
|
wind: data.hourly.windspeed_10m?.[i] ?? null,
|
||||||
windDir: data.hourly.winddirection_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
|
// Every hour across the full forecast_days=8 window, not just today — same
|
||||||
// "tap a Coming up card" hourly-detail modal needs tomorrow/day-after (and, where
|
// reasoning as fetchMetOfficeHourly above.
|
||||||
// available, days 4-7) too. This is free: the hourly= block already covers all 8
|
function openMeteoHourlyPoints(hourlyRows) {
|
||||||
// days in this one call, today's strip just used to discard everything past today.
|
return hourlyRows.map(r => ({
|
||||||
const hourlyPoints = hourlyRows
|
|
||||||
.map(r => ({
|
|
||||||
resolution: 'hourly',
|
resolution: 'hourly',
|
||||||
ts: r.time,
|
ts: r.time,
|
||||||
temp_c: r.temp,
|
temp_c: r.temp,
|
||||||
|
|
@ -175,10 +177,10 @@ async function fetchFromOpenMeteo(lat, lon) {
|
||||||
icon: iconFromOpenMeteo(r.code, r.hour),
|
icon: iconFromOpenMeteo(r.code, r.hour),
|
||||||
source: 'open_meteo',
|
source: 'open_meteo',
|
||||||
}))
|
}))
|
||||||
|
}
|
||||||
const nextDayKeys = [...new Set(hourlyRows.map(r => r.dateKey))].filter(d => d !== today).slice(0, 2)
|
function openMeteoThreeHourlyPoints(hourlyRows, dayKeys) {
|
||||||
const threeHourlyPoints = hourlyRows
|
return hourlyRows
|
||||||
.filter(r => nextDayKeys.includes(r.dateKey) && r.hour % 3 === 0)
|
.filter(r => dayKeys.includes(r.dateKey) && r.hour % 3 === 0)
|
||||||
.map(r => ({
|
.map(r => ({
|
||||||
resolution: '3hourly',
|
resolution: '3hourly',
|
||||||
ts: r.time,
|
ts: r.time,
|
||||||
|
|
@ -191,19 +193,9 @@ async function fetchFromOpenMeteo(lat, lon) {
|
||||||
icon: iconFromOpenMeteo(r.code, r.hour),
|
icon: iconFromOpenMeteo(r.code, r.hour),
|
||||||
source: 'open_meteo',
|
source: 'open_meteo',
|
||||||
}))
|
}))
|
||||||
|
}
|
||||||
const skipKeys = new Set([today, ...nextDayKeys])
|
function openMeteoDailyPoints(dailyRows, skipKeys) {
|
||||||
const dailyRows = data.daily.time.map((t, i) => ({
|
return dailyRows
|
||||||
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
|
|
||||||
.filter(r => !skipKeys.has(r.dateKey))
|
.filter(r => !skipKeys.has(r.dateKey))
|
||||||
.slice(0, 4)
|
.slice(0, 4)
|
||||||
.map(r => ({
|
.map(r => ({
|
||||||
|
|
@ -218,12 +210,11 @@ async function fetchFromOpenMeteo(lat, lon) {
|
||||||
icon: iconFromOpenMeteo(r.code, 12),
|
icon: iconFromOpenMeteo(r.code, 12),
|
||||||
source: 'open_meteo',
|
source: 'open_meteo',
|
||||||
}))
|
}))
|
||||||
|
}
|
||||||
const astroPoints = dailyRows
|
function openMeteoAstroPoints(dailyRows, astroKeys) {
|
||||||
.filter(r => skipKeys.has(r.dateKey))
|
return dailyRows
|
||||||
|
.filter(r => astroKeys.includes(r.dateKey))
|
||||||
.map(r => ({ date: r.dateKey, sunrise: r.sunrise, sunset: r.sunset }))
|
.map(r => ({ date: r.dateKey, sunrise: r.sunrise, sunset: r.sunset }))
|
||||||
|
|
||||||
return { hourlyPoints, threeHourlyPoints, dailyPoints, astroPoints }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Storage ──────────────────────────────────────────────────────────────
|
// ── Storage ──────────────────────────────────────────────────────────────
|
||||||
|
|
@ -276,9 +267,11 @@ async function replaceAstro(rows) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// refreshForecast() — the scheduler's hourly entrypoint. Never throws; records
|
// refreshForecast() — the scheduler's hourly entrypoint. Never throws; records
|
||||||
// success/failure in `status` so the admin page can show it, same fail-safe posture
|
// success/failure per tier in `status` so the admin page can show exactly which of
|
||||||
// as hvac's weather compensation (missing config or a dead provider just means the
|
// hourly/3-hourly/daily is actually coming from Met Office vs Open-Meteo, rather
|
||||||
// display keeps showing the last good fetch, never a blank/broken screen).
|
// 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) {
|
export async function refreshForecast(log = console) {
|
||||||
try {
|
try {
|
||||||
const location = await getIntegration('hotel_location')
|
const location = await getIntegration('hotel_location')
|
||||||
|
|
@ -290,31 +283,76 @@ export async function refreshForecast(log = console) {
|
||||||
return
|
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')
|
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) {
|
if (metOffice?.api_key) {
|
||||||
try {
|
const spotKey = metOffice.api_key
|
||||||
result = await fetchFromMetOffice(lat, lon, metOffice.api_key)
|
const forecastKey = metOffice.forecast_api_key || metOffice.api_key
|
||||||
provider = 'met_office'
|
|
||||||
} catch (err) {
|
const [hourlyR, threeHourlyR, dailyR] = await Promise.allSettled([
|
||||||
log.error?.(`[weather] Met Office fetch failed, falling back to Open-Meteo: ${err.message}`)
|
fetchMetOfficeHourly(lat, lon, forecastKey),
|
||||||
result = await fetchFromOpenMeteo(lat, lon)
|
fetchMetOfficeThreeHourly(lat, lon, forecastKey, dayKeys),
|
||||||
provider = 'open_meteo'
|
fetchMetOfficeDaily(lat, lon, spotKey, skipKeys, astroKeys),
|
||||||
}
|
])
|
||||||
|
|
||||||
|
if (hourlyR.status === 'fulfilled') {
|
||||||
|
hourlyPoints = hourlyR.value
|
||||||
|
tierSource.hourly = 'met_office'
|
||||||
} else {
|
} else {
|
||||||
result = await fetchFromOpenMeteo(lat, lon)
|
log.error?.(`[weather] Met Office hourly failed, falling back to Open-Meteo: ${hourlyR.reason.message}`)
|
||||||
provider = 'open_meteo'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await replaceResolution('hourly', result.hourlyPoints)
|
if (threeHourlyR.status === 'fulfilled') {
|
||||||
await replaceResolution('3hourly', result.threeHourlyPoints)
|
threeHourlyPoints = threeHourlyR.value
|
||||||
await replaceResolution('daily', result.dailyPoints)
|
tierSource.threeHourly = 'met_office'
|
||||||
await replaceAstro(result.astroPoints)
|
} 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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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_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)
|
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) {
|
} catch (err) {
|
||||||
await setStatus('last_error', err.message)
|
await setStatus('last_error', err.message)
|
||||||
log.error?.(`[weather] refresh failed: ${err.message}`)
|
log.error?.(`[weather] refresh failed: ${err.message}`)
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,11 @@ export default function Admin() {
|
||||||
</div>
|
</div>
|
||||||
<div className="wx-status-row">
|
<div className="wx-status-row">
|
||||||
<span className="wx-status-label">Provider</span>
|
<span className="wx-status-label">Provider</span>
|
||||||
<span className="wx-status-value">{status?.last_provider ?? '—'}</span>
|
<span className="wx-status-value">
|
||||||
|
{status?.last_provider
|
||||||
|
? `hourly: ${status.last_provider.hourly ?? '—'} · 3-hourly: ${status.last_provider.threeHourly ?? '—'} · daily: ${status.last_provider.daily ?? '—'}`
|
||||||
|
: '—'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="wx-status-row">
|
<div className="wx-status-row">
|
||||||
<span className="wx-status-label">Status</span>
|
<span className="wx-status-label">Status</span>
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,16 @@ export interface Summary {
|
||||||
umbrella: boolean // real rain chance now or later today
|
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 {
|
export interface ForecastPayload {
|
||||||
hourly: ForecastPoint[] // today, full day
|
hourly: ForecastPoint[] // today, full day
|
||||||
threeHourly: ForecastPoint[] // next 2 days
|
threeHourly: ForecastPoint[] // next 2 days
|
||||||
|
|
@ -37,7 +47,7 @@ export interface ForecastPayload {
|
||||||
summary: Summary | null // null only if there's no hourly data yet
|
summary: Summary | null // null only if there's no hourly data yet
|
||||||
meta: {
|
meta: {
|
||||||
fetched_at: string | null
|
fetched_at: string | null
|
||||||
provider: 'met_office' | 'open_meteo' | null
|
provider: TierProvider | null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,7 +62,7 @@ export interface DisplayConfig {
|
||||||
|
|
||||||
export interface AdminStatus {
|
export interface AdminStatus {
|
||||||
last_fetch_at: string | null
|
last_fetch_at: string | null
|
||||||
last_provider: string | null
|
last_provider: TierProvider | null
|
||||||
last_error: string | null
|
last_error: string | null
|
||||||
point_counts: { hourly: number; threeHourly: number; daily: number }
|
point_counts: { hourly: number; threeHourly: number; daily: number }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue