diff --git a/backend/src/lib/summary.js b/backend/src/lib/summary.js new file mode 100644 index 0000000..9283f8e --- /dev/null +++ b/backend/src/lib/summary.js @@ -0,0 +1,68 @@ +// Plain-language summary of today's forecast ("Scattered showers today", +// "Rain likely later", "Don't forget your umbrella"...). Neither Met Office's Global +// Spot API nor Open-Meteo return anything like this — both are purely numeric/coded +// (temps, weather codes, precip%) — so this is generated from today's already-fetched +// hourly points (icon + precip_prob), rule-based, no extra API call and no LLM. +// +// Computed fresh per-request in routes/forecast.js (not stored/cached at fetch time), +// since "later" is relative to whoever's looking at the screen right now, not to when +// the hourly data was last fetched. +const RAIN_ICONS = new Set(['drizzle', 'rain-light', 'rain-heavy', 'sleet', 'hail']) +const SNOW_ICONS = new Set(['snow-light', 'snow-heavy']) + +// summarizeToday(points, nowHour) — `points` is today's hourly forecast_points rows +// (already icon-normalized), `nowHour` is 0-23 in Europe/London. Returns null if +// there's nothing to summarize (no data yet). +export function summarizeToday(points, nowHour) { + if (!points.length) return null + + const isRain = p => RAIN_ICONS.has(p.icon) + const isSnow = p => SNOW_ICONS.has(p.icon) + const isThunder = p => p.icon === 'thunder' + const isHeavyRain = p => p.icon === 'rain-heavy' + + const rainPoints = points.filter(isRain) + const snowPoints = points.filter(isSnow) + const thunderPoints = points.filter(isThunder) + const rainFraction = rainPoints.length / points.length + const snowFraction = snowPoints.length / points.length + + const laterPoints = points.filter(p => hourOf(p) >= nowHour) + const earlierPoints = points.filter(p => hourOf(p) < nowHour) + const laterWetFraction = laterPoints.length + ? laterPoints.filter(p => isRain(p) || isThunder(p)).length / laterPoints.length + : 0 + const earlierWetCount = earlierPoints.filter(p => isRain(p) || isThunder(p)).length + + let headline + if (snowFraction > 0.3) { + headline = 'Snow expected today' + } else if (rainPoints.length === 0 && thunderPoints.length === 0) { + headline = 'Dry today' + } else if (rainPoints.some(isHeavyRain) && rainFraction > 0.5) { + headline = 'Heavy rain today' + } else if (rainFraction > 0.6) { + headline = 'Rain likely for much of the day' + } else if (laterWetFraction > 0.4 && earlierWetCount === 0) { + headline = 'Rain likely later' + } else if (rainFraction > 0) { + headline = 'Scattered showers today' + } else { + headline = 'Thunderstorms possible' + } + + // Append rather than replace the headline when thunder shows up alongside a + // rain pattern that already earned its own headline above. + const note = thunderPoints.length > 0 && headline !== 'Thunderstorms possible' + ? 'Thunderstorms possible' + : null + + // Umbrella nudge: real rain chance either right now or coming up later today. + const umbrella = laterWetFraction > 0.25 || rainFraction > 0.25 + + return { headline, note, umbrella } +} + +function hourOf(point) { + return parseInt(new Date(point.ts).toLocaleString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', hour12: false })) +} diff --git a/backend/src/routes/forecast.js b/backend/src/routes/forecast.js index dd44d23..6a8ba64 100644 --- a/backend/src/routes/forecast.js +++ b/backend/src/routes/forecast.js @@ -1,6 +1,7 @@ import { pool, getStatus } from '../db.js' import { requireAuth, requireCap } from '../auth.js' import { refreshForecast } from '../lib/weather-fetch.js' +import { summarizeToday } from '../lib/summary.js' function rowToPoint(r) { return { @@ -23,13 +24,19 @@ async function buildPayload() { `SELECT date, sunrise, sunset FROM forecast_astro ORDER BY date ASC` ) const status = await getStatus() + const hourly = rows.filter(r => r.resolution === 'hourly').map(rowToPoint) + // "Later" is relative to whoever's looking at the screen right now, not to when + // the hourly data was last fetched — compute this fresh per-request, not at + // refresh time (see lib/summary.js). + const nowHour = parseInt(new Date().toLocaleString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', hour12: false })) return { - hourly: rows.filter(r => r.resolution === 'hourly').map(rowToPoint), + hourly, 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 }])), + summary: summarizeToday(hourly, nowHour), 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 50091d3..1b42eb8 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -73,10 +73,27 @@ html, body, #root { height: 100%; margin: 0; font-size: 14px; } .wx-now { display: flex; align-items: center; + flex-wrap: wrap; gap: clamp(10px, 1.8vw, 24px); } .wx-now-temp { font-size: clamp(2.4rem, 5.5vw, 4.6rem); font-weight: 700; line-height: 1; } .wx-now-meta { display: flex; flex-direction: column; gap: 4px; color: var(--text-muted); font-size: clamp(0.8rem, 1.05vw, 1.05rem); } +.wx-summary-headline { color: var(--text); font-weight: 600; font-size: clamp(0.95rem, 1.3vw, 1.3rem); } +.wx-summary-note { color: #fbbf24; } +.wx-umbrella-tip { + display: inline-flex; + align-items: center; + gap: 8px; + margin-left: auto; + padding: clamp(6px, 0.9vh, 10px) clamp(12px, 1.4vw, 18px); + border-radius: 999px; + background: rgba(125, 211, 252, 0.14); + border: 1px solid rgba(125, 211, 252, 0.35); + color: #7dd3fc; + font-weight: 600; + font-size: clamp(0.75rem, 0.95vw, 0.95rem); + white-space: nowrap; +} .wx-section-label { font-size: clamp(0.7rem, 0.95vw, 0.9rem); diff --git a/frontend/src/pages/Display.tsx b/frontend/src/pages/Display.tsx index 91a95e6..be22dda 100644 --- a/frontend/src/pages/Display.tsx +++ b/frontend/src/pages/Display.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react' -import { CloudOff, Sunrise, Sunset } from 'lucide-react' +import { CloudOff, Sunrise, Sunset, Umbrella } from 'lucide-react' import { fetchForecast, fetchDisplayConfig } from '../api' import { WeatherIcon } from '../lib/icons' import type { ForecastPayload, ForecastPoint, Astro } from '../types' @@ -155,9 +155,20 @@ export default function Display() {
{roundTemp(currentPoint.temp_c)}
- {currentPoint.feels_like_c != null && Feels like {roundTemp(currentPoint.feels_like_c)}} - {currentPoint.precip_prob != null && {Math.round(currentPoint.precip_prob)}% chance of rain} + {data.summary && {data.summary.headline}} + + {currentPoint.feels_like_c != null && <>Feels like {roundTemp(currentPoint.feels_like_c)}} + {currentPoint.feels_like_c != null && currentPoint.precip_prob != null && ' · '} + {currentPoint.precip_prob != null && <>{Math.round(currentPoint.precip_prob)}% chance of rain} + + {data.summary?.note && {data.summary.note}}
+ {data.summary?.umbrella && ( + + + Don't forget your umbrella + + )} )} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 67b7381..6356a8d 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -23,11 +23,18 @@ export interface Astro { sunset: string | null } +export interface Summary { + headline: string // "Scattered showers today", "Rain likely later", ... + note: string | null // e.g. "Thunderstorms possible", alongside the headline + umbrella: boolean // real rain chance now or later today +} + 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 + summary: Summary | null // null only if there's no hourly data yet meta: { fetched_at: string | null provider: 'met_office' | 'open_meteo' | null