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:
parent
c6be990f6b
commit
13fb30d9a3
10 changed files with 370 additions and 75 deletions
|
|
@ -65,8 +65,13 @@ async function fetchFromMetOffice(lat, lon, apiKey) {
|
|||
|
||||
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
|
||||
.filter(p => londonDateKey(p.time) === today)
|
||||
.map(p => ({
|
||||
resolution: 'hourly',
|
||||
ts: p.time,
|
||||
|
|
@ -153,8 +158,11 @@ async function fetchFromOpenMeteo(lat, lon) {
|
|||
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
|
||||
.filter(r => r.dateKey === today)
|
||||
.map(r => ({
|
||||
resolution: 'hourly',
|
||||
ts: r.time,
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
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(
|
||||
`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(
|
||||
`SELECT date, sunrise, sunset FROM forecast_astro ORDER BY date ASC`
|
||||
)
|
||||
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
|
||||
// the hourly data was last fetched — compute this fresh per-request, not at
|
||||
// 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
|
||||
// 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
|
||||
// project_weather memory's access-model decision).
|
||||
export async function publicForecastRoutes(app) {
|
||||
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/*
|
||||
|
|
|
|||
|
|
@ -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}`, {
|
||||
|
|
|
|||
17
frontend/src/components/AstroRow.tsx
Normal file
17
frontend/src/components/AstroRow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
110
frontend/src/components/DayModal.tsx
Normal file
110
frontend/src/components/DayModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
frontend/src/components/PointMeta.tsx
Normal file
28
frontend/src/components/PointMeta.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
35
frontend/src/lib/format.ts
Normal file
35
frontend/src/lib/format.ts
Normal 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)}°`
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue