Display: wind dir/speed + rain%, sunrise/sunset, wrap today, merge Coming up

- Today's hourly cards and the next-2-days 3-hourly cards now always show
  rain chance% and wind (compass direction + mph), not just rain% above a
  20% threshold. New wind_dir_deg column on forecast_points (Met Office
  windDirectionFrom10m / Open-Meteo winddirection_10m).
- New forecast_astro table: sunrise/sunset for today + next 2 days, shown
  next to each day's section label. No extra API calls needed — both
  providers' daily responses already carry sunrise/sunset for those dates,
  just weren't being extracted before.
- Today's hourly strip now sits in its own bordered block (wx-today-block)
  so it reads as a distinct group instead of blending into the cards below.
- "Next 2 days" no longer has its own separate heading — tomorrow and the
  day after now sit under the single "Coming up" label, same as days 4-7.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-08-25 12:48:11 +00:00
parent f977a5778f
commit 0a6eec65e7
6 changed files with 194 additions and 49 deletions

View file

@ -76,8 +76,33 @@ html, body, #root { height: 100%; margin: 0; font-size: 14px; }
color: var(--text-muted);
margin-bottom: clamp(8px, 1.2vh, 14px);
}
.wx-section-label-row {
display: flex;
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
margin-bottom: clamp(8px, 1.2vh, 14px);
}
.wx-section-label-row .wx-section-label { margin-bottom: 0; }
/* Today's hourly course */
/* Sunrise/sunset, shown next to a day's label */
.wx-astro { display: flex; gap: clamp(10px, 1.2vw, 18px); color: var(--text-muted); font-size: clamp(0.75rem, 0.9vw, 0.95rem); }
.wx-astro-item { display: inline-flex; align-items: center; gap: 4px; }
/* Rain%/wind, shown on hourly and 3-hourly point cards */
.wx-meta-precip { font-size: clamp(0.62rem, 0.75vw, 0.75rem); color: #7dd3fc; }
.wx-meta-wind { font-size: clamp(0.62rem, 0.75vw, 0.75rem); color: var(--text-muted); white-space: nowrap; }
/* Today — wrapped in its own block so its hourly cards read as one distinct
group, not a continuation of the "Coming up" cards below it. */
.wx-today-block {
background: var(--surface);
border: 1px solid var(--surface-2);
border-radius: var(--radius);
padding: clamp(10px, 1.6vh, 18px);
margin-bottom: clamp(16px, 2.5vh, 28px);
}
.wx-hourly-row {
display: flex;
gap: clamp(6px, 1vw, 14px);
@ -86,45 +111,46 @@ html, body, #root { height: 100%; margin: 0; font-size: 14px; }
flex: 0 0 auto;
}
.wx-hour-card {
flex: 1 0 64px;
flex: 1 0 68px;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
gap: 5px;
padding: clamp(8px, 1vh, 14px) 4px;
border-radius: var(--radius);
background: var(--surface);
background: var(--surface-2);
}
.wx-hour-card.now { background: rgba(201,168,76,0.16); border: 1px solid rgba(201,168,76,0.4); }
.wx-hour-time { font-size: clamp(0.75rem, 0.9vw, 0.95rem); color: var(--text-muted); }
.wx-hour-temp { font-size: clamp(0.95rem, 1.1vw, 1.2rem); font-weight: 600; }
.wx-hour-precip { font-size: clamp(0.65rem, 0.8vw, 0.8rem); color: #7dd3fc; }
/* Next 2 days — 3-hourly */
/* Next 2 days — 3-hourly, both under the "Coming up" label */
.wx-next-days {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: clamp(12px, 1.6vw, 24px);
margin: clamp(16px, 2.5vh, 28px) 0;
margin-bottom: clamp(16px, 2.5vh, 28px);
}
.wx-next-day-card {
background: var(--surface);
border-radius: var(--radius);
padding: clamp(10px, 1.4vh, 18px);
}
.wx-next-day-title { font-weight: 700; font-size: clamp(0.95rem, 1.2vw, 1.2rem); margin-bottom: 8px; }
.wx-next-day-title-row { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.wx-next-day-title { font-weight: 700; font-size: clamp(0.95rem, 1.2vw, 1.2rem); }
.wx-next-day-points { display: flex; gap: clamp(4px, 0.8vw, 10px); overflow-x: auto; }
.wx-point { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1 0 52px; }
.wx-point { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1 0 56px; }
.wx-point-time { font-size: clamp(0.65rem, 0.8vw, 0.8rem); color: var(--text-muted); }
.wx-point-temp { font-size: clamp(0.85rem, 1vw, 1.05rem); font-weight: 600; }
/* Days 4-7 — plain daily row */
/* Days 4-7 — plain daily row, still under the "Coming up" label above */
.wx-daily-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: clamp(10px, 1.4vw, 20px);
margin-top: auto;
}
.wx-day-card {
display: flex;
flex-direction: column;

View file

@ -1,8 +1,8 @@
import { useEffect, useRef, useState } from 'react'
import { CloudOff } from 'lucide-react'
import { CloudOff, Sunrise, Sunset } from 'lucide-react'
import { fetchForecast } from '../api'
import { WeatherIcon } from '../lib/icons'
import type { ForecastPayload, ForecastPoint } from '../types'
import type { ForecastPayload, ForecastPoint, Astro } from '../types'
const POLL_MS = 5 * 60 * 1000 // client polls more often than the server's hourly
// refresh just so a stale tab catches up quickly —
@ -21,14 +21,50 @@ function dateKeyOf(iso: string): string {
function formatHour(iso: string): string {
return new Date(iso).toLocaleTimeString('en-GB', { timeZone: TZ, hour: 'numeric', hour12: false }) + ':00'
}
function formatClockTime(iso: string): string {
return new Date(iso).toLocaleTimeString('en-GB', { timeZone: TZ, hour: '2-digit', minute: '2-digit' })
}
function formatDayName(iso: string): string {
return new Date(iso).toLocaleDateString('en-GB', { timeZone: TZ, weekday: 'long' })
}
const COMPASS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']
function compassFromDeg(deg: number): string {
return COMPASS[Math.round(deg / 45) % 8]
}
function roundTemp(c: number): string {
return `${Math.round(c)}°`
}
function AstroRow({ astro }: { astro?: Astro }) {
if (!astro || (!astro.sunrise && !astro.sunset)) return null
return (
<span className="wx-astro">
{astro.sunrise && (
<span className="wx-astro-item"><Sunrise size={16} strokeWidth={1.75} />{formatClockTime(astro.sunrise)}</span>
)}
{astro.sunset && (
<span className="wx-astro-item"><Sunset size={16} strokeWidth={1.75} />{formatClockTime(astro.sunset)}</span>
)}
</span>
)
}
function PointMeta({ point }: { point: ForecastPoint }) {
return (
<>
{point.precip_prob != null && <span className="wx-meta-precip">{Math.round(point.precip_prob)}%</span>}
{point.wind_speed_mph != null && (
<span className="wx-meta-wind">
{point.wind_dir_deg != null ? `${compassFromDeg(point.wind_dir_deg)} ` : ''}
{Math.round(point.wind_speed_mph)}mph
</span>
)}
</>
)
}
export default function Display() {
const [data, setData] = useState<ForecastPayload | null>(null)
const [error, setError] = useState(false)
@ -76,6 +112,7 @@ export default function Display() {
const nowHour = clock.getHours()
const currentPoint = data.hourly.find(p => hourOf(p.ts) === nowHour) ?? data.hourly[0]
const todayKey = data.hourly[0] ? dateKeyOf(data.hourly[0].ts) : null
const dayKeys = [...new Set(data.threeHourly.map(p => dateKeyOf(p.ts)))].slice(0, 2)
const nextDayGroups = dayKeys.map(key => ({
@ -110,24 +147,37 @@ export default function Display() {
</div>
)}
<div className="wx-section-label">Today</div>
<div className="wx-hourly-row" ref={hourlyRowRef}>
{data.hourly.map(p => (
<HourCard key={p.ts} point={p} isNow={hourOf(p.ts) === nowHour} />
))}
{/* Today gets its own bordered block so its hourly cards read as one distinct
group, not a continuation of the "Coming up" cards below. */}
<div className="wx-today-block">
<div className="wx-section-label-row">
<span className="wx-section-label">Today</span>
{todayKey && <AstroRow astro={data.astro[todayKey]} />}
</div>
<div className="wx-hourly-row" ref={hourlyRowRef}>
{data.hourly.map(p => (
<HourCard key={p.ts} point={p} isNow={hourOf(p.ts) === nowHour} />
))}
</div>
</div>
<div className="wx-section-label">Coming up</div>
{nextDayGroups.length > 0 && (
<div className="wx-next-days">
{nextDayGroups.map(g => (
<div className="wx-next-day-card" key={g.key}>
<div className="wx-next-day-title">{formatDayName(g.points[0].ts)}</div>
<div className="wx-next-day-title-row">
<span className="wx-next-day-title">{formatDayName(g.points[0].ts)}</span>
<AstroRow astro={data.astro[g.key]} />
</div>
<div className="wx-next-day-points">
{g.points.map(p => (
<div className="wx-point" key={p.ts}>
<span className="wx-point-time">{formatHour(p.ts)}</span>
<WeatherIcon icon={p.icon} size={26} />
<span className="wx-point-temp">{roundTemp(p.temp_c)}</span>
<PointMeta point={p} />
</div>
))}
</div>
@ -136,7 +186,6 @@ export default function Display() {
</div>
)}
<div className="wx-section-label">Coming up</div>
<div className="wx-daily-row">
{data.daily.map(p => (
<div className="wx-day-card" key={p.ts}>
@ -166,9 +215,7 @@ function HourCard({ point, isNow }: { point: ForecastPoint; isNow: boolean }) {
<span className="wx-hour-time">{isNow ? 'Now' : formatHour(point.ts)}</span>
<WeatherIcon icon={point.icon} size={26} />
<span className="wx-hour-temp">{roundTemp(point.temp_c)}</span>
{point.precip_prob != null && point.precip_prob >= 20 && (
<span className="wx-hour-precip">{Math.round(point.precip_prob)}%</span>
)}
<PointMeta point={point} />
</div>
)
}

View file

@ -14,13 +14,20 @@ export interface ForecastPoint {
feels_like_c: number | null
precip_prob: number | null
wind_speed_mph: number | null
wind_dir_deg: number | null
icon: IconKey
}
export interface Astro {
sunrise: string | null
sunset: string | null
}
export interface ForecastPayload {
hourly: ForecastPoint[] // today, full day
threeHourly: ForecastPoint[] // next 2 days
daily: ForecastPoint[] // days 4-7
astro: Record<string, Astro> // keyed by YYYY-MM-DD (London-local) — today + next 2 days
meta: {
fetched_at: string | null
provider: 'met_office' | 'open_meteo' | null