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:
parent
f977a5778f
commit
0a6eec65e7
6 changed files with 194 additions and 49 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<span className="wx-astro">
|
||||
{astro.sunrise && (
|
||||
<span className="wx-astro-item"><Sunrise size={16} strokeWidth={1.75} />{formatClockTime(astro.sunrise)}</span>
|
||||
)}
|
||||
{astro.sunset && (
|
||||
<span className="wx-astro-item"><Sunset size={16} strokeWidth={1.75} />{formatClockTime(astro.sunset)}</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function PointMeta({ point }: { point: ForecastPoint }) {
|
||||
return (
|
||||
<>
|
||||
{point.precip_prob != null && <span className="wx-meta-precip">{Math.round(point.precip_prob)}%</span>}
|
||||
{point.wind_speed_mph != null && (
|
||||
<span className="wx-meta-wind">
|
||||
{point.wind_dir_deg != null ? `${compassFromDeg(point.wind_dir_deg)} ` : ''}
|
||||
{Math.round(point.wind_speed_mph)}mph
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Display() {
|
||||
const [data, setData] = useState<ForecastPayload | null>(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() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="wx-section-label">Today</div>
|
||||
<div className="wx-hourly-row" ref={hourlyRowRef}>
|
||||
{data.hourly.map(p => (
|
||||
<HourCard key={p.ts} point={p} isNow={hourOf(p.ts) === nowHour} />
|
||||
))}
|
||||
{/* Today gets its own bordered block so its hourly cards read as one distinct
|
||||
group, not a continuation of the "Coming up" cards below. */}
|
||||
<div className="wx-today-block">
|
||||
<div className="wx-section-label-row">
|
||||
<span className="wx-section-label">Today</span>
|
||||
{todayKey && <AstroRow astro={data.astro[todayKey]} />}
|
||||
</div>
|
||||
<div className="wx-hourly-row" ref={hourlyRowRef}>
|
||||
{data.hourly.map(p => (
|
||||
<HourCard key={p.ts} point={p} isNow={hourOf(p.ts) === nowHour} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wx-section-label">Coming up</div>
|
||||
|
||||
{nextDayGroups.length > 0 && (
|
||||
<div className="wx-next-days">
|
||||
{nextDayGroups.map(g => (
|
||||
<div className="wx-next-day-card" key={g.key}>
|
||||
<div className="wx-next-day-title">{formatDayName(g.points[0].ts)}</div>
|
||||
<div className="wx-next-day-title-row">
|
||||
<span className="wx-next-day-title">{formatDayName(g.points[0].ts)}</span>
|
||||
<AstroRow astro={data.astro[g.key]} />
|
||||
</div>
|
||||
<div className="wx-next-day-points">
|
||||
{g.points.map(p => (
|
||||
<div className="wx-point" key={p.ts}>
|
||||
<span className="wx-point-time">{formatHour(p.ts)}</span>
|
||||
<WeatherIcon icon={p.icon} size={26} />
|
||||
<span className="wx-point-temp">{roundTemp(p.temp_c)}</span>
|
||||
<PointMeta point={p} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -136,7 +186,6 @@ export default function Display() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="wx-section-label">Coming up</div>
|
||||
<div className="wx-daily-row">
|
||||
{data.daily.map(p => (
|
||||
<div className="wx-day-card" key={p.ts}>
|
||||
|
|
@ -166,9 +215,7 @@ function HourCard({ point, isNow }: { point: ForecastPoint; isNow: boolean }) {
|
|||
<span className="wx-hour-time">{isNow ? 'Now' : formatHour(point.ts)}</span>
|
||||
<WeatherIcon icon={point.icon} size={26} />
|
||||
<span className="wx-hour-temp">{roundTemp(point.temp_c)}</span>
|
||||
{point.precip_prob != null && point.precip_prob >= 20 && (
|
||||
<span className="wx-hour-precip">{Math.round(point.precip_prob)}%</span>
|
||||
)}
|
||||
<PointMeta point={point} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, Astro> // keyed by YYYY-MM-DD (London-local) — today + next 2 days
|
||||
meta: {
|
||||
fetched_at: string | null
|
||||
provider: 'met_office' | 'open_meteo' | null
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue