commit 276c04f8c6adf72ffb4bb0c7a662af616591535e Author: jtricerolph Date: Sun Jul 26 18:00:42 2026 +0000 Scaffold Phase 1 hvac app — NewBook-driven room TRV heating scheduler Ports the state machine, retry/backoff, and guest-override detection from the retired homeassistant-newbook-heating-component, without depending on Home Assistant. Backend (Fastify/pg) + frontend (React/Vite/TS) following standard stack conventions; LXC 128 (127 was already taken by utilities). MHI/Midea/Daikin/boiler drivers and the shared MQTT broker (LXC 104) are later phases/infra, not included here. Co-Authored-By: Claude Sonnet 5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c878b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.env +uploads/ +*.log diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..b4cc893 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,8 @@ +FROM node:22-alpine +WORKDIR /app +COPY package.json . +RUN npm install --omit=dev +COPY src ./src +RUN mkdir -p /app/uploads +EXPOSE 3001 +CMD ["node", "src/index.js"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..e8f2d57 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,21 @@ +{ + "name": "hnf-hvac-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/multipart": "^8.3.0", + "@fastify/static": "^7.0.4", + "fastify": "^4.28.1", + "jose": "^5.9.6", + "luxon": "^3.5.0", + "mqtt": "^5.10.3", + "pg": "^8.13.1", + "sharp": "^0.33.0" + } +} diff --git a/backend/src/auth.js b/backend/src/auth.js new file mode 100644 index 0000000..5ea5ef3 --- /dev/null +++ b/backend/src/auth.js @@ -0,0 +1,57 @@ +import { jwtVerify } from 'jose' +import { isOnsite } from './ip-check.js' + +const APP_SLUG = process.env.APP_SLUG || 'hvac' +const secret = new TextEncoder().encode(process.env.CENTRAL_AUTH_SECRET || '') + +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 { + // Legacy token — grant view-only until re-login + caps = ['view'] + } + + 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}` }) + } + } +} diff --git a/backend/src/db.js b/backend/src/db.js new file mode 100644 index 0000000..9dde17b --- /dev/null +++ b/backend/src/db.js @@ -0,0 +1,148 @@ +import pg from 'pg' + +const { Pool } = pg +export const pool = new Pool({ connectionString: process.env.DATABASE_URL }) + +export async function initDb() { + await pool.query(` + -- Generic key/value config store (hk-planner/maintenance pattern) — holds + -- excluded_site_ids, poll_interval_minutes, maintenance integration URL/key, etc. + CREATE TABLE IF NOT EXISTS config ( + key TEXT PRIMARY KEY, + value JSONB NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + -- Zones: rooms (synced from NewBook) and public areas (manual, Phase 3 control). + -- Single table for the whole device lifetime — Phase 2/3 aircon-specific columns + -- (cool setpoint, fan mode) land here later as nullable ADD COLUMNs, not a new table. + CREATE TABLE IF NOT EXISTS zones ( + id SERIAL PRIMARY KEY, + zone_type TEXT NOT NULL DEFAULT 'room', -- room | public_area + source TEXT NOT NULL DEFAULT 'manual', -- newbook | manual + newbook_site_id TEXT UNIQUE, + name TEXT NOT NULL, + active BOOLEAN NOT NULL DEFAULT TRUE, -- soft-deactivate on NewBook sync, never hard delete + occupied_temp NUMERIC(4,1) NOT NULL DEFAULT 22.0, + vacant_temp NUMERIC(4,1) NOT NULL DEFAULT 16.0, + heating_offset_min INT NOT NULL DEFAULT 120, -- minutes before arrival to start heating + cooling_offset_min INT NOT NULL DEFAULT -30, -- minutes after departure to stop (negative = before checkout) + auto_mode BOOLEAN NOT NULL DEFAULT TRUE, + sync_valves BOOLEAN NOT NULL DEFAULT TRUE, -- sync guest setpoint changes across valves in the same zone + exclude_bathroom BOOLEAN NOT NULL DEFAULT TRUE, -- bathroom valve excluded from guest-adjustment sync + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + -- Device mapping — one row per physical device, assigned to a zone by staff via + -- the Devices onboarding flow (never auto-mapped from device naming conventions). + CREATE TABLE IF NOT EXISTS zone_devices ( + id SERIAL PRIMARY KEY, + zone_id INT REFERENCES zones(id) ON DELETE SET NULL, + device_type TEXT NOT NULL, -- shelly_trv | mhi_modbus | midea | daikin | home_assistant + external_ref TEXT NOT NULL, -- MAC / Modbus address / LAN device ID / HA entity_id + discovered_name TEXT, -- raw name/label from the device — display-only, never parsed + location TEXT, -- bedroom / bathroom / living / free text, set by staff + maintenance_asset_id INT, -- set once "Create asset in Maintenance" succeeds (Phase 1: stub, always NULL) + last_seen TIMESTAMPTZ, + battery_pct INT, + health_state TEXT NOT NULL DEFAULT 'healthy', -- healthy | degraded | poor | unresponsive | calibration_error + wifi_rssi INT, + current_target_temp NUMERIC(4,1), + target_origin TEXT, -- automation | guest | pending | unknown (Shelly source field) + device_ip TEXT, -- for the HTTP wake-up fallback + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS zone_devices_zone_idx ON zone_devices (zone_id); + CREATE UNIQUE INDEX IF NOT EXISTS zone_devices_type_ref_idx ON zone_devices (device_type, external_ref); + + -- Two photo slots per device (device photo + serial/model plate) — same pattern + -- as maintenance's task_photos: multipart + sharp, uploads_data volume. + CREATE TABLE IF NOT EXISTS device_photos ( + id SERIAL PRIMARY KEY, + device_id INT NOT NULL REFERENCES zone_devices(id) ON DELETE CASCADE, + file_name TEXT NOT NULL, + file_path TEXT NOT NULL, + mime_type TEXT, + file_size INT, + photo_type TEXT NOT NULL DEFAULT 'device', -- device | serial_plate + uploaded_by TEXT, + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS device_photos_device_idx ON device_photos (device_id); + + -- Minimal, TTL'd booking cache — room-planner style (no full booking mirror). + -- Kept per-zone so a NewBook fetch failure can fall back to the last successful + -- read instead of treating "fetch failed" as "no booking" (fail-safe requirement). + CREATE TABLE IF NOT EXISTS booking_cache ( + zone_id INT PRIMARY KEY REFERENCES zones(id) ON DELETE CASCADE, + booking_data JSONB, + fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + -- Persisted current room state — survives backend restarts, and lets the + -- scheduler apply setpoints only at state *transitions* (never poll-and-reassert + -- during 'occupied', which would stomp guest button/WS adjustments). + CREATE TABLE IF NOT EXISTS zone_state ( + zone_id INT PRIMARY KEY REFERENCES zones(id) ON DELETE CASCADE, + room_state TEXT NOT NULL DEFAULT 'vacant', -- vacant | booked | heating_up | occupied | cooling_down + last_booking_status TEXT, + last_transition_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + -- Rolling log of state transitions, manual overrides, sync runs and errors. + CREATE TABLE IF NOT EXISTS activity_log ( + id SERIAL PRIMARY KEY, + zone_id INT REFERENCES zones(id) ON DELETE CASCADE, + event_type TEXT NOT NULL, -- transition | override | sync | device_command | error + from_state TEXT, + to_state TEXT, + note TEXT, + source TEXT NOT NULL DEFAULT 'scheduler', -- scheduler | manual | sync + user_email TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS activity_log_zone_idx ON activity_log (zone_id, created_at DESC); + `) + + await seedDefaults() +} + +async function seedDefaults() { + const defaults = { + excluded_site_ids: [], + poll_interval_minutes: 10, // matches the old integration's DEFAULT_SCAN_INTERVAL + default_arrival_time: '15:00:00', + default_departure_time: '10:00:00', + maintenance_url: '', + maintenance_api_key: '', + } + for (const [key, value] of Object.entries(defaults)) { + await pool.query( + `INSERT INTO config (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING`, + [key, JSON.stringify(value)] + ) + } +} + +export async function getConfig(key, fallback = null) { + const { rows } = await pool.query('SELECT value FROM config WHERE key = $1', [key]) + return rows.length ? rows[0].value : fallback +} + +export async function setConfig(key, value) { + await pool.query( + `INSERT INTO config (key, value, updated_at) VALUES ($1, $2, NOW()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`, + [key, JSON.stringify(value)] + ) +} + +export async function logActivity(zoneId, eventType, { fromState, toState, note, source = 'scheduler', userEmail } = {}) { + await pool.query( + `INSERT INTO activity_log (zone_id, event_type, from_state, to_state, note, source, user_email) + VALUES ($1,$2,$3,$4,$5,$6,$7)`, + [zoneId, eventType, fromState || null, toState || null, note || null, source, userEmail || null] + ) +} diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..e5b0f7e --- /dev/null +++ b/backend/src/index.js @@ -0,0 +1,57 @@ +import Fastify from 'fastify' +import cookie from '@fastify/cookie' +import cors from '@fastify/cors' +import multipart from '@fastify/multipart' +import staticFiles from '@fastify/static' +import { fileURLToPath } from 'url' +import { dirname, join } from 'path' +import { initDb } from './db.js' +import { connect as connectMqtt } from './lib/mqtt.js' +import { startScheduler } from './lib/scheduler.js' +import { zoneRoutes } from './routes/zones.js' +import { deviceRoutes } from './routes/devices.js' +import { statusRoutes } from './routes/status.js' +import { overrideRoutes } from './routes/override.js' +import { configRoutes } from './routes/config.js' +import { settingsRoutes } from './routes/settings.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const UPLOADS_DIR = join(__dirname, '..', 'uploads') + +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, +}) +await app.register(multipart, { limits: { fileSize: 10 * 1024 * 1024 } }) +await app.register(staticFiles, { + root: UPLOADS_DIR, + prefix: '/api/uploads/', + decorateReply: false, +}) + +app.get('/health', async () => ({ status: 'healthy', version: process.env.BUILD_VERSION || String(startedAt) })) + +await app.register(zoneRoutes) +await app.register(deviceRoutes, { uploadsDir: UPLOADS_DIR }) +await app.register(statusRoutes) +await app.register(overrideRoutes) +await app.register(configRoutes) +await app.register(settingsRoutes) + +try { + await initDb() + + // MQTT broker (shared infra, LXC 104) may not exist yet — connect() handles that + // gracefully with its own backoff and never blocks startup. + connectMqtt().catch(err => app.log.warn(`MQTT connect failed at startup: ${err.message}`)) + + await startScheduler() + await app.listen({ port: 3001, host: '0.0.0.0' }) +} catch (err) { + app.log.error(err) + process.exit(1) +} diff --git a/backend/src/ip-check.js b/backend/src/ip-check.js new file mode 100644 index 0000000..4d8cb19 --- /dev/null +++ b/backend/src/ip-check.js @@ -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 +} diff --git a/backend/src/lib/drivers/homeassistant.js b/backend/src/lib/drivers/homeassistant.js new file mode 100644 index 0000000..ee02294 --- /dev/null +++ b/backend/src/lib/drivers/homeassistant.js @@ -0,0 +1,100 @@ +// Optional escape-hatch driver — same discover()/getStatus()/setTarget() interface +// as every other driver, backed by Home Assistant's REST API instead of a direct +// protocol. Per the plan: HA is not required by hvac and nothing else depends on +// it existing. Credentials (base_url + long-lived access token) are meant to be +// configured once, centrally, as a new integration type in the shared `settings` +// service (same tier as NewBook/SMTP) — that settings endpoint does not exist yet, +// so this driver no-ops (returns empty / not-configured) until it does. No +// hardcoded credentials, no fake integration. +const DEVICE_TYPE = 'home_assistant' + +async function getCredentials() { + try { + const url = `${process.env.SETTINGS_URL}/settings/api/internal/integration/home_assistant` + const res = await fetch(url, { + headers: { Authorization: `Bearer ${process.env.SETTINGS_SECRET}` }, + signal: AbortSignal.timeout(5000), + }) + if (!res.ok) return null + const s = await res.json() + if (!s.base_url || !s.access_token) return null + return { baseUrl: s.base_url.replace(/\/$/, ''), token: s.access_token } + } catch { + return null + } +} + +// GET /api/states filtered to climate.* entities. +export async function discover() { + const creds = await getCredentials() + if (!creds) { + return { ok: false, error: 'Home Assistant integration not configured in settings', devices: [] } + } + try { + const res = await fetch(`${creds.baseUrl}/api/states`, { + headers: { Authorization: `Bearer ${creds.token}` }, + signal: AbortSignal.timeout(10000), + }) + if (!res.ok) throw new Error(`HA API ${res.status}`) + const states = await res.json() + const devices = states + .filter(s => s.entity_id?.startsWith('climate.')) + .map(s => ({ + external_ref: s.entity_id, + discovered_name: s.attributes?.friendly_name || s.entity_id, + model: 'home_assistant', + })) + return { ok: true, devices } + } catch (err) { + return { ok: false, error: err.message, devices: [] } + } +} + +export async function getStatus(externalRef) { + const creds = await getCredentials() + if (!creds) return null + try { + const res = await fetch(`${creds.baseUrl}/api/states/${encodeURIComponent(externalRef)}`, { + headers: { Authorization: `Bearer ${creds.token}` }, + signal: AbortSignal.timeout(10000), + }) + if (!res.ok) return null + const state = await res.json() + return { + external_ref: externalRef, + current_target_temp: state.attributes?.temperature ?? null, + health_state: 'healthy', + } + } catch { + return null + } +} + +export async function setTarget(externalRef, { mode, tempC }) { + const creds = await getCredentials() + if (!creds) return false + try { + if (tempC != null) { + await fetch(`${creds.baseUrl}/api/services/climate/set_temperature`, { + method: 'POST', + headers: { Authorization: `Bearer ${creds.token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ entity_id: externalRef, temperature: tempC }), + signal: AbortSignal.timeout(10000), + }) + } + if (mode) { + await fetch(`${creds.baseUrl}/api/services/climate/set_hvac_mode`, { + method: 'POST', + headers: { Authorization: `Bearer ${creds.token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ entity_id: externalRef, hvac_mode: mode }), + signal: AbortSignal.timeout(10000), + }) + } + return true + } catch (err) { + console.error('[homeassistant driver] setTarget failed:', err.message) + return false + } +} + +export { DEVICE_TYPE } diff --git a/backend/src/lib/drivers/trv.js b/backend/src/lib/drivers/trv.js new file mode 100644 index 0000000..cf37eeb --- /dev/null +++ b/backend/src/lib/drivers/trv.js @@ -0,0 +1,44 @@ +// Driver interface implementation for Shelly Gen1 TRVs, backed by lib/mqtt.js. +// Every driver in the stack exposes the same shape: discover() / getStatus(externalRef) / +// setTarget(externalRef, { mode, tempC }) — the scheduler and the Devices onboarding +// flow only ever talk to this interface, never to MQTT directly. +import { pool } from '../../db.js' +import { getDiscoveredDevices, setTargetWithRetry, isConnected } from '../mqtt.js' + +export const DEVICE_TYPE = 'shelly_trv' + +// Returns raw devices seen on the MQTT broker (from shellies/announce + +// shellies/+/settings), regardless of whether they're already mapped to a zone. +// Never auto-maps by parsing the device name — that's a staff action in the UI. +export async function discover() { + if (!isConnected()) { + return { ok: false, error: 'MQTT broker not connected', devices: [] } + } + const devices = getDiscoveredDevices().map(d => ({ + external_ref: d.deviceId, + discovered_name: d.name, + model: d.model, + mac: d.mac, + ip: d.ip, + })) + return { ok: true, devices } +} + +export async function getStatus(externalRef) { + const { rows } = await pool.query( + `SELECT external_ref, current_target_temp, target_origin, health_state, battery_pct, wifi_rssi, last_seen + FROM zone_devices WHERE device_type = $1 AND external_ref = $2`, + [DEVICE_TYPE, externalRef] + ) + return rows[0] || null +} + +// mode is accepted for interface parity with other drivers (Modbus/Midea/Daikin +// have real heat/cool/off modes) — Shelly TRVs are heat-only, so it's ignored here. +export async function setTarget(externalRef, { tempC }) { + const { rows } = await pool.query( + `SELECT device_ip FROM zone_devices WHERE device_type = $1 AND external_ref = $2`, + [DEVICE_TYPE, externalRef] + ) + return setTargetWithRetry({ external_ref: externalRef, device_ip: rows[0]?.device_ip || null }, tempC) +} diff --git a/backend/src/lib/maintenance-client.js b/backend/src/lib/maintenance-client.js new file mode 100644 index 0000000..14a5d8e --- /dev/null +++ b/backend/src/lib/maintenance-client.js @@ -0,0 +1,21 @@ +// STUB — "Create asset in Maintenance" is explicitly out of scope for this build. +// +// Per the hvac plan doc (§ "Create a maintenance asset from a device"), the real +// integration needs a joint change in `maintenance` first: its own self-service +// Settings → API Keys page (copied from forecasting's ApiKeysPage pattern) plus a +// new X-API-Key-authenticated `POST /api/public/assets` endpoint. Neither exists +// in `maintenance` yet — it currently has no API-key auth at all. Building a real +// client against an endpoint that doesn't exist would just be a fake integration, +// so this module intentionally does nothing beyond returning a clear "not +// implemented" result. Wire this up for real once the maintenance-side work lands +// (tracked as its own small piece of work, not bundled into hvac's deploy). +export async function createMaintenanceAsset(_device, _zone) { + return { + ok: false, + notImplemented: true, + error: 'Maintenance asset creation is not yet available — maintenance needs its own ' + + 'Settings → API Keys page and a public asset-creation endpoint first (see hvac plan, ' + + '"Create a maintenance asset from a device"). Configure maintenance_url / maintenance_api_key ' + + 'in hvac Settings once that lands.', + } +} diff --git a/backend/src/lib/mqtt.js b/backend/src/lib/mqtt.js new file mode 100644 index 0000000..1a53b0e --- /dev/null +++ b/backend/src/lib/mqtt.js @@ -0,0 +1,335 @@ +// Mosquitto client wrapper for Shelly Gen1 TRVs. +// +// Ported from the retired homeassistant-newbook-heating-component's trv_monitor.py / +// shelly_detector.py — same topic convention, same retry/backoff/stagger/wake-up +// reliability logic, same guest-vs-automation source detection. The broker itself +// is shared infra (its own LXC, not part of this app) and doesn't exist yet at the +// time this file is written, so every public function here degrades cleanly when +// the broker is unreachable rather than crashing the backend. +// +// Credentials are fetched at runtime from the settings service, not baked into +// docker-compose — same self-service pattern as newbook.js's getCredentials(). +// NOTE: the settings endpoint below (`/settings/api/internal/integration/mqtt`) +// does not exist yet — this client is written against the shape described in the +// hvac plan doc so it "just works" once settings adds it. Until then every +// connection attempt fails fast and retries in the background; nothing here blocks +// backend startup. +import mqtt from 'mqtt' +import { pool } from '../db.js' + +const CLIENT_NAME = 'hvac-backend' + +// Same reliability tuning as the old trv_monitor.py: 30s -> 30min backoff, staggered +// per-valve commands ~10s apart, HTTP wake-up before declaring a TRV unresponsive. +const RETRY_DELAYS_MS = [30, 60, 120, 300, 300, 600, 600, 900, 900, 1800].map(s => s * 1000) +const STAGGER_MS = 10 * 1000 +const BROKER_RECONNECT_BACKOFF_MS = [30, 60, 120, 300, 600, 1800].map(s => s * 1000) // 30s -> 30min + +const GUEST_SOURCES = ['button', 'WS'] +const AUTOMATION_SOURCES = ['mqtt', 'http'] + +let client = null +let connecting = false +let reconnectAttempt = 0 + +// device_id (Shelly's own id, e.g. "shellytrv-84FD270DD7CC") -> { mac, model, ip, name, lastAnnounceAt } +const discovered = new Map() + +async function getCredentials() { + const url = `${process.env.SETTINGS_URL}/settings/api/internal/integration/mqtt?client=${CLIENT_NAME}` + const res = await fetch(url, { + headers: { Authorization: `Bearer ${process.env.SETTINGS_SECRET}` }, + signal: AbortSignal.timeout(5000), + }) + if (!res.ok) throw new Error(`Settings service returned ${res.status} fetching MQTT credentials`) + const s = await res.json() + if (!s.host || !s.username || !s.password) throw new Error('MQTT broker credentials not configured in settings') + return { host: s.host, port: s.port || 1883, username: s.username, password: s.password } +} + +// Parse a Shelly Gen1 announce/settings payload. Gen2+ devices (which carry a +// "gen" field) are explicitly unsupported — same guard as the retired +// shelly_detector.py, since Gen2 uses RPC-over-MQTT, not these flat topics. +function isGen1(payload) { + return !('gen' in payload) +} + +function recordDiscovery(deviceId, { mac, model, ip, name }) { + const existing = discovered.get(deviceId) || {} + discovered.set(deviceId, { + deviceId, + mac: mac || existing.mac || '', + model: model || existing.model || '', + ip: ip || existing.ip || '', + name: name || existing.name || deviceId, + lastAnnounceAt: new Date().toISOString(), + }) +} + +async function handleAnnounce(payload) { + if (!isGen1(payload)) return + if (!payload.id || !payload.model) return + if (payload.model !== 'SHTRV-01') return // TRVs only in Phase 1 + recordDiscovery(payload.id, { mac: payload.mac, model: payload.model, ip: payload.ip }) +} + +async function handleSettings(deviceId, payload) { + const deviceInfo = payload.device || {} + if (!deviceInfo.type || !deviceInfo.mac) return + if ('gen' in payload) return + if (deviceInfo.type !== 'SHTRV-01') return + recordDiscovery(deviceId, { + mac: deviceInfo.mac, + model: deviceInfo.type, + ip: payload.wifi_sta?.ip || '', + name: payload.name || deviceId, + }) +} + +// shellies/room-{room_id}-{location}-trv/thermostat/0/target_t -> { temp, source } +// source: button|WS = guest-initiated (never resync from these); mqtt|http = our own +// automation's command (safe to resync other valves in the room). +async function handleStatus(externalRef, payload) { + const targetTemp = payload.temp + const source = payload.source + const isGuest = GUEST_SOURCES.includes(source) + const origin = isGuest ? 'guest' : (AUTOMATION_SOURCES.includes(source) ? 'automation' : 'unknown') + + try { + await pool.query( + `UPDATE zone_devices + SET last_seen = NOW(), current_target_temp = $2, target_origin = $3, updated_at = NOW() + WHERE device_type = 'shelly_trv' AND external_ref = $1`, + [externalRef, targetTemp ?? null, origin] + ) + } catch (err) { + // DB not reachable / table not ready yet — don't crash the MQTT client over it + console.error('[mqtt] failed to record status update:', err.message) + } + + if (isGuest && targetTemp != null) { + await syncGuestAdjustmentToRoom(externalRef, targetTemp).catch(err => { + console.error('[mqtt] guest valve sync failed:', err.message) + }) + } +} + +// Debounce so we don't loop: our own resync command comes back as source='mqtt' +// (automation), which already wouldn't re-trigger this function — but keep a +// time-based guard too, matching the old trv_monitor.py's SYNC_DEBOUNCE_SECONDS. +const SYNC_DEBOUNCE_MS = 30 * 1000 +const lastGuestSyncAt = new Map() // external_ref -> timestamp ms + +// Guest-initiated Temperature Respect + valve sync: when a guest adjusts one TRV +// (source=button|WS), sync the new setpoint to the room's other valves — unless +// the zone has sync_valves disabled, or the source/target valve is the bathroom +// and exclude_bathroom is enabled. Never touches valves in other zones. +async function syncGuestAdjustmentToRoom(externalRef, tempC) { + const last = lastGuestSyncAt.get(externalRef) + if (last && Date.now() - last < SYNC_DEBOUNCE_MS) return + + const { rows } = await pool.query( + `SELECT zd.zone_id, zd.location, z.sync_valves, z.exclude_bathroom + FROM zone_devices zd JOIN zones z ON z.id = zd.zone_id + WHERE zd.device_type = 'shelly_trv' AND zd.external_ref = $1`, + [externalRef] + ) + const row = rows[0] + if (!row || !row.zone_id || !row.sync_valves) return + + const sourceIsBathroom = (row.location || '').toLowerCase().includes('bathroom') + if (row.exclude_bathroom && sourceIsBathroom) return // bathroom adjustments don't propagate + + const { rows: siblings } = await pool.query( + `SELECT external_ref, device_ip, location FROM zone_devices + WHERE device_type = 'shelly_trv' AND zone_id = $1 AND external_ref != $2`, + [row.zone_id, externalRef] + ) + const targets = siblings.filter(s => !(row.exclude_bathroom && (s.location || '').toLowerCase().includes('bathroom'))) + if (!targets.length) return + + lastGuestSyncAt.set(externalRef, Date.now()) + console.log(`[mqtt] guest adjusted ${externalRef} to ${tempC}°C — syncing ${targets.length} other valve(s) in zone ${row.zone_id}`) + await batchSetZoneTemperature(targets.map(t => ({ external_ref: t.external_ref, device_ip: t.device_ip })), tempC) +} + +function topicMatchesStatus(topic) { + return /^shellies\/(.+)\/thermostat\/0\/target_t$/.exec(topic) +} + +function scheduleReconnect() { + if (connecting) return + const delay = BROKER_RECONNECT_BACKOFF_MS[Math.min(reconnectAttempt, BROKER_RECONNECT_BACKOFF_MS.length - 1)] + reconnectAttempt++ + console.warn(`[mqtt] broker unreachable — retrying in ${delay / 1000}s`) + setTimeout(() => { connect().catch(() => {}) }, delay) +} + +export async function connect() { + if (connecting || client?.connected) return + connecting = true + try { + const creds = await getCredentials() + await new Promise((resolve, reject) => { + const c = mqtt.connect(`mqtt://${creds.host}:${creds.port}`, { + username: creds.username, + password: creds.password, + clientId: `${CLIENT_NAME}-${Math.random().toString(16).slice(2, 8)}`, + connectTimeout: 10000, + reconnectPeriod: 0, // we own reconnect/backoff ourselves + }) + + c.on('connect', () => { + client = c + reconnectAttempt = 0 + connecting = false + console.log('[mqtt] connected to broker') + c.subscribe(['shellies/announce', 'shellies/+/settings', 'shellies/+/thermostat/0/target_t']) + resolve() + }) + + c.on('message', (topic, message) => { + let payload + try { payload = JSON.parse(message.toString()) } catch { return } + + if (topic === 'shellies/announce') { + handleAnnounce(payload).catch(() => {}) + return + } + const settingsMatch = /^shellies\/(.+)\/settings$/.exec(topic) + if (settingsMatch) { + handleSettings(settingsMatch[1], payload).catch(() => {}) + return + } + const statusMatch = topicMatchesStatus(topic) + if (statusMatch) { + handleStatus(statusMatch[1], payload).catch(() => {}) + } + }) + + c.on('error', (err) => { + console.error('[mqtt] connection error:', err.message) + }) + + c.on('close', () => { + if (client === c) client = null + connecting = false + scheduleReconnect() + }) + + // mqtt.js's own connect() already retried internally on the initial attempt; + // if it never even reaches 'connect' fire our own backoff. + setTimeout(() => { + if (!client) { c.end(true); connecting = false; reject(new Error('MQTT connect timed out')) } + }, 12000) + }) + } catch (err) { + connecting = false + console.error('[mqtt] could not connect (broker/credentials unavailable):', err.message) + scheduleReconnect() + } +} + +export function isConnected() { + return !!client?.connected +} + +export function getDiscoveredDevices() { + return Array.from(discovered.values()) +} + +// Publish a setpoint command to a single TRV. Does NOT wait for acknowledgment — +// use setTargetWithRetry() for the full reliability pipeline. +function publishTarget(externalRef, tempC) { + if (!client?.connected) throw new Error('MQTT broker not connected') + const topic = `shellies/${externalRef}/thermostat/0/command/target_t` + client.publish(topic, String(tempC)) +} + +async function waitForAck(externalRef, tempC, timeoutMs) { + const start = Date.now() + while (Date.now() - start < timeoutMs) { + const { rows } = await pool.query( + `SELECT current_target_temp, updated_at FROM zone_devices + WHERE device_type = 'shelly_trv' AND external_ref = $1`, + [externalRef] + ) + const row = rows[0] + if (row?.current_target_temp != null && Math.abs(Number(row.current_target_temp) - tempC) < 0.1 + && new Date(row.updated_at).getTime() >= start) { + return true + } + await new Promise(r => setTimeout(r, 5000)) + } + return false +} + +// HTTP GET wake-up fallback — Shelly devices in deep sleep sometimes respond to a +// plain HTTP GET even when not responding over MQTT. Best-effort only. +async function tryHttpWakeUp(deviceIp) { + if (!deviceIp) return false + try { + const ctrl = new AbortController() + const timer = setTimeout(() => ctrl.abort(), 10000) + const res = await fetch(`http://${deviceIp}/status`, { signal: ctrl.signal }) + clearTimeout(timer) + return res.ok + } catch { + return false + } +} + +// Set a single TRV's target temperature with exponential-backoff retry (30s -> 30min) +// and an HTTP wake-up attempt before finally marking the device unresponsive. +export async function setTargetWithRetry(device, tempC) { + const { external_ref: externalRef, device_ip: deviceIp } = device + + for (let attempt = 0; attempt < RETRY_DELAYS_MS.length; attempt++) { + try { + publishTarget(externalRef, tempC) + } catch (err) { + console.error(`[mqtt] publish failed for ${externalRef}:`, err.message) + } + + const acked = await waitForAck(externalRef, tempC, RETRY_DELAYS_MS[attempt]) + if (acked) { + await pool.query( + `UPDATE zone_devices SET health_state = 'healthy' WHERE device_type = 'shelly_trv' AND external_ref = $1`, + [externalRef] + ) + return true + } + + const healthState = attempt >= 5 ? 'poor' : attempt >= 3 ? 'degraded' : 'healthy' + await pool.query( + `UPDATE zone_devices SET health_state = $2 WHERE device_type = 'shelly_trv' AND external_ref = $1`, + [externalRef, healthState] + ).catch(() => {}) + } + + // All MQTT retries exhausted — try HTTP wake-up before giving up + const wokeUp = await tryHttpWakeUp(deviceIp) + if (wokeUp) { + try { publishTarget(externalRef, tempC) } catch { /* ignore */ } + const acked = await waitForAck(externalRef, tempC, 30000) + if (acked) return true + } + + await pool.query( + `UPDATE zone_devices SET health_state = 'unresponsive' WHERE device_type = 'shelly_trv' AND external_ref = $1`, + [externalRef] + ).catch(() => {}) + console.error(`[mqtt] ${externalRef} failed to acknowledge target ${tempC}°C after ${RETRY_DELAYS_MS.length} attempts${wokeUp ? ' + HTTP wake-up' : ''}`) + return false +} + +// Set temperature for multiple TRVs in a zone, staggered ~10s apart so several +// battery-powered valves don't all wake their WiFi radio at once. +export async function batchSetZoneTemperature(devices, tempC) { + const results = {} + for (let i = 0; i < devices.length; i++) { + if (i > 0) await new Promise(r => setTimeout(r, STAGGER_MS)) + results[devices[i].external_ref] = await setTargetWithRetry(devices[i], tempC) + } + return results +} diff --git a/backend/src/lib/newbook.js b/backend/src/lib/newbook.js new file mode 100644 index 0000000..493e08a --- /dev/null +++ b/backend/src/lib/newbook.js @@ -0,0 +1,80 @@ +// Adapted (near-verbatim) from room-planner/backend/src/lib/newbook.js. +// Credentials come from the shared settings service, never a docker-compose env var. +const API_BASE = 'https://api.newbook.cloud/rest/' + +async function getCredentials() { + const url = `${process.env.SETTINGS_URL}/settings/api/internal/integration/newbook` + const res = await fetch(url, { + headers: { Authorization: `Bearer ${process.env.SETTINGS_SECRET}` }, + signal: AbortSignal.timeout(5000), + }) + if (!res.ok) throw new Error(`Settings service returned ${res.status} fetching NewBook credentials`) + const s = await res.json() + return { + username: s.username || '', + password: s.password || '', + apiKey: s.api_key || '', + region: s.region || 'eu', + } +} + +async function callApi(endpoint, data = {}) { + const creds = await getCredentials() + if (!creds.username || !creds.password || !creds.apiKey) { + throw new Error('NewBook API credentials not configured') + } + + // Single-hotel scope — matches room-planner's convention, no multi-property support. + const locationId = process.env.NEWBOOK_LOCATION_ID + const body = { ...data, region: creds.region, api_key: creds.apiKey } + if (locationId) body.location_id = locationId + + const auth = Buffer.from(`${creds.username}:${creds.password}`).toString('base64') + const ctrl = new AbortController() + const timer = setTimeout(() => ctrl.abort(), 30000) + + try { + const res = await fetch(API_BASE + endpoint, { + method: 'POST', + signal: ctrl.signal, + headers: { 'Content-Type': 'application/json', 'Authorization': `Basic ${auth}` }, + body: JSON.stringify(body), + }) + clearTimeout(timer) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`NewBook API ${res.status}: ${text.slice(0, 200)}`) + } + return await res.json() + } catch (err) { + clearTimeout(timer) + throw err + } +} + +// Fetch all sites (rooms) — used by the zones sync + include/exclude checklist. +export async function fetchSites() { + const res = await callApi('sites_list', {}) + return res?.data ?? [] +} + +// Fetch bookings whose stay overlaps the date window (list_type 'staying' includes +// a booking arriving on period_to). This is what the scheduler polls to build the +// room state machine — see lib/scheduler.js. +export async function fetchBookings(fromDate, toDate) { + const res = await callApi('bookings_list', { + period_from: `${fromDate} 00:00:00`, + period_to: `${toDate} 23:59:59`, + list_type: 'staying', + }) + return res?.data ?? [] +} + +export async function testConnection() { + try { + const sites = await fetchSites() + return { ok: true, message: `Connected. Found ${sites.length} site(s).` } + } catch (err) { + return { ok: false, error: err.message } + } +} diff --git a/backend/src/lib/scheduler.js b/backend/src/lib/scheduler.js new file mode 100644 index 0000000..a9fda90 --- /dev/null +++ b/backend/src/lib/scheduler.js @@ -0,0 +1,238 @@ +// Room state machine + should_heat decision, ported from the retired +// homeassistant-newbook-heating-component's booking_processor.py / heating_controller.py +// (see /docs/HEATING_LOGIC.md in that archive for the worked examples this was tested +// against: walk-in immediate heat, advance-booking pre-heat, multi-day stay, departed +// cooling). All time math runs in Europe/London via luxon so the BST/GMT transition +// doesn't silently break "minutes before a wall-clock time" arithmetic. +import { DateTime } from 'luxon' +import { pool, getConfig, logActivity } from '../db.js' +import { fetchBookings } from './newbook.js' +import { batchSetZoneTemperature } from './mqtt.js' + +const TZ = 'Europe/London' +const ACTIVE_BOOKING_STATUSES = ['confirmed', 'unconfirmed', 'arrived'] +const HEAT_STATES = ['heating_up', 'occupied'] +const COOL_STATES = ['vacant', 'cooling_down', 'booked'] + +let timerHandle = null +let running = false + +function parseNbDateTime(str) { + if (!str) return null + const dt = DateTime.fromFormat(str, 'yyyy-MM-dd HH:mm:ss', { zone: TZ }) + if (dt.isValid) return dt + const dateOnly = DateTime.fromFormat(str, 'yyyy-MM-dd', { zone: TZ }) + return dateOnly.isValid ? dateOnly : null +} + +function parseHHMMSS(str, fallback) { + if (!str) return fallback + const parts = str.split(':').map(Number) + if (parts.some(Number.isNaN)) return fallback + return { hour: parts[0], minute: parts[1] || 0 } +} + +// calculate_heating_schedule() — heating_start = arrival - heating_offset_minutes, +// cooling_start = departure + cooling_offset_minutes (cooling_offset can be negative +// to stop heating BEFORE checkout). Arrival uses the EARLIER of actual vs default +// arrival time; departure uses the LATER of actual vs default departure time. +export function calculateHeatingSchedule(zone, booking, defaults) { + if (!booking) return null + const arrivalRaw = parseNbDateTime(booking.booking_arrival) + const departureRaw = parseNbDateTime(booking.booking_departure) + if (!arrivalRaw || !departureRaw) return null + + const defaultArrival = parseHHMMSS(defaults.default_arrival_time, { hour: 15, minute: 0 }) + const defaultDeparture = parseHHMMSS(defaults.default_departure_time, { hour: 10, minute: 0 }) + + const actualArrivalMins = arrivalRaw.hour * 60 + arrivalRaw.minute + const defaultArrivalMins = defaultArrival.hour * 60 + defaultArrival.minute + const earliestArrivalMins = Math.min(actualArrivalMins, defaultArrivalMins) + const arrival = arrivalRaw.startOf('day').plus({ minutes: earliestArrivalMins }) + + const actualDepartureMins = departureRaw.hour * 60 + departureRaw.minute + const defaultDepartureMins = defaultDeparture.hour * 60 + defaultDeparture.minute + const latestDepartureMins = Math.max(actualDepartureMins, defaultDepartureMins) + const departure = departureRaw.startOf('day').plus({ minutes: latestDepartureMins }) + + const heatingStart = arrival.plus({ minutes: -zone.heating_offset_min }) + const coolingStart = departure.plus({ minutes: zone.cooling_offset_min }) + + return { heatingStart, coolingStart, arrival, departure } +} + +// determine_room_state() — priority order: 1) explicit booking status (arrived/departed +// override everything), 2) time-based state from the schedule, 3) vacant if no booking. +export function determineRoomState(booking, schedule, now) { + if (!booking) return 'vacant' + const status = (booking.booking_status || '').toLowerCase() + + if (status === 'departed') return 'cooling_down' + if (status === 'arrived') return 'occupied' + + if (!schedule) return 'booked' + const { heatingStart, coolingStart, arrival } = schedule + if (!heatingStart || !coolingStart) return 'booked' + + // A booking arriving on a future date shouldn't show as "booked" today. + if (arrival.toISODate() > now.toISODate()) return 'vacant' + + // heating_up covers both pre-arrival preheat AND "past arrival but not checked + // in yet" — keep heating until the PMS actually marks the guest arrived. + if (now >= heatingStart && now < coolingStart) return 'heating_up' + if (now >= coolingStart) return 'cooling_down' + if (now < heatingStart) return 'booked' + return 'vacant' // shouldn't happen +} + +// should_heat() — auto mode AND an active booking status AND the room state in +// [heating_up, occupied]. All three must hold. +export function shouldHeat(booking, roomState, autoMode) { + if (!autoMode) return false + if (!booking) return false + const status = (booking.booking_status || '').toLowerCase() + if (!ACTIVE_BOOKING_STATUSES.includes(status)) return false + return HEAT_STATES.includes(roomState) +} + +async function getZoneDevices(zoneId) { + const { rows } = await pool.query( + `SELECT id, external_ref, device_ip, device_type FROM zone_devices WHERE zone_id = $1`, + [zoneId] + ) + return rows +} + +async function applyTarget(zone, tempC) { + const devices = await getZoneDevices(zone.id) + const trvs = devices.filter(d => d.device_type === 'shelly_trv') + if (!trvs.length) return { ok: false, reason: 'no_devices' } + const results = await batchSetZoneTemperature(trvs, tempC) + const successful = Object.values(results).filter(Boolean).length + return { ok: successful > 0, successful, total: trvs.length } +} + +// Apply heating logic ONLY at state transitions — never poll-and-reassert during +// 'occupied', which would stomp a guest's button/WS adjustment made mid-stay. +async function applyStateTransition(zone, oldState, newState) { + const target = HEAT_STATES.includes(newState) ? Number(zone.occupied_temp) : Number(zone.vacant_temp) + const result = await applyTarget(zone, target) + await logActivity(zone.id, 'transition', { + fromState: oldState, + toState: newState, + note: result.ok + ? `Set ${target}°C (${result.successful}/${result.total} devices)` + : `Failed to set ${target}°C (${result.reason || `${result.successful || 0}/${result.total || 0} devices`})`, + source: 'scheduler', + }) +} + +async function getZoneState(zoneId) { + const { rows } = await pool.query('SELECT * FROM zone_state WHERE zone_id = $1', [zoneId]) + return rows[0] || null +} + +async function saveZoneState(zoneId, roomState, bookingStatus) { + await pool.query( + `INSERT INTO zone_state (zone_id, room_state, last_booking_status, last_transition_at, updated_at) + VALUES ($1, $2, $3, NOW(), NOW()) + ON CONFLICT (zone_id) DO UPDATE SET + room_state = EXCLUDED.room_state, + last_booking_status = EXCLUDED.last_booking_status, + last_transition_at = CASE WHEN zone_state.room_state != EXCLUDED.room_state THEN NOW() ELSE zone_state.last_transition_at END, + updated_at = NOW()`, + [zoneId, roomState, bookingStatus] + ) +} + +// One poll cycle: fetch bookings once, walk every active room zone, apply heating +// logic only where the room's state has actually changed since the last cycle. +export async function pollOnce() { + const defaults = { + default_arrival_time: await getConfig('default_arrival_time', '15:00:00'), + default_departure_time: await getConfig('default_departure_time', '10:00:00'), + } + + const now = DateTime.now().setZone(TZ) + const today = now.toISODate() + + const { rows: zones } = await pool.query( + `SELECT * FROM zones WHERE active = TRUE AND zone_type = 'room' AND newbook_site_id IS NOT NULL` + ) + if (!zones.length) return + + // Fail-safe: if NewBook is unreachable, keep every zone's last-known-good state — + // "fetch failed" must never be treated the same as "no booking" (that would turn + // off heating in an occupied room on a transient API blip). + let bookings + try { + bookings = await fetchBookings(today, today) + } catch (err) { + console.error('[scheduler] NewBook fetch failed — keeping last-known-good state:', err.message) + await logActivity(null, 'error', { note: `NewBook poll failed: ${err.message}`, source: 'scheduler' }) + return + } + + const bookingsBySite = new Map() + for (const b of bookings) { + const siteId = String(b.site_id ?? b.room_id ?? '') + if (!siteId) continue + // If multiple bookings overlap today for a site, prefer the one that's + // actually arrived, then the one arriving soonest. + const existing = bookingsBySite.get(siteId) + if (!existing) { bookingsBySite.set(siteId, b); continue } + const existingStatus = (existing.booking_status || '').toLowerCase() + const candidateStatus = (b.booking_status || '').toLowerCase() + if (candidateStatus === 'arrived' && existingStatus !== 'arrived') bookingsBySite.set(siteId, b) + } + + for (const zone of zones) { + try { + const booking = bookingsBySite.get(String(zone.newbook_site_id)) || null + + // Cache the booking on every successful fetch so a future failed fetch has + // something to fall back to (booking_cache is per-zone, TTL is implicit — + // the fail-safe path above never even reaches this loop on a failed fetch). + await pool.query( + `INSERT INTO booking_cache (zone_id, booking_data, fetched_at) VALUES ($1, $2, NOW()) + ON CONFLICT (zone_id) DO UPDATE SET booking_data = EXCLUDED.booking_data, fetched_at = NOW()`, + [zone.id, booking ? JSON.stringify(booking) : null] + ) + + const schedule = booking ? calculateHeatingSchedule(zone, booking, defaults) : null + const newState = determineRoomState(booking, schedule, now) + const prev = await getZoneState(zone.id) + const oldState = prev?.room_state || 'vacant' + + await saveZoneState(zone.id, newState, booking?.booking_status || null) + + if (!zone.auto_mode) continue // manual override in effect — don't touch setpoints + if (newState === oldState) continue // no transition — never poll-and-reassert + + await applyStateTransition(zone, oldState, newState) + } catch (err) { + console.error(`[scheduler] zone ${zone.id} update failed:`, err.message) + await logActivity(zone.id, 'error', { note: err.message, source: 'scheduler' }) + } + } +} + +export async function startScheduler() { + const minutes = Number(await getConfig('poll_interval_minutes', 10)) || 10 + const intervalMs = Math.max(1, minutes) * 60 * 1000 + + const tick = async () => { + if (running) return + running = true + try { await pollOnce() } catch (err) { console.error('[scheduler] poll failed:', err.message) } + running = false + } + + await tick() + timerHandle = setInterval(tick, intervalMs) +} + +export function stopScheduler() { + if (timerHandle) clearInterval(timerHandle) + timerHandle = null +} diff --git a/backend/src/routes/config.js b/backend/src/routes/config.js new file mode 100644 index 0000000..5933161 --- /dev/null +++ b/backend/src/routes/config.js @@ -0,0 +1,27 @@ +import { requireAuth, requireCap } from '../auth.js' +import { pool } from '../db.js' + +// Generic key/value config store (hk-planner/maintenance pattern). Zone-specific +// settings (excluded_site_ids, etc.) go through their own routes in zones.js so +// each has proper validation — this is the catch-all for simple scalar settings +// like poll_interval_minutes, default_arrival_time, maintenance_url/api_key. +export async function configRoutes(app) { + app.addHook('preHandler', requireAuth) + + app.get('/api/config', { preHandler: requireCap('view') }, async () => { + const { rows } = await pool.query('SELECT key, value FROM config ORDER BY key') + return Object.fromEntries(rows.map(r => [r.key, r.value])) + }) + + app.put('/api/config/:key', { preHandler: requireCap('settings') }, async (req, reply) => { + const { key } = req.params + const { value } = req.body || {} + if (value === undefined) return reply.status(400).send({ error: 'value required' }) + await pool.query( + `INSERT INTO config (key, value, updated_at) VALUES ($1, $2, NOW()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`, + [key, JSON.stringify(value)] + ) + return { ok: true } + }) +} diff --git a/backend/src/routes/devices.js b/backend/src/routes/devices.js new file mode 100644 index 0000000..805ced6 --- /dev/null +++ b/backend/src/routes/devices.js @@ -0,0 +1,160 @@ +import { mkdir, unlink, writeFile } from 'fs/promises' +import { randomUUID } from 'crypto' +import { join } from 'path' +import sharp from 'sharp' +import { requireAuth, requireCap } from '../auth.js' +import { pool, logActivity } from '../db.js' +import * as trvDriver from '../lib/drivers/trv.js' +import * as haDriver from '../lib/drivers/homeassistant.js' +import { createMaintenanceAsset } from '../lib/maintenance-client.js' + +const ALLOWED_IMAGES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'] +const PHOTO_TYPES = ['device', 'serial_plate'] + +// device_type -> driver implementing discover()/getStatus()/setTarget(). Phase 2/3 +// types (mhi_modbus, midea, daikin) are enum values only — no driver yet. +const DRIVERS = { + shelly_trv: trvDriver, + home_assistant: haDriver, +} +const NOT_YET_IMPLEMENTED = ['mhi_modbus', 'midea', 'daikin'] + +export async function deviceRoutes(app, opts) { + const UPLOADS_DIR = opts.uploadsDir + app.addHook('preHandler', requireAuth) + + // GET /api/devices — every mapped + unassigned device, with zone name and photo count + app.get('/api/devices', { preHandler: requireCap('view') }, async () => { + const { rows } = await pool.query(` + SELECT zd.*, z.name AS zone_name, + (SELECT COUNT(*)::int FROM device_photos p WHERE p.device_id = zd.id) AS photo_count + FROM zone_devices zd + LEFT JOIN zones z ON z.id = zd.zone_id + ORDER BY zd.zone_id NULLS FIRST, zd.device_type, zd.discovered_name + `) + return rows + }) + + // POST /api/devices/discover — { device_type } -> runs that driver's discover(), + // and inserts any newly-seen device as an unassigned row (never auto-linked to a zone). + app.post('/api/devices/discover', { preHandler: requireCap('manage_devices') }, async (req, reply) => { + const { device_type } = req.body || {} + if (!device_type) return reply.status(400).send({ error: 'device_type required' }) + + if (NOT_YET_IMPLEMENTED.includes(device_type)) { + return reply.status(200).send({ + ok: true, devices: [], + note: `${device_type} discovery isn't implemented yet — Phase 1 only covers Shelly TRVs. ` + + `This device type exists as a column enum value ready for its Phase 2/3 driver.`, + }) + } + + const driver = DRIVERS[device_type] + if (!driver) return reply.status(400).send({ error: `Unknown device_type: ${device_type}` }) + + const result = await driver.discover() + if (!result.ok) return reply.status(200).send(result) // e.g. broker/HA not reachable — not a hard error + + let discoveredCount = 0 + for (const d of result.devices) { + const res = await pool.query( + `INSERT INTO zone_devices (device_type, external_ref, discovered_name) + VALUES ($1, $2, $3) + ON CONFLICT (device_type, external_ref) DO UPDATE SET discovered_name = EXCLUDED.discovered_name, last_seen = NOW() + RETURNING (xmax = 0) AS inserted`, + [device_type, d.external_ref, d.discovered_name || d.external_ref] + ) + if (res.rows[0].inserted) discoveredCount++ + } + + return { ok: true, found: result.devices.length, new: discoveredCount } + }) + + // PATCH /api/devices/:id — zone/location assignment (a living mapping, not a one-time wizard) + app.patch('/api/devices/:id', { preHandler: requireCap('manage_devices') }, async (req, reply) => { + const { rows: existing } = await pool.query('SELECT * FROM zone_devices WHERE id = $1', [req.params.id]) + if (!existing.length) return reply.status(404).send({ error: 'Device not found' }) + const d = existing[0] + const { zone_id, location } = req.body || {} + + const { rows } = await pool.query( + `UPDATE zone_devices SET zone_id = $1, location = $2, updated_at = NOW() WHERE id = $3 RETURNING *`, + [zone_id !== undefined ? zone_id : d.zone_id, location !== undefined ? location : d.location, req.params.id] + ) + return rows[0] + }) + + // POST /api/devices/:id/photos — multipart: file + photo_type (device | serial_plate) + app.post('/api/devices/:id/photos', { preHandler: requireCap('manage_devices') }, async (req, reply) => { + const deviceId = parseInt(req.params.id) + const { rows } = await pool.query('SELECT id FROM zone_devices WHERE id = $1', [deviceId]) + if (!rows.length) return reply.status(404).send({ error: 'Device not found' }) + + let fileData = null, photoType = 'device' + for await (const part of req.parts()) { + if (part.type === 'file') { + if (!ALLOWED_IMAGES.includes(part.mimetype)) { + return reply.status(400).send({ error: 'Only JPEG, PNG and WebP images are allowed' }) + } + const chunks = [] + for await (const chunk of part.file) chunks.push(chunk) + const raw = Buffer.concat(chunks) + + // Auto-rotate (phone EXIF), resize to 1800px max, re-encode as JPEG — same as maintenance's task_photos. + const processed = await sharp(raw) + .rotate() + .resize(1800, 1800, { fit: 'inside', withoutEnlargement: true }) + .jpeg({ quality: 82, progressive: true }) + .toBuffer() + + const filename = randomUUID() + '.jpg' + const dir = join(UPLOADS_DIR, 'devices', String(deviceId)) + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, filename), processed) + fileData = { originalName: part.filename, savedAs: filename, size: processed.length } + } else { + const val = await part.value + if (part.fieldname === 'photo_type' && PHOTO_TYPES.includes(String(val))) photoType = String(val) + } + } + if (!fileData?.savedAs) return reply.status(400).send({ error: 'No file uploaded' }) + + const filePath = `/devices/${deviceId}/${fileData.savedAs}` + const { rows: ins } = await pool.query( + `INSERT INTO device_photos (device_id, file_name, file_path, mime_type, file_size, photo_type, uploaded_by) + VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`, + [deviceId, fileData.originalName, filePath, 'image/jpeg', fileData.size, photoType, req.user.email] + ) + return ins[0] + }) + + app.get('/api/devices/:id/photos', { preHandler: requireCap('view') }, async (req) => { + const { rows } = await pool.query( + 'SELECT * FROM device_photos WHERE device_id = $1 ORDER BY uploaded_at DESC', + [req.params.id] + ) + return rows + }) + + app.delete('/api/photos/:id', { preHandler: requireCap('manage_devices') }, async (req, reply) => { + const { rows } = await pool.query('SELECT * FROM device_photos WHERE id = $1', [req.params.id]) + if (!rows.length) return reply.status(404).send({ error: 'Not found' }) + await unlink(join(UPLOADS_DIR, rows[0].file_path)).catch(() => {}) + await pool.query('DELETE FROM device_photos WHERE id = $1', [req.params.id]) + return { ok: true } + }) + + // POST /api/devices/:id/create-maintenance-asset — explicit stub. Real integration + // needs maintenance's own Settings -> API Keys page + POST /api/public/assets first + // (see lib/maintenance-client.js). Returns a clear "not implemented" response rather + // than faking a cross-app call. + app.post('/api/devices/:id/create-maintenance-asset', { preHandler: requireCap('manage_devices') }, async (req, reply) => { + const { rows } = await pool.query( + `SELECT zd.*, z.name AS zone_name FROM zone_devices zd LEFT JOIN zones z ON z.id = zd.zone_id WHERE zd.id = $1`, + [req.params.id] + ) + if (!rows.length) return reply.status(404).send({ error: 'Device not found' }) + const result = await createMaintenanceAsset(rows[0], { name: rows[0].zone_name }) + return reply.status(501).send(result) + }) +} diff --git a/backend/src/routes/override.js b/backend/src/routes/override.js new file mode 100644 index 0000000..391d8b9 --- /dev/null +++ b/backend/src/routes/override.js @@ -0,0 +1,41 @@ +import { requireAuth, requireCap } from '../auth.js' +import { pool, logActivity } from '../db.js' +import { batchSetZoneTemperature } from '../lib/mqtt.js' + +// Manual force-temperature — the equivalent of the old integration's +// newbook.force_room_temperature service. Disables auto mode for the zone so the +// scheduler leaves it alone until staff switch auto mode back on (zones.js PATCH). +export async function overrideRoutes(app) { + app.addHook('preHandler', requireAuth) + + app.post('/api/zones/:id/override', { preHandler: requireCap('control') }, async (req, reply) => { + const { temp_c } = req.body || {} + const tempC = Number(temp_c) + if (!Number.isFinite(tempC)) return reply.status(400).send({ error: 'temp_c must be a number' }) + + const { rows } = await pool.query('SELECT * FROM zones WHERE id = $1', [req.params.id]) + if (!rows.length) return reply.status(404).send({ error: 'Zone not found' }) + const zone = rows[0] + + await pool.query('UPDATE zones SET auto_mode = FALSE WHERE id = $1', [zone.id]) + + const { rows: devices } = await pool.query( + `SELECT external_ref, device_ip FROM zone_devices WHERE zone_id = $1 AND device_type = 'shelly_trv'`, + [zone.id] + ) + if (!devices.length) { + return reply.status(200).send({ ok: false, error: 'No TRVs mapped to this zone', auto_mode: false }) + } + + const results = await batchSetZoneTemperature(devices, tempC) + const successful = Object.values(results).filter(Boolean).length + + await logActivity(zone.id, 'override', { + note: `Manual override to ${tempC}°C (${successful}/${devices.length} devices, auto mode disabled)`, + source: 'manual', + userEmail: req.user.email, + }) + + return { ok: successful > 0, successful, total: devices.length, auto_mode: false } + }) +} diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js new file mode 100644 index 0000000..024e9a9 --- /dev/null +++ b/backend/src/routes/settings.js @@ -0,0 +1,25 @@ +import { requireAuth, requireCap } from '../auth.js' +import { testConnection } from '../lib/newbook.js' +import { isConnected as mqttConnected } from '../lib/mqtt.js' + +// Settings-page-backing endpoints that don't fit the generic config.js key/value +// store: connection tests and the MQTT broker's live status. The Home Assistant +// integration itself is configured centrally in the shared `settings` app, not +// here — this just reports whether it's reachable (via the driver's own no-op +// behaviour when unconfigured). +export async function settingsRoutes(app) { + app.addHook('preHandler', requireAuth) + + app.post('/api/settings/newbook-test', { preHandler: requireCap('settings') }, async () => { + return testConnection() + }) + + app.get('/api/settings/mqtt-status', { preHandler: requireCap('settings') }, async () => { + return { + connected: mqttConnected(), + note: mqttConnected() + ? 'Connected to the shared MQTT broker.' + : 'Not connected — the shared MQTT broker (LXC 104) may not be provisioned yet, or hvac-backend has no credentials in settings.', + } + }) +} diff --git a/backend/src/routes/status.js b/backend/src/routes/status.js new file mode 100644 index 0000000..1f649f6 --- /dev/null +++ b/backend/src/routes/status.js @@ -0,0 +1,59 @@ +import { requireAuth, requireCap } from '../auth.js' +import { pool } from '../db.js' +import { isConnected as mqttConnected } from '../lib/mqtt.js' + +export async function statusRoutes(app) { + app.addHook('preHandler', requireAuth) + + // GET /api/status — live status per zone: room state, target temp, device health/battery + app.get('/api/status', { preHandler: requireCap('view') }, async () => { + const { rows: zones } = await pool.query(` + SELECT z.*, zs.room_state, zs.last_transition_at, zs.last_booking_status + FROM zones z + LEFT JOIN zone_state zs ON zs.zone_id = z.id + WHERE z.active = TRUE + ORDER BY z.zone_type, z.name + `) + + const { rows: devices } = await pool.query(` + SELECT id, zone_id, device_type, external_ref, discovered_name, location, + health_state, battery_pct, wifi_rssi, current_target_temp, target_origin, last_seen + FROM zone_devices WHERE zone_id IS NOT NULL + `) + const devicesByZone = new Map() + for (const d of devices) { + if (!devicesByZone.has(d.zone_id)) devicesByZone.set(d.zone_id, []) + devicesByZone.get(d.zone_id).push(d) + } + + return { + mqtt_connected: mqttConnected(), + zones: zones.map(z => ({ + ...z, + room_state: z.room_state || 'vacant', + target_temp: ['heating_up', 'occupied'].includes(z.room_state) ? z.occupied_temp : z.vacant_temp, + devices: devicesByZone.get(z.id) || [], + })), + } + }) + + // GET /api/status/activity — recent activity log, optionally filtered by zone + app.get('/api/status/activity', { preHandler: requireCap('view') }, async (req) => { + const { zone_id, limit } = req.query || {} + const lim = Math.min(parseInt(limit) || 100, 500) + if (zone_id) { + const { rows } = await pool.query( + `SELECT al.*, z.name AS zone_name FROM activity_log al LEFT JOIN zones z ON z.id = al.zone_id + WHERE al.zone_id = $1 ORDER BY al.created_at DESC LIMIT $2`, + [zone_id, lim] + ) + return rows + } + const { rows } = await pool.query( + `SELECT al.*, z.name AS zone_name FROM activity_log al LEFT JOIN zones z ON z.id = al.zone_id + ORDER BY al.created_at DESC LIMIT $1`, + [lim] + ) + return rows + }) +} diff --git a/backend/src/routes/zones.js b/backend/src/routes/zones.js new file mode 100644 index 0000000..69e8205 --- /dev/null +++ b/backend/src/routes/zones.js @@ -0,0 +1,145 @@ +import { requireAuth, requireCap } from '../auth.js' +import { pool, getConfig, setConfig, logActivity } from '../db.js' +import { fetchSites, testConnection } from '../lib/newbook.js' + +export async function zoneRoutes(app) { + app.addHook('preHandler', requireAuth) + + // GET /api/zones — all zones with device counts + app.get('/api/zones', { preHandler: requireCap('view') }, async () => { + const { rows } = await pool.query(` + SELECT z.*, COUNT(zd.id)::int AS device_count + FROM zones z + LEFT JOIN zone_devices zd ON zd.zone_id = z.id + GROUP BY z.id + ORDER BY z.zone_type, z.name + `) + return rows + }) + + // PATCH /api/zones/:id — per-zone config: temps, offsets, auto_mode, sync_valves, exclude_bathroom + app.patch('/api/zones/:id', { preHandler: requireCap('schedule_edit') }, async (req, reply) => { + const { rows: existing } = await pool.query('SELECT * FROM zones WHERE id = $1', [req.params.id]) + if (!existing.length) return reply.status(404).send({ error: 'Zone not found' }) + const z = existing[0] + const { + name, occupied_temp, vacant_temp, heating_offset_min, cooling_offset_min, + auto_mode, sync_valves, exclude_bathroom, + } = req.body || {} + + const { rows } = await pool.query( + `UPDATE zones SET + name = $1, occupied_temp = $2, vacant_temp = $3, heating_offset_min = $4, + cooling_offset_min = $5, auto_mode = $6, sync_valves = $7, exclude_bathroom = $8 + WHERE id = $9 RETURNING *`, + [ + name ?? z.name, + occupied_temp ?? z.occupied_temp, + vacant_temp ?? z.vacant_temp, + heating_offset_min ?? z.heating_offset_min, + cooling_offset_min ?? z.cooling_offset_min, + auto_mode ?? z.auto_mode, + sync_valves ?? z.sync_valves, + exclude_bathroom ?? z.exclude_bathroom, + req.params.id, + ] + ) + if (auto_mode !== undefined && auto_mode !== z.auto_mode) { + await logActivity(z.id, 'override', { + note: auto_mode ? 'Auto mode re-enabled' : 'Auto mode disabled', + source: 'manual', + userEmail: req.user.email, + }) + } + return rows[0] + }) + + // POST /api/zones — manual zone creation (public areas, or any non-NewBook device group) + app.post('/api/zones', { preHandler: requireCap('settings') }, async (req, reply) => { + const { name, zone_type, occupied_temp, vacant_temp } = req.body || {} + if (!name) return reply.status(400).send({ error: 'name required' }) + if (!['room', 'public_area'].includes(zone_type)) { + return reply.status(400).send({ error: "zone_type must be 'room' or 'public_area'" }) + } + const { rows } = await pool.query( + `INSERT INTO zones (name, zone_type, source, newbook_site_id, occupied_temp, vacant_temp) + VALUES ($1, $2, 'manual', NULL, $3, $4) RETURNING *`, + [name, zone_type, occupied_temp || 22.0, vacant_temp || 16.0] + ) + return rows[0] + }) + + // ── NewBook include/exclude checklist (hk-planner's CategorySettings.tsx pattern) ── + + // GET /api/zones/newbook-sites — live sites list + excluded flag, for the checklist UI + app.get('/api/zones/newbook-sites', { preHandler: requireCap('settings') }, async (req, reply) => { + let sites + try { + sites = await fetchSites() + } catch (err) { + return reply.status(502).send({ error: `NewBook error: ${err.message}` }) + } + const excluded = (await getConfig('excluded_site_ids', [])) || [] + return { + sites: sites.map(s => ({ + site_id: String(s.site_id), + site_name: s.site_name, + category_name: s.category_name || null, + excluded: excluded.includes(String(s.site_id)), + })), + } + }) + + // PUT /api/zones/newbook-sites — save the excluded_site_ids list + app.put('/api/zones/newbook-sites', { preHandler: requireCap('settings') }, async (req, reply) => { + const { excluded_site_ids } = req.body || {} + if (!Array.isArray(excluded_site_ids)) return reply.status(400).send({ error: 'excluded_site_ids must be an array' }) + await setConfig('excluded_site_ids', excluded_site_ids.map(String)) + return { ok: true } + }) + + app.post('/api/zones/newbook-test', { preHandler: requireCap('settings') }, async () => { + return testConnection() + }) + + // POST /api/zones/sync-newbook — upsert a zone per non-excluded site (maintenance's + // locations.js sync-newbook pattern): ON CONFLICT (newbook_site_id) DO UPDATE, and + // soft-deactivate (never delete) any previously-synced zone whose site disappeared. + app.post('/api/zones/sync-newbook', { preHandler: requireCap('settings') }, async (req, reply) => { + let sites + try { + sites = await fetchSites() + } catch (err) { + return reply.status(502).send({ error: `NewBook error: ${err.message}` }) + } + + const excluded = new Set((await getConfig('excluded_site_ids', [])) || []) + const included = sites.filter(s => !excluded.has(String(s.site_id))) + + let created = 0, updated = 0 + for (const site of included) { + const siteId = String(site.site_id) + const name = site.site_name || `Room ${siteId}` + const res = await pool.query( + `INSERT INTO zones (name, zone_type, source, newbook_site_id) + VALUES ($1, 'room', 'newbook', $2) + ON CONFLICT (newbook_site_id) DO UPDATE SET name = EXCLUDED.name, active = TRUE + RETURNING (xmax = 0) AS inserted`, + [name, siteId] + ) + res.rows[0].inserted ? created++ : updated++ + } + + // Zones synced from NewBook whose site is now excluded/gone are deactivated, + // never deleted — preserves schedule config + activity history. + const includedIds = included.map(s => String(s.site_id)) + await pool.query( + `UPDATE zones SET active = FALSE + WHERE source = 'newbook' AND newbook_site_id IS NOT NULL AND NOT (newbook_site_id = ANY($1))`, + [includedIds.length ? includedIds : ['']] + ) + + await logActivity(null, 'sync', { note: `NewBook sync: ${created} created, ${updated} updated`, source: 'sync', userEmail: req.user.email }) + return { ok: true, created, updated, total: included.length } + }) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bf707b7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +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} + - APP_SLUG=hvac + - OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-disabled} + - NEWBOOK_LOCATION_ID=${NEWBOOK_LOCATION_ID:-} + volumes: + - uploads_data:/app/uploads + 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 + +volumes: + uploads_data: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..134f80d --- /dev/null +++ b/frontend/Dockerfile @@ -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/hvac +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..9a72893 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + HVAC + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..c6284ac --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,40 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + client_max_body_size 12m; + + location /hvac/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 /hvac/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 /hvac/health { + proxy_pass http://backend:3001/health; + } + + location ~* /hvac/.*\.(js|css|png|ico|svg|woff2?)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + location /hvac/ { + add_header Cache-Control "no-cache" always; + try_files $uri /hvac/index.html; + } + + location = / { + return 301 /hvac/; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..8211a51 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1901 @@ +{ + "name": "hnf-hvac-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hnf-hvac-frontend", + "version": "1.0.0", + "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" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz", + "integrity": "sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.3.tgz", + "integrity": "sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.3.tgz", + "integrity": "sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.3.tgz", + "integrity": "sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.3.tgz", + "integrity": "sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.3.tgz", + "integrity": "sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.3.tgz", + "integrity": "sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.3.tgz", + "integrity": "sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.3.tgz", + "integrity": "sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.3.tgz", + "integrity": "sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.3.tgz", + "integrity": "sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.3.tgz", + "integrity": "sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.3.tgz", + "integrity": "sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.3.tgz", + "integrity": "sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.3.tgz", + "integrity": "sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.3.tgz", + "integrity": "sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.3.tgz", + "integrity": "sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.3.tgz", + "integrity": "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.3.tgz", + "integrity": "sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.3.tgz", + "integrity": "sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.3.tgz", + "integrity": "sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.3.tgz", + "integrity": "sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.3.tgz", + "integrity": "sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.3.tgz", + "integrity": "sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.3.tgz", + "integrity": "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.4.tgz", + "integrity": "sha512-s4+sLr9mZ/CyqeRritFeYV/Zx73OAtmaHn6kkBS1XRoJn1hrg3xIDUcpicAEX68tkcIN0iBCgti31C8zxtkhsQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.396", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz", + "integrity": "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz", + "integrity": "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.3", + "@rollup/rollup-android-arm64": "4.62.3", + "@rollup/rollup-darwin-arm64": "4.62.3", + "@rollup/rollup-darwin-x64": "4.62.3", + "@rollup/rollup-freebsd-arm64": "4.62.3", + "@rollup/rollup-freebsd-x64": "4.62.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.3", + "@rollup/rollup-linux-arm-musleabihf": "4.62.3", + "@rollup/rollup-linux-arm64-gnu": "4.62.3", + "@rollup/rollup-linux-arm64-musl": "4.62.3", + "@rollup/rollup-linux-loong64-gnu": "4.62.3", + "@rollup/rollup-linux-loong64-musl": "4.62.3", + "@rollup/rollup-linux-ppc64-gnu": "4.62.3", + "@rollup/rollup-linux-ppc64-musl": "4.62.3", + "@rollup/rollup-linux-riscv64-gnu": "4.62.3", + "@rollup/rollup-linux-riscv64-musl": "4.62.3", + "@rollup/rollup-linux-s390x-gnu": "4.62.3", + "@rollup/rollup-linux-x64-gnu": "4.62.3", + "@rollup/rollup-linux-x64-musl": "4.62.3", + "@rollup/rollup-openbsd-x64": "4.62.3", + "@rollup/rollup-openharmony-arm64": "4.62.3", + "@rollup/rollup-win32-arm64-msvc": "4.62.3", + "@rollup/rollup-win32-ia32-msvc": "4.62.3", + "@rollup/rollup-win32-x64-gnu": "4.62.3", + "@rollup/rollup-win32-x64-msvc": "4.62.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..0da6cbc --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "hnf-hvac-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" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..6aa8843 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,33 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' +import AuthGate, { useAuth } from './components/AuthGate' +import Layout from './components/Layout' +import Dashboard from './pages/Dashboard' +import Devices from './pages/Devices' +import Settings from './pages/Settings' +import { can } from './types' + +function Home() { + const { user } = useAuth() + if (can(user, 'view')) return + if (can(user, 'manage_devices')) return + if (can(user, 'settings')) return + return

You don't have access to any HVAC pages yet.

+} + +export default function App() { + return ( + + + + + } /> + } /> + } /> + } /> + } /> + + + + + ) +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..4d479d5 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,108 @@ +import type { + Zone, ZoneStatus, Device, DevicePhoto, ActivityEntry, NewbookSite, AppConfig, +} from './types' + +const BASE = '/hvac/api' + +async function request(path: string, opts: RequestInit = {}): Promise { + 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() +} + +// Zones +export function fetchZones(): Promise { + return request('/zones') +} +export function updateZone(id: number, body: Record): Promise { + return request(`/zones/${id}`, { method: 'PATCH', body: JSON.stringify(body) }) +} +export function createZone(body: { name: string; zone_type: 'room' | 'public_area'; occupied_temp?: number; vacant_temp?: number }): Promise { + return request('/zones', { method: 'POST', body: JSON.stringify(body) }) +} +export function fetchNewbookSites(): Promise<{ sites: NewbookSite[] }> { + return request('/zones/newbook-sites') +} +export function saveExcludedSites(excludedSiteIds: string[]): Promise<{ ok: boolean }> { + return request('/zones/newbook-sites', { method: 'PUT', body: JSON.stringify({ excluded_site_ids: excludedSiteIds }) }) +} +export function testNewbookConnection(): Promise<{ ok: boolean; message?: string; error?: string }> { + return request('/zones/newbook-test', { method: 'POST', body: JSON.stringify({}) }) +} +export function syncZonesFromNewbook(): Promise<{ ok: boolean; created: number; updated: number; total: number }> { + return request('/zones/sync-newbook', { method: 'POST', body: JSON.stringify({}) }) +} + +// Status / activity +export function fetchStatus(): Promise<{ mqtt_connected: boolean; zones: ZoneStatus[] }> { + return request('/status') +} +export function fetchActivity(zoneId?: number, limit = 100): Promise { + const params = new URLSearchParams() + if (zoneId) params.set('zone_id', String(zoneId)) + params.set('limit', String(limit)) + return request(`/status/activity?${params.toString()}`) +} + +// Override +export function overrideZone(id: number, tempC: number): Promise<{ ok: boolean; successful: number; total: number; auto_mode: boolean; error?: string }> { + return request(`/zones/${id}/override`, { method: 'POST', body: JSON.stringify({ temp_c: tempC }) }) +} + +// Devices +export function fetchDevices(): Promise { + return request('/devices') +} +export function discoverDevices(deviceType: string): Promise<{ ok: boolean; devices?: unknown[]; found?: number; new?: number; note?: string; error?: string }> { + return request('/devices/discover', { method: 'POST', body: JSON.stringify({ device_type: deviceType }) }) +} +export function updateDevice(id: number, body: { zone_id?: number | null; location?: string }): Promise { + return request(`/devices/${id}`, { method: 'PATCH', body: JSON.stringify(body) }) +} +export function createMaintenanceAsset(id: number): Promise<{ ok: boolean; notImplemented?: boolean; error?: string }> { + return request(`/devices/${id}/create-maintenance-asset`, { method: 'POST', body: JSON.stringify({}) }) +} + +// Device photos — multipart, so no JSON content-type header +export async function uploadDevicePhoto(deviceId: number, file: File, photoType: 'device' | 'serial_plate'): Promise { + const form = new FormData() + form.append('photo_type', photoType) + form.append('file', file) + const res = await fetch(`${BASE}/devices/${deviceId}/photos`, { method: 'POST', credentials: 'include', body: form }) + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })) + throw new Error(err.error || `Upload failed: ${res.status}`) + } + return res.json() +} +export function fetchDevicePhotos(deviceId: number): Promise { + return request(`/devices/${deviceId}/photos`) +} +export function deleteDevicePhoto(id: number): Promise<{ ok: boolean }> { + return request(`/photos/${id}`, { method: 'DELETE' }) +} +export function photoUrl(filePath: string): string { + return `${BASE}/uploads${filePath}` +} + +// Config / settings +export function fetchConfig(): Promise { + return request('/config') +} +export function updateConfig(key: string, value: unknown): Promise<{ ok: boolean }> { + return request(`/config/${key}`, { method: 'PUT', body: JSON.stringify({ value }) }) +} +export function fetchMqttStatus(): Promise<{ connected: boolean; note: string }> { + return request('/settings/mqtt-status') +} diff --git a/frontend/src/components/AuthGate.tsx b/frontend/src/components/AuthGate.tsx new file mode 100644 index 0000000..e6d396a --- /dev/null +++ b/frontend/src/components/AuthGate.tsx @@ -0,0 +1,166 @@ +import { useEffect, useRef, useState, createContext, useContext } from 'react' +import type { User } from '../types' + +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 +} + +// Only bounce to the central login when actually embedded in the portal shell. +// A directly-opened browser tab must never navigate away from its own scope. +function isEmbedded() { + return window.top !== window +} + +interface AuthCtx { user: User } +const Ctx = createContext(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(null) + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + const timerRef = useRef | null>(null) + + useEffect(() => { + fetch('/api/auth/verify?app=hvac', { 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/hvac')}` + } else { + setState('login') + } + }) + .catch(() => { if (!isEmbedded()) setState('login') }) + }, []) + + // Inactivity auto-logout — configurable per device (Admin Settings → Device). + 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=hvac', { 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 ( +
+ Loading… +
+ ) + } + + if (state === 'login') { + return ( +
+
+

+ HVAC +

+
+ setEmail(e.target.value)} + placeholder="Email" required autoComplete="email" + style={inputStyle} + /> + setPassword(e.target.value)} + placeholder="Password" required autoComplete="current-password" + style={inputStyle} + /> + {error &&

{error}

} + +
+
+
+ ) + } + + return ( + + {children} + + ) +} + +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', +} diff --git a/frontend/src/components/DevicePhotoUpload.tsx b/frontend/src/components/DevicePhotoUpload.tsx new file mode 100644 index 0000000..02523fc --- /dev/null +++ b/frontend/src/components/DevicePhotoUpload.tsx @@ -0,0 +1,93 @@ +import { useRef, useState } from 'react' +import { Camera, Image as ImageIcon, Loader2, X } from 'lucide-react' +import type { DevicePhoto, PhotoType } from '../types' +import { uploadDevicePhoto, deleteDevicePhoto, photoUrl } from '../api' + +// Camera/library capture split: one input with capture="environment" (opens the +// device camera directly on mobile), one plain file input (opens the photo +// library/file picker) — same split used elsewhere in the stack for mobile uploads. +export default function DevicePhotoUpload({ deviceId, photoType, photos, onChanged, canDelete }: { + deviceId: number + photoType: PhotoType + photos: DevicePhoto[] + onChanged: () => void + canDelete: boolean +}) { + const [uploading, setUploading] = useState(false) + const [error, setError] = useState('') + const [lightbox, setLightbox] = useState(null) + const cameraInput = useRef(null) + const libraryInput = useRef(null) + + const slotPhotos = photos.filter(p => p.photo_type === photoType) + + async function handleFile(file: File | undefined) { + if (!file) return + setUploading(true); setError('') + try { + await uploadDevicePhoto(deviceId, file, photoType) + onChanged() + } catch (e) { + setError(e instanceof Error ? e.message : 'Upload failed') + } finally { + setUploading(false) + } + } + + async function remove(id: number) { + await deleteDevicePhoto(id).catch(() => {}) + onChanged() + } + + return ( +
+
+ {slotPhotos.map(p => ( +
+ setLightbox(photoUrl(p.file_path))} /> + {canDelete && ( + + )} +
+ ))} + {uploading && ( +
+
+ +
+
+ )} +
+ + {error &&
{error}
} + +
+ + { handleFile(e.target.files?.[0]); e.target.value = '' }} + /> + + { handleFile(e.target.files?.[0]); e.target.value = '' }} + /> +
+ + {lightbox && ( +
setLightbox(null)}> + + +
+ )} +
+ ) +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..bec0041 --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,72 @@ +import { useState, useEffect } from 'react' +import { NavLink, useLocation } from 'react-router-dom' +import { Thermometer, LayoutGrid, Cpu, Settings, Menu, LogOut } from 'lucide-react' +import { useAuth } from './AuthGate' +import { can } from '../types' + +const ICON_PROPS = { size: 16, strokeWidth: 1.75 } + +const NAV = [ + { to: '/dashboard', label: 'Dashboard', icon: LayoutGrid, cap: 'view' }, + { to: '/devices', label: 'Devices', icon: Cpu, cap: 'manage_devices' }, + { to: '/settings', label: 'Settings', icon: Settings, cap: 'settings' }, +] + +export default function Layout({ children }: { children: React.ReactNode }) { + const { user } = useAuth() + const items = NAV.filter(n => can(user, n.cap)) + const location = useLocation() + const [menuOpen, setMenuOpen] = useState(false) + + async function logout() { + await fetch('/hvac/api/auth/logout', { method: 'POST', credentials: 'include' }) + window.location.reload() + } + + useEffect(() => { setMenuOpen(false) }, [location.pathname]) + + return ( +
+ + + {menuOpen &&
setMenuOpen(false)} />} + +
+ + + HVAC +
+ +
+ {children} +
+
+ ) +} diff --git a/frontend/src/components/ZoneCard.tsx b/frontend/src/components/ZoneCard.tsx new file mode 100644 index 0000000..118f585 --- /dev/null +++ b/frontend/src/components/ZoneCard.tsx @@ -0,0 +1,27 @@ +import { Thermometer, BatteryLow } from 'lucide-react' +import type { ZoneStatus } from '../types' +import { ROOM_STATE_LABELS } from '../types' + +export default function ZoneCard({ zone, onClick }: { zone: ZoneStatus; onClick: () => void }) { + const unhealthy = zone.devices.filter(d => d.health_state !== 'healthy').length + const lowBattery = zone.devices.some(d => d.battery_pct != null && d.battery_pct < 30) + + return ( +
+
+ {zone.name} + {!zone.auto_mode && Manual} +
+
{ROOM_STATE_LABELS[zone.room_state]}
+
+ + {Number(zone.target_temp).toFixed(1)}°C +
+
+ {zone.device_count ?? zone.devices.length} device{(zone.device_count ?? zone.devices.length) === 1 ? '' : 's'} + {unhealthy > 0 && {unhealthy} needs attention} + {lowBattery && } +
+
+ ) +} diff --git a/frontend/src/components/ZoneDetailModal.tsx b/frontend/src/components/ZoneDetailModal.tsx new file mode 100644 index 0000000..5cd6560 --- /dev/null +++ b/frontend/src/components/ZoneDetailModal.tsx @@ -0,0 +1,180 @@ +import { useEffect, useState } from 'react' +import { X, Thermometer, History } from 'lucide-react' +import type { ZoneStatus, ActivityEntry } from '../types' +import { ROOM_STATE_LABELS, can } from '../types' +import { useAuth } from './AuthGate' +import { updateZone, overrideZone, fetchActivity } from '../api' + +export default function ZoneDetailModal({ zone, onClose, onSaved }: { + zone: ZoneStatus + onClose: () => void + onSaved: () => void +}) { + const { user } = useAuth() + const canEdit = can(user, 'schedule_edit') + const canControl = can(user, 'control') + + const [occupiedTemp, setOccupiedTemp] = useState(zone.occupied_temp) + const [vacantTemp, setVacantTemp] = useState(zone.vacant_temp) + const [heatingOffset, setHeatingOffset] = useState(zone.heating_offset_min) + const [coolingOffset, setCoolingOffset] = useState(zone.cooling_offset_min) + const [autoMode, setAutoMode] = useState(zone.auto_mode) + const [syncValves, setSyncValves] = useState(zone.sync_valves) + const [excludeBathroom, setExcludeBathroom] = useState(zone.exclude_bathroom) + const [overrideTemp, setOverrideTemp] = useState(zone.occupied_temp) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + const [msg, setMsg] = useState('') + const [activity, setActivity] = useState([]) + + useEffect(() => { + fetchActivity(zone.id, 20).then(setActivity).catch(() => {}) + }, [zone.id]) + + async function save() { + setSaving(true); setError(''); setMsg('') + try { + await updateZone(zone.id, { + occupied_temp: Number(occupiedTemp), + vacant_temp: Number(vacantTemp), + heating_offset_min: heatingOffset, + cooling_offset_min: coolingOffset, + auto_mode: autoMode, + sync_valves: syncValves, + exclude_bathroom: excludeBathroom, + }) + setMsg('Saved') + onSaved() + } catch (e) { + setError(e instanceof Error ? e.message : 'Save failed') + } finally { + setSaving(false) + } + } + + async function forceTemp() { + setSaving(true); setError(''); setMsg('') + try { + const res = await overrideZone(zone.id, Number(overrideTemp)) + if (res.ok) { + setMsg(`Set to ${overrideTemp}°C (${res.successful}/${res.total} devices) — auto mode disabled`) + setAutoMode(false) + onSaved() + } else { + setError(res.error || 'Override failed') + } + } catch (e) { + setError(e instanceof Error ? e.message : 'Override failed') + } finally { + setSaving(false) + } + } + + return ( +
+
e.stopPropagation()}> +
+

{zone.name}

+ +
+ +
+ {ROOM_STATE_LABELS[zone.room_state]} +
+ + {error &&
{error}
} + {msg &&
{msg}
} + +
Devices
+ {zone.devices.length === 0 &&

No devices mapped to this zone yet — assign some on the Devices page.

} + {zone.devices.map(d => ( +
+
+
{d.location || d.discovered_name || d.external_ref}
+
+ {d.current_target_temp != null ? `${Number(d.current_target_temp).toFixed(1)}°C` : '—'} + {d.battery_pct != null && ` · ${d.battery_pct}% battery`} +
+
+ {d.health_state.replace('_', ' ')} +
+ ))} + + {canControl && ( + <> +
Manual Override
+
+
+ setOverrideTemp(e.target.value)} /> +
+ +
+

Disables auto mode for this zone until re-enabled below.

+ + )} + + {canEdit && ( + <> +
Schedule
+
+
+ + setOccupiedTemp(e.target.value)} /> +
+
+ + setVacantTemp(e.target.value)} /> +
+
+
+
+ + setHeatingOffset(parseInt(e.target.value) || 0)} /> +
+
+ + setCoolingOffset(parseInt(e.target.value) || 0)} /> +
+
+ + + + +
+ +
+ + )} + + {activity.length > 0 && ( + <> +
Recent activity
+
+ {activity.map(a => ( +
+
+ {a.note} +
{new Date(a.created_at).toLocaleString()} · {a.source}
+
+
+ ))} +
+ + )} +
+
+ ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..21b96c2 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,394 @@ +/* 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 + semantic tokens */ +:root { + --app-primary: #c1440e; + --app-primary-light: #e0692f; + + /* Room state colours — vacant/booked = cool greys/blues, heating_up/occupied = warm */ + --st-vacant: #64748b; + --st-booked: #2563eb; + --st-heating-up: #d97706; + --st-occupied: #dc2626; + --st-cooling-down: #0891b2; + + --health-healthy: #16a34a; + --health-degraded: #d97706; + --health-poor: #ea580c; + --health-unresponsive: #dc2626; + --health-calibration: #7c3aed; + + --danger-bg: #fef2f2; + --warn-bg: #fffbeb; + --ok-bg: #f0fdf4; + + --sidebar-w: 240px; + --topbar-h: 56px; +} + +*, *::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; } + +/* ── App shell ─────────────────────────────────────────────── */ +.app-shell { display: flex; height: 100vh; overflow: hidden; } + +.sidebar { + width: var(--sidebar-w); + background: var(--navy); + display: flex; + flex-direction: column; + flex-shrink: 0; + overflow-y: auto; +} +.sidebar-logo { + padding: 20px 16px 12px; + color: var(--gold); + font-size: 13px; + font-weight: 600; + letter-spacing: .05em; + text-transform: uppercase; + display: flex; + align-items: center; + gap: 8px; +} +.sidebar-logo svg { opacity: .8; } +.sidebar-nav { flex: 1; padding: 8px 0; } +.sidebar-nav a { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + color: var(--text-muted); + text-decoration: none; + font-size: 13.5px; + transition: background .15s, color .15s; +} +.sidebar-nav a:hover { background: var(--surface); color: var(--text); } +.sidebar-nav a.active { background: rgba(201,168,76,.1); color: var(--gold); } +.sidebar-user { + padding: 12px 16px; + border-top: 1px solid var(--surface-2); + color: var(--text-muted); + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.top-bar { + display: none; + height: var(--topbar-h); + background: var(--navy); + color: var(--text); + align-items: center; + padding: 0 12px; + gap: 10px; + flex-shrink: 0; +} +.top-bar-title { flex: 1; font-size: 15px; font-weight: 600; color: var(--gold); } + +.page-content { flex: 1; overflow-y: auto; display: flex; flex-direction: column; } + +@media (max-width: 768px) { + .sidebar { + position: fixed; + top: 0; left: 0; bottom: 0; + z-index: 200; + transform: translateX(calc(-1 * var(--sidebar-w))); + transition: transform 0.25s ease; + } + .app-shell.menu-open .sidebar { transform: translateX(0); } + .top-bar { display: flex; } + .app-shell { flex-direction: column; } + .field-row { flex-direction: column; } +} + +.menu-backdrop { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.5); + z-index: 199; +} + +.top-bar-burger { + background: none; + border: none; + color: var(--text); + cursor: pointer; + display: flex; + align-items: center; + padding: 4px; + flex-shrink: 0; +} + +/* ── Page chrome ───────────────────────────────────────────── */ +.page { padding: 20px; max-width: 1100px; width: 100%; margin: 0 auto; } +.page-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; } +.page-header h1 { font-size: 18px; margin: 0; flex: 1; } + +/* ── Buttons ───────────────────────────────────────────────── */ +.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); } +.btn-danger { background: var(--danger); border-color: var(--danger); color: #fff; } +.btn-sm { padding: 4px 10px; font-size: 12px; border-radius: 8px; } + +/* ── Forms ─────────────────────────────────────────────────── */ +.field { margin-bottom: 12px; } +.field label { display: block; font-size: 12px; font-weight: 600; color: var(--text-mid); margin-bottom: 4px; } +.field input[type="text"], .field input[type="email"], .field input[type="date"], +.field input[type="number"], .field select, .field textarea { + width: 100%; + border: 1px solid var(--card-border); + border-radius: 8px; + padding: 8px 10px; + font-size: 13.5px; + font-family: var(--font); + color: var(--text-dark); + background: var(--card-bg); +} +.field textarea { min-height: 72px; resize: vertical; } +.field-row { display: flex; gap: 12px; } +.field-row > .field { flex: 1; } +.field-check { display: flex; align-items: center; gap: 8px; font-size: 13.5px; cursor: pointer; } +.field-check input { width: 16px; height: 16px; accent-color: var(--gold); } +.field-hint { font-size: 11.5px; color: var(--text-mid); margin-top: 3px; } + +/* ── Cards ─────────────────────────────────────────────────── */ +.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; +} + +/* ── Zone grid / cards ─────────────────────────────────────── */ +.zone-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; } +.zone-card { + cursor: pointer; + transition: box-shadow .12s, transform .12s; + border-left: 4px solid var(--st-vacant); +} +.zone-card:hover { box-shadow: var(--shadow-md); transform: translateY(-1px); } +.zone-card.st-vacant { border-left-color: var(--st-vacant); } +.zone-card.st-booked { border-left-color: var(--st-booked); } +.zone-card.st-heating_up { border-left-color: var(--st-heating-up); } +.zone-card.st-occupied { border-left-color: var(--st-occupied); } +.zone-card.st-cooling_down { border-left-color: var(--st-cooling-down); } +.zone-card-title { font-weight: 600; font-size: 14px; margin-bottom: 4px; display: flex; align-items: center; gap: 6px; justify-content: space-between; } +.zone-card-temp { font-size: 22px; font-weight: 700; margin: 6px 0 2px; } +.zone-card-meta { font-size: 12px; color: var(--text-mid); display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } + +/* ── Badges ────────────────────────────────────────────────── */ +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + border-radius: 20px; + padding: 2px 9px; + font-size: 11px; + font-weight: 600; + color: #fff; + white-space: nowrap; +} +.badge-st-vacant { background: var(--st-vacant); } +.badge-st-booked { background: var(--st-booked); } +.badge-st-heating_up { background: var(--st-heating-up); } +.badge-st-occupied { background: var(--st-occupied); } +.badge-st-cooling_down { background: var(--st-cooling-down); } + +.badge-health-healthy { background: var(--health-healthy); } +.badge-health-degraded { background: var(--health-degraded); } +.badge-health-poor { background: var(--health-poor); } +.badge-health-unresponsive { background: var(--health-unresponsive); } +.badge-health-calibration_error { background: var(--health-calibration); } + +.badge-outline { + background: transparent; + border: 1px solid var(--card-border); + color: var(--text-mid); + font-weight: 500; +} + +/* ── Modal ─────────────────────────────────────────────────── */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(15,15,32,.55); + display: flex; + align-items: flex-start; + justify-content: center; + padding: 24px 12px; + z-index: 100; + overflow-y: auto; +} +.modal { + background: var(--card-bg); + border-radius: var(--radius); + box-shadow: var(--shadow-md); + width: 100%; + max-width: 680px; + padding: 20px; + margin: auto 0; +} +.modal-header { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 14px; } +.modal-header h2 { font-size: 16px; margin: 0; flex: 1; } +.modal-close { + background: none; + border: none; + cursor: pointer; + color: var(--text-mid); + padding: 2px; + display: flex; +} +.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; flex-wrap: wrap; } + +/* ── Timeline / activity log ──────────────────────────────── */ +.timeline { margin: 8px 0; } +.timeline-item { + display: flex; + gap: 10px; + padding: 8px 0; + border-bottom: 1px solid var(--card-border); + font-size: 13px; +} +.timeline-item:last-child { border-bottom: none; } +.timeline-icon { color: var(--text-mid); flex-shrink: 0; margin-top: 1px; } +.timeline-body { flex: 1; min-width: 0; } +.timeline-meta { font-size: 11.5px; color: var(--text-mid); margin-top: 2px; } + +/* ── Photos ────────────────────────────────────────────────── */ +.photo-grid { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0; } +.photo-thumb { + width: 84px; + height: 84px; + border-radius: 8px; + object-fit: cover; + border: 1px solid var(--card-border); + cursor: pointer; +} +.photo-thumb-uploading { + display: flex; align-items: center; justify-content: center; + background: var(--card-border); color: var(--text-mid); +} +@keyframes spin { to { transform: rotate(360deg); } } +.spin { animation: spin .75s linear infinite; } +.photo-thumb-wrap { position: relative; } +.photo-del { + position: absolute; + top: -6px; + right: -6px; + background: var(--danger); + color: #fff; + border: none; + border-radius: 50%; + width: 20px; + height: 20px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} +.lightbox-overlay { + position: fixed; inset: 0; z-index: 200; + background: rgba(0,0,0,.92); + display: flex; align-items: center; justify-content: center; + padding: 16px; +} +.lightbox-img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 4px; } +.lightbox-close { + position: absolute; top: 16px; right: 16px; + background: rgba(255,255,255,.15); border: none; border-radius: 50%; + width: 38px; height: 38px; display: flex; align-items: center; justify-content: center; + cursor: pointer; color: #fff; transition: background .12s; +} +.lightbox-close:hover { background: rgba(255,255,255,.28); } + +/* ── Tables ────────────────────────────────────────────────── */ +.table-wrap { overflow-x: auto; background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); box-shadow: var(--shadow-sm); } +table.data { width: 100%; border-collapse: collapse; font-size: 13px; } +table.data th { + text-align: left; + padding: 9px 12px; + font-size: 11.5px; + text-transform: uppercase; + letter-spacing: .04em; + color: var(--text-mid); + border-bottom: 1px solid var(--card-border); + white-space: nowrap; +} +table.data td { padding: 9px 12px; border-bottom: 1px solid var(--card-border); vertical-align: top; } +table.data tr:last-child td { border-bottom: none; } + +/* ── Misc ──────────────────────────────────────────────────── */ +.empty-state { text-align: center; color: var(--text-mid); padding: 40px 16px; font-size: 13.5px; } +.error-banner { + background: var(--danger-bg); + border: 1px solid var(--danger); + color: var(--danger); + border-radius: var(--radius); + padding: 10px 14px; + margin-bottom: 12px; + font-size: 13px; +} +.ok-banner { + background: var(--ok-bg); + border: 1px solid var(--health-healthy); + color: var(--health-healthy); + border-radius: var(--radius); + padding: 10px 14px; + margin-bottom: 12px; + font-size: 13px; +} +.section-title { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--text-mid); margin: 18px 0 8px; } +.muted { color: var(--text-mid); } + +/* Sidebar scrollbar */ +.sidebar::-webkit-scrollbar, .sidebar-nav::-webkit-scrollbar { width: 4px; } +.sidebar::-webkit-scrollbar-track, .sidebar-nav::-webkit-scrollbar-track { background: transparent; } +.sidebar::-webkit-scrollbar-thumb, .sidebar-nav::-webkit-scrollbar-thumb { background: rgba(201,168,76,0.35); border-radius: 2px; } +.sidebar::-webkit-scrollbar-thumb:hover, .sidebar-nav::-webkit-scrollbar-thumb:hover { background: rgba(201,168,76,0.65); } +.sidebar, .sidebar-nav { scrollbar-width: thin; scrollbar-color: rgba(201,168,76,0.35) transparent; } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..4a1b150 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +) diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..5f43e58 --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState, useCallback } from 'react' +import { WifiOff } from 'lucide-react' +import { fetchStatus } from '../api' +import type { ZoneStatus } from '../types' +import ZoneCard from '../components/ZoneCard' +import ZoneDetailModal from '../components/ZoneDetailModal' + +const POLL_MS = 30000 + +export default function Dashboard() { + const [zones, setZones] = useState([]) + const [mqttConnected, setMqttConnected] = useState(true) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [selected, setSelected] = useState(null) + + const load = useCallback(() => { + fetchStatus() + .then(res => { + setZones(res.zones) + setMqttConnected(res.mqtt_connected) + setError('') + }) + .catch(e => setError(e instanceof Error ? e.message : 'Failed to load status')) + .finally(() => setLoading(false)) + }, []) + + useEffect(() => { + load() + const t = setInterval(load, POLL_MS) + return () => clearInterval(t) + }, [load]) + + const rooms = zones.filter(z => z.zone_type === 'room') + const publicAreas = zones.filter(z => z.zone_type === 'public_area') + + if (loading) return

Loading…

+ + return ( +
+
+

Dashboard

+
+ + {error &&
{error}
} + {!mqttConnected && ( +
+ + MQTT broker not connected — device status may be stale and setpoint changes won't reach TRVs. +
+ )} + +
Rooms
+ {rooms.length === 0 ? ( +
No room zones yet — sync from NewBook on the Settings page.
+ ) : ( +
+ {rooms.map(z => setSelected(z)} />)} +
+ )} + + {publicAreas.length > 0 && ( + <> +
Public Areas
+
+ {publicAreas.map(z => setSelected(z)} />)} +
+ + )} + + {selected && ( + setSelected(null)} + onSaved={() => { load(); setSelected(null) }} + /> + )} +
+ ) +} diff --git a/frontend/src/pages/Devices.tsx b/frontend/src/pages/Devices.tsx new file mode 100644 index 0000000..fd4207f --- /dev/null +++ b/frontend/src/pages/Devices.tsx @@ -0,0 +1,193 @@ +import { Fragment, useEffect, useState } from 'react' +import { RadioTower, ChevronDown, ChevronRight, Wrench } from 'lucide-react' +import { + fetchDevices, discoverDevices, updateDevice, fetchZones, fetchDevicePhotos, createMaintenanceAsset, +} from '../api' +import type { Device, Zone, DevicePhoto, DeviceType } from '../types' +import { DEVICE_TYPE_LABELS, IMPLEMENTED_DEVICE_TYPES } from '../types' +import DevicePhotoUpload from '../components/DevicePhotoUpload' +import { useAuth } from '../components/AuthGate' +import { can } from '../types' + +const ALL_DEVICE_TYPES: DeviceType[] = ['shelly_trv', 'mhi_modbus', 'midea', 'daikin', 'home_assistant'] + +export default function Devices() { + const { user } = useAuth() + const canManage = can(user, 'manage_devices') + + const [devices, setDevices] = useState([]) + const [zones, setZones] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [msg, setMsg] = useState('') + const [discovering, setDiscovering] = useState(null) + const [expanded, setExpanded] = useState(null) + const [photosByDevice, setPhotosByDevice] = useState>({}) + + function load() { + Promise.all([fetchDevices(), fetchZones()]) + .then(([d, z]) => { setDevices(d); setZones(z) }) + .catch(e => setError(e instanceof Error ? e.message : 'Failed to load')) + .finally(() => setLoading(false)) + } + useEffect(load, []) + + async function runDiscover(deviceType: DeviceType) { + setDiscovering(deviceType); setError(''); setMsg('') + try { + const res = await discoverDevices(deviceType) + if (res.note) setMsg(res.note) + else if (res.ok) setMsg(`Found ${res.found ?? 0} device(s), ${res.new ?? 0} new`) + else setMsg(res.error || 'Discovery returned no result') + load() + } catch (e) { + setError(e instanceof Error ? e.message : 'Discovery failed') + } finally { + setDiscovering(null) + } + } + + async function assign(id: number, zoneId: number | null, location: string) { + try { + await updateDevice(id, { zone_id: zoneId, location }) + load() + } catch (e) { + setError(e instanceof Error ? e.message : 'Save failed') + } + } + + async function toggleExpand(id: number) { + if (expanded === id) { setExpanded(null); return } + setExpanded(id) + if (!photosByDevice[id]) { + const photos = await fetchDevicePhotos(id).catch(() => []) + setPhotosByDevice(prev => ({ ...prev, [id]: photos })) + } + } + + async function reloadPhotos(id: number) { + const photos = await fetchDevicePhotos(id).catch(() => []) + setPhotosByDevice(prev => ({ ...prev, [id]: photos })) + } + + async function createAsset(id: number) { + const res = await createMaintenanceAsset(id).catch(e => ({ ok: false, error: e.message })) + setMsg(res.error || (res.ok ? 'Asset created' : 'Not available yet')) + } + + if (loading) return

Loading…

+ + return ( +
+
+

Devices

+
+ + {error &&
{error}
} + {msg &&
{msg}
} + + {canManage && ( + <> +
Discover
+
+ {ALL_DEVICE_TYPES.map(dt => ( + + ))} +
+ + )} + +
Mapped & Discovered Devices
+ {devices.length === 0 ? ( +
No devices discovered yet — run a discover scan above.
+ ) : ( +
+ + + + + + + + + + + + + + {devices.map(d => ( + + toggleExpand(d.id)}> + + + + + + + + + {expanded === d.id && ( + + + + )} + + ))} + +
DeviceTypeZoneLocationHealthPhotos
{expanded === d.id ? : }{d.discovered_name || d.external_ref}
{d.external_ref}
{DEVICE_TYPE_LABELS[d.device_type]} e.stopPropagation()}> + + e.stopPropagation()}> + assign(d.id, d.zone_id, e.target.value)} + style={{ width: 120, border: '1px solid var(--card-border)', borderRadius: 6, padding: '4px 6px', fontSize: 12.5 }} + /> + {d.health_state.replace('_', ' ')}{d.photo_count}
+
+
+
Device photo
+ reloadPhotos(d.id)} + canDelete={canManage} + /> +
+
+
Serial / model plate photo
+ reloadPhotos(d.id)} + canDelete={canManage} + /> +
+
+ {canManage && d.zone_id && ( + + )} +
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx new file mode 100644 index 0000000..878a652 --- /dev/null +++ b/frontend/src/pages/Settings.tsx @@ -0,0 +1,213 @@ +import { useEffect, useState } from 'react' +import { Wifi, WifiOff } from 'lucide-react' +import { + fetchNewbookSites, saveExcludedSites, testNewbookConnection, syncZonesFromNewbook, + fetchConfig, updateConfig, fetchMqttStatus, createZone, +} from '../api' +import type { NewbookSite, AppConfig } from '../types' + +export default function Settings() { + const [sites, setSites] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [msg, setMsg] = useState('') + const [testing, setTesting] = useState(false) + const [syncing, setSyncing] = useState(false) + + const [config, setConfig] = useState(null) + const [pollMinutes, setPollMinutes] = useState(10) + const [defaultArrival, setDefaultArrival] = useState('15:00:00') + const [defaultDeparture, setDefaultDeparture] = useState('10:00:00') + const [maintUrl, setMaintUrl] = useState('') + const [maintKey, setMaintKey] = useState('') + const [configSaving, setConfigSaving] = useState(false) + + const [mqttConnected, setMqttConnected] = useState(null) + const [mqttNote, setMqttNote] = useState('') + + const [publicAreaName, setPublicAreaName] = useState('') + const [creatingZone, setCreatingZone] = useState(false) + + function loadSites() { + fetchNewbookSites() + .then(res => setSites(res.sites)) + .catch(e => setError(e instanceof Error ? e.message : 'Failed to load NewBook sites')) + .finally(() => setLoading(false)) + } + + useEffect(() => { + loadSites() + fetchConfig().then(cfg => { + setConfig(cfg) + setPollMinutes(cfg.poll_interval_minutes ?? 10) + setDefaultArrival(cfg.default_arrival_time ?? '15:00:00') + setDefaultDeparture(cfg.default_departure_time ?? '10:00:00') + setMaintUrl(cfg.maintenance_url ?? '') + setMaintKey(cfg.maintenance_api_key ?? '') + }).catch(() => {}) + fetchMqttStatus().then(s => { setMqttConnected(s.connected); setMqttNote(s.note) }).catch(() => {}) + }, []) + + function toggleExcluded(siteId: string) { + setSites(sites.map(s => s.site_id === siteId ? { ...s, excluded: !s.excluded } : s)) + } + + async function saveSites() { + setError(''); setMsg('') + try { + await saveExcludedSites(sites.filter(s => s.excluded).map(s => s.site_id)) + setMsg('Saved'); setTimeout(() => setMsg(''), 2500) + } catch (e) { + setError(e instanceof Error ? e.message : 'Save failed') + } + } + + async function handleTest() { + setTesting(true); setError(''); setMsg('') + try { + const res = await testNewbookConnection() + setMsg(res.ok ? `Connection OK: ${res.message || ''}` : `Failed: ${res.error || 'unknown'}`) + } catch (e) { + setError(e instanceof Error ? e.message : 'Test failed') + } finally { + setTesting(false) + } + } + + async function handleSync() { + setSyncing(true); setError(''); setMsg('') + try { + const res = await syncZonesFromNewbook() + setMsg(`Synced: ${res.created} created, ${res.updated} updated (${res.total} active sites)`) + } catch (e) { + setError(e instanceof Error ? e.message : 'Sync failed') + } finally { + setSyncing(false) + } + } + + async function saveGeneralConfig() { + setConfigSaving(true); setError(''); setMsg('') + try { + await Promise.all([ + updateConfig('poll_interval_minutes', pollMinutes), + updateConfig('default_arrival_time', defaultArrival), + updateConfig('default_departure_time', defaultDeparture), + updateConfig('maintenance_url', maintUrl), + updateConfig('maintenance_api_key', maintKey), + ]) + setMsg('Settings saved'); setTimeout(() => setMsg(''), 2500) + } catch (e) { + setError(e instanceof Error ? e.message : 'Save failed') + } finally { + setConfigSaving(false) + } + } + + async function handleCreatePublicArea(e: React.FormEvent) { + e.preventDefault() + if (!publicAreaName.trim()) return + setCreatingZone(true); setError(''); setMsg('') + try { + await createZone({ name: publicAreaName.trim(), zone_type: 'public_area' }) + setPublicAreaName('') + setMsg('Public area zone created — assign devices to it on the Devices page.') + } catch (e) { + setError(e instanceof Error ? e.message : 'Create failed') + } finally { + setCreatingZone(false) + } + } + + if (loading) return

Loading…

+ + return ( +
+
+

Settings

+
+ + {error &&
{error}
} + {msg &&
{msg}
} + +
MQTT Broker
+
+ {mqttConnected ? : } + {mqttNote || 'Checking…'} +
+ +
NewBook Rooms
+

+ Untick rooms that shouldn't get NewBook-driven heating scheduling (e.g. owner-occupied or out-of-service rooms). +

+
+ {sites.map(s => ( + + ))} + {sites.length === 0 &&

No sites returned — check NewBook connection.

} +
+
+ + + +
+ +
Add a Public Area
+
+ setPublicAreaName(e.target.value)} + style={{ flex: 1, border: '1px solid var(--card-border)', borderRadius: 8, padding: '8px 10px' }} + /> + +
+ +
Scheduling
+
+
+ + setPollMinutes(parseInt(e.target.value) || 10)} /> +
+
+ + setDefaultArrival(e.target.value)} placeholder="15:00:00" /> +
+
+ + setDefaultDeparture(e.target.value)} placeholder="10:00:00" /> +
+
+ +
Maintenance Integration
+

