Bigger tip chip(s), soft umbrella tier, and a jacket tip

- Umbrella tip now bigger (larger padding/font/icon) and there can be
  more than one chip shown at once (e.g. cold + rainy).
- Added a softer 'You might want an umbrella' tier: shown when the
  existing strong threshold isn't met but any remaining hour today
  still has a real (>10%) rain chance -- previously that case showed
  nothing at all.
- Added a jacket tip: shown when today's forecast high is below 12°C.
  Both new thresholds are named constants at the top of summary.js,
  easy to retune without touching the logic.

summary.js's umbrella boolean is replaced by a tips[] array so future
nudges (e.g. a UV/sun-cream tip, if we add UV index data) slot in the
same way without another one-off boolean + span.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-08-31 12:05:11 +00:00
parent 335e6e31a8
commit b4f1e81eed
4 changed files with 69 additions and 16 deletions

View file

@ -10,6 +10,11 @@
const RAIN_ICONS = new Set(['drizzle', 'rain-light', 'rain-heavy', 'sleet', 'hail']) const RAIN_ICONS = new Set(['drizzle', 'rain-light', 'rain-heavy', 'sleet', 'hail'])
const SNOW_ICONS = new Set(['snow-light', 'snow-heavy']) 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 // summarizeToday(points, nowHour) — `points` is today's hourly forecast_points rows
// (already icon-normalized), `nowHour` is 0-23 in Europe/London. Returns null if // (already icon-normalized), `nowHour` is 0-23 in Europe/London. Returns null if
// there's nothing to summarize (no data yet). // there's nothing to summarize (no data yet).
@ -73,16 +78,37 @@ export function summarizeToday(points, nowHour) {
? 'Thunderstorms possible' ? 'Thunderstorms possible'
: null : null
// Umbrella nudge: real rain chance either right now or coming up later today. // Tip chips — small, actionable nudges under the headline. Each is
const umbrella = laterWetFraction > 0.25 || rainFraction > 0.25 // 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 { return {
headline, headline,
note, note,
umbrella,
highTemp: highPoint.temp_c, highTemp: highPoint.temp_c,
highFeelsLike: highPoint.feels_like_c, highFeelsLike: highPoint.feels_like_c,
rainChance: rainPeakPoint ? { prob: rainPeakPoint.precip_prob, ts: rainPeakPoint.ts } : null, rainChance: rainPeakPoint ? { prob: rainPeakPoint.precip_prob, ts: rainPeakPoint.ts } : null,
tips,
} }
} }

View file

@ -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-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-headline { color: var(--text); font-weight: 600; font-size: clamp(0.95rem, 1.3vw, 1.3rem); }
.wx-summary-note { color: #fbbf24; } .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; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 10px;
margin-left: auto; padding: clamp(8px, 1.2vh, 13px) clamp(14px, 1.7vw, 22px);
padding: clamp(6px, 0.9vh, 10px) clamp(12px, 1.4vw, 18px);
border-radius: 999px; border-radius: 999px;
background: rgba(125, 211, 252, 0.14); background: rgba(125, 211, 252, 0.14);
border: 1px solid rgba(125, 211, 252, 0.35); border: 1px solid rgba(125, 211, 252, 0.35);
color: #7dd3fc; color: #7dd3fc;
font-weight: 600; font-weight: 600;
font-size: clamp(0.75rem, 0.95vw, 0.95rem); font-size: clamp(0.9rem, 1.15vw, 1.15rem);
white-space: nowrap; white-space: nowrap;
} }

View file

@ -1,12 +1,20 @@
import { useEffect, useRef, useState } from 'react' 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 { fetchForecast, fetchDisplayConfig } from '../api'
import { WeatherIcon } from '../lib/icons' import { WeatherIcon } from '../lib/icons'
import { PointMeta } from '../components/PointMeta' import { PointMeta } from '../components/PointMeta'
import { AstroRow } from '../components/AstroRow' import { AstroRow } from '../components/AstroRow'
import { DayModal } from '../components/DayModal' import { DayModal } from '../components/DayModal'
import { TZ, londonNow, hourOf, dateKeyOf, formatHour, formatDayName, roundTemp } from '../lib/format' 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<TipKey, typeof Umbrella> = {
umbrella: Umbrella,
'umbrella-soft': Umbrella,
jacket: Shirt,
}
const POLL_MS = 5 * 60 * 1000 // client polls more often than the server's hourly const POLL_MS = 5 * 60 * 1000 // client polls more often than the server's hourly
// refresh just so a stale tab catches up quickly — // refresh just so a stale tab catches up quickly —
@ -142,11 +150,18 @@ export default function Display() {
</span> </span>
{data.summary?.note && <span className="wx-summary-note">{data.summary.note}</span>} {data.summary?.note && <span className="wx-summary-note">{data.summary.note}</span>}
</div> </div>
{data.summary?.umbrella && ( {!!data.summary?.tips.length && (
<span className="wx-umbrella-tip"> <div className="wx-tips">
<Umbrella size={18} strokeWidth={1.75} /> {data.summary.tips.map(tip => {
Don't forget your umbrella const Icon = TIP_ICONS[tip.key]
</span> return (
<span className="wx-tip" key={tip.key}>
<Icon size={22} strokeWidth={1.75} />
{tip.text}
</span>
)
})}
</div>
)} )}
</div> </div>
)} )}

View file

@ -23,13 +23,15 @@ export interface Astro {
sunset: string | null sunset: string | null
} }
export type TipKey = 'umbrella' | 'umbrella-soft' | 'jacket'
export interface Summary { export interface Summary {
headline: string // "Scattered showers today", "Rain likely later", ... headline: string // "Scattered showers today", "Rain likely later", ...
note: string | null // e.g. "Thunderstorms possible", alongside the headline 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) highTemp: number // today's forecast high (°C)
highFeelsLike: number | null // feels-like at the same hour as highTemp, not "now" 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 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' export type Provider = 'met_office' | 'open_meteo'