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())
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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/*
|
||||||
|
|
|
||||||
|
|
@ -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}`, {
|
||||||
|
|
|
||||||
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);
|
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; }
|
||||||
|
|
|
||||||
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 { 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue