Display: wind dir/speed + rain%, sunrise/sunset, wrap today, merge Coming up

- Today's hourly cards and the next-2-days 3-hourly cards now always show
  rain chance% and wind (compass direction + mph), not just rain% above a
  20% threshold. New wind_dir_deg column on forecast_points (Met Office
  windDirectionFrom10m / Open-Meteo winddirection_10m).
- New forecast_astro table: sunrise/sunset for today + next 2 days, shown
  next to each day's section label. No extra API calls needed — both
  providers' daily responses already carry sunrise/sunset for those dates,
  just weren't being extracted before.
- Today's hourly strip now sits in its own bordered block (wx-today-block)
  so it reads as a distinct group instead of blending into the cards below.
- "Next 2 days" no longer has its own separate heading — tomorrow and the
  day after now sit under the single "Coming up" label, same as days 4-7.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-08-25 12:48:11 +00:00
parent f977a5778f
commit 0a6eec65e7
6 changed files with 194 additions and 49 deletions

View file

@ -30,6 +30,7 @@ export async function initDb() {
feels_like_c NUMERIC,
precip_prob NUMERIC,
wind_speed_mph NUMERIC,
wind_dir_deg NUMERIC,
icon TEXT NOT NULL,
condition_text TEXT,
source TEXT NOT NULL,
@ -37,7 +38,18 @@ export async function initDb() {
UNIQUE (resolution, ts)
);
CREATE INDEX IF NOT EXISTS forecast_points_res_ts_idx ON forecast_points (resolution, ts);
-- Sunrise/sunset for today + the next 2 days — shown next to each day's label
-- on the display. Only 3 rows at a time; replaced wholesale each refresh cycle
-- alongside forecast_points (see lib/weather-fetch.js's replaceAstro()).
CREATE TABLE IF NOT EXISTS forecast_astro (
date TEXT PRIMARY KEY,
sunrise TIMESTAMPTZ,
sunset TIMESTAMPTZ,
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`)
}
export async function getStatus() {

View file

@ -1,7 +1,8 @@
// Fetches the reception forecast (today hourly, next-2-days 3-hourly, days 4-7 daily)
// and stores it in forecast_points. Reuses the same settings-app integrations hvac
// already uses (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.
// Fetches the reception forecast (today hourly, next-2-days 3-hourly, days 4-7 daily,
// plus sunrise/sunset for today + next 2 days) and stores it in forecast_points /
// forecast_astro. Reuses the same settings-app integrations hvac already uses
// (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
@ -12,11 +13,12 @@
// 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 are taken from the published Met Office DataHub Site Specific
// Forecast API docs but NOT yet verified live — if either 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.
// 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.
import { pool, setStatus } from '../db.js'
import { iconFromMetOffice, iconFromOpenMeteo } from './icons.js'
@ -73,6 +75,7 @@ async function fetchFromMetOffice(lat, lon, apiKey) {
feels_like_c: p.feelsLikeTemperature ?? null,
precip_prob: p.probOfPrecipitation ?? null,
wind_speed_mph: p.windSpeed10m != null ? p.windSpeed10m * 2.237 : null, // m/s -> mph
wind_dir_deg: p.windDirectionFrom10m ?? null,
icon: iconFromMetOffice(p.significantWeatherCode),
source: 'met_office',
}))
@ -90,6 +93,7 @@ async function fetchFromMetOffice(lat, lon, apiKey) {
feels_like_c: p.feelsLikeTemperature ?? null,
precip_prob: p.probOfPrecipitation ?? null,
wind_speed_mph: p.windSpeed10m != null ? p.windSpeed10m * 2.237 : null,
wind_dir_deg: p.windDirectionFrom10m ?? null,
icon: iconFromMetOffice(p.significantWeatherCode),
source: 'met_office',
}))
@ -108,19 +112,28 @@ async function fetchFromMetOffice(lat, lon, apiKey) {
feels_like_c: null,
precip_prob: p.dayProbabilityOfPrecipitation ?? null,
wind_speed_mph: p.midday10MWindSpeed != null ? p.midday10MWindSpeed * 2.237 : null,
wind_dir_deg: null,
icon: iconFromMetOffice(p.daySignificantWeatherCode),
source: 'met_office',
}))
.filter(p => typeof p.temp_c === 'number')
return { hourlyPoints, threeHourlyPoints, dailyPoints }
// 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)))
.map(p => ({ date: londonDateKey(p.time), sunrise: p.sunrise ?? null, sunset: p.sunset ?? null }))
return { hourlyPoints, threeHourlyPoints, dailyPoints, astroPoints }
}
// ── Open-Meteo fallback (no key required) ───────────────────────────────────
async function fetchFromOpenMeteo(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` +
`&daily=temperature_2m_max,temperature_2m_min,weathercode` +
`&hourly=temperature_2m,apparent_temperature,precipitation_probability,weathercode,windspeed_10m,winddirection_10m` +
`&daily=temperature_2m_max,temperature_2m_min,weathercode,sunrise,sunset` +
`&timezone=Europe%2FLondon&forecast_days=8`
const res = await fetch(url, { signal: AbortSignal.timeout(8000) })
if (!res.ok) throw new Error(`Open-Meteo returned ${res.status}`)
@ -137,6 +150,7 @@ async function fetchFromOpenMeteo(lat, lon) {
precipProb: data.hourly.precipitation_probability?.[i] ?? null,
code: data.hourly.weathercode[i],
wind: data.hourly.windspeed_10m?.[i] ?? null,
windDir: data.hourly.winddirection_10m?.[i] ?? null,
}))
const hourlyPoints = hourlyRows
@ -149,6 +163,7 @@ async function fetchFromOpenMeteo(lat, lon) {
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',
}))
@ -164,19 +179,23 @@ async function fetchFromOpenMeteo(lat, lon) {
feels_like_c: r.feelsLike,
precip_prob: r.precipProb,
wind_speed_mph: r.wind != null ? r.wind * 0.621 : null,
wind_dir_deg: r.windDir,
icon: iconFromOpenMeteo(r.code, r.hour),
source: 'open_meteo',
}))
const skipKeys = new Set([today, ...nextDayKeys])
const dailyPoints = 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],
}))
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
.filter(r => !skipKeys.has(r.dateKey))
.slice(0, 4)
.map(r => ({
@ -187,11 +206,16 @@ async function fetchFromOpenMeteo(lat, lon) {
feels_like_c: null,
precip_prob: null,
wind_speed_mph: null,
wind_dir_deg: null,
icon: iconFromOpenMeteo(r.code, 12),
source: 'open_meteo',
}))
return { hourlyPoints, threeHourlyPoints, dailyPoints }
const astroPoints = dailyRows
.filter(r => skipKeys.has(r.dateKey))
.map(r => ({ date: r.dateKey, sunrise: r.sunrise, sunset: r.sunset }))
return { hourlyPoints, threeHourlyPoints, dailyPoints, astroPoints }
}
// ── Storage ──────────────────────────────────────────────────────────────
@ -203,14 +227,35 @@ async function replaceResolution(resolution, points) {
for (const p of points) {
await client.query(
`INSERT INTO forecast_points
(resolution, ts, temp_c, temp_min_c, feels_like_c, precip_prob, wind_speed_mph, icon, source)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
(resolution, ts, temp_c, temp_min_c, feels_like_c, precip_prob, wind_speed_mph, wind_dir_deg, icon, source)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT (resolution, ts) DO UPDATE SET
temp_c = EXCLUDED.temp_c, temp_min_c = EXCLUDED.temp_min_c,
feels_like_c = EXCLUDED.feels_like_c, precip_prob = EXCLUDED.precip_prob,
wind_speed_mph = EXCLUDED.wind_speed_mph, icon = EXCLUDED.icon,
source = EXCLUDED.source, fetched_at = NOW()`,
[p.resolution, p.ts, p.temp_c, p.temp_min_c, p.feels_like_c, p.precip_prob, p.wind_speed_mph, p.icon, p.source]
wind_speed_mph = EXCLUDED.wind_speed_mph, wind_dir_deg = EXCLUDED.wind_dir_deg,
icon = EXCLUDED.icon, source = EXCLUDED.source, fetched_at = NOW()`,
[p.resolution, p.ts, p.temp_c, p.temp_min_c, p.feels_like_c, p.precip_prob, p.wind_speed_mph, p.wind_dir_deg, p.icon, p.source]
)
}
await client.query('COMMIT')
} catch (err) {
await client.query('ROLLBACK')
throw err
} finally {
client.release()
}
}
async function replaceAstro(rows) {
const client = await pool.connect()
try {
await client.query('BEGIN')
await client.query('DELETE FROM forecast_astro')
for (const r of rows) {
await client.query(
`INSERT INTO forecast_astro (date, sunrise, sunset) VALUES ($1,$2,$3)
ON CONFLICT (date) DO UPDATE SET sunrise = EXCLUDED.sunrise, sunset = EXCLUDED.sunset, fetched_at = NOW()`,
[r.date, r.sunrise, r.sunset]
)
}
await client.query('COMMIT')
@ -256,11 +301,12 @@ export async function refreshForecast(log = console) {
await replaceResolution('hourly', result.hourlyPoints)
await replaceResolution('3hourly', result.threeHourlyPoints)
await replaceResolution('daily', result.dailyPoints)
await replaceAstro(result.astroPoints)
await setStatus('last_fetch_at', new Date().toISOString())
await setStatus('last_provider', provider)
await setStatus('last_error', null)
log.info?.(`[weather] refreshed via ${provider}: ${result.hourlyPoints.length} hourly, ${result.threeHourlyPoints.length} 3-hourly, ${result.dailyPoints.length} daily`)
log.info?.(`[weather] refreshed via ${provider}: ${result.hourlyPoints.length} hourly, ${result.threeHourlyPoints.length} 3-hourly, ${result.dailyPoints.length} daily, ${result.astroPoints.length} astro`)
} catch (err) {
await setStatus('last_error', err.message)
log.error?.(`[weather] refresh failed: ${err.message}`)

View file

@ -10,6 +10,7 @@ function rowToPoint(r) {
feels_like_c: r.feels_like_c != null ? Number(r.feels_like_c) : null,
precip_prob: r.precip_prob != null ? Number(r.precip_prob) : null,
wind_speed_mph: r.wind_speed_mph != null ? Number(r.wind_speed_mph) : null,
wind_dir_deg: r.wind_dir_deg != null ? Number(r.wind_dir_deg) : null,
icon: r.icon,
}
}
@ -18,11 +19,17 @@ async function buildPayload() {
const { rows } = await pool.query(
`SELECT * FROM forecast_points ORDER BY ts ASC`
)
const { rows: astroRows } = await pool.query(
`SELECT date, sunrise, sunset FROM forecast_astro ORDER BY date ASC`
)
const status = await getStatus()
return {
hourly: rows.filter(r => r.resolution === 'hourly').map(rowToPoint),
threeHourly: rows.filter(r => r.resolution === '3hourly').map(rowToPoint),
daily: rows.filter(r => r.resolution === 'daily').map(rowToPoint),
// Keyed by date (YYYY-MM-DD, London-local) so the frontend can look up
// today/tomorrow/day-after's sunrise+sunset next to each section's label.
astro: Object.fromEntries(astroRows.map(r => [r.date, { sunrise: r.sunrise, sunset: r.sunset }])),
meta: {
fetched_at: status.last_fetch_at ?? null,
provider: status.last_provider ?? null,