+ Not yet available — maintenance needs its own Settings → API Keys page first. + Once that lands, generate a key there and paste it here to enable "Create asset in Maintenance" on the Devices page. +

+
+
+ + setMaintUrl(e.target.value)} placeholder="https://hotel.example.com/maintenance" /> +
+
+ + setMaintKey(e.target.value)} placeholder="paste key" /> +
+
+ +
+ +
+
+ ) +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..a01af37 --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,128 @@ +export type ZoneType = 'room' | 'public_area' +export type ZoneSource = 'newbook' | 'manual' +export type RoomState = 'vacant' | 'booked' | 'heating_up' | 'occupied' | 'cooling_down' +export type DeviceType = 'shelly_trv' | 'mhi_modbus' | 'midea' | 'daikin' | 'home_assistant' +export type HealthState = 'healthy' | 'degraded' | 'poor' | 'unresponsive' | 'calibration_error' +export type PhotoType = 'device' | 'serial_plate' + +export const ROOM_STATE_LABELS: Record = { + vacant: 'Vacant', + booked: 'Booked', + heating_up: 'Heating Up', + occupied: 'Occupied', + cooling_down: 'Cooling Down', +} + +export const DEVICE_TYPE_LABELS: Record = { + shelly_trv: 'Shelly TRV', + mhi_modbus: 'MHI Aircon (Modbus)', + midea: 'Midea Split', + daikin: 'Daikin Split', + home_assistant: 'Home Assistant', +} + +// Device types with an implemented Phase 1 driver — everything else in +// DeviceType is an enum value only, ready for its Phase 2/3 driver. +export const IMPLEMENTED_DEVICE_TYPES: DeviceType[] = ['shelly_trv', 'home_assistant'] + +export interface Zone { + id: number + zone_type: ZoneType + source: ZoneSource + newbook_site_id: string | null + name: string + active: boolean + occupied_temp: string + vacant_temp: string + heating_offset_min: number + cooling_offset_min: number + auto_mode: boolean + sync_valves: boolean + exclude_bathroom: boolean + created_at: string + device_count?: number +} + +export interface ZoneStatus extends Zone { + room_state: RoomState + target_temp: string + last_transition_at: string | null + last_booking_status: string | null + devices: DeviceSummary[] +} + +export interface DeviceSummary { + id: number + zone_id: number | null + device_type: DeviceType + external_ref: string + discovered_name: string | null + location: string | null + health_state: HealthState + battery_pct: number | null + wifi_rssi: number | null + current_target_temp: string | null + target_origin: string | null + last_seen: string | null +} + +export interface Device extends DeviceSummary { + zone_name: string | null + maintenance_asset_id: number | null + device_ip: string | null + photo_count: number + created_at: string + updated_at: string +} + +export interface DevicePhoto { + id: number + device_id: number + file_name: string + file_path: string + mime_type: string + photo_type: PhotoType + uploaded_by: string + uploaded_at: string +} + +export interface ActivityEntry { + id: number + zone_id: number | null + zone_name: string | null + event_type: string + from_state: string | null + to_state: string | null + note: string | null + source: string + user_email: string | null + created_at: string +} + +export interface NewbookSite { + site_id: string + site_name: string + category_name: string | null + excluded: boolean +} + +export interface AppConfig { + poll_interval_minutes: number + default_arrival_time: string + default_departure_time: string + maintenance_url: string + maintenance_api_key: string + excluded_site_ids?: string[] +} + +export interface User { + user_id: number + name: string + email: string + is_admin: boolean + caps: string[] // bare slugs — verify?app=hvac strips the prefix +} + +export function can(user: User, cap: string): boolean { + return user.is_admin || user.caps.includes(cap) +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..79a2287 --- /dev/null +++ b/frontend/tsconfig.json @@ -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"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..2e0f030 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + base: '/hvac/', + plugins: [react()], +}) diff --git a/seed-app.js b/seed-app.js new file mode 100644 index 0000000..a0daf02 --- /dev/null +++ b/seed-app.js @@ -0,0 +1,50 @@ +#!/usr/bin/env node +// Run from hvac/ dir: DATABASE_URL=... node seed-app.js +import pg from 'pg' + +const { Pool } = pg +const pool = new Pool({ connectionString: process.env.DATABASE_URL }) + +await pool.query(` + INSERT INTO apps (slug, name, description, base_path, icon, theme_color, category, internal_host, internal_port) + VALUES ('hvac', 'HVAC', 'Room heating control — NewBook-driven TRV scheduling, aircon and boiler (phased)', '/hvac', 'Thermometer', '#c1440e', 'Operations', '10.10.10.128', 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 +`) + +// Seed capabilities. public_area_control / boiler_view / boiler_control are +// Phase 3/4 capabilities seeded now (per the plan) so role assignment doesn't +// need a second migration later — no routes are gated on them yet. +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 + ('view', 'View', 'View zone dashboard, device status and activity', 1), + ('control', 'Manual Override', 'Force a zone''s temperature and disable auto mode', 2), + ('schedule_edit', 'Edit Schedules', 'Adjust per-zone temps, offsets and auto mode', 3), + ('manage_devices', 'Manage Devices', 'Discover, map, photograph devices; sync zones from NewBook',4), + ('public_area_control', 'Public Area Control', 'Central control of public-area zones (Phase 3)', 5), + ('boiler_view', 'Boiler — View', 'View boiler controller status (Phase 4)', 6), + ('boiler_control', 'Boiler — Control', 'Adjust boiler weather-compensation / pump disable (Phase 4)',7), + ('settings', 'Settings', 'Configure hvac app settings', 8) + ) AS c(slug, name, description, sort_order) + WHERE a.slug = 'hvac' + ON CONFLICT (app_id, slug) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + sort_order = EXCLUDED.sort_order +`) + +// No default Staff grants — this app drives physical room heating, so access is +// admin-assigned per role via the portal (same convention as wages/utilities, +// the two most recently-added apps). + +console.log('hvac app seeded.') +await pool.end()