Tap a Coming up card -> hourly-detail modal, 20s idle auto-close

Backend now stores hourly points for every day the provider returns (not
just today) -- for Open-Meteo this is free, the hourly= block already
covers the full forecast_days=8 window in the one call, today's strip just
discarded everything past today. Met Office's hourly product window isn't
verified this far out (only daily has been exercised live so far); an
empty result for a given date just means "no hourly detail", handled as a
graceful fallback, not an error.

New GET /api/public/forecast/hourly?date=YYYY-MM-DD backs the modal. The
main /api/public/forecast payload's `hourly` field is now its own scoped
query (today only) rather than filtering the much bigger stored set in JS.

Frontend: tapping any "Coming up" card (tomorrow/day-after or days 4-7)
opens DayModal with that day's hourly points, falling back to the
already-known 3-hourly points (or the single daily summary) if the
provider doesn't have hourly detail that far out. Auto-closes after 20s of
no interaction (pointerdown/keydown/wheel/scroll all reset the timer), with
a depleting progress bar so the countdown isn't a surprise. Extracted
PointMeta/AstroRow and the date/format helpers out of Display.tsx into
shared modules since DayModal needs them too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-08-25 13:26:57 +00:00
parent c6be990f6b
commit 13fb30d9a3
10 changed files with 370 additions and 75 deletions

View file

