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:
commit
f977a5778f
36 changed files with 8125 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
7
backend/Dockerfile
Normal file
7
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
FROM node:22-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json .
|
||||||
|
RUN npm install --omit=dev
|
||||||
|
COPY src ./src
|
||||||
|
EXPOSE 3001
|
||||||
|
CMD ["node", "src/index.js"]
|
||||||
16
backend/package.json
Normal file
16
backend/package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"name": "hnf-weather-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/index.js",
|
||||||
|
"dev": "node --watch src/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cookie": "^9.4.0",
|
||||||
|
"@fastify/cors": "^9.0.1",
|
||||||
|
"fastify": "^4.28.1",
|
||||||
|
"jose": "^5.9.6",
|
||||||
|
"pg": "^8.13.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
59
backend/src/auth.js
Normal file
59
backend/src/auth.js
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { jwtVerify } from 'jose'
|
||||||
|
import { isOnsite } from './ip-check.js'
|
||||||
|
|
||||||
|
const APP_SLUG = process.env.APP_SLUG || 'weather'
|
||||||
|
const secret = new TextEncoder().encode(process.env.CENTRAL_AUTH_SECRET || '')
|
||||||
|
|
||||||
|
// Only the /admin surface uses this — the reception display itself is deliberately
|
||||||
|
// public (see routes/forecast.js's publicForecastRoutes), so this only gates the
|
||||||
|
// staff status/refresh page.
|
||||||
|
export async function requireAuth(request, reply) {
|
||||||
|
const token = request.cookies?.hnf_session
|
||||||
|
if (!token) return reply.status(401).send({ error: 'Not authenticated' })
|
||||||
|
|
||||||
|
let payload
|
||||||
|
try {
|
||||||
|
const { payload: p } = await jwtVerify(token, secret)
|
||||||
|
payload = p
|
||||||
|
} catch {
|
||||||
|
return reply.status(401).send({ error: 'Invalid session' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload.apps?.includes(APP_SLUG)) {
|
||||||
|
return reply.status(403).send({ error: 'No permission for this app' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload.offsite_allowed) {
|
||||||
|
const clientIP = request.headers['x-real-ip'] || request.ip
|
||||||
|
if (!(await isOnsite(clientIP))) {
|
||||||
|
return reply.status(403).send({ error: 'Access restricted to site network' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = `${APP_SLUG}:`
|
||||||
|
let caps
|
||||||
|
if (Array.isArray(payload.caps)) {
|
||||||
|
caps = payload.caps.filter(c => c.startsWith(prefix)).map(c => c.slice(prefix.length))
|
||||||
|
} else {
|
||||||
|
caps = ['use']
|
||||||
|
}
|
||||||
|
|
||||||
|
request.user = {
|
||||||
|
email: payload.sub,
|
||||||
|
name: payload.name,
|
||||||
|
is_admin: payload.is_admin ?? false,
|
||||||
|
caps,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasCap(request, cap) {
|
||||||
|
return request.user?.is_admin === true || request.user?.caps?.includes(cap) === true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireCap(cap) {
|
||||||
|
return async (request, reply) => {
|
||||||
|
if (!hasCap(request, cap)) {
|
||||||
|
return reply.status(403).send({ error: `Missing capability: ${cap}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
backend/src/db.js
Normal file
54
backend/src/db.js
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import pg from 'pg'
|
||||||
|
|
||||||
|
const { Pool } = pg
|
||||||
|
export const pool = new Pool({ connectionString: process.env.DATABASE_URL })
|
||||||
|
|
||||||
|
export async function initDb() {
|
||||||
|
await pool.query(`
|
||||||
|
-- App-wide key/value status (last fetch time/provider/error) — same KV pattern as
|
||||||
|
-- tasks' config table, just used for status rather than user-facing settings since
|
||||||
|
-- the real settings (hotel_location, met_office key) live centrally in the
|
||||||
|
-- shared 'settings' app, not here.
|
||||||
|
CREATE TABLE IF NOT EXISTS status (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value JSONB NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- One row per forecast data point. 'resolution' is which display tier it belongs
|
||||||
|
-- to: 'hourly' (today, full day), '3hourly' (next 2 days), 'daily' (days 4-7).
|
||||||
|
-- temp_min_c only populated for 'daily' rows (hourly/3hourly are point-in-time).
|
||||||
|
-- Each refresh cycle replaces the full set for a resolution in one transaction
|
||||||
|
-- (see lib/weather-fetch.js) rather than patching individual rows, so stale points
|
||||||
|
-- from a provider that later drops out of range never linger.
|
||||||
|
CREATE TABLE IF NOT EXISTS forecast_points (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
resolution TEXT NOT NULL,
|
||||||
|
ts TIMESTAMPTZ NOT NULL,
|
||||||
|
temp_c NUMERIC NOT NULL,
|
||||||
|
temp_min_c NUMERIC,
|
||||||
|
feels_like_c NUMERIC,
|
||||||
|
precip_prob NUMERIC,
|
||||||
|
wind_speed_mph NUMERIC,
|
||||||
|
icon TEXT NOT NULL,
|
||||||
|
condition_text TEXT,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (resolution, ts)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS forecast_points_res_ts_idx ON forecast_points (resolution, ts);
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStatus() {
|
||||||
|
const { rows } = await pool.query('SELECT key, value FROM status')
|
||||||
|
return Object.fromEntries(rows.map(r => [r.key, r.value]))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setStatus(key, value) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO status (key, value, updated_at) VALUES ($1, $2, NOW())
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`,
|
||||||
|
[key, JSON.stringify(value)]
|
||||||
|
)
|
||||||
|
}
|
||||||
30
backend/src/index.js
Normal file
30
backend/src/index.js
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import Fastify from 'fastify'
|
||||||
|
import cookie from '@fastify/cookie'
|
||||||
|
import cors from '@fastify/cors'
|
||||||
|
import { initDb } from './db.js'
|
||||||
|
import { startScheduler } from './lib/scheduler.js'
|
||||||
|
import { publicForecastRoutes, internalForecastRoutes, adminForecastRoutes } from './routes/forecast.js'
|
||||||
|
|
||||||
|
const app = Fastify({ logger: true, trustProxy: true })
|
||||||
|
const startedAt = Date.now()
|
||||||
|
|
||||||
|
await app.register(cookie)
|
||||||
|
await app.register(cors, {
|
||||||
|
origin: process.env.CORS_ORIGIN || false,
|
||||||
|
credentials: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/health', async () => ({ status: 'healthy', version: process.env.BUILD_VERSION || String(startedAt) }))
|
||||||
|
|
||||||
|
await app.register(publicForecastRoutes)
|
||||||
|
await app.register(internalForecastRoutes)
|
||||||
|
await app.register(adminForecastRoutes)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await initDb()
|
||||||
|
startScheduler(app)
|
||||||
|
await app.listen({ port: 3001, host: '0.0.0.0' })
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error(err)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
80
backend/src/ip-check.js
Normal file
80
backend/src/ip-check.js
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import dns from 'dns/promises'
|
||||||
|
|
||||||
|
const raw = (process.env.OFFICE_IP_CHECK || 'disabled').trim()
|
||||||
|
const matchers = raw.split(',').map(s => s.trim()).filter(Boolean)
|
||||||
|
|
||||||
|
const TTL = 5 * 60 * 1000
|
||||||
|
const cache = new Map()
|
||||||
|
|
||||||
|
const PUBLIC_IP_URLS = [
|
||||||
|
'https://api.ipify.org',
|
||||||
|
'https://ifconfig.co/ip',
|
||||||
|
'https://icanhazip.com',
|
||||||
|
]
|
||||||
|
|
||||||
|
function normalizeIP(ip) {
|
||||||
|
return ip?.startsWith('::ffff:') ? ip.slice(7) : ip
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIPv4(s) {
|
||||||
|
return /^\d{1,3}(\.\d{1,3}){3}$/.test(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ipInCidr(ip, cidr) {
|
||||||
|
const [range, bits] = cidr.split('/')
|
||||||
|
if (!isIPv4(ip) || !isIPv4(range)) return false
|
||||||
|
const mask = ~(2 ** (32 - parseInt(bits)) - 1) >>> 0
|
||||||
|
const toInt = s => s.split('.').reduce((a, o) => (a << 8) + parseInt(o), 0) >>> 0
|
||||||
|
return (toInt(ip) & mask) === (toInt(range) & mask)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPublicIP() {
|
||||||
|
for (const url of PUBLIC_IP_URLS) {
|
||||||
|
try {
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
const timer = setTimeout(() => ctrl.abort(), 4000)
|
||||||
|
const res = await fetch(url, { signal: ctrl.signal })
|
||||||
|
clearTimeout(timer)
|
||||||
|
if (!res.ok) continue
|
||||||
|
const ip = (await res.text()).trim()
|
||||||
|
if (isIPv4(ip)) return ip
|
||||||
|
} catch {
|
||||||
|
// try next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveDynamic(key, resolver) {
|
||||||
|
const hit = cache.get(key)
|
||||||
|
if (hit && Date.now() < hit.expiry) return hit.ip
|
||||||
|
const ip = await resolver()
|
||||||
|
if (ip) {
|
||||||
|
cache.set(key, { ip, expiry: Date.now() + TTL })
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
return hit ? hit.ip : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isOnsite(requestIP) {
|
||||||
|
if (matchers.length === 0 || matchers.includes('disabled')) return true
|
||||||
|
const ip = normalizeIP(requestIP)
|
||||||
|
if (!ip) return false
|
||||||
|
|
||||||
|
for (const m of matchers) {
|
||||||
|
if (m === 'auto') {
|
||||||
|
const pub = await resolveDynamic('auto', fetchPublicIP)
|
||||||
|
if (pub && ip === pub) return true
|
||||||
|
} else if (m.includes('/')) {
|
||||||
|
if (ipInCidr(ip, m)) return true
|
||||||
|
} else if (/[a-zA-Z]/.test(m)) {
|
||||||
|
const resolved = await resolveDynamic(m, async () => {
|
||||||
|
try { return (await dns.resolve4(m))[0] } catch { return null }
|
||||||
|
})
|
||||||
|
if (resolved && ip === resolved) return true
|
||||||
|
} else {
|
||||||
|
if (ip === m) return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
58
backend/src/lib/icons.js
Normal file
58
backend/src/lib/icons.js
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
// Normalizes each provider's own weather-condition code into one shared vocabulary
|
||||||
|
// so routes/frontend never need to know which provider a point came from. Frontend
|
||||||
|
// maps these keys to Lucide icons (see frontend/src/lib/icons.tsx) — keep the two
|
||||||
|
// vocabularies in sync if you add a key here.
|
||||||
|
export const ICON_KEYS = [
|
||||||
|
'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',
|
||||||
|
]
|
||||||
|
|
||||||
|
// Met Office DataHub "significantWeatherCode" table (0-30). Documented at
|
||||||
|
// https://datahub.metoffice.gov.uk — day/night only differentiate for the
|
||||||
|
// clear/partly-cloudy/shower codes; everything else (mist, rain, snow, thunder...)
|
||||||
|
// is a single code regardless of time of day. Same code table across the hourly,
|
||||||
|
// three-hourly and daily endpoints.
|
||||||
|
const MET_OFFICE_MAP = {
|
||||||
|
0: 'clear-night', 1: 'clear-day',
|
||||||
|
2: 'partly-cloudy-night', 3: 'partly-cloudy-day',
|
||||||
|
5: 'fog', 6: 'fog',
|
||||||
|
7: 'cloudy', 8: 'overcast',
|
||||||
|
9: 'rain-light', 10: 'rain-light', 11: 'drizzle', 12: 'rain-light',
|
||||||
|
13: 'rain-heavy', 14: 'rain-heavy', 15: 'rain-heavy',
|
||||||
|
16: 'sleet', 17: 'sleet', 18: 'sleet',
|
||||||
|
19: 'hail', 20: 'hail', 21: 'hail',
|
||||||
|
22: 'snow-light', 23: 'snow-light', 24: 'snow-light',
|
||||||
|
25: 'snow-heavy', 26: 'snow-heavy', 27: 'snow-heavy',
|
||||||
|
28: 'thunder', 29: 'thunder', 30: 'thunder',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function iconFromMetOffice(code) {
|
||||||
|
return MET_OFFICE_MAP[code] ?? 'cloudy'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open-Meteo uses the WMO weather-interpretation code table (0-99), which is
|
||||||
|
// time-of-day agnostic — pass the point's local hour so clear/partly-cloudy can
|
||||||
|
// still pick a day/night variant. hour outside [6,20) is treated as night; no
|
||||||
|
// real sunrise/sunset calc, close enough for an icon choice.
|
||||||
|
export function iconFromOpenMeteo(code, hour) {
|
||||||
|
const isNight = hour < 6 || hour >= 20
|
||||||
|
if (code === 0) return isNight ? 'clear-night' : 'clear-day'
|
||||||
|
if (code === 1 || code === 2) return isNight ? 'partly-cloudy-night' : 'partly-cloudy-day'
|
||||||
|
if (code === 3) return 'overcast'
|
||||||
|
if (code === 45 || code === 48) return 'fog'
|
||||||
|
if (code === 51 || code === 53 || code === 55) return 'drizzle'
|
||||||
|
if (code === 56 || code === 57) return 'sleet'
|
||||||
|
if (code === 61 || code === 63 || code === 80) return 'rain-light'
|
||||||
|
if (code === 65 || code === 81 || code === 82) return 'rain-heavy'
|
||||||
|
if (code === 66 || code === 67) return 'sleet'
|
||||||
|
if (code === 71 || code === 73 || code === 77 || code === 85) return 'snow-light'
|
||||||
|
if (code === 75 || code === 86) return 'snow-heavy'
|
||||||
|
if (code === 96 || code === 99) return 'thunder'
|
||||||
|
if (code === 95) return 'thunder'
|
||||||
|
return 'cloudy'
|
||||||
|
}
|
||||||
10
backend/src/lib/scheduler.js
Normal file
10
backend/src/lib/scheduler.js
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { refreshForecast } from './weather-fetch.js'
|
||||||
|
|
||||||
|
// "Updates hourly" per the spec — forecasts don't move fast enough to justify polling
|
||||||
|
// more often, and it keeps both providers well inside any rate limit.
|
||||||
|
const REFRESH_INTERVAL_MS = 60 * 60 * 1000
|
||||||
|
|
||||||
|
export function startScheduler(app) {
|
||||||
|
refreshForecast(app.log).catch(err => app.log.error(err))
|
||||||
|
setInterval(() => refreshForecast(app.log).catch(err => app.log.error(err)), REFRESH_INTERVAL_MS)
|
||||||
|
}
|
||||||
268
backend/src/lib/weather-fetch.js
Normal file
268
backend/src/lib/weather-fetch.js
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
// Fetches the reception forecast (today hourly, next-2-days 3-hourly, days 4-7 daily)
|
||||||
|
// and stores it in forecast_points. Reuses the same settings-app integrations hvac
|
||||||
|
// already uses (hotel_location + met_office, via SETTINGS_URL/SETTINGS_SECRET) rather
|
||||||
|
// than asking for its own copy of the credentials — see lib/weather.js in hvac.
|
||||||
|
//
|
||||||
|
// Provider selection: Met Office DataHub Global Spot if an api_key is configured and
|
||||||
|
// the integration is enabled; otherwise, or if any Met Office call fails, Open-Meteo
|
||||||
|
// (no key required). Same fail-soft posture as hvac's getPeakTempC() — never throw
|
||||||
|
// out to the scheduler, just record the error in `status` and keep serving whatever
|
||||||
|
// was fetched last time.
|
||||||
|
//
|
||||||
|
// IMPORTANT: only the daily endpoint (sitespecific/v0/point/daily) has been exercised
|
||||||
|
// against a live Met Office key so far (hvac, 2026-08-25 — see project_hvac memory's
|
||||||
|
// "Live subscription mismatch" note). The hourly and three-hourly endpoint paths and
|
||||||
|
// field names below are taken from the published Met Office DataHub Site Specific
|
||||||
|
// Forecast API docs but NOT yet verified live — if either 403s or comes back with
|
||||||
|
// unexpected fields, it'll throw and this whole refresh degrades to Open-Meteo (see
|
||||||
|
// the catch in refreshForecast), so a doc mismatch here fails safe, it just won't use
|
||||||
|
// Met Office data until the field names are corrected against a real response.
|
||||||
|
import { pool, setStatus } from '../db.js'
|
||||||
|
import { iconFromMetOffice, iconFromOpenMeteo } from './icons.js'
|
||||||
|
|
||||||
|
const MET_OFFICE_BASE = 'https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point'
|
||||||
|
|
||||||
|
async function getIntegration(slug) {
|
||||||
|
const url = `${process.env.SETTINGS_URL}/settings/api/internal/integration/${slug}`
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${process.env.SETTINGS_SECRET}` },
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
})
|
||||||
|
if (res.status === 404 || res.status === 403) return null
|
||||||
|
if (!res.ok) throw new Error(`Settings service returned ${res.status} fetching ${slug}`)
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
function londonDateKey(isoTime) {
|
||||||
|
// en-CA gives YYYY-MM-DD, which sorts/compares correctly as a plain string.
|
||||||
|
return new Date(isoTime).toLocaleDateString('en-CA', { timeZone: 'Europe/London' })
|
||||||
|
}
|
||||||
|
function londonHour(isoTime) {
|
||||||
|
return parseInt(new Date(isoTime).toLocaleString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', hour12: false }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Met Office DataHub (Global Spot) ────────────────────────────────────────
|
||||||
|
async function fetchMetOfficeTimestep(timestep, lat, lon, apiKey) {
|
||||||
|
const res = await fetch(
|
||||||
|
`${MET_OFFICE_BASE}/${timestep}?latitude=${lat}&longitude=${lon}`,
|
||||||
|
{ headers: { apikey: apiKey }, signal: AbortSignal.timeout(8000) }
|
||||||
|
)
|
||||||
|
if (!res.ok) throw new Error(`Met Office DataHub (${timestep}) returned ${res.status}`)
|
||||||
|
const data = await res.json()
|
||||||
|
const series = data?.features?.[0]?.properties?.timeSeries
|
||||||
|
if (!Array.isArray(series)) throw new Error(`Met Office DataHub (${timestep}) response missing timeSeries`)
|
||||||
|
return series
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFromMetOffice(lat, lon, apiKey) {
|
||||||
|
const [hourly, threeHourly, daily] = await Promise.all([
|
||||||
|
fetchMetOfficeTimestep('hourly', lat, lon, apiKey),
|
||||||
|
fetchMetOfficeTimestep('three-hourly', lat, lon, apiKey),
|
||||||
|
fetchMetOfficeTimestep('daily', lat, lon, apiKey),
|
||||||
|
])
|
||||||
|
|
||||||
|
const today = londonDateKey(new Date().toISOString())
|
||||||
|
|
||||||
|
const hourlyPoints = hourly
|
||||||
|
.filter(p => londonDateKey(p.time) === today)
|
||||||
|
.map(p => ({
|
||||||
|
resolution: 'hourly',
|
||||||
|
ts: p.time,
|
||||||
|
temp_c: p.screenTemperature,
|
||||||
|
temp_min_c: null,
|
||||||
|
feels_like_c: p.feelsLikeTemperature ?? null,
|
||||||
|
precip_prob: p.probOfPrecipitation ?? null,
|
||||||
|
wind_speed_mph: p.windSpeed10m != null ? p.windSpeed10m * 2.237 : null, // m/s -> mph
|
||||||
|
icon: iconFromMetOffice(p.significantWeatherCode),
|
||||||
|
source: 'met_office',
|
||||||
|
}))
|
||||||
|
.filter(p => typeof p.temp_c === 'number')
|
||||||
|
|
||||||
|
// three-hourly covers today too; keep only the next two calendar days.
|
||||||
|
const dayKeys = [...new Set(threeHourly.map(p => londonDateKey(p.time)))].filter(d => d !== today).slice(0, 2)
|
||||||
|
const threeHourlyPoints = threeHourly
|
||||||
|
.filter(p => dayKeys.includes(londonDateKey(p.time)))
|
||||||
|
.map(p => ({
|
||||||
|
resolution: '3hourly',
|
||||||
|
ts: p.time,
|
||||||
|
temp_c: p.maxScreenAirTemp ?? p.screenTemperature,
|
||||||
|
temp_min_c: p.minScreenAirTemp ?? null,
|
||||||
|
feels_like_c: p.feelsLikeTemperature ?? null,
|
||||||
|
precip_prob: p.probOfPrecipitation ?? null,
|
||||||
|
wind_speed_mph: p.windSpeed10m != null ? p.windSpeed10m * 2.237 : null,
|
||||||
|
icon: iconFromMetOffice(p.significantWeatherCode),
|
||||||
|
source: 'met_office',
|
||||||
|
}))
|
||||||
|
.filter(p => typeof p.temp_c === 'number')
|
||||||
|
|
||||||
|
// daily covers ~7 days; skip today + the 2 days already covered by three-hourly, take next 4.
|
||||||
|
const skipKeys = new Set([today, ...dayKeys])
|
||||||
|
const dailyPoints = daily
|
||||||
|
.filter(p => !skipKeys.has(londonDateKey(p.time)))
|
||||||
|
.slice(0, 4)
|
||||||
|
.map(p => ({
|
||||||
|
resolution: 'daily',
|
||||||
|
ts: p.time,
|
||||||
|
temp_c: p.dayMaxScreenTemperature,
|
||||||
|
temp_min_c: p.nightMinScreenTemperature ?? null,
|
||||||
|
feels_like_c: null,
|
||||||
|
precip_prob: p.dayProbabilityOfPrecipitation ?? null,
|
||||||
|
wind_speed_mph: p.midday10MWindSpeed != null ? p.midday10MWindSpeed * 2.237 : null,
|
||||||
|
icon: iconFromMetOffice(p.daySignificantWeatherCode),
|
||||||
|
source: 'met_office',
|
||||||
|
}))
|
||||||
|
.filter(p => typeof p.temp_c === 'number')
|
||||||
|
|
||||||
|
return { hourlyPoints, threeHourlyPoints, dailyPoints }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Open-Meteo fallback (no key required) ───────────────────────────────────
|
||||||
|
async function fetchFromOpenMeteo(lat, lon) {
|
||||||
|
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}` +
|
||||||
|
`&hourly=temperature_2m,apparent_temperature,precipitation_probability,weathercode,windspeed_10m` +
|
||||||
|
`&daily=temperature_2m_max,temperature_2m_min,weathercode` +
|
||||||
|
`&timezone=Europe%2FLondon&forecast_days=8`
|
||||||
|
const res = await fetch(url, { signal: AbortSignal.timeout(8000) })
|
||||||
|
if (!res.ok) throw new Error(`Open-Meteo returned ${res.status}`)
|
||||||
|
const data = await res.json()
|
||||||
|
if (!data?.hourly?.time || !data?.daily?.time) throw new Error('Open-Meteo response missing hourly/daily blocks')
|
||||||
|
|
||||||
|
const today = londonDateKey(new Date().toISOString())
|
||||||
|
const hourlyRows = data.hourly.time.map((t, i) => ({
|
||||||
|
time: t,
|
||||||
|
dateKey: londonDateKey(t),
|
||||||
|
hour: londonHour(t),
|
||||||
|
temp: data.hourly.temperature_2m[i],
|
||||||
|
feelsLike: data.hourly.apparent_temperature?.[i] ?? null,
|
||||||
|
precipProb: data.hourly.precipitation_probability?.[i] ?? null,
|
||||||
|
code: data.hourly.weathercode[i],
|
||||||
|
wind: data.hourly.windspeed_10m?.[i] ?? null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const hourlyPoints = hourlyRows
|
||||||
|
.filter(r => r.dateKey === today)
|
||||||
|
.map(r => ({
|
||||||
|
resolution: 'hourly',
|
||||||
|
ts: r.time,
|
||||||
|
temp_c: r.temp,
|
||||||
|
temp_min_c: null,
|
||||||
|
feels_like_c: r.feelsLike,
|
||||||
|
precip_prob: r.precipProb,
|
||||||
|
wind_speed_mph: r.wind != null ? r.wind * 0.621 : null, // km/h -> mph
|
||||||
|
icon: iconFromOpenMeteo(r.code, r.hour),
|
||||||
|
source: 'open_meteo',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const nextDayKeys = [...new Set(hourlyRows.map(r => r.dateKey))].filter(d => d !== today).slice(0, 2)
|
||||||
|
const threeHourlyPoints = hourlyRows
|
||||||
|
.filter(r => nextDayKeys.includes(r.dateKey) && r.hour % 3 === 0)
|
||||||
|
.map(r => ({
|
||||||
|
resolution: '3hourly',
|
||||||
|
ts: r.time,
|
||||||
|
temp_c: r.temp,
|
||||||
|
temp_min_c: null,
|
||||||
|
feels_like_c: r.feelsLike,
|
||||||
|
precip_prob: r.precipProb,
|
||||||
|
wind_speed_mph: r.wind != null ? r.wind * 0.621 : null,
|
||||||
|
icon: iconFromOpenMeteo(r.code, r.hour),
|
||||||
|
source: 'open_meteo',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const skipKeys = new Set([today, ...nextDayKeys])
|
||||||
|
const dailyPoints = data.daily.time
|
||||||
|
.map((t, i) => ({
|
||||||
|
time: t,
|
||||||
|
dateKey: londonDateKey(`${t}T12:00:00`),
|
||||||
|
max: data.daily.temperature_2m_max[i],
|
||||||
|
min: data.daily.temperature_2m_min[i],
|
||||||
|
code: data.daily.weathercode[i],
|
||||||
|
}))
|
||||||
|
.filter(r => !skipKeys.has(r.dateKey))
|
||||||
|
.slice(0, 4)
|
||||||
|
.map(r => ({
|
||||||
|
resolution: 'daily',
|
||||||
|
ts: `${r.time}T12:00:00Z`,
|
||||||
|
temp_c: r.max,
|
||||||
|
temp_min_c: r.min,
|
||||||
|
feels_like_c: null,
|
||||||
|
precip_prob: null,
|
||||||
|
wind_speed_mph: null,
|
||||||
|
icon: iconFromOpenMeteo(r.code, 12),
|
||||||
|
source: 'open_meteo',
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { hourlyPoints, threeHourlyPoints, dailyPoints }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Storage ──────────────────────────────────────────────────────────────
|
||||||
|
async function replaceResolution(resolution, points) {
|
||||||
|
const client = await pool.connect()
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN')
|
||||||
|
await client.query('DELETE FROM forecast_points WHERE resolution = $1', [resolution])
|
||||||
|
for (const p of points) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO forecast_points
|
||||||
|
(resolution, ts, temp_c, temp_min_c, feels_like_c, precip_prob, wind_speed_mph, icon, source)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||||
|
ON CONFLICT (resolution, ts) DO UPDATE SET
|
||||||
|
temp_c = EXCLUDED.temp_c, temp_min_c = EXCLUDED.temp_min_c,
|
||||||
|
feels_like_c = EXCLUDED.feels_like_c, precip_prob = EXCLUDED.precip_prob,
|
||||||
|
wind_speed_mph = EXCLUDED.wind_speed_mph, icon = EXCLUDED.icon,
|
||||||
|
source = EXCLUDED.source, fetched_at = NOW()`,
|
||||||
|
[p.resolution, p.ts, p.temp_c, p.temp_min_c, p.feels_like_c, p.precip_prob, p.wind_speed_mph, p.icon, p.source]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
await client.query('COMMIT')
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK')
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
client.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshForecast() — the scheduler's hourly entrypoint. Never throws; records
|
||||||
|
// success/failure in `status` so the admin page can show it, same fail-safe posture
|
||||||
|
// as hvac's weather compensation (missing config or a dead provider just means the
|
||||||
|
// display keeps showing the last good fetch, never a blank/broken screen).
|
||||||
|
export async function refreshForecast(log = console) {
|
||||||
|
try {
|
||||||
|
const location = await getIntegration('hotel_location')
|
||||||
|
const lat = location?.latitude
|
||||||
|
const lon = location?.longitude
|
||||||
|
if (!lat || !lon) {
|
||||||
|
await setStatus('last_error', 'hotel_location not configured in settings')
|
||||||
|
log.warn?.('[weather] hotel_location not configured — skipping refresh')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const metOffice = await getIntegration('met_office')
|
||||||
|
let result, provider
|
||||||
|
if (metOffice?.api_key) {
|
||||||
|
try {
|
||||||
|
result = await fetchFromMetOffice(lat, lon, metOffice.api_key)
|
||||||
|
provider = 'met_office'
|
||||||
|
} catch (err) {
|
||||||
|
log.error?.(`[weather] Met Office fetch failed, falling back to Open-Meteo: ${err.message}`)
|
||||||
|
result = await fetchFromOpenMeteo(lat, lon)
|
||||||
|
provider = 'open_meteo'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result = await fetchFromOpenMeteo(lat, lon)
|
||||||
|
provider = 'open_meteo'
|
||||||
|
}
|
||||||
|
|
||||||
|
await replaceResolution('hourly', result.hourlyPoints)
|
||||||
|
await replaceResolution('3hourly', result.threeHourlyPoints)
|
||||||
|
await replaceResolution('daily', result.dailyPoints)
|
||||||
|
|
||||||
|
await setStatus('last_fetch_at', new Date().toISOString())
|
||||||
|
await setStatus('last_provider', provider)
|
||||||
|
await setStatus('last_error', null)
|
||||||
|
log.info?.(`[weather] refreshed via ${provider}: ${result.hourlyPoints.length} hourly, ${result.threeHourlyPoints.length} 3-hourly, ${result.dailyPoints.length} daily`)
|
||||||
|
} catch (err) {
|
||||||
|
await setStatus('last_error', err.message)
|
||||||
|
log.error?.(`[weather] refresh failed: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
78
backend/src/routes/forecast.js
Normal file
78
backend/src/routes/forecast.js
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { pool, getStatus } from '../db.js'
|
||||||
|
import { requireAuth, requireCap } from '../auth.js'
|
||||||
|
import { refreshForecast } from '../lib/weather-fetch.js'
|
||||||
|
|
||||||
|
function rowToPoint(r) {
|
||||||
|
return {
|
||||||
|
ts: r.ts,
|
||||||
|
temp_c: Number(r.temp_c),
|
||||||
|
temp_min_c: r.temp_min_c != null ? Number(r.temp_min_c) : null,
|
||||||
|
feels_like_c: r.feels_like_c != null ? Number(r.feels_like_c) : null,
|
||||||
|
precip_prob: r.precip_prob != null ? Number(r.precip_prob) : null,
|
||||||
|
wind_speed_mph: r.wind_speed_mph != null ? Number(r.wind_speed_mph) : null,
|
||||||
|
icon: r.icon,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildPayload() {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT * FROM forecast_points ORDER BY ts ASC`
|
||||||
|
)
|
||||||
|
const status = await getStatus()
|
||||||
|
return {
|
||||||
|
hourly: rows.filter(r => r.resolution === 'hourly').map(rowToPoint),
|
||||||
|
threeHourly: rows.filter(r => r.resolution === '3hourly').map(rowToPoint),
|
||||||
|
daily: rows.filter(r => r.resolution === 'daily').map(rowToPoint),
|
||||||
|
meta: {
|
||||||
|
fetched_at: status.last_fetch_at ?? null,
|
||||||
|
provider: status.last_provider ?? null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service-to-service — same bearer-secret convention as settings' /internal/*
|
||||||
|
// routes, for other apps (e.g. a future hvac refactor) to read the stored forecast
|
||||||
|
// instead of polling Met Office/Open-Meteo themselves.
|
||||||
|
export async function internalForecastRoutes(app) {
|
||||||
|
app.get('/api/internal/forecast', async (req, reply) => {
|
||||||
|
const token = (req.headers.authorization || '').replace(/^Bearer\s+/i, '')
|
||||||
|
if (!token || token !== process.env.WEATHER_SECRET) {
|
||||||
|
return reply.status(401).send({ error: 'Unauthorized' })
|
||||||
|
}
|
||||||
|
return buildPayload()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Staff-only status/refresh page — normal internal JWT auth, gated behind the
|
||||||
|
// 'settings' capability (see seed-app.js — same slug every app uses for "configure
|
||||||
|
// this app's own settings").
|
||||||
|
export async function adminForecastRoutes(app) {
|
||||||
|
app.get('/api/admin/status', { preHandler: [requireAuth, requireCap('settings')] }, async () => {
|
||||||
|
const status = await getStatus()
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT resolution, COUNT(*)::int AS count FROM forecast_points GROUP BY resolution`
|
||||||
|
)
|
||||||
|
const counts = Object.fromEntries(rows.map(r => [r.resolution, r.count]))
|
||||||
|
return {
|
||||||
|
last_fetch_at: status.last_fetch_at ?? null,
|
||||||
|
last_provider: status.last_provider ?? null,
|
||||||
|
last_error: status.last_error ?? null,
|
||||||
|
point_counts: { hourly: counts.hourly ?? 0, threeHourly: counts['3hourly'] ?? 0, daily: counts.daily ?? 0 },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/admin/refresh', { preHandler: [requireAuth, requireCap('settings')] }, async (req, reply) => {
|
||||||
|
await refreshForecast(app.log)
|
||||||
|
const status = await getStatus()
|
||||||
|
if (status.last_error) return reply.status(502).send({ error: status.last_error })
|
||||||
|
return { ok: true, last_fetch_at: status.last_fetch_at, provider: status.last_provider }
|
||||||
|
})
|
||||||
|
}
|
||||||
38
docker-compose.yml
Normal file
38
docker-compose.yml
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
security_opt:
|
||||||
|
- apparmor=unconfined
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
|
- CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
|
||||||
|
- SETTINGS_URL=${SETTINGS_URL}
|
||||||
|
- SETTINGS_SECRET=${SETTINGS_SECRET}
|
||||||
|
- WEATHER_SECRET=${WEATHER_SECRET}
|
||||||
|
- APP_SLUG=weather
|
||||||
|
- VITE_HOTEL_NAME=${VITE_HOTEL_NAME}
|
||||||
|
- OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-disabled}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3001/health || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
args:
|
||||||
|
VITE_HOTEL_NAME: ${VITE_HOTEL_NAME}
|
||||||
|
security_opt:
|
||||||
|
- apparmor=unconfined
|
||||||
|
ports:
|
||||||
|
- "${FRONTEND_PORT:-3080}:80"
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
driver: bridge
|
||||||
13
frontend/Dockerfile
Normal file
13
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json .
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
ARG VITE_HOTEL_NAME
|
||||||
|
ENV VITE_HOTEL_NAME=$VITE_HOTEL_NAME
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html/weather
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
18
frontend/index.html
Normal file
18
frontend/index.html
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Weather" />
|
||||||
|
<link rel="apple-touch-icon" href="/weather/icons/icon-192.png" />
|
||||||
|
<meta name="theme-color" content="#0ea5e9" />
|
||||||
|
<title>Weather</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
40
frontend/nginx.conf
Normal file
40
frontend/nginx.conf
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
client_max_body_size 12m;
|
||||||
|
|
||||||
|
location /weather/api/auth/ {
|
||||||
|
proxy_pass http://10.10.10.101:3001/api/auth/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /weather/api/ {
|
||||||
|
proxy_pass http://backend:3001/api/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
add_header Cache-Control "no-store";
|
||||||
|
}
|
||||||
|
|
||||||
|
location /weather/health {
|
||||||
|
proxy_pass http://backend:3001/health;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* /weather/.*\.(js|css|png|ico|svg|woff2?)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /weather/ {
|
||||||
|
add_header Cache-Control "no-cache" always;
|
||||||
|
try_files $uri /weather/index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = / {
|
||||||
|
return 301 /weather/;
|
||||||
|
}
|
||||||
|
}
|
||||||
6244
frontend/package-lock.json
generated
Normal file
6244
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
25
frontend/package.json
Normal file
25
frontend/package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"name": "hnf-weather-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.28.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.1",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^6.0.5",
|
||||||
|
"vite-plugin-pwa": "^1.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
frontend/public/icons/icon-192.png
Normal file
BIN
frontend/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
BIN
frontend/public/icons/icon-512.png
Normal file
BIN
frontend/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
29
frontend/src/App.tsx
Normal file
29
frontend/src/App.tsx
Normal 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
36
frontend/src/api.ts
Normal 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' })
|
||||||
|
}
|
||||||
165
frontend/src/components/AuthGate.tsx
Normal file
165
frontend/src/components/AuthGate.tsx
Normal 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',
|
||||||
|
}
|
||||||
89
frontend/src/components/IosInstallHint.tsx
Normal file
89
frontend/src/components/IosInstallHint.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
44
frontend/src/components/UpdateBanner.tsx
Normal file
44
frontend/src/components/UpdateBanner.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
43
frontend/src/hooks/useVersionCheck.ts
Normal file
43
frontend/src/hooks/useVersionCheck.ts
Normal 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
185
frontend/src/index.css
Normal 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; }
|
||||||
31
frontend/src/lib/icons.tsx
Normal file
31
frontend/src/lib/icons.tsx
Normal 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
17
frontend/src/main.tsx
Normal 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>
|
||||||
|
)
|
||||||
100
frontend/src/pages/Admin.tsx
Normal file
100
frontend/src/pages/Admin.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
178
frontend/src/pages/Display.tsx
Normal file
178
frontend/src/pages/Display.tsx
Normal 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
13
frontend/src/sw.js
Normal 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
47
frontend/src/types.ts
Normal 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
9
frontend/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_HOTEL_NAME: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
19
frontend/tsconfig.json
Normal file
19
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
34
frontend/vite.config.ts
Normal file
34
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
base: '/weather/',
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
VitePWA({
|
||||||
|
strategies: 'injectManifest',
|
||||||
|
srcDir: 'src',
|
||||||
|
filename: 'sw.js',
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
manifest: {
|
||||||
|
name: 'Weather',
|
||||||
|
short_name: 'Weather',
|
||||||
|
// Root, not /admin — installing this PWA is for the reception kiosk screen,
|
||||||
|
// so "open the app" should land straight on the public display.
|
||||||
|
start_url: '/weather/',
|
||||||
|
scope: '/weather/',
|
||||||
|
display: 'standalone',
|
||||||
|
theme_color: '#0ea5e9',
|
||||||
|
background_color: '#0ea5e9',
|
||||||
|
icons: [
|
||||||
|
{ src: '/weather/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||||
|
{ src: '/weather/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
injectManifest: {
|
||||||
|
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
44
seed-app.js
Normal file
44
seed-app.js
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// Run from weather/ dir: DATABASE_URL=... node seed-app.js
|
||||||
|
import pg from 'pg'
|
||||||
|
|
||||||
|
const { Pool } = pg
|
||||||
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
|
||||||
|
|
||||||
|
// base_path is '/weather/admin', not '/weather' — the portal card should take staff
|
||||||
|
// straight to the authed status/refresh page, not the public reception display at
|
||||||
|
// the app's bare root. Precedented by forecasting's 'history' sub-route (see
|
||||||
|
// project_weather memory / auth/src/db.js). internal_host/port ARE set (unlike
|
||||||
|
// that precedent) since weather is its own real deployment, LXC 131.
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO apps (slug, name, description, base_path, icon, theme_color, category, internal_host, internal_port)
|
||||||
|
VALUES ('weather', 'Weather', 'Reception forecast display — today hourly, next 2 days, 7-day outlook', '/weather/admin', 'CloudSun', '#0ea5e9', 'Hotel', '10.10.10.131', 3080)
|
||||||
|
ON CONFLICT (slug) DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
base_path = EXCLUDED.base_path,
|
||||||
|
icon = EXCLUDED.icon,
|
||||||
|
theme_color = EXCLUDED.theme_color,
|
||||||
|
category = EXCLUDED.category,
|
||||||
|
internal_host = EXCLUDED.internal_host,
|
||||||
|
internal_port = EXCLUDED.internal_port
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Single capability — the public display itself needs no capability at all (see
|
||||||
|
// routes/forecast.js's publicForecastRoutes). 'settings' gates the status/refresh
|
||||||
|
// admin page, same slug every app uses for "configure this app's own settings".
|
||||||
|
// Deliberately NOT auto-granted to Staff below (unlike tasks' 'use') — leave it for
|
||||||
|
// an admin to grant explicitly via the portal (Admin → Roles), since it's an
|
||||||
|
// admin-facing status page, not a general-use feature.
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO app_capabilities (app_id, slug, name, description, sort_order)
|
||||||
|
SELECT a.id, c.slug, c.name, c.description, c.sort_order
|
||||||
|
FROM apps a, (VALUES
|
||||||
|
('settings', 'Settings', 'View forecast fetch status and trigger a manual refresh', 1)
|
||||||
|
) AS c(slug, name, description, sort_order)
|
||||||
|
WHERE a.slug = 'weather'
|
||||||
|
ON CONFLICT (app_id, slug) DO NOTHING
|
||||||
|
`)
|
||||||
|
|
||||||
|
console.log('weather app seeded.')
|
||||||
|
await pool.end()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue