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() {