diff --git a/backend/src/lib/summary.js b/backend/src/lib/summary.js index 0fb2a7c..5f0fb77 100644 --- a/backend/src/lib/summary.js +++ b/backend/src/lib/summary.js @@ -10,6 +10,11 @@ const RAIN_ICONS = new Set(['drizzle', 'rain-light', 'rain-heavy', 'sleet', 'hail']) const SNOW_ICONS = new Set(['snow-light', 'snow-heavy']) +// Tunable thresholds for the "now" panel's tip chips — all easy to adjust here +// without touching the logic that uses them. +const UMBRELLA_SOFT_PRECIP_PCT = 10 // any remaining hour above this -> "might want" +const JACKET_HIGH_TEMP_C = 12 // today's forecast high below this -> jacket tip + // 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). @@ -73,16 +78,37 @@ export function summarizeToday(points, nowHour) { ? 'Thunderstorms possible' : null - // Umbrella nudge: real rain chance either right now or coming up later today. - const umbrella = laterWetFraction > 0.25 || rainFraction > 0.25 + // Tip chips — small, actionable nudges under the headline. Each is + // independent (a cold rainy day can show both), ordered by how likely + // reception actually wants a guest to see it. + const tips = [] + + // Umbrella: the existing "real rain chance" threshold gets the strong + // wording; falling short of that but with a real (if lesser) chance + // still on the cards for later today gets a softer nudge instead of + // nothing at all. + const umbrellaStrong = laterWetFraction > 0.25 || rainFraction > 0.25 + const umbrellaSoft = !umbrellaStrong && + laterPoints.some(p => p.precip_prob != null && p.precip_prob > UMBRELLA_SOFT_PRECIP_PCT) + if (umbrellaStrong) { + tips.push({ key: 'umbrella', text: "Don't forget your umbrella" }) + } else if (umbrellaSoft) { + tips.push({ key: 'umbrella-soft', text: 'You might want an umbrella' }) + } + + // Jacket: keyed off today's forecast high, not "right now" -- same framing + // as the big temp above it. + if (highPoint.temp_c < JACKET_HIGH_TEMP_C) { + tips.push({ key: 'jacket', text: "Don't forget your jacket" }) + } return { headline, note, - umbrella, highTemp: highPoint.temp_c, highFeelsLike: highPoint.feels_like_c, rainChance: rainPeakPoint ? { prob: rainPeakPoint.precip_prob, ts: rainPeakPoint.ts } : null, + tips, } } diff --git a/frontend/src/index.css b/frontend/src/index.css index aa4d373..05c1364 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -94,18 +94,28 @@ html, body, #root { height: 100%; margin: 0; font-size: 14px; } .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 { +/* Tip chips (umbrella/jacket/...) -- can be more than one at once (e.g. a cold + rainy day), so this wraps multiple .wx-tip pills rather than a single fixed + span. Sized up from the old single-tip version to read clearly at a glance + on the kiosk tablet. */ +.wx-tips { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: clamp(8px, 1vw, 12px); + margin-left: auto; +} +.wx-tip { display: inline-flex; align-items: center; - gap: 8px; - margin-left: auto; - padding: clamp(6px, 0.9vh, 10px) clamp(12px, 1.4vw, 18px); + gap: 10px; + padding: clamp(8px, 1.2vh, 13px) clamp(14px, 1.7vw, 22px); 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); + font-size: clamp(0.9rem, 1.15vw, 1.15rem); white-space: nowrap; } diff --git a/frontend/src/pages/Display.tsx b/frontend/src/pages/Display.tsx index d672afc..cc85f14 100644 --- a/frontend/src/pages/Display.tsx +++ b/frontend/src/pages/Display.tsx @@ -1,12 +1,20 @@ import { useEffect, useRef, useState } from 'react' -import { CloudOff, Umbrella } from 'lucide-react' +import { CloudOff, Umbrella, Shirt } from 'lucide-react' import { fetchForecast, fetchDisplayConfig } from '../api' import { WeatherIcon } from '../lib/icons' import { PointMeta } from '../components/PointMeta' import { AstroRow } from '../components/AstroRow' import { DayModal } from '../components/DayModal' import { TZ, londonNow, hourOf, dateKeyOf, formatHour, formatDayName, roundTemp } from '../lib/format' -import type { ForecastPayload, ForecastPoint, Astro } from '../types' +import type { ForecastPayload, ForecastPoint, Astro, TipKey } from '../types' + +// One icon per tip key -- 'umbrella-soft' reuses the umbrella icon, just with +// softer wording (see summary.js), so it doesn't need its own entry here. +const TIP_ICONS: Record = { + umbrella: Umbrella, + 'umbrella-soft': Umbrella, + jacket: Shirt, +} const POLL_MS = 5 * 60 * 1000 // client polls more often than the server's hourly // refresh just so a stale tab catches up quickly — @@ -142,11 +150,18 @@ export default function Display() { {data.summary?.note && {data.summary.note}} - {data.summary?.umbrella && ( - - - Don't forget your umbrella - + {!!data.summary?.tips.length && ( +
+ {data.summary.tips.map(tip => { + const Icon = TIP_ICONS[tip.key] + return ( + + + {tip.text} + + ) + })} +
)} )} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 01ea991..c7d9899 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -23,13 +23,15 @@ export interface Astro { sunset: string | null } +export type TipKey = 'umbrella' | 'umbrella-soft' | 'jacket' + 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 highTemp: number // today's forecast high (°C) highFeelsLike: number | null // feels-like at the same hour as highTemp, not "now" rainChance: { prob: number; ts: string } | null // today's peak precip% + when it falls + tips: { key: TipKey; text: string }[] // umbrella/jacket/etc nudge chips, 0+ at once } export type Provider = 'met_office' | 'open_meteo'