Add plain-language summary + umbrella tip for today
Neither Met Office's Global Spot API nor Open-Meteo return a narrative text summary -- both are purely numeric/coded. New lib/summary.js derives one rule-based from today's already-fetched hourly points (icon + precip_prob), no extra API call, no LLM: - Headline: "Scattered showers today", "Rain likely later", "Heavy rain today", "Dry today", etc. -- compares rain distribution across the whole day vs specifically the hours still ahead of "now". - Note: "Thunderstorms possible", appended when a thunder icon shows up. - Umbrella nudge: shown when there's a real rain chance now or later today. Computed fresh per-request (not cached at fetch time), since "later" means later than whoever's looking at the screen right now, not later than the last hourly refresh. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
3beac76ae6
commit
435b59b3fe
5 changed files with 114 additions and 4 deletions
68
backend/src/lib/summary.js
Normal file
68
backend/src/lib/summary.js
Normal file
|
|
@ -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 }))
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { pool, getStatus } from '../db.js'
|
import { pool, getStatus } from '../db.js'
|
||||||
import { requireAuth, requireCap } from '../auth.js'
|
import { requireAuth, requireCap } from '../auth.js'
|
||||||
import { refreshForecast } from '../lib/weather-fetch.js'
|
import { refreshForecast } from '../lib/weather-fetch.js'
|
||||||
|
import { summarizeToday } from '../lib/summary.js'
|
||||||
|
|
||||||
function rowToPoint(r) {
|
function rowToPoint(r) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -23,13 +24,19 @@ async function buildPayload() {
|
||||||
`SELECT date, sunrise, sunset FROM forecast_astro ORDER BY date ASC`
|
`SELECT date, sunrise, sunset FROM forecast_astro ORDER BY date ASC`
|
||||||
)
|
)
|
||||||
const status = await getStatus()
|
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 {
|
return {
|
||||||
hourly: rows.filter(r => r.resolution === 'hourly').map(rowToPoint),
|
hourly,
|
||||||
threeHourly: rows.filter(r => r.resolution === '3hourly').map(rowToPoint),
|
threeHourly: rows.filter(r => r.resolution === '3hourly').map(rowToPoint),
|
||||||
daily: rows.filter(r => r.resolution === 'daily').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
|
// 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.
|
// 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 }])),
|
astro: Object.fromEntries(astroRows.map(r => [r.date, { sunrise: r.sunrise, sunset: r.sunset }])),
|
||||||
|
summary: summarizeToday(hourly, nowHour),
|
||||||
meta: {
|
meta: {
|
||||||
fetched_at: status.last_fetch_at ?? null,
|
fetched_at: status.last_fetch_at ?? null,
|
||||||
provider: status.last_provider ?? null,
|
provider: status.last_provider ?? null,
|
||||||
|
|
|
||||||
|
|
@ -73,10 +73,27 @@ html, body, #root { height: 100%; margin: 0; font-size: 14px; }
|
||||||
.wx-now {
|
.wx-now {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: clamp(10px, 1.8vw, 24px);
|
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-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-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 {
|
.wx-section-label {
|
||||||
font-size: clamp(0.7rem, 0.95vw, 0.9rem);
|
font-size: clamp(0.7rem, 0.95vw, 0.9rem);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, useRef, useState } from 'react'
|
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 { fetchForecast, fetchDisplayConfig } from '../api'
|
||||||
import { WeatherIcon } from '../lib/icons'
|
import { WeatherIcon } from '../lib/icons'
|
||||||
import type { ForecastPayload, ForecastPoint, Astro } from '../types'
|
import type { ForecastPayload, ForecastPoint, Astro } from '../types'
|
||||||
|
|
@ -155,9 +155,20 @@ export default function Display() {
|
||||||
<WeatherIcon icon={currentPoint.icon} size={72} />
|
<WeatherIcon icon={currentPoint.icon} size={72} />
|
||||||
<div className="wx-now-temp">{roundTemp(currentPoint.temp_c)}</div>
|
<div className="wx-now-temp">{roundTemp(currentPoint.temp_c)}</div>
|
||||||
<div className="wx-now-meta">
|
<div className="wx-now-meta">
|
||||||
{currentPoint.feels_like_c != null && <span>Feels like {roundTemp(currentPoint.feels_like_c)}</span>}
|
{data.summary && <span className="wx-summary-headline">{data.summary.headline}</span>}
|
||||||
{currentPoint.precip_prob != null && <span>{Math.round(currentPoint.precip_prob)}% chance of rain</span>}
|
<span>
|
||||||
|
{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</>}
|
||||||
|
</span>
|
||||||
|
{data.summary?.note && <span className="wx-summary-note">{data.summary.note}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
{data.summary?.umbrella && (
|
||||||
|
<span className="wx-umbrella-tip">
|
||||||
|
<Umbrella size={18} strokeWidth={1.75} />
|
||||||
|
Don't forget your umbrella
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,18 @@ export interface Astro {
|
||||||
sunset: string | null
|
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 {
|
export interface ForecastPayload {
|
||||||
hourly: ForecastPoint[] // today, full day
|
hourly: ForecastPoint[] // today, full day
|
||||||
threeHourly: ForecastPoint[] // next 2 days
|
threeHourly: ForecastPoint[] // next 2 days
|
||||||
daily: ForecastPoint[] // days 4-7
|
daily: ForecastPoint[] // days 4-7
|
||||||
astro: Record<string, Astro> // keyed by YYYY-MM-DD (London-local) — today + next 2 days
|
astro: Record<string, Astro> // keyed by YYYY-MM-DD (London-local) — today + next 2 days
|
||||||
|
summary: Summary | null // null only if there's no hourly data yet
|
||||||
meta: {
|
meta: {
|
||||||
fetched_at: string | null
|
fetched_at: string | null
|
||||||
provider: 'met_office' | 'open_meteo' | null
|
provider: 'met_office' | 'open_meteo' | null
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue