Scaffold weather app: reception forecast display + shared internal API

- Backend: Fastify service polling Met Office DataHub (Global Spot: hourly,
  three-hourly, daily endpoints) with Open-Meteo fallback, hourly refresh.
  Reuses settings' hotel_location/met_office integrations (same pattern as
  hvac's lib/weather.js) rather than its own credential copy.
- Three display tiers stored in one forecast_points table: today (hourly),
  next 2 days (3-hourly), days 4-7 (plain daily) - 7 days total.
- Public /api/public/forecast (no auth) feeds the reception kiosk display;
  /api/internal/forecast (WEATHER_SECRET bearer) lets other apps read the
  stored forecast instead of polling providers themselves; /api/admin/*
  (normal JWT + settings cap) is the staff status/refresh page.
- Frontend: root / is the public full-screen Display (no AuthGate at all -
  first genuinely public app in the stack); /admin is the authed status
  page. apps.base_path is /weather/admin so the portal card opens the
  admin view directly, precedented by forecasting's history sub-route.
- LXC 131 / 10.10.10.131, registered in auth/src/db.js, push-to-forgejo.sh,
  and stack-init/install-stack.sh (deploy_weather + --only dispatch + full
  install chain).
- Typechecks and builds clean; not yet deployed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-08-25 12:22:53 +00:00
commit f977a5778f
36 changed files with 8125 additions and 0 deletions

29
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,29 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import AuthGate from './components/AuthGate'
import { UpdateBanner } from './components/UpdateBanner'
import IosInstallHint from './components/IosInstallHint'
import { useVersionCheck } from './hooks/useVersionCheck'
import Display from './pages/Display'
import Admin from './pages/Admin'
// Root ("/") is the public, unauthenticated reception display — that's this app's
// primary purpose, not a staff tool (see project_weather memory). Only "/admin" is
// gated behind the normal AuthGate + 'settings' capability, matching every other
// app's login pattern. The portal's card links straight to /admin (apps.base_path =
// '/weather/admin' in the auth DB) — see seed-app.js.
export default function App() {
const updateAvailable = useVersionCheck('/weather/health')
return (
<>
<BrowserRouter basename="/weather">
<Routes>
<Route path="/admin" element={<AuthGate><Admin /></AuthGate>} />
<Route path="/" element={<Display />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
<UpdateBanner visible={updateAvailable} />
<IosInstallHint />
</>
)
}

36
frontend/src/api.ts Normal file
View file

@ -0,0 +1,36 @@
import type { ForecastPayload, AdminStatus } from './types'
const BASE = '/weather/api'
// Public forecast — deliberately no credentials/401-redirect handling. This is
// called from the unauthenticated Display page and must work with no session at all.
export async function fetchForecast(): Promise<ForecastPayload> {
const res = await fetch(`${BASE}/public/forecast`)
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}`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...opts.headers },
...opts,
})
if (res.status === 401) {
;(window.top ?? window).location.href = '/login'
throw new Error('Unauthenticated')
}
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || `Request failed: ${res.status}`)
}
return res.json()
}
export function fetchAdminStatus(): Promise<AdminStatus> {
return adminRequest('/admin/status')
}
export function triggerRefresh(): Promise<{ ok: boolean; last_fetch_at: string; provider: string }> {
return adminRequest('/admin/refresh', { method: 'POST' })
}

View file

@ -0,0 +1,165 @@
import { useEffect, useRef, useState, createContext, useContext } from 'react'
import type { User } from '../types'
// Only wraps the /admin route — see App.tsx. The reception display at "/" is
// deliberately public and never renders this component.
function getInactivityMs(): number | null {
if (window.matchMedia('(display-mode: standalone)').matches) return null
const c = document.cookie.split(';').map(s => s.trim()).find(s => s.startsWith('hnf_inactivity_mins='))
if (!c) return null
const mins = parseInt(c.split('=')[1])
return isNaN(mins) || mins <= 0 ? null : mins * 60 * 1000
}
function isEmbedded() {
return window.top !== window
}
interface AuthCtx { user: User }
const Ctx = createContext<AuthCtx | null>(null)
export function useAuth() {
const ctx = useContext(Ctx)
if (!ctx) throw new Error('useAuth must be used inside AuthGate')
return ctx
}
export default function AuthGate({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<'checking' | 'authed' | 'login'>('checking')
const [user, setUser] = useState<User | null>(null)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
fetch('/api/auth/verify?app=weather', { credentials: 'include' })
.then(async r => {
if (r.ok) {
setUser(await r.json())
setState('authed')
} else if (isEmbedded()) {
window.top!.location.href = `/login?from=${encodeURIComponent('/app/weather')}`
} else {
setState('login')
}
})
.catch(() => { if (!isEmbedded()) setState('login') })
}, [])
useEffect(() => {
const ms = getInactivityMs()
if (state !== 'authed' || !ms) return
const timeoutMs: number = ms
async function forceLogout() {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
setUser(null)
setState('login')
}
function reset() {
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(forceLogout, timeoutMs)
}
const events = ['mousemove', 'keydown', 'click', 'touchstart'] as const
events.forEach(e => window.addEventListener(e, reset, { passive: true }))
reset()
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
events.forEach(e => window.removeEventListener(e, reset))
}
}, [state])
async function login(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError('')
try {
const res = await fetch('/api/auth/login', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (!res.ok) { setError('Invalid email or password'); return }
const verify = await fetch('/api/auth/verify?app=weather', { credentials: 'include' })
if (verify.ok) {
setUser(await verify.json())
setState('authed')
} else {
setError("You don't have access to this app.")
}
} catch {
setError('Connection error — please try again')
} finally {
setLoading(false)
}
}
if (state === 'checking') {
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
height: '100vh', fontFamily: 'var(--font)', color: 'var(--text-mid)'
}}>
Loading…
</div>
)
}
if (state === 'login') {
return (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
justifyContent: 'center', height: '100dvh', padding: '1.5rem',
background: 'var(--navy-dark)',
}}>
<div style={{
background: 'var(--navy)', borderRadius: 'var(--radius)',
padding: '2rem', width: '100%', maxWidth: '360px',
border: '1px solid var(--surface-2)',
}}>
<h1 style={{ fontSize: '1.4rem', marginBottom: '1.5rem', color: 'var(--gold)' }}>
Weather — Admin
</h1>
<form onSubmit={login} style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<input
type="email" value={email} onChange={e => setEmail(e.target.value)}
placeholder="Email" required autoComplete="email"
style={inputStyle}
/>
<input
type="password" value={password} onChange={e => setPassword(e.target.value)}
placeholder="Password" required autoComplete="current-password"
style={inputStyle}
/>
{error && <p style={{ color: 'var(--danger)', fontSize: '0.875rem' }}>{error}</p>}
<button type="submit" disabled={loading} style={{
background: loading ? 'var(--surface-2)' : 'var(--gold)',
color: loading ? 'var(--text-muted)' : 'var(--navy-dark)',
border: 'none', borderRadius: '6px', padding: '0.625rem',
fontSize: '1rem', fontWeight: 600, marginTop: '0.25rem',
}}>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
</div>
)
}
return (
<Ctx.Provider value={{ user: user! }}>
{children}
</Ctx.Provider>
)
}
const inputStyle: React.CSSProperties = {
background: 'var(--navy-dark)', border: '1px solid var(--surface-2)',
borderRadius: '6px', color: 'var(--text)', padding: '0.625rem 0.75rem',
fontSize: '1rem', width: '100%', outline: 'none',
}

View file

@ -0,0 +1,89 @@
import { useEffect, useState } from 'react'
import { Share, ExternalLink, X } from 'lucide-react'
function isIos() {
return /iPad|iPhone|iPod/.test(navigator.userAgent) ||
// iPadOS 13+ reports as 'MacIntel' but has touch support, unlike a real Mac
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
}
function isStandalone() {
return window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as unknown as { standalone?: boolean }).standalone === true
}
// Safari's UA also matches "Safari", so this checks for the other iOS
// browsers/in-app webviews that spoof it — none of them can add to the
// home screen; only Safari itself can.
function isNonSafariIosBrowser() {
return /CriOS|FxiOS|EdgiOS|OPiOS|mercury|GSA|DuckDuckGo|Instagram|FBAN|FBAV|Line\//.test(navigator.userAgent)
}
/**
* iOS has no `beforeinstallprompt` API — Safari never fires it, so the
* portal's Install button (?install=1) does nothing there. This shows the
* manual steps instead: Share -> Add to Home Screen in Safari, or a prompt
* to switch to Safari first if the page was opened in another browser/app.
*/
export default function IosInstallHint() {
const [mode, setMode] = useState<'safari' | 'other' | null>(null)
useEffect(() => {
if (!isIos() || isStandalone()) return
if (!new URLSearchParams(window.location.search).has('install')) return
setMode(isNonSafariIosBrowser() ? 'other' : 'safari')
}, [])
if (!mode) return null
return (
<div style={{
position: 'fixed', inset: 0, zIndex: 9999,
background: 'rgba(15,15,32,0.55)',
display: 'flex', alignItems: 'flex-end', justifyContent: 'center',
padding: '0 16px 24px',
}}>
<div style={{
position: 'relative',
width: '100%', maxWidth: '360px',
background: 'var(--card-bg)', borderRadius: 'var(--radius)',
border: '1px solid var(--card-border)', boxShadow: 'var(--shadow-md)',
padding: '20px', textAlign: 'center',
}}>
<button
onClick={() => setMode(null)}
aria-label="Dismiss"
style={{
position: 'absolute', top: '10px', right: '10px',
background: 'none', border: 'none', color: 'var(--text-mid)',
padding: '4px', cursor: 'pointer', lineHeight: 0,
}}
>
<X size={16} strokeWidth={1.75} />
</button>
<div style={{
width: '44px', height: '44px', margin: '0 auto 12px',
borderRadius: '50%', background: 'var(--gold)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{mode === 'safari'
? <Share size={22} strokeWidth={1.75} color="var(--navy)" />
: <ExternalLink size={22} strokeWidth={1.75} color="var(--navy)" />}
</div>
{mode === 'safari' ? (
<p style={{ fontSize: '14px', color: 'var(--text-dark)', lineHeight: 1.5, margin: 0 }}>
To install this app, tap the <strong>Share</strong> icon in Safari's
toolbar, then choose <strong>Add to Home Screen</strong>.
</p>
) : (
<p style={{ fontSize: '14px', color: 'var(--text-dark)', lineHeight: 1.5, margin: 0 }}>
iOS only allows installing apps from <strong>Safari</strong>. Open this
page in Safari, then tap Share → <strong>Add to Home Screen</strong>.
</p>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,44 @@
import { RefreshCw } from 'lucide-react'
export function UpdateBanner({ visible }: { visible: boolean }) {
if (!visible) return null
return (
<div style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
zIndex: 9999,
background: 'var(--navy)',
color: 'var(--text)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '12px',
padding: '10px 16px',
fontSize: '14px',
boxShadow: '0 -2px 8px rgba(0,0,0,0.3)',
}}>
<span>A new version is available.</span>
<button
onClick={() => window.location.reload()}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
background: 'var(--gold)',
color: 'var(--navy)',
border: 'none',
borderRadius: '4px',
padding: '6px 14px',
fontWeight: 600,
cursor: 'pointer',
fontSize: '13px',
}}
>
<RefreshCw size={14} strokeWidth={1.75} />
Reload
</button>
</div>
)
}

View file

@ -0,0 +1,43 @@
import { useEffect, useState } from 'react'
const POLL_MS = 2 * 60 * 1000
export function useVersionCheck(healthUrl: string) {
const [updateAvailable, setUpdateAvailable] = useState(false)
useEffect(() => {
let seenVersion: string | null = null
async function check() {
try {
const res = await fetch(healthUrl, { cache: 'no-store' })
if (!res.ok) return
const data = await res.json()
const v: string | undefined = data.version
if (!v) return
if (seenVersion === null) {
seenVersion = v
} else if (v !== seenVersion) {
setUpdateAvailable(true)
}
} catch {
// network error — skip silently
}
}
check()
const interval = setInterval(check, POLL_MS)
function onVisible() {
if (document.visibilityState === 'visible') check()
}
document.addEventListener('visibilitychange', onVisible)
return () => {
clearInterval(interval)
document.removeEventListener('visibilitychange', onVisible)
}
}, [healthUrl])
return updateAvailable
}

185
frontend/src/index.css Normal file
View file

@ -0,0 +1,185 @@
/* Stack design system tokens — include verbatim in every app */
:root {
--navy: #1a1a2e;
--navy-dark: #0f0f20;
--gold: #c9a84c;
--gold-light: #e8c96d;
--surface: rgba(255,255,255,0.07);
--surface-2: rgba(255,255,255,0.08);
--text: rgba(255,255,255,0.88);
--text-muted: rgba(255,255,255,0.48);
--body-bg: #f4f5f7;
--card-bg: #ffffff;
--card-border: #e4e8ee;
--text-dark: #1e293b;
--text-mid: #64748b;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.07), 0 1px 2px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
--danger: #dc2626;
--radius: 10px;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
body { background: var(--body-bg); color: var(--text-dark); font-family: var(--font); }
/* App theme */
:root {
--app-primary: #0ea5e9;
--app-primary-light: #38bdf8;
}
*, *::before, *::after { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; font-size: 14px; }
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--card-border); border-radius: 2px; }
/* ── Reception display (full-screen, public, no chrome) ─────────────────── */
.wx-display {
height: 100vh;
width: 100vw;
overflow: hidden;
background: linear-gradient(160deg, var(--navy-dark) 0%, var(--navy) 60%, #14304a 100%);
color: var(--text);
display: flex;
flex-direction: column;
padding: clamp(16px, 3vh, 40px) clamp(20px, 4vw, 56px);
font-size: clamp(14px, 1.1vw, 18px);
}
.wx-display-header {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: clamp(12px, 2vh, 28px);
flex-wrap: wrap;
gap: 12px;
}
.wx-clock { font-size: clamp(2.2rem, 5vw, 4rem); font-weight: 700; line-height: 1; letter-spacing: -0.02em; }
.wx-date { color: var(--text-muted); font-size: clamp(1rem, 1.6vw, 1.4rem); margin-top: 4px; }
.wx-hotel-name { color: var(--gold); font-size: clamp(1.1rem, 1.8vw, 1.6rem); font-weight: 600; letter-spacing: .03em; text-transform: uppercase; }
.wx-now {
display: flex;
align-items: center;
gap: clamp(12px, 2vw, 28px);
margin-bottom: clamp(16px, 3vh, 32px);
}
.wx-now-temp { font-size: clamp(3.2rem, 7vw, 6rem); font-weight: 700; line-height: 1; }
.wx-now-meta { display: flex; flex-direction: column; gap: 4px; color: var(--text-muted); font-size: clamp(0.9rem, 1.2vw, 1.15rem); }
.wx-section-label {
font-size: clamp(0.75rem, 1vw, 0.95rem);
font-weight: 700;
text-transform: uppercase;
letter-spacing: .08em;
color: var(--text-muted);
margin-bottom: clamp(8px, 1.2vh, 14px);
}
/* Today's hourly course */
.wx-hourly-row {
display: flex;
gap: clamp(6px, 1vw, 14px);
overflow-x: auto;
padding-bottom: 4px;
flex: 0 0 auto;
}
.wx-hour-card {
flex: 1 0 64px;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: clamp(8px, 1vh, 14px) 4px;
border-radius: var(--radius);
background: var(--surface);
}
.wx-hour-card.now { background: rgba(201,168,76,0.16); border: 1px solid rgba(201,168,76,0.4); }
.wx-hour-time { font-size: clamp(0.75rem, 0.9vw, 0.95rem); color: var(--text-muted); }
.wx-hour-temp { font-size: clamp(0.95rem, 1.1vw, 1.2rem); font-weight: 600; }
.wx-hour-precip { font-size: clamp(0.65rem, 0.8vw, 0.8rem); color: #7dd3fc; }
/* Next 2 days — 3-hourly */
.wx-next-days {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: clamp(12px, 1.6vw, 24px);
margin: clamp(16px, 2.5vh, 28px) 0;
}
.wx-next-day-card {
background: var(--surface);
border-radius: var(--radius);
padding: clamp(10px, 1.4vh, 18px);
}
.wx-next-day-title { font-weight: 700; font-size: clamp(0.95rem, 1.2vw, 1.2rem); margin-bottom: 8px; }
.wx-next-day-points { display: flex; gap: clamp(4px, 0.8vw, 10px); overflow-x: auto; }
.wx-point { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1 0 52px; }
.wx-point-time { font-size: clamp(0.65rem, 0.8vw, 0.8rem); color: var(--text-muted); }
.wx-point-temp { font-size: clamp(0.85rem, 1vw, 1.05rem); font-weight: 600; }
/* Days 4-7 — plain daily row */
.wx-daily-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: clamp(10px, 1.4vw, 20px);
margin-top: auto;
}
.wx-day-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: clamp(10px, 1.4vh, 16px) 6px;
border-radius: var(--radius);
background: var(--surface);
}
.wx-day-name { font-weight: 700; font-size: clamp(0.85rem, 1vw, 1.05rem); }
.wx-day-temps { display: flex; gap: 8px; font-size: clamp(0.8rem, 0.95vw, 1rem); }
.wx-day-max { font-weight: 600; }
.wx-day-min { color: var(--text-muted); }
.wx-updated { text-align: right; color: var(--text-muted); font-size: clamp(0.7rem, 0.8vw, 0.85rem); margin-top: 10px; }
.wx-fallback-screen {
height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 12px; background: var(--navy-dark); color: var(--text-muted);
}
/* ── 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; }
.wx-admin-panel h1 { font-size: 20px; margin: 0 0 4px; color: var(--text-dark); }
.wx-admin-sub { color: var(--text-mid); font-size: 13px; margin-bottom: 20px; }
.wx-status-row { display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-bottom: 1px solid var(--card-border); font-size: 13.5px; }
.wx-status-row:last-child { border-bottom: none; }
.wx-status-label { color: var(--text-mid); }
.wx-status-value { font-weight: 600; color: var(--text-dark); }
.wx-status-value.ok { color: #16a34a; }
.wx-status-value.err { color: var(--danger); }
.wx-admin-actions { display: flex; gap: 10px; margin-top: 16px; }
.wx-admin-link { color: var(--gold); text-decoration: none; font-weight: 500; }
.wx-admin-link:hover { text-decoration: underline; }
/* buttons — same convention as every other app */
.btn {
display: inline-flex; align-items: center; gap: 6px;
border: 1px solid var(--card-border); background: var(--card-bg); color: var(--text-dark);
border-radius: var(--radius); padding: 7px 14px; font-size: 13px; cursor: pointer;
font-family: var(--font); transition: background .12s, border-color .12s;
}
.btn:hover { border-color: var(--text-mid); }
.btn:disabled { opacity: .5; cursor: default; }
.btn-primary { background: var(--gold); border-color: var(--gold); color: var(--navy); font-weight: 600; }
.btn-primary:hover { background: var(--gold-light); border-color: var(--gold-light); }
.card {
background: var(--card-bg); border: 1px solid var(--card-border);
border-radius: var(--radius); box-shadow: var(--shadow-sm); padding: 14px 16px; margin-bottom: 10px;
}
.error-banner {
background: #fef2f2; border: 1px solid var(--danger); color: var(--danger);
border-radius: var(--radius); padding: 10px 14px; margin-bottom: 12px; font-size: 13px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.spin { animation: spin .75s linear infinite; }

View file

@ -0,0 +1,31 @@
import {
Sun, Moon, CloudSun, CloudMoon, Cloud, Cloudy,
CloudFog, CloudDrizzle, CloudRain, CloudRainWind,
Snowflake, CloudSnow, CloudLightning,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import type { IconKey } from '../types'
// Mirrors backend/src/lib/icons.js's ICON_KEYS — keep in sync.
export const ICON_COMPONENTS: Record<IconKey, LucideIcon> = {
'clear-day': Sun,
'clear-night': Moon,
'partly-cloudy-day': CloudSun,
'partly-cloudy-night': CloudMoon,
cloudy: Cloud,
overcast: Cloudy,
fog: CloudFog,
drizzle: CloudDrizzle,
'rain-light': CloudRain,
'rain-heavy': CloudRainWind,
sleet: CloudRainWind,
hail: Snowflake,
'snow-light': CloudSnow,
'snow-heavy': CloudSnow,
thunder: CloudLightning,
}
export function WeatherIcon({ icon, size = 24, color }: { icon: IconKey; size?: number; color?: string }) {
const Icon = ICON_COMPONENTS[icon] ?? Cloud
return <Icon size={size} strokeWidth={1.75} color={color} />
}

17
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,17 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
if (new URLSearchParams(window.location.search).has('install')) {
window.addEventListener('beforeinstallprompt', e => {
e.preventDefault()
;(e as Event & { prompt: () => Promise<void> }).prompt()
}, { once: true })
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

View file

@ -0,0 +1,100 @@
import { useEffect, useState } from 'react'
import { RefreshCw, ExternalLink } from 'lucide-react'
import { useAuth } from '../components/AuthGate'
import { fetchAdminStatus, triggerRefresh } from '../api'
import { can } from '../types'
import type { AdminStatus } from '../types'
// The app's config (hotel location, Met Office API key) lives centrally in the
// shared `settings` app — this page only shows fetch status and lets a settings-
// capable user force an immediate refresh, it doesn't duplicate that config form.
export default function Admin() {
const { user } = useAuth()
const [status, setStatus] = useState<AdminStatus | null>(null)
const [error, setError] = useState('')
const [refreshing, setRefreshing] = useState(false)
const canManage = can(user, 'settings')
async function load() {
try {
setStatus(await fetchAdminStatus())
setError('')
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load status')
}
}
useEffect(() => { load() }, [])
async function refresh() {
setRefreshing(true)
setError('')
try {
await triggerRefresh()
await load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Refresh failed')
} finally {
setRefreshing(false)
}
}
return (
<div className="wx-admin-shell">
<div className="wx-admin-panel">
<h1>Weather</h1>
<p className="wx-admin-sub">
Reception forecast display — signed in as {user.name}.{' '}
<a className="wx-admin-link" href="/weather/" target="_blank" rel="noreferrer">
Open the display <ExternalLink size={12} strokeWidth={1.75} style={{ verticalAlign: '-1px' }} />
</a>
</p>
{error && <div className="error-banner">{error}</div>}
<div className="card">
<div className="wx-status-row">
<span className="wx-status-label">Last fetch</span>
<span className="wx-status-value">
{status?.last_fetch_at ? new Date(status.last_fetch_at).toLocaleString('en-GB', { timeZone: 'Europe/London' }) : '—'}
</span>
</div>
<div className="wx-status-row">
<span className="wx-status-label">Provider</span>
<span className="wx-status-value">{status?.last_provider ?? '—'}</span>
</div>
<div className="wx-status-row">
<span className="wx-status-label">Status</span>
<span className={`wx-status-value ${status?.last_error ? 'err' : 'ok'}`}>
{status?.last_error ?? 'OK'}
</span>
</div>
<div className="wx-status-row">
<span className="wx-status-label">Points stored</span>
<span className="wx-status-value">
{status ? `${status.point_counts.hourly} hourly · ${status.point_counts.threeHourly} 3-hourly · ${status.point_counts.daily} daily` : '—'}
</span>
</div>
</div>
{!status?.last_fetch_at && !error && (
<div className="error-banner">
No forecast fetched yet — check that <strong>hotel_location</strong> is configured in
Settings → Integrations. Met Office is optional; Open-Meteo is used automatically if it's
not configured.
</div>
)}
{canManage && (
<div className="wx-admin-actions">
<button className="btn btn-primary" onClick={refresh} disabled={refreshing}>
<RefreshCw size={14} strokeWidth={1.75} className={refreshing ? 'spin' : undefined} />
{refreshing ? 'Refreshing…' : 'Refresh now'}
</button>
</div>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,178 @@
import { useEffect, useRef, useState } from 'react'
import { CloudOff } from 'lucide-react'
import { fetchForecast } from '../api'
import { WeatherIcon } from '../lib/icons'
import type { ForecastPayload, ForecastPoint } 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 formatDayName(iso: string): string {
return new Date(iso).toLocaleDateString('en-GB', { timeZone: TZ, weekday: 'long' })
}
function roundTemp(c: number): string {
return `${Math.round(c)}°`
}
export default function Display() {
const [data, setData] = useState<ForecastPayload | null>(null)
const [error, setError] = useState(false)
const [clock, setClock] = useState(() => londonNow())
const hotelName = import.meta.env.VITE_HOTEL_NAME || 'Hotel'
const hourlyRowRef = useRef<HTMLDivElement>(null)
useEffect(() => {
let cancelled = false
async function load() {
try {
const payload = await fetchForecast()
if (!cancelled) { setData(payload); setError(false) }
} catch {
if (!cancelled) setError(true)
}
}
load()
const interval = setInterval(load, POLL_MS)
return () => { cancelled = true; clearInterval(interval) }
}, [])
useEffect(() => {
const t = setInterval(() => setClock(londonNow()), 30_000)
return () => clearInterval(t)
}, [])
useEffect(() => {
// Bring "now" into view on the hourly strip once data first arrives.
const el = hourlyRowRef.current?.querySelector('.now') as HTMLElement | null
el?.scrollIntoView({ inline: 'center', block: 'nearest' })
}, [data])
if (error && !data) {
return (
<div className="wx-fallback-screen">
<CloudOff size={48} strokeWidth={1.5} />
<p>Forecast unavailable — check back shortly.</p>
</div>
)
}
if (!data) {
return <div className="wx-fallback-screen">Loading forecast…</div>
}
const nowHour = clock.getHours()
const currentPoint = data.hourly.find(p => hourOf(p.ts) === nowHour) ?? data.hourly[0]
const dayKeys = [...new Set(data.threeHourly.map(p => dateKeyOf(p.ts)))].slice(0, 2)
const nextDayGroups = dayKeys.map(key => ({
key,
points: data.threeHourly.filter(p => dateKeyOf(p.ts) === key),
}))
const updatedAgo = data.meta.fetched_at ? minutesAgo(data.meta.fetched_at) : null
return (
<div className="wx-display">
<div className="wx-display-header">
<div>
<div className="wx-clock">
{clock.toLocaleTimeString('en-GB', { timeZone: TZ, hour: '2-digit', minute: '2-digit' })}
</div>
<div className="wx-date">
{clock.toLocaleDateString('en-GB', { timeZone: TZ, weekday: 'long', day: 'numeric', month: 'long' })}
</div>
</div>
<div className="wx-hotel-name">{hotelName}</div>
</div>
{currentPoint && (
<div className="wx-now">
<WeatherIcon icon={currentPoint.icon} size={72} />
<div className="wx-now-temp">{roundTemp(currentPoint.temp_c)}</div>
<div className="wx-now-meta">
{currentPoint.feels_like_c != null && <span>Feels like {roundTemp(currentPoint.feels_like_c)}</span>}
{currentPoint.precip_prob != null && <span>{Math.round(currentPoint.precip_prob)}% chance of rain</span>}
</div>
</div>
)}
<div className="wx-section-label">Today</div>
<div className="wx-hourly-row" ref={hourlyRowRef}>
{data.hourly.map(p => (
<HourCard key={p.ts} point={p} isNow={hourOf(p.ts) === nowHour} />
))}
</div>
{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-title">{formatDayName(g.points[0].ts)}</div>
<div className="wx-next-day-points">
{g.points.map(p => (
<div className="wx-point" key={p.ts}>
<span className="wx-point-time">{formatHour(p.ts)}</span>
<WeatherIcon icon={p.icon} size={26} />
<span className="wx-point-temp">{roundTemp(p.temp_c)}</span>
</div>
))}
</div>
</div>
))}
</div>
)}
<div className="wx-section-label">Coming up</div>
<div className="wx-daily-row">
{data.daily.map(p => (
<div className="wx-day-card" key={p.ts}>
<span className="wx-day-name">{formatDayName(p.ts)}</span>
<WeatherIcon icon={p.icon} size={32} />
<span className="wx-day-temps">
<span className="wx-day-max">{roundTemp(p.temp_c)}</span>
{p.temp_min_c != null && <span className="wx-day-min">{roundTemp(p.temp_min_c)}</span>}
</span>
</div>
))}
</div>
{updatedAgo != null && (
<div className="wx-updated">
Updated {updatedAgo < 1 ? 'just now' : `${updatedAgo}m ago`}
{error ? ' — showing last known forecast' : ''}
</div>
)}
</div>
)
}
function HourCard({ point, isNow }: { point: ForecastPoint; isNow: boolean }) {
return (
<div className={`wx-hour-card${isNow ? ' now' : ''}`}>
<span className="wx-hour-time">{isNow ? 'Now' : formatHour(point.ts)}</span>
<WeatherIcon icon={point.icon} size={26} />
<span className="wx-hour-temp">{roundTemp(point.temp_c)}</span>
{point.precip_prob != null && point.precip_prob >= 20 && (
<span className="wx-hour-precip">{Math.round(point.precip_prob)}%</span>
)}
</div>
)
}
function minutesAgo(iso: string): number {
return Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 60_000))
}

13
frontend/src/sw.js Normal file
View file

@ -0,0 +1,13 @@
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching'
import { NavigationRoute, registerRoute } from 'workbox-routing'
precacheAndRoute(self.__WB_MANIFEST)
// SPA fallback: navigate requests that don't match a cached asset serve index.html.
// No push-notification handlers here — unlike tasks/wages, this app has nothing to
// push (the reception display just polls; the admin page has no reminders).
registerRoute(
new NavigationRoute(createHandlerBoundToURL('/weather/index.html'), {
denylist: [/\/api\//],
})
)

47
frontend/src/types.ts Normal file
View file

@ -0,0 +1,47 @@
export type IconKey =
| 'clear-day' | 'clear-night'
| 'partly-cloudy-day' | 'partly-cloudy-night'
| 'cloudy' | 'overcast' | 'fog'
| 'drizzle' | 'rain-light' | 'rain-heavy'
| 'sleet' | 'hail'
| 'snow-light' | 'snow-heavy'
| 'thunder'
export interface ForecastPoint {
ts: string
temp_c: number
temp_min_c: number | null
feels_like_c: number | null
precip_prob: number | null
wind_speed_mph: number | null
icon: IconKey
}
export interface ForecastPayload {
hourly: ForecastPoint[] // today, full day
threeHourly: ForecastPoint[] // next 2 days
daily: ForecastPoint[] // days 4-7
meta: {
fetched_at: string | null
provider: 'met_office' | 'open_meteo' | null
}
}
export interface AdminStatus {
last_fetch_at: string | null
last_provider: string | null
last_error: string | null
point_counts: { hourly: number; threeHourly: number; daily: number }
}
export interface User {
user_id: number
name: string
email: string
is_admin: boolean
caps: string[]
}
export function can(user: User, cap: string): boolean {
return user.is_admin || user.caps.includes(cap)
}

9
frontend/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_HOTEL_NAME: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}