Kiosk: show weather compensation summary in the header

Same info the Dashboard's banner shows (forecast high, AC default power/mode/
setpoint + active weather tier), plus TRV default temp alongside it since the
kiosk has no Settings page to check that separately. Weather/AC data reuses
the existing /api/status poll; TRV default is a one-time /api/config fetch
on login (rarely changes, not worth polling every 12s).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-08-27 12:40:22 +00:00
parent 9b4e49b801
commit 371909bded
3 changed files with 59 additions and 10 deletions

View file

@ -218,6 +218,9 @@ export function kioskFetchStatus(): Promise<{
}> { }> {
return krequest('/status') return krequest('/status')
} }
export function kioskFetchConfig(): Promise<AppConfig> {
return krequest('/config')
}
export function kioskFetchTrvStatus(deviceId: number): Promise<TrvStatus> { export function kioskFetchTrvStatus(deviceId: number): Promise<TrvStatus> {
return krequest(`/devices/${deviceId}/trv-status`) return krequest(`/devices/${deviceId}/trv-status`)
} }

View file

@ -446,10 +446,15 @@ table.data tr:last-child td { border-bottom: none; }
} }
.kiosk-header { .kiosk-header {
display: flex; align-items: center; justify-content: space-between; display: flex; flex-direction: column; gap: 6px;
padding: 14px 18px; background: var(--navy); color: var(--text); flex-shrink: 0; padding: 14px 18px; background: var(--navy); color: var(--text); flex-shrink: 0;
} }
.kiosk-header-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.kiosk-header-title { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 16px; color: var(--gold); } .kiosk-header-title { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 16px; color: var(--gold); }
/* Same weather-compensation summary as the Dashboard's banner (plus TRV
default, which the kiosk has no Settings page to show separately) — see
buildInfoLine() in Kiosk.tsx. */
.kiosk-header-info { font-size: 12px; color: var(--text-muted); }
.kiosk-error { margin: 12px 16px 0; } .kiosk-error { margin: 12px 16px 0; }

View file

