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 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-26 18:00:42 +00:00
commit 276c04f8c6
42 changed files with 5461 additions and 0 deletions

View file

@ -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<ActivityEntry[]>([])
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 (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>{zone.name}</h2>
<button className="modal-close" onClick={onClose}><X size={18} strokeWidth={1.75} /></button>
</div>
<div className={`badge badge-st-${zone.room_state}`} style={{ marginBottom: 10 }}>
{ROOM_STATE_LABELS[zone.room_state]}
</div>
{error && <div className="error-banner">{error}</div>}
{msg && <div className="ok-banner">{msg}</div>}
<div className="section-title">Devices</div>
{zone.devices.length === 0 && <p className="muted">No devices mapped to this zone yet — assign some on the Devices page.</p>}
{zone.devices.map(d => (
<div key={d.id} className="card" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontWeight: 600, fontSize: 13 }}>{d.location || d.discovered_name || d.external_ref}</div>
<div className="muted" style={{ fontSize: 12 }}>
{d.current_target_temp != null ? `${Number(d.current_target_temp).toFixed(1)}°C` : '—'}
{d.battery_pct != null && ` · ${d.battery_pct}% battery`}
</div>
</div>
<span className={`badge badge-health-${d.health_state}`}>{d.health_state.replace('_', ' ')}</span>
</div>
))}
{canControl && (
<>
<div className="section-title">Manual Override</div>
<div className="field-row">
<div className="field" style={{ flex: 'none', width: 120 }}>
<input type="number" step="0.5" value={overrideTemp} onChange={e => setOverrideTemp(e.target.value)} />
</div>
<button className="btn" disabled={saving} onClick={forceTemp}>
<Thermometer size={14} strokeWidth={1.75} /> Force temperature
</button>
</div>
<p className="field-hint">Disables auto mode for this zone until re-enabled below.</p>
</>
)}
{canEdit && (
<>
<div className="section-title">Schedule</div>
<div className="field-row">
<div className="field">
<label>Occupied temp (°C)</label>
<input type="number" step="0.5" value={occupiedTemp} onChange={e => setOccupiedTemp(e.target.value)} />
</div>
<div className="field">
<label>Vacant temp (°C)</label>
<input type="number" step="0.5" value={vacantTemp} onChange={e => setVacantTemp(e.target.value)} />
</div>
</div>
<div className="field-row">
<div className="field">
<label>Heating offset (mins before arrival)</label>
<input type="number" value={heatingOffset} onChange={e => setHeatingOffset(parseInt(e.target.value) || 0)} />
</div>
<div className="field">
<label>Cooling offset (mins after departure)</label>
<input type="number" value={coolingOffset} onChange={e => setCoolingOffset(parseInt(e.target.value) || 0)} />
</div>
</div>
<label className="field-check">
<input type="checkbox" checked={autoMode} onChange={e => setAutoMode(e.target.checked)} />
Auto mode (NewBook-driven scheduling)
</label>
<label className="field-check">
<input type="checkbox" checked={syncValves} onChange={e => setSyncValves(e.target.checked)} />
Sync guest adjustments across valves in this zone
</label>
<label className="field-check">
<input type="checkbox" checked={excludeBathroom} onChange={e => setExcludeBathroom(e.target.checked)} />
Exclude bathroom valve from sync
</label>
<div className="modal-actions">
<button className="btn btn-primary" disabled={saving} onClick={save}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</>
)}
{activity.length > 0 && (
<>
<div className="section-title"><History size={12} strokeWidth={1.75} style={{ verticalAlign: '-1px', marginRight: 4 }} />Recent activity</div>
<div className="timeline">
{activity.map(a => (
<div key={a.id} className="timeline-item">
<div className="timeline-body">
{a.note}
<div className="timeline-meta">{new Date(a.created_at).toLocaleString()} · {a.source}</div>
</div>
</div>
))}
</div>
</>
)}
</div>
</div>
)
}