@ -65,8 +65,13 @@ async function fetchFromMetOffice(lat, lon, apiKey) {
const today = londonDateKey(new Date().toISOString()) const today = londonDateKey(new Date().toISOString())
// Store every hour the endpoint returns, not just today's — the "tap a Coming up
// card" hourly-detail modal needs this for tomorrow/day-after too (see
// routes/forecast.js's /forecast/hourly). Met Office's hourly product window
// isn't documented/verified here (unlike Open-Meteo, which we know covers the
// full forecast_days=8 range) — whatever it actually returns just becomes
// whatever range the modal can show; no assumption made about how far it goes.
const hourlyPoints = hourly const hourlyPoints = hourly
.filter(p => londonDateKey(p.time) === today)
.map(p => ({ .map(p => ({
resolution: 'hourly', resolution: 'hourly',
ts: p.time, ts: p.time,
@ -153,8 +158,11 @@ async function fetchFromOpenMeteo(lat, lon) {
windDir: data.hourly.winddirection_10m?.[i] ?? null, windDir: data.hourly.winddirection_10m?.[i] ?? null,
})) }))
// Store every hour across the full forecast_days=8 window, not just today — the
// "tap a Coming up card" hourly-detail modal needs tomorrow/day-after (and, where
// available, days 4-7) too. This is free: the hourly= block already covers all 8
// days in this one call, today's strip just used to discard everything past today.
const hourlyPoints = hourlyRows const hourlyPoints = hourlyRows
.filter(r => r.dateKey === today)
.map(r => ({ .map(r => ({
resolution: 'hourly', resolution: 'hourly',
ts: r.time, ts: r.time,

View file

@ -16,15 +16,25 @@ function rowToPoint(r) {
} }
} }
// forecast_points' 'hourly' resolution now holds every day the provider returned
// (see lib/weather-fetch.js — needed for the per-day hourly modal below), not just
// today, so the main payload's `hourly` field is scoped to today with its own query
// rather than filtering a much bigger already-fetched set in JS.
async function buildPayload() { async function buildPayload() {
const { rows: hourlyToday } = await pool.query(
`SELECT * FROM forecast_points
WHERE resolution = 'hourly'
AND (ts AT TIME ZONE 'Europe/London')::date = (NOW() AT TIME ZONE 'Europe/London')::date
ORDER BY ts ASC`
)
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT * FROM forecast_points ORDER BY ts ASC` `SELECT * FROM forecast_points WHERE resolution IN ('3hourly', 'daily') ORDER BY ts ASC`
) )
const { rows: astroRows } = await pool.query( const { rows: astroRows } = await pool.query(
`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) const hourly = hourlyToday.map(rowToPoint)
// "Later" is relative to whoever's looking at the screen right now, not to when // "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 // the hourly data was last fetched — compute this fresh per-request, not at
// refresh time (see lib/summary.js). // refresh time (see lib/summary.js).
@ -44,12 +54,37 @@ async function buildPayload() {
} }
} }
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
// Public, unauthenticated — this is the whole point of the app: a reception lobby // Public, unauthenticated — this is the whole point of the app: a reception lobby
// display can't reasonably sit behind a login screen. No PII, no write actions, just // display can't reasonably sit behind a login screen. No PII, no write actions, just
// read-only forecast data, so it deliberately skips requireAuth entirely (see // read-only forecast data, so it deliberately skips requireAuth entirely (see
// project_weather memory's access-model decision). // project_weather memory's access-model decision).
export async function publicForecastRoutes(app) { export async function publicForecastRoutes(app) {
app.get('/api/public/forecast', async () => buildPayload()) app.get('/api/public/forecast', async () => buildPayload())
// GET /api/public/forecast/hourly?date=YYYY-MM-DD — backs the "tap a Coming up
// card" modal. Whether a date beyond tomorrow/day-after actually has hourly rows
// depends entirely on how far the active provider's hourly product reaches
// (confirmed for Open-Meteo: the full forecast_days=8 window; NOT verified for Met
// Office — see lib/weather-fetch.js) — an empty `points` array just means "no
// hourly detail for this day", the frontend falls back to whatever coarser data
// it already has (3-hourly or the daily summary) rather than treating it as an
// error.
app.get('/api/public/forecast/hourly', async (req, reply) => {
const date = req.query?.date
if (typeof date !== 'string' || !DATE_RE.test(date)) {
return reply.status(400).send({ error: 'date must be YYYY-MM-DD' })
}
const { rows } = await pool.query(
`SELECT * FROM forecast_points
WHERE resolution = 'hourly'
AND (ts AT TIME ZONE 'Europe/London')::date = $1::date
ORDER BY ts ASC`,
[date]
)
return { date, points: rows.map(rowToPoint) }
})
} }
// Service-to-service — same bearer-secret convention as settings' /internal/* // Service-to-service — same bearer-secret convention as settings' /internal/*

View file

@ -1,4 +1,4 @@
import type { ForecastPayload, AdminStatus, DisplayConfig } from './types' import type { ForecastPayload, AdminStatus, DisplayConfig, DayHourly } from './types'
const BASE = '/weather/api' const BASE = '/weather/api'
@ -17,6 +17,16 @@ export async function fetchDisplayConfig(): Promise<DisplayConfig> {
return res.json() return res.json()
} }
// Backs the "tap a Coming up card" modal — hourly detail for one specific day.
// `points` can be empty (e.g. a day beyond how far the active provider's hourly
// product actually reaches); that's not an error, the modal falls back to whatever
// coarser data it already has for that day.
export async function fetchHourlyForDate(date: string): Promise<DayHourly> {
const res = await fetch(`${BASE}/public/forecast/hourly?date=${encodeURIComponent(date)}`)
if (!res.ok) throw new Error(`Request failed: ${res.status}`)
return res.json()
}
// Admin (authed) requests — same 401-redirect pattern as every other app's api.ts. // Admin (authed) requests — same 401-redirect pattern as every other app's api.ts.
async function adminRequest<T>(path: string, opts: RequestInit = {}): Promise<T> { async function adminRequest<T>(path: string, opts: RequestInit = {}): Promise<T> {
const res = await fetch(`${BASE}${path}`, { const res = await fetch(`${BASE}${path}`, {

View file

@ -0,0 +1,17 @@
import { Sunrise, Sunset } from 'lucide-react'
import { formatClockTime } from '../lib/format'
import type { Astro } from '../types'
export 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>
)
}

View file

@ -0,0 +1,110 @@
import { useEffect, useRef, useState } from 'react'
import { X, CloudOff } from 'lucide-react'
import { fetchHourlyForDate } from '../api'
import { WeatherIcon } from '../lib/icons'
import { PointMeta } from './PointMeta'
import { AstroRow } from './AstroRow'
import { formatHour, roundTemp } from '../lib/format'
import type { ForecastPoint, Astro } from '../types'
// Auto-dismiss back to the idle display after this long with no interaction —
// this is a reception kiosk, a modal left open by whoever last tapped it would
// otherwise sit there indefinitely for the next guest walking past.
const IDLE_MS = 20_000
interface DayModalProps {
dateKey: string // YYYY-MM-DD
dayLabel: string // "Tuesday"
fallbackPoints: ForecastPoint[] // already-known 3-hourly points, or a single daily point
fallbackResolution: '3hourly' | 'daily'
astro?: Astro
onClose: () => void
}
export function DayModal({ dateKey, dayLabel, fallbackPoints, fallbackResolution, astro, onClose }: DayModalProps) {
const [points, setPoints] = useState<ForecastPoint[] | null>(null)
const [error, setError] = useState(false)
const [resetTick, setResetTick] = useState(0)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
let cancelled = false
fetchHourlyForDate(dateKey)
.then(r => { if (!cancelled) setPoints(r.points) })
.catch(() => { if (!cancelled) setError(true) })
return () => { cancelled = true }
}, [dateKey])
useEffect(() => {
function reset() {
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(onClose, IDLE_MS)
setResetTick(t => t + 1)
}
reset()
// pointerdown covers touch + mouse + stylus in one; deliberately no mousemove —
// that would fire continuously and defeat the point of an idle timeout.
const events = ['pointerdown', 'keydown', 'wheel'] as const
events.forEach(e => window.addEventListener(e, reset))
window.addEventListener('scroll', reset, true) // capture — scroll doesn't bubble
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
events.forEach(e => window.removeEventListener(e, reset))
window.removeEventListener('scroll', reset, true)
}
}, [onClose])
const hasHourly = points != null && points.length > 0
const resolution = hasHourly ? 'hourly' : fallbackResolution
const showPoints = hasHourly ? points! : fallbackPoints
return (
<div className="wx-modal-overlay" onClick={onClose}>
<div className="wx-modal" onClick={e => e.stopPropagation()}>
<div className="wx-modal-header">
<div>
<div className="wx-modal-title">{dayLabel}</div>
<AstroRow astro={astro} />
</div>
<button className="wx-modal-close" onClick={onClose} aria-label="Close">
<X size={22} strokeWidth={1.75} />
</button>
</div>
{points === null && !error && <div className="wx-modal-empty">Loading…</div>}
{error && points === null && (
<div className="wx-modal-empty">
<CloudOff size={28} strokeWidth={1.5} />
<p>Couldn't load hourly detail.</p>
</div>
)}
{points !== null && (
<>
{!hasHourly && (
<p className="wx-modal-note">
{fallbackResolution === '3hourly'
? "Hourly detail isn't available this far ahead — showing 3-hour steps instead."
: "Hourly detail isn't available this far ahead — showing the daily overview instead."}
</p>
)}
<div className="wx-modal-hourly">
{showPoints.map(p => (
<div className="wx-modal-point" key={p.ts}>
{resolution !== 'daily' && <span className="wx-modal-point-time">{formatHour(p.ts)}</span>}
<WeatherIcon icon={p.icon} size={34} />
<span className="wx-modal-point-temp">{roundTemp(p.temp_c)}</span>
<PointMeta point={p} large />
</div>
))}
</div>
</>
)}
<div className="wx-modal-timeout-track">
<div className="wx-modal-timeout-bar" key={resetTick} />
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,28 @@
import { Droplet, Wind } from 'lucide-react'
import { compassFromDeg } from '../lib/format'
import type { ForecastPoint } from '../types'
// Rain% and wind, each on their own line with a small leading icon. `large` bumps
// the text size for today's hourly cards and the day modal; the next-2-days cards
// stay at the smaller default so today still reads as the primary/detailed view.
export function PointMeta({ point, large }: { point: ForecastPoint; large?: boolean }) {
if (point.precip_prob == null && point.wind_speed_mph == null) return null
const iconSize = large ? 13 : 10
return (
<span className={`wx-meta${large ? ' wx-meta-lg' : ''}`}>
{point.precip_prob != null && (
<span className="wx-meta-line wx-meta-precip">
<Droplet size={iconSize} strokeWidth={2} />
{Math.round(point.precip_prob)}%
</span>
)}
{point.wind_speed_mph != null && (
<span className="wx-meta-line wx-meta-wind">
<Wind size={iconSize} strokeWidth={2} />
{point.wind_dir_deg != null ? `${compassFromDeg(point.wind_dir_deg)} ` : ''}
{Math.round(point.wind_speed_mph)}mph
</span>
)}
</span>
)
}

View file

@ -239,6 +239,76 @@ html, body, #root { height: 100%; margin: 0; font-size: 14px; }
gap: 12px; background: var(--navy-dark); color: var(--text-muted); gap: 12px; background: var(--navy-dark); color: var(--text-muted);
} }
/* Every "Coming up" card opens the hourly-detail modal on tap — see DayModal. */
.wx-tappable { cursor: pointer; transition: background .12s, transform .08s; }
.wx-tappable:hover { background: var(--surface-2); }
.wx-tappable:active { transform: scale(0.97); }
/* ── Day-detail modal (opened by tapping a "Coming up" card) ────────────── */
.wx-modal-overlay {
position: fixed;
inset: 0;
background: rgba(15,15,32,0.72);
backdrop-filter: blur(2px);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
z-index: 500;
}
.wx-modal {
background: var(--navy);
border: 1px solid var(--surface-2);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
width: min(92vw, 920px);
max-height: 85vh;
padding: clamp(18px, 2.6vh, 32px);
display: flex;
flex-direction: column;
gap: 16px;
color: var(--text);
overflow: hidden;
}
.wx-modal-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.wx-modal-title { font-size: clamp(1.3rem, 2.4vw, 2rem); font-weight: 700; color: var(--gold); }
.wx-modal-close {
background: none; border: none; color: var(--text-muted); cursor: pointer;
padding: 4px; display: flex; flex-shrink: 0;
}
.wx-modal-close:hover { color: var(--text); }
.wx-modal-note { color: var(--text-muted); font-size: clamp(0.8rem, 1vw, 0.95rem); margin: 0; }
.wx-modal-empty {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 10px; color: var(--text-muted); padding: 40px 0;
}
.wx-modal-hourly {
display: flex;
align-items: stretch;
gap: clamp(8px, 1.2vw, 16px);
overflow-x: auto;
padding-bottom: 4px;
}
.wx-modal-point {
flex: 1 0 clamp(64px, 7vw, 90px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: clamp(10px, 1.4vh, 16px) 6px;
border-radius: var(--radius);
background: var(--surface-2);
}
.wx-modal-point-time { font-size: clamp(0.72rem, 0.85vw, 0.9rem); color: var(--text-muted); }
.wx-modal-point-temp { font-size: clamp(1.1rem, 1.3vw, 1.4rem); font-weight: 600; }
/* Idle-timeout progress bar — depletes over the 20s auto-close window, restarting
(via the DayModal's resetTick key, which remounts this element) on interaction. */
.wx-modal-timeout-track { height: 3px; background: var(--surface-2); border-radius: 2px; overflow: hidden; flex-shrink: 0; }
.wx-modal-timeout-bar { height: 100%; background: var(--gold); animation: wx-timeout-deplete 20s linear forwards; }
@keyframes wx-timeout-deplete { from { width: 100%; } to { width: 0%; } }
/* ── Admin (authed) page ──────────────────────────────────────────────── */ /* ── Admin (authed) page ──────────────────────────────────────────────── */
.wx-admin-shell { min-height: 100vh; background: var(--body-bg); display: flex; justify-content: center; padding: 32px 16px; } .wx-admin-shell { min-height: 100vh; background: var(--body-bg); display: flex; justify-content: center; padding: 32px 16px; }
.wx-admin-panel { width: 100%; max-width: 560px; } .wx-admin-panel { width: 100%; max-width: 560px; }

View file

@ -0,0 +1,35 @@
// Shared date/time/unit formatting for the display — used by both Display.tsx and
// the DayModal it opens, so it lives here rather than being duplicated or exported
// awkwardly out of the page component.
export const TZ = 'Europe/London'
export function londonNow(): Date {
return new Date(new Date().toLocaleString('en-US', { timeZone: TZ }))
}
export function hourOf(iso: string): number {
return parseInt(new Date(iso).toLocaleString('en-GB', { timeZone: TZ, hour: '2-digit', hour12: false }))
}
export function dateKeyOf(iso: string): string {
return new Date(iso).toLocaleDateString('en-CA', { timeZone: TZ })
}
export function formatHour(iso: string): string {
return new Date(iso).toLocaleTimeString('en-GB', { timeZone: TZ, hour: 'numeric', hour12: false }) + ':00'
}
export function formatClockTime(iso: string): string {
return new Date(iso).toLocaleTimeString('en-GB', { timeZone: TZ, hour: '2-digit', minute: '2-digit' })
}
export function formatDayName(iso: string): string {
return new Date(iso).toLocaleDateString('en-GB', { timeZone: TZ, weekday: 'long' })
}
export function formatDayDate(iso: string): string {
return new Date(iso).toLocaleDateString('en-GB', { timeZone: TZ, day: 'numeric', month: 'long' })
}
const COMPASS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']
export function compassFromDeg(deg: number): string {
return COMPASS[Math.round(deg / 45) % 8]
}
export function roundTemp(c: number): string {
return `${Math.round(c)}°`
}

View file

@ -1,79 +1,23 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { CloudOff, Sunrise, Sunset, Umbrella, Droplet, Wind } from 'lucide-react' import { CloudOff, 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 { 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 } from '../types'
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 —
// the actual new data only shows up on the hour. // the actual new data only shows up on the hour.
const TZ = 'Europe/London'
function londonNow(): Date { interface SelectedDay {
return new Date(new Date().toLocaleString('en-US', { timeZone: TZ })) dateKey: string
} dayLabel: string
function hourOf(iso: string): number { fallbackPoints: ForecastPoint[]
return parseInt(new Date(iso).toLocaleString('en-GB', { timeZone: TZ, hour: '2-digit', hour12: false })) fallbackResolution: '3hourly' | 'daily'
} astro?: Astro
function dateKeyOf(iso: string): string {
return new Date(iso).toLocaleDateString('en-CA', { timeZone: TZ })
}
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>
)
}
// Rain% and wind, each on their own line with a small leading icon. `large` bumps
// the text size for today's hourly cards; the next-2-days cards stay at the
// smaller default so today still reads as the primary/detailed view.
function PointMeta({ point, large }: { point: ForecastPoint; large?: boolean }) {
if (point.precip_prob == null && point.wind_speed_mph == null) return null
const iconSize = large ? 13 : 10
return (
<span className={`wx-meta${large ? ' wx-meta-lg' : ''}`}>
{point.precip_prob != null && (
<span className="wx-meta-line wx-meta-precip">
<Droplet size={iconSize} strokeWidth={2} />
{Math.round(point.precip_prob)}%
</span>
)}
{point.wind_speed_mph != null && (
<span className="wx-meta-line wx-meta-wind">
<Wind size={iconSize} strokeWidth={2} />
{point.wind_dir_deg != null ? `${compassFromDeg(point.wind_dir_deg)} ` : ''}
{Math.round(point.wind_speed_mph)}mph
</span>
)}
</span>
)
} }
export default function Display() { export default function Display() {
@ -85,6 +29,7 @@ export default function Display() {
const [displayTitle, setDisplayTitle] = useState<string | null>(null) const [displayTitle, setDisplayTitle] = useState<string | null>(null)
const hotelName = displayTitle || import.meta.env.VITE_HOTEL_NAME || 'Hotel' const hotelName = displayTitle || import.meta.env.VITE_HOTEL_NAME || 'Hotel'
const hourlyRowRef = useRef<HTMLDivElement>(null) const hourlyRowRef = useRef<HTMLDivElement>(null)
const [selectedDay, setSelectedDay] = useState<SelectedDay | null>(null)
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@ -196,12 +141,23 @@ export default function Display() {
<div className="wx-section-label">Coming up</div> <div className="wx-section-label">Coming up</div>
{/* One grid track shared by both — sized to fit whatever's left of the {/* One grid track shared by both — sized to fit whatever's left of the
viewport, so this can never push the page past 100vh. */} viewport, so this can never push the page past 100vh. Every card here
opens the hourly-detail modal on tap (see DayModal). */}
<div className="wx-coming-up"> <div className="wx-coming-up">
{nextDayGroups.length > 0 && ( {nextDayGroups.length > 0 && (
<div className="wx-next-days"> <div className="wx-next-days">
{nextDayGroups.map(g => ( {nextDayGroups.map(g => (
<div className="wx-next-day-card" key={g.key}> <div
className="wx-next-day-card wx-tappable"
key={g.key}
onClick={() => setSelectedDay({
dateKey: g.key,
dayLabel: formatDayName(g.points[0].ts),
fallbackPoints: g.points,
fallbackResolution: '3hourly',
astro: data.astro[g.key],
})}
>
<div className="wx-next-day-title-row"> <div className="wx-next-day-title-row">
<span className="wx-next-day-title">{formatDayName(g.points[0].ts)}</span> <span className="wx-next-day-title">{formatDayName(g.points[0].ts)}</span>
<AstroRow astro={data.astro[g.key]} /> <AstroRow astro={data.astro[g.key]} />
@ -223,7 +179,17 @@ export default function Display() {
<div className="wx-daily-row"> <div className="wx-daily-row">
{data.daily.map(p => ( {data.daily.map(p => (
<div className="wx-day-card" key={p.ts}> <div
className="wx-day-card wx-tappable"
key={p.ts}
onClick={() => setSelectedDay({
dateKey: dateKeyOf(p.ts),
dayLabel: formatDayName(p.ts),
fallbackPoints: [p],
fallbackResolution: 'daily',
astro: data.astro[dateKeyOf(p.ts)],
})}
>
<span className="wx-day-name">{formatDayName(p.ts)}</span> <span className="wx-day-name">{formatDayName(p.ts)}</span>
<WeatherIcon icon={p.icon} size={28} /> <WeatherIcon icon={p.icon} size={28} />
<span className="wx-day-temps"> <span className="wx-day-temps">
@ -241,6 +207,17 @@ export default function Display() {
{error ? ' — showing last known forecast' : ''} {error ? ' — showing last known forecast' : ''}
</div> </div>
)} )}
{selectedDay && (
<DayModal
dateKey={selectedDay.dateKey}
dayLabel={selectedDay.dayLabel}
fallbackPoints={selectedDay.fallbackPoints}
fallbackResolution={selectedDay.fallbackResolution}
astro={selectedDay.astro}
onClose={() => setSelectedDay(null)}
/>
)}
</div> </div>
) )
} }

View file

@ -41,6 +41,11 @@ export interface ForecastPayload {
} }
} }
export interface DayHourly {
date: string // YYYY-MM-DD, London-local
points: ForecastPoint[] // can be empty — see api.ts's fetchHourlyForDate
}
export interface DisplayConfig { export interface DisplayConfig {
display_title: string | null // null = no override, frontend falls back to VITE_HOTEL_NAME display_title: string | null // null = no override, frontend falls back to VITE_HOTEL_NAME
} }