@ -1,8 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { Thermometer, CheckSquare, X } from 'lucide-react' import { Thermometer, CheckSquare, X } from 'lucide-react'
import { kioskLogin, kioskFetchStatus, KioskSessionError } from '../api' import { kioskLogin, kioskFetchStatus, kioskFetchConfig, KioskSessionError } from '../api'
import type { DeviceSummary, ZoneStatus } from '../types' import type { ActiveAcTemplate, DeviceSummary, ZoneStatus } from '../types'
import { KIOSK_DEVICE_TYPES } from '../types' import { KIOSK_DEVICE_TYPES, DEFAULT_HVAC_TEMPLATE } from '../types'
import KioskZoneBlock from '../components/KioskZoneBlock' import KioskZoneBlock from '../components/KioskZoneBlock'
import KioskZonePanel from '../components/KioskZonePanel' import KioskZonePanel from '../components/KioskZonePanel'
import KioskModal from '../components/KioskModal' import KioskModal from '../components/KioskModal'
@ -17,6 +17,28 @@ function liveDevicesOf(zone: ZoneStatus): DeviceSummary[] {
return zone.devices.filter(d => KIOSK_DEVICE_TYPES.includes(d.device_type)) return zone.devices.filter(d => KIOSK_DEVICE_TYPES.includes(d.device_type))
} }
function cap(s: string) {
return s[0].toUpperCase() + s.slice(1)
}
// Same weather-compensation summary the Dashboard shows (Settings → Weather
// Compensation drives it) — "what would a room transitioning to occupied get
// right now", plus the TRV occupied default alongside it since the kiosk has
// no separate Settings page to check it on. Renders only the parts that have
// loaded, so a slow weather fetch doesn't hide the AC/TRV defaults too.
function buildInfoLine(weatherPeakC: number | null, activeAcTemplate: ActiveAcTemplate | null, trvDefaultTemp: number | null): string[] {
const parts: string[] = []
if (weatherPeakC != null) parts.push(`Forecast high ${weatherPeakC}°C`)
if (activeAcTemplate) {
const bits = [activeAcTemplate.power === 'on' ? 'ON' : 'OFF']
if (activeAcTemplate.mode) bits.push(cap(activeAcTemplate.mode))
if (activeAcTemplate.setpoint != null) bits.push(`${activeAcTemplate.setpoint}°C`)
parts.push(`AC default ${bits.join(' ')}${activeAcTemplate.tierApplied ? ` (${cap(activeAcTemplate.tierApplied)} tier)` : ''}`)
}
if (trvDefaultTemp != null) parts.push(`TRV default ${trvDefaultTemp}°C`)
return parts
}
// The whole hvac SPA shares one index.html, so there's only one <link // The whole hvac SPA shares one index.html, so there's only one <link
// rel="manifest"> in the document — "Install" from any route otherwise always // rel="manifest"> in the document — "Install" from any route otherwise always
// installs the main app (start_url '/hvac/', staff login). Swap it to a // installs the main app (start_url '/hvac/', staff login). Swap it to a
@ -51,6 +73,9 @@ export default function Kiosk() {
const [zones, setZones] = useState<ZoneStatus[]>([]) const [zones, setZones] = useState<ZoneStatus[]>([])
const [loadError, setLoadError] = useState('') const [loadError, setLoadError] = useState('')
const [weatherPeakC, setWeatherPeakC] = useState<number | null>(null)
const [activeAcTemplate, setActiveAcTemplate] = useState<ActiveAcTemplate | null>(null)
const [trvDefaultTemp, setTrvDefaultTemp] = useState<number | null>(null)
const [selectionMode, setSelectionMode] = useState(false) const [selectionMode, setSelectionMode] = useState(false)
const [selectedZoneIds, setSelectedZoneIds] = useState<Set<number>>(new Set()) const [selectedZoneIds, setSelectedZoneIds] = useState<Set<number>>(new Set())
@ -63,6 +88,8 @@ export default function Kiosk() {
kioskFetchStatus() kioskFetchStatus()
.then(s => { .then(s => {
setZones(s.zones.filter(z => liveDevicesOf(z).length > 0)) setZones(s.zones.filter(z => liveDevicesOf(z).length > 0))
setWeatherPeakC(s.weather_peak_c)
setActiveAcTemplate(s.active_ac_template)
setLoadError('') setLoadError('')
setAuthState('authed') setAuthState('authed')
}) })
@ -74,6 +101,15 @@ export default function Kiosk() {
useEffect(() => { load() }, [load]) useEffect(() => { load() }, [load])
// TRV default temp doesn't come back from /api/status (see buildInfoLine) and
// rarely changes — fetch it once on login rather than on every POLL_MS tick.
useEffect(() => {
if (authState !== 'authed') return
kioskFetchConfig()
.then(cfg => setTrvDefaultTemp(cfg.hvac_defaults?.trv_occupied_temp ?? DEFAULT_HVAC_TEMPLATE.trv_occupied_temp))
.catch(() => {})
}, [authState])
useEffect(() => { useEffect(() => {
if (authState !== 'authed') { if (authState !== 'authed') {
if (pollRef.current) clearInterval(pollRef.current) if (pollRef.current) clearInterval(pollRef.current)
@ -150,15 +186,20 @@ export default function Kiosk() {
) )
} }
const infoParts = buildInfoLine(weatherPeakC, activeAcTemplate, trvDefaultTemp)
return ( return (
<div className="kiosk-shell"> <div className="kiosk-shell">
<header className="kiosk-header"> <header className="kiosk-header">
<span className="kiosk-header-title"> <div className="kiosk-header-row">
<Thermometer size={18} strokeWidth={1.75} /> HVAC Kiosk <span className="kiosk-header-title">
</span> <Thermometer size={18} strokeWidth={1.75} /> HVAC Kiosk
<button className={`btn btn-sm${selectionMode ? ' btn-primary' : ''}`} onClick={toggleSelectionMode}> </span>
<CheckSquare size={14} strokeWidth={1.75} /> {selectionMode ? 'Cancel select' : 'Select'} <button className={`btn btn-sm${selectionMode ? ' btn-primary' : ''}`} onClick={toggleSelectionMode}>
</button> <CheckSquare size={14} strokeWidth={1.75} /> {selectionMode ? 'Cancel select' : 'Select'}
</button>
</div>
{infoParts.length > 0 && <div className="kiosk-header-info">{infoParts.join(' · ')}</div>}
</header> </header>
{loadError && <div className="error-banner kiosk-error">{loadError}</div>} {loadError && <div className="error-banner kiosk-error">{loadError}</div>}