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>
68 lines
3 KiB
JavaScript
68 lines
3 KiB
JavaScript
// 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 }))
|
|
}
|