From 5fc576aabd4ca22690428d1f4e1fe6ef9208f307 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Tue, 25 Aug 2026 11:42:01 +0000 Subject: [PATCH] Show weather compensation summary banner on the Dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New lib/ac-template.js factors the "which occupied AC template applies right now" resolution out of scheduler.js (dedupes the tier-pick loop that used to live separately in scheduler.js and status.js) into a shared resolveOccupiedAcTier()/resolveGlobalOccupiedAcTemplate() pair. GET /api/status now returns active_ac_template alongside weather_peak_c. Dashboard shows a small banner above the zone grid: "Forecast high today: XC" + "Aircon default: ON, 20C, High fan — Warm tier" — so staff don't have to visit Settings to see what today's weather tier resolved to. Co-Authored-By: Claude Sonnet 5 --- backend/src/lib/ac-template.js | 32 ++++++++++++++++++++++++++++++++ backend/src/lib/scheduler.js | 28 +++++++--------------------- backend/src/routes/status.js | 15 +++++++-------- frontend/src/api.ts | 10 ++++++++-- frontend/src/pages/Dashboard.tsx | 27 ++++++++++++++++++++++++++- frontend/src/types.ts | 8 ++++++++ 6 files changed, 88 insertions(+), 32 deletions(-) create mode 100644 backend/src/lib/ac-template.js diff --git a/backend/src/lib/ac-template.js b/backend/src/lib/ac-template.js new file mode 100644 index 0000000..2935167 --- /dev/null +++ b/backend/src/lib/ac-template.js @@ -0,0 +1,32 @@ +// "Which occupied AC template applies right now" — shared by lib/scheduler.js (to +// actually command devices at a state transition) and routes/status.js (to show what's +// currently in effect on the Dashboard), so the weather-tier pick logic only lives once. + +// Highest tier in hvacDefaults.ac_occupied_tiers whose peak_above_c today's forecast +// peak clears (tiers stored ascending — see DEFAULT_HVAC_TEMPLATE in db.js). null if +// there's no forecast, no tiers configured, or peakTempC doesn't clear even the lowest. +export function resolveOccupiedAcTier(hvacDefaults, peakTempC) { + if (peakTempC == null) return null + const tiers = hvacDefaults.ac_occupied_tiers || [] + let picked = null + for (const tier of tiers) { + if (peakTempC >= Number(tier.peak_above_c)) picked = tier + } + return picked +} + +// The GLOBAL template that would apply to any room transitioning into occupied right +// now — hvacDefaults.ac_occupied, or a weather tier override on top if one clears. Does +// NOT include per-zone field overrides (those are zone-specific and applied on top of +// this in scheduler.js's resolveAcTemplate()). +export function resolveGlobalOccupiedAcTemplate(hvacDefaults, peakTempC) { + const tier = resolveOccupiedAcTier(hvacDefaults, peakTempC) + const base = tier || hvacDefaults.ac_occupied || {} + return { + power: base.power, + mode: base.mode, + setpoint: base.setpoint, + fan_speed: base.fan_speed, + tierApplied: tier?.name ?? null, + } +} diff --git a/backend/src/lib/scheduler.js b/backend/src/lib/scheduler.js index 8fd8db5..eaeeca8 100644 --- a/backend/src/lib/scheduler.js +++ b/backend/src/lib/scheduler.js @@ -10,6 +10,7 @@ import { fetchBookings } from './newbook.js' import { batchSetZoneTemperature } from './mqtt.js' import { commandAcDevice } from './ac-control.js' import { getPeakTempC } from './weather.js' +import { resolveOccupiedAcTier } from './ac-template.js' const TZ = 'Europe/London' const ACTIVE_BOOKING_STATUSES = ['confirmed', 'unconfirmed', 'arrived'] @@ -181,33 +182,18 @@ async function applyTarget(zone, devices, tempC) { return { ok: successful > 0, successful, total: trvs.length } } -// Weather compensation — pick the HIGHEST tier in hvacDefaults.ac_occupied_tiers whose -// peak_above_c today's forecast peak clears (tiers are stored ascending; see -// DEFAULT_HVAC_TEMPLATE in db.js). Returns null (→ use the plain ac_occupied template) -// if there's no forecast, tiers aren't configured, or peakTempC doesn't clear even the -// lowest threshold. Global only for now — no per-zone tier overrides, weather is the -// same for the whole building. -function resolveAcTier(hvacDefaults, peakTempC) { - if (peakTempC == null) return null - const tiers = hvacDefaults.ac_occupied_tiers || [] - let picked = null - for (const tier of tiers) { - if (peakTempC >= Number(tier.peak_above_c)) picked = tier - } - return picked -} - // Resolve the effective occupied/vacant aircon template for a zone: each field is the // zone's own override if set, else the matching field from the global hvac_defaults // template — same per-field inherit-if-NULL relationship as the TRV temps/offsets. -// On the occupied branch, a weather tier (if one clears its threshold) overrides the -// global ac_occupied template wholesale, BEFORE the zone's own per-field overrides are -// applied on top — a zone override should still win over weather, same precedence a -// staff member would expect from "I set this room's aircon manually". +// On the occupied branch, a weather tier (if one clears its threshold, see +// lib/ac-template.js) overrides the global ac_occupied template wholesale, BEFORE the +// zone's own per-field overrides are applied on top — a zone override should still win +// over weather, same precedence a staff member would expect from "I set this room's +// aircon manually". function resolveAcTemplate(zone, newState, hvacDefaults, peakTempC) { const occupied = HEAT_STATES.includes(newState) const prefix = occupied ? 'ac_occupied' : 'ac_vacant' - const tier = occupied ? resolveAcTier(hvacDefaults, peakTempC) : null + const tier = occupied ? resolveOccupiedAcTier(hvacDefaults, peakTempC) : null const globalTemplate = tier || (occupied ? hvacDefaults.ac_occupied : hvacDefaults.ac_vacant) || {} const power = zone[`${prefix}_power`] ?? globalTemplate.power const setpoint = zone[`${prefix}_setpoint`] ?? globalTemplate.setpoint diff --git a/backend/src/routes/status.js b/backend/src/routes/status.js index c638388..20e5e40 100644 --- a/backend/src/routes/status.js +++ b/backend/src/routes/status.js @@ -2,6 +2,7 @@ import { requireAuth, requireCap } from '../auth.js' import { pool, getConfig, DEFAULT_HVAC_TEMPLATE } from '../db.js' import { isConnected as mqttConnected } from '../lib/mqtt.js' import { getPeakTempC } from '../lib/weather.js' +import { resolveGlobalOccupiedAcTemplate } from '../lib/ac-template.js' export async function statusRoutes(app) { app.addHook('preHandler', requireAuth) @@ -13,13 +14,10 @@ export async function statusRoutes(app) { // would use right now); doesn't retroactively affect already-applied AC targets. // getPeakTempC() is TTL-cached internally so this is cheap to call on every status poll. const weatherPeakC = await getPeakTempC() - const tiers = hvacDefaults.ac_occupied_tiers || [] - let weatherTier = null - if (weatherPeakC != null) { - for (const tier of tiers) { - if (weatherPeakC >= Number(tier.peak_above_c)) weatherTier = tier.name - } - } + // What ANY room transitioning into occupied right now would get — the same + // resolution the scheduler itself uses (lib/ac-template.js), shown here purely for + // visibility (Dashboard summary banner). Doesn't reflect per-zone overrides. + const activeAcTemplate = resolveGlobalOccupiedAcTemplate(hvacDefaults, weatherPeakC) const { rows: zones } = await pool.query(` SELECT z.*, zs.room_state, zs.last_transition_at, zs.last_booking_status, zs.booking_kind, zs.current_night, zs.total_nights, zs.has_next_booking, zs.vacant_nights @@ -45,7 +43,8 @@ export async function statusRoutes(app) { return { mqtt_connected: mqttConnected(), weather_peak_c: weatherPeakC, - weather_tier: weatherTier, + weather_tier: activeAcTemplate.tierApplied, + active_ac_template: activeAcTemplate, zones: zones.map(z => ({ ...z, room_state: z.room_state || 'vacant', diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 2b4b43a..4f3732d 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,6 +1,6 @@ import type { Zone, ZoneStatus, Device, DevicePhoto, ActivityEntry, NewbookSite, AppConfig, - MhiGateway, MhiStatus, + MhiGateway, MhiStatus, ActiveAcTemplate, } from './types' const BASE = '/hvac/api' @@ -46,7 +46,13 @@ export function syncZonesFromNewbook(): Promise<{ ok: boolean; created: number; } // Status / activity -export function fetchStatus(): Promise<{ mqtt_connected: boolean; weather_peak_c: number | null; weather_tier: string | null; zones: ZoneStatus[] }> { +export function fetchStatus(): Promise<{ + mqtt_connected: boolean + weather_peak_c: number | null + weather_tier: string | null + active_ac_template: ActiveAcTemplate + zones: ZoneStatus[] +}> { return request('/status') } export function fetchActivity(zoneId?: number, limit = 100): Promise { diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 5f43e58..0fe86b9 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,24 +1,32 @@ import { useEffect, useState, useCallback } from 'react' import { WifiOff } from 'lucide-react' import { fetchStatus } from '../api' -import type { ZoneStatus } from '../types' +import type { ZoneStatus, ActiveAcTemplate } from '../types' import ZoneCard from '../components/ZoneCard' import ZoneDetailModal from '../components/ZoneDetailModal' const POLL_MS = 30000 +function cap(s: string) { + return s[0].toUpperCase() + s.slice(1) +} + 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 [weatherPeakC, setWeatherPeakC] = useState(null) + const [activeAcTemplate, setActiveAcTemplate] = useState(null) const load = useCallback(() => { fetchStatus() .then(res => { setZones(res.zones) setMqttConnected(res.mqtt_connected) + setWeatherPeakC(res.weather_peak_c) + setActiveAcTemplate(res.active_ac_template) setError('') }) .catch(e => setError(e instanceof Error ? e.message : 'Failed to load status')) @@ -42,6 +50,23 @@ export default function Dashboard() {

Dashboard

+ {weatherPeakC != null && activeAcTemplate && ( +
+
+ Forecast high today: {weatherPeakC}°C +
+
+ Aircon default: {activeAcTemplate.power === 'on' ? 'ON' : 'OFF'} + {activeAcTemplate.setpoint != null && <>, {activeAcTemplate.setpoint}°C} + {activeAcTemplate.fan_speed && <>, {cap(activeAcTemplate.fan_speed)} fan} + {activeAcTemplate.tierApplied && <> — {cap(activeAcTemplate.tierApplied)} tier} +
+
+ )} + {error &&
{error}
} {!mqttConnected && (
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index f0175e7..b13e4ee 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -221,6 +221,14 @@ export interface HvacWeatherTier extends HvacAcTemplate { peak_above_c: number } +// What GET /api/status resolves as "the occupied AC template any room transitioning +// into occupied right now would get" — hvac_defaults.ac_occupied, or a weather tier +// override on top if one clears (tierApplied names it; null means the plain baseline +// applies). Doesn't reflect any individual room's own per-field overrides. +export interface ActiveAcTemplate extends HvacAcTemplate { + tierApplied: string | null +} + export interface HvacDefaults { arrival_prep_offset_min: number departure_prep_offset_min: number