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

@ -1,4 +1,4 @@
import type { ForecastPayload, AdminStatus, DisplayConfig } from './types'
import type { ForecastPayload, AdminStatus, DisplayConfig, DayHourly } from './types'
const BASE = '/weather/api'
@ -17,6 +17,16 @@ export async function fetchDisplayConfig(): Promise<DisplayConfig> {
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.
async function adminRequest<T>(path: string, opts: RequestInit = {}): Promise<T> {
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);
}
/* 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 ──────────────────────────────────────────────── */
.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; }

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 { CloudOff, Sunrise, Sunset, Umbrella, Droplet, Wind } from 'lucide-react'
import { CloudOff, Umbrella } 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'
const POLL_MS = 5 * 60 * 1000 // client polls more often than the server's hourly
// refresh just so a stale tab catches up quickly —
// the actual new data only shows up on the hour.
const TZ = 'Europe/London'
function londonNow(): Date {
return new Date(new Date().toLocaleString('en-US', { timeZone: TZ }))
}
function hourOf(iso: string): number {
return parseInt(new Date(iso).toLocaleString('en-GB', { timeZone: TZ, hour: '2-digit', hour12: false }))
}
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>
)
interface SelectedDay {
dateKey: string
dayLabel: string
fallbackPoints: ForecastPoint[]
fallbackResolution: '3hourly' | 'daily'
astro?: Astro
}
export default function Display() {
@ -85,6 +29,7 @@ export default function Display() {
const [displayTitle, setDisplayTitle] = useState<string | null>(null)
const hotelName = displayTitle || import.meta.env.VITE_HOTEL_NAME || 'Hotel'
const hourlyRowRef = useRef<HTMLDivElement>(null)
const [selectedDay, setSelectedDay] = useState<SelectedDay | null>(null)
useEffect(() => {
let cancelled = false
@ -196,12 +141,23 @@ export default function Display() {
<div className="wx-section-label">Coming up</div>
{/* 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">
{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-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">
<span className="wx-next-day-title">{formatDayName(g.points[0].ts)}</span>
<AstroRow astro={data.astro[g.key]} />
@ -223,7 +179,17 @@ export default function Display() {
<div className="wx-daily-row">
{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>
<WeatherIcon icon={p.icon} size={28} />
<span className="wx-day-temps">
@ -241,6 +207,17 @@ export default function Display() {
{error ? ' — showing last known forecast' : ''}
</div>
)}
{selectedDay && (
<DayModal
dateKey={selectedDay.dateKey}
dayLabel={selectedDay.dayLabel}
fallbackPoints={selectedDay.fallbackPoints}
fallbackResolution={selectedDay.fallbackResolution}
astro={selectedDay.astro}
onClose={() => setSelectedDay(null)}
/>
)}
</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 {
display_title: string | null // null = no override, frontend falls back to VITE_HOTEL_NAME
}