diff --git a/backend/src/lib/weather-fetch.js b/backend/src/lib/weather-fetch.js index 1634e3b..a7b7561 100644 --- a/backend/src/lib/weather-fetch.js +++ b/backend/src/lib/weather-fetch.js @@ -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, diff --git a/backend/src/routes/forecast.js b/backend/src/routes/forecast.js index 6a8ba64..e99a701 100644 --- a/backend/src/routes/forecast.js +++ b/backend/src/routes/forecast.js @@ -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/* diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 022d398..2e4b090 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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 { 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 { + 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(path: string, opts: RequestInit = {}): Promise { const res = await fetch(`${BASE}${path}`, { diff --git a/frontend/src/components/AstroRow.tsx b/frontend/src/components/AstroRow.tsx new file mode 100644 index 0000000..5efb890 --- /dev/null +++ b/frontend/src/components/AstroRow.tsx @@ -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 ( + + {astro.sunrise && ( + {formatClockTime(astro.sunrise)} + )} + {astro.sunset && ( + {formatClockTime(astro.sunset)} + )} + + ) +} diff --git a/frontend/src/components/DayModal.tsx b/frontend/src/components/DayModal.tsx new file mode 100644 index 0000000..5f0cdff --- /dev/null +++ b/frontend/src/components/DayModal.tsx @@ -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(null) + const [error, setError] = useState(false) + const [resetTick, setResetTick] = useState(0) + const timerRef = useRef | 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 ( +
+
e.stopPropagation()}> +
+
+
{dayLabel}
+ +
+ +
+ + {points === null && !error &&
Loading…
} + {error && points === null && ( +
+ +

Couldn't load hourly detail.

+
+ )} + + {points !== null && ( + <> + {!hasHourly && ( +

+ {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."} +

+ )} +
+ {showPoints.map(p => ( +
+ {resolution !== 'daily' && {formatHour(p.ts)}} + + {roundTemp(p.temp_c)} + +
+ ))} +
+ + )} + +
+
+
+
+
+ ) +} diff --git a/frontend/src/components/PointMeta.tsx b/frontend/src/components/PointMeta.tsx new file mode 100644 index 0000000..52a21bc --- /dev/null +++ b/frontend/src/components/PointMeta.tsx @@ -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 ( + + {point.precip_prob != null && ( + + + {Math.round(point.precip_prob)}% + + )} + {point.wind_speed_mph != null && ( + + + {point.wind_dir_deg != null ? `${compassFromDeg(point.wind_dir_deg)} ` : ''} + {Math.round(point.wind_speed_mph)}mph + + )} + + ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 26512c4..f1f3ab4 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; } diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts new file mode 100644 index 0000000..2f8fba3 --- /dev/null +++ b/frontend/src/lib/format.ts @@ -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)}°` +} diff --git a/frontend/src/pages/Display.tsx b/frontend/src/pages/Display.tsx index da37a09..b49f122 100644 --- a/frontend/src/pages/Display.tsx +++ b/frontend/src/pages/Display.tsx @@ -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 ( - - {astro.sunrise && ( - {formatClockTime(astro.sunrise)} - )} - {astro.sunset && ( - {formatClockTime(astro.sunset)} - )} - - ) -} - -// 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 ( - - {point.precip_prob != null && ( - - - {Math.round(point.precip_prob)}% - - )} - {point.wind_speed_mph != null && ( - - - {point.wind_dir_deg != null ? `${compassFromDeg(point.wind_dir_deg)} ` : ''} - {Math.round(point.wind_speed_mph)}mph - - )} - - ) +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(null) const hotelName = displayTitle || import.meta.env.VITE_HOTEL_NAME || 'Hotel' const hourlyRowRef = useRef(null) + const [selectedDay, setSelectedDay] = useState(null) useEffect(() => { let cancelled = false @@ -196,12 +141,23 @@ export default function Display() {
Coming up
{/* 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). */}
{nextDayGroups.length > 0 && (
{nextDayGroups.map(g => ( -
+
setSelectedDay({ + dateKey: g.key, + dayLabel: formatDayName(g.points[0].ts), + fallbackPoints: g.points, + fallbackResolution: '3hourly', + astro: data.astro[g.key], + })} + >
{formatDayName(g.points[0].ts)} @@ -223,7 +179,17 @@ export default function Display() {
{data.daily.map(p => ( -
+
setSelectedDay({ + dateKey: dateKeyOf(p.ts), + dayLabel: formatDayName(p.ts), + fallbackPoints: [p], + fallbackResolution: 'daily', + astro: data.astro[dateKeyOf(p.ts)], + })} + > {formatDayName(p.ts)} @@ -241,6 +207,17 @@ export default function Display() { {error ? ' — showing last known forecast' : ''}
)} + + {selectedDay && ( + setSelectedDay(null)} + /> + )}
) } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 6356a8d..6b2bcab 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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 }