diff --git a/backend/src/db.js b/backend/src/db.js index f21c9c3..faad4b5 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -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() { diff --git a/backend/src/lib/weather-fetch.js b/backend/src/lib/weather-fetch.js index 511ddd8..1634e3b 100644 --- a/backend/src/lib/weather-fetch.js +++ b/backend/src/lib/weather-fetch.js @@ -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}`) diff --git a/backend/src/routes/forecast.js b/backend/src/routes/forecast.js index e87759c..dd44d23 100644 --- a/backend/src/routes/forecast.js +++ b/backend/src/routes/forecast.js @@ -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, diff --git a/frontend/src/index.css b/frontend/src/index.css index 7bb048a..4ff322c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -76,8 +76,33 @@ html, body, #root { height: 100%; margin: 0; font-size: 14px; } color: var(--text-muted); margin-bottom: clamp(8px, 1.2vh, 14px); } +.wx-section-label-row { + display: flex; + align-items: baseline; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + margin-bottom: clamp(8px, 1.2vh, 14px); +} +.wx-section-label-row .wx-section-label { margin-bottom: 0; } -/* Today's hourly course */ +/* Sunrise/sunset, shown next to a day's label */ +.wx-astro { display: flex; gap: clamp(10px, 1.2vw, 18px); color: var(--text-muted); font-size: clamp(0.75rem, 0.9vw, 0.95rem); } +.wx-astro-item { display: inline-flex; align-items: center; gap: 4px; } + +/* Rain%/wind, shown on hourly and 3-hourly point cards */ +.wx-meta-precip { font-size: clamp(0.62rem, 0.75vw, 0.75rem); color: #7dd3fc; } +.wx-meta-wind { font-size: clamp(0.62rem, 0.75vw, 0.75rem); color: var(--text-muted); white-space: nowrap; } + +/* Today — wrapped in its own block so its hourly cards read as one distinct + group, not a continuation of the "Coming up" cards below it. */ +.wx-today-block { + background: var(--surface); + border: 1px solid var(--surface-2); + border-radius: var(--radius); + padding: clamp(10px, 1.6vh, 18px); + margin-bottom: clamp(16px, 2.5vh, 28px); +} .wx-hourly-row { display: flex; gap: clamp(6px, 1vw, 14px); @@ -86,45 +111,46 @@ html, body, #root { height: 100%; margin: 0; font-size: 14px; } flex: 0 0 auto; } .wx-hour-card { - flex: 1 0 64px; + flex: 1 0 68px; display: flex; flex-direction: column; align-items: center; - gap: 6px; + gap: 5px; padding: clamp(8px, 1vh, 14px) 4px; border-radius: var(--radius); - background: var(--surface); + background: var(--surface-2); } .wx-hour-card.now { background: rgba(201,168,76,0.16); border: 1px solid rgba(201,168,76,0.4); } .wx-hour-time { font-size: clamp(0.75rem, 0.9vw, 0.95rem); color: var(--text-muted); } .wx-hour-temp { font-size: clamp(0.95rem, 1.1vw, 1.2rem); font-weight: 600; } -.wx-hour-precip { font-size: clamp(0.65rem, 0.8vw, 0.8rem); color: #7dd3fc; } -/* Next 2 days — 3-hourly */ +/* Next 2 days — 3-hourly, both under the "Coming up" label */ .wx-next-days { display: grid; grid-template-columns: repeat(2, 1fr); gap: clamp(12px, 1.6vw, 24px); - margin: clamp(16px, 2.5vh, 28px) 0; + margin-bottom: clamp(16px, 2.5vh, 28px); } .wx-next-day-card { background: var(--surface); border-radius: var(--radius); padding: clamp(10px, 1.4vh, 18px); } -.wx-next-day-title { font-weight: 700; font-size: clamp(0.95rem, 1.2vw, 1.2rem); margin-bottom: 8px; } +.wx-next-day-title-row { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; } +.wx-next-day-title { font-weight: 700; font-size: clamp(0.95rem, 1.2vw, 1.2rem); } .wx-next-day-points { display: flex; gap: clamp(4px, 0.8vw, 10px); overflow-x: auto; } -.wx-point { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1 0 52px; } +.wx-point { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1 0 56px; } .wx-point-time { font-size: clamp(0.65rem, 0.8vw, 0.8rem); color: var(--text-muted); } .wx-point-temp { font-size: clamp(0.85rem, 1vw, 1.05rem); font-weight: 600; } -/* Days 4-7 — plain daily row */ +/* Days 4-7 — plain daily row, still under the "Coming up" label above */ .wx-daily-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: clamp(10px, 1.4vw, 20px); margin-top: auto; } + .wx-day-card { display: flex; flex-direction: column; diff --git a/frontend/src/pages/Display.tsx b/frontend/src/pages/Display.tsx index 5bb4735..90cd373 100644 --- a/frontend/src/pages/Display.tsx +++ b/frontend/src/pages/Display.tsx @@ -1,8 +1,8 @@ import { useEffect, useRef, useState } from 'react' -import { CloudOff } from 'lucide-react' +import { CloudOff, Sunrise, Sunset } from 'lucide-react' import { fetchForecast } from '../api' import { WeatherIcon } from '../lib/icons' -import type { ForecastPayload, ForecastPoint } from '../types' +import type { ForecastPayload, ForecastPoint, Astro } from '../types' const POLL_MS = 5 * 60 * 1000 // client polls more often than the server's hourly // refresh just so a stale tab catches up quickly — @@ -21,14 +21,50 @@ function dateKeyOf(iso: string): string { function formatHour(iso: string): string { return new Date(iso).toLocaleTimeString('en-GB', { timeZone: TZ, hour: 'numeric', hour12: false }) + ':00' } +function formatClockTime(iso: string): string { + return new Date(iso).toLocaleTimeString('en-GB', { timeZone: TZ, hour: '2-digit', minute: '2-digit' }) +} function formatDayName(iso: string): string { return new Date(iso).toLocaleDateString('en-GB', { timeZone: TZ, weekday: 'long' }) } +const COMPASS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'] +function compassFromDeg(deg: number): string { + return COMPASS[Math.round(deg / 45) % 8] +} + function roundTemp(c: number): string { return `${Math.round(c)}°` } +function AstroRow({ astro }: { astro?: Astro }) { + if (!astro || (!astro.sunrise && !astro.sunset)) return null + return ( + + {astro.sunrise && ( + {formatClockTime(astro.sunrise)} + )} + {astro.sunset && ( + {formatClockTime(astro.sunset)} + )} + + ) +} + +function PointMeta({ point }: { point: ForecastPoint }) { + return ( + <> + {point.precip_prob != null && {Math.round(point.precip_prob)}%} + {point.wind_speed_mph != null && ( + + {point.wind_dir_deg != null ? `${compassFromDeg(point.wind_dir_deg)} ` : ''} + {Math.round(point.wind_speed_mph)}mph + + )} + + ) +} + export default function Display() { const [data, setData] = useState(null) const [error, setError] = useState(false) @@ -76,6 +112,7 @@ export default function Display() { const nowHour = clock.getHours() const currentPoint = data.hourly.find(p => hourOf(p.ts) === nowHour) ?? data.hourly[0] + const todayKey = data.hourly[0] ? dateKeyOf(data.hourly[0].ts) : null const dayKeys = [...new Set(data.threeHourly.map(p => dateKeyOf(p.ts)))].slice(0, 2) const nextDayGroups = dayKeys.map(key => ({ @@ -110,24 +147,37 @@ export default function Display() { )} -
Today
-
- {data.hourly.map(p => ( - - ))} + {/* Today gets its own bordered block so its hourly cards read as one distinct + group, not a continuation of the "Coming up" cards below. */} +
+
+ Today + {todayKey && } +
+
+ {data.hourly.map(p => ( + + ))} +
+
Coming up
+ {nextDayGroups.length > 0 && (
{nextDayGroups.map(g => (
-
{formatDayName(g.points[0].ts)}
+
+ {formatDayName(g.points[0].ts)} + +
{g.points.map(p => (
{formatHour(p.ts)} {roundTemp(p.temp_c)} +
))}
@@ -136,7 +186,6 @@ export default function Display() {
)} -
Coming up
{data.daily.map(p => (
@@ -166,9 +215,7 @@ function HourCard({ point, isNow }: { point: ForecastPoint; isNow: boolean }) { {isNow ? 'Now' : formatHour(point.ts)} {roundTemp(point.temp_c)} - {point.precip_prob != null && point.precip_prob >= 20 && ( - {Math.round(point.precip_prob)}% - )} +
) } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 742adff..c65aaf7 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -14,13 +14,20 @@ export interface ForecastPoint { feels_like_c: number | null precip_prob: number | null wind_speed_mph: number | null + wind_dir_deg: number | null icon: IconKey } +export interface Astro { + sunrise: string | null + sunset: string | null +} + export interface ForecastPayload { hourly: ForecastPoint[] // today, full day threeHourly: ForecastPoint[] // next 2 days daily: ForecastPoint[] // days 4-7 + astro: Record // keyed by YYYY-MM-DD (London-local) — today + next 2 days meta: { fetched_at: string | null provider: 'met_office' | 'open_meteo' | null