diff --git a/backend/src/db.js b/backend/src/db.js index 0a262c4..f11ab9c 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -99,6 +99,15 @@ export async function initDb() { room_state TEXT NOT NULL DEFAULT 'vacant', -- vacant | booked | heating_up | occupied | cooling_down last_booking_status TEXT, last_transition_at TIMESTAMPTZ, + -- Dashboard's takeoff/landing/stayover icon + night-count, computed alongside + -- room_state from whichever booking is currently governing the room (see + -- resolveGoverningBooking() in lib/scheduler.js). NULL when there's no booking + -- whose stay actually covers today (room_state 'booked' from a future arrival, + -- or 'vacant'). + booking_kind TEXT, -- arrival | departure | stayover | NULL + current_night INT, + total_nights INT, + has_next_booking BOOLEAN NOT NULL DEFAULT FALSE, -- turnover hint: another booking follows a departure today updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); @@ -182,6 +191,11 @@ export async function initDb() { await pool.query(`ALTER TABLE zones ADD COLUMN IF NOT EXISTS ac_vacant_setpoint NUMERIC(4,1)`) await pool.query(`ALTER TABLE zones ADD COLUMN IF NOT EXISTS ac_vacant_fan_speed TEXT`) + await pool.query(`ALTER TABLE zone_state ADD COLUMN IF NOT EXISTS booking_kind TEXT`) + await pool.query(`ALTER TABLE zone_state ADD COLUMN IF NOT EXISTS current_night INT`) + await pool.query(`ALTER TABLE zone_state ADD COLUMN IF NOT EXISTS total_nights INT`) + await pool.query(`ALTER TABLE zone_state ADD COLUMN IF NOT EXISTS has_next_booking BOOLEAN NOT NULL DEFAULT FALSE`) + await seedDefaults() } diff --git a/backend/src/lib/scheduler.js b/backend/src/lib/scheduler.js index e5368f9..5412793 100644 --- a/backend/src/lib/scheduler.js +++ b/backend/src/lib/scheduler.js @@ -12,18 +12,11 @@ import { commandAcDevice } from './ac-control.js' const TZ = 'Europe/London' const ACTIVE_BOOKING_STATUSES = ['confirmed', 'unconfirmed', 'arrived'] +const CANCELLED_STATUSES = ['cancelled', 'no_show'] const HEAT_STATES = ['heating_up', 'occupied'] const COOL_STATES = ['vacant', 'cooling_down', 'booked'] const AC_DEVICE_TYPES = ['mhi_modbus', 'mhi_mqtt'] // matches routes/override.js's mhi-control scope -// On a same-day turnover NewBook's 'staying' list returns BOTH the departing guest's -// booking and the incoming guest's booking for one site — there's no single "the" -// booking to pick upfront. Rank each candidate's resulting room_state and take the -// most active one: someone actually in-house beats any prep state; actively prepping -// for an arrival beats a static post-checkout "cooling down" state; a future stay -// that hasn't started its prep window yet is least urgent of the non-vacant states. -const STATE_PRIORITY = { occupied: 4, heating_up: 3, cooling_down: 2, booked: 1, vacant: 0 } - let timerHandle = null let running = false @@ -76,6 +69,10 @@ export function calculateHeatingSchedule(zone, booking, defaults) { // 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. +// A booking arriving days from now still resolves to 'booked' here (not 'vacant') — +// resolveGoverningBooking() below is what decides whether a booking is even relevant +// to today at all; by the time one reaches here, it's the room's current governing +// booking and should always render as at least 'booked'. export function determineRoomState(booking, schedule, now) { if (!booking) return 'vacant' const status = (booking.booking_status || '').toLowerCase() @@ -84,18 +81,73 @@ export function determineRoomState(booking, schedule, now) { if (status === 'arrived') return 'occupied' if (!schedule) return 'booked' - const { heatingStart, coolingStart, arrival } = schedule + const { heatingStart, coolingStart } = 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 + return 'booked' +} + +function bookingDateRange(booking) { + const arrival = parseNbDateTime(booking.booking_arrival) + const departure = parseNbDateTime(booking.booking_departure) + if (!arrival || !departure) return null + return { arrivalDate: arrival.startOf('day'), departureDate: departure.startOf('day') } +} + +// Same-day turnovers, multi-night stays and future-but-not-yet-relevant bookings can +// all be present in one site's candidate list (the scheduler now fetches yesterday..+7 +// days) — there's no single "the" booking without resolving against today's date: +// 1. "active today" = any non-cancelled booking whose [arrival, departure] window +// covers today, preferring one NOT YET marked departed — so a departing guest's +// booking keeps governing (blue -> purple) right up until checkout is confirmed +// in NewBook, even if a same-day arrival is also active-today. Once marked +// departed it drops out of contention and the incoming booking (itself +// active-today, since its arrival date is today) naturally takes over. +// 2. otherwise, the soonest upcoming booking (arrival >= today) governs — purely for +// colour ('booked' ahead of its own prep window). No kind/night-count is shown +// for it, since today isn't actually one of its nights yet. +// The 'departure'-kind case also flags hasNextBooking when a distinct booking is +// already lined up, for the dashboard's takeoff+landing turnover hint. +function resolveGoverningBooking(candidates, todayISO) { + const usable = candidates.filter(b => + !CANCELLED_STATUSES.includes((b.booking_status || '').toLowerCase()) && bookingDateRange(b) + ) + + const activeToday = usable + .filter(b => { + const { arrivalDate, departureDate } = bookingDateRange(b) + return arrivalDate.toISODate() <= todayISO && departureDate.toISODate() >= todayISO + }) + .filter(b => (b.booking_status || '').toLowerCase() !== 'departed') + .sort((a, b) => bookingDateRange(a).arrivalDate - bookingDateRange(b).arrivalDate) + const current = activeToday[0] || null + + const upcoming = usable + .filter(b => b !== current && bookingDateRange(b).arrivalDate.toISODate() >= todayISO) + .sort((a, b) => bookingDateRange(a).arrivalDate - bookingDateRange(b).arrivalDate) + const next = upcoming[0] || null + + const governing = current || next + if (!governing || governing !== current) { + // No booking at all, or the only candidate is booked ahead (today isn't one of + // its nights yet) — no icon/night-count either way. + return { governing, kind: null, currentNight: null, totalNights: null, hasNextBooking: false } + } + + const { arrivalDate, departureDate } = bookingDateRange(current) + const totalNights = Math.max(1, Math.round(departureDate.diff(arrivalDate, 'days').days)) + const nightsIn = Math.round(DateTime.fromISO(todayISO, { zone: TZ }).diff(arrivalDate, 'days').days) + const currentNight = Math.min(totalNights, Math.max(1, nightsIn + 1)) + const kind = departureDate.toISODate() === todayISO ? 'departure' + : arrivalDate.toISODate() === todayISO ? 'arrival' + : 'stayover' + const hasNextBooking = kind === 'departure' && !!next + + return { governing, kind, currentNight, totalNights, hasNextBooking } } // should_heat() — auto mode AND an active booking status AND the room state in @@ -191,16 +243,24 @@ async function getZoneState(zoneId) { return rows[0] || null } -async function saveZoneState(zoneId, roomState, bookingStatus) { +async function saveZoneState(zoneId, roomState, bookingStatus, meta = {}) { + const { kind = null, currentNight = null, totalNights = null, hasNextBooking = false } = meta 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()) + `INSERT INTO zone_state ( + zone_id, room_state, last_booking_status, booking_kind, current_night, total_nights, + has_next_booking, last_transition_at, updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW()) ON CONFLICT (zone_id) DO UPDATE SET room_state = EXCLUDED.room_state, last_booking_status = EXCLUDED.last_booking_status, + booking_kind = EXCLUDED.booking_kind, + current_night = EXCLUDED.current_night, + total_nights = EXCLUDED.total_nights, + has_next_booking = EXCLUDED.has_next_booking, 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] + [zoneId, roomState, bookingStatus, kind, currentNight, totalNights, hasNextBooking] ) } @@ -229,18 +289,21 @@ export async function pollOnce() { // 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). + // Window is yesterday..+7 days, not just today: a booking departing today can have + // arrived any number of nights ago, and resolveGoverningBooking() needs upcoming + // bookings in view too (turnover hint, "booked" ahead of its own prep window). let bookings try { - bookings = await fetchBookings(today, today) + bookings = await fetchBookings(now.minus({ days: 1 }).toISODate(), now.plus({ days: 7 }).toISODate()) } 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 } - // Group every booking overlapping today by site — may be more than one on a - // same-day turnover, resolved per-zone below (each zone applies its own offsets, - // so the "most active" candidate can only be determined once we have the zone). + // Group every booking in the window by site — same-day turnovers, multi-night + // stays and upcoming bookings can all land on one site; resolveGoverningBooking() + // picks the one relevant to today per-zone below. const bookingsBySite = new Map() for (const b of bookings) { const siteId = String(b.site_id ?? b.room_id ?? '') @@ -252,29 +315,23 @@ export async function pollOnce() { for (const zone of zones) { try { const candidates = bookingsBySite.get(String(zone.newbook_site_id)) || [] + const { governing: booking, kind, currentNight, totalNights, hasNextBooking } = resolveGoverningBooking(candidates, today) - let booking = null, newState = 'vacant' - for (const b of candidates) { - const schedule = calculateHeatingSchedule(zone, b, defaults) - const st = determineRoomState(b, schedule, now) - if (!booking || STATE_PRIORITY[st] > STATE_PRIORITY[newState]) { - booking = b; newState = st - } - } - - // Cache the winning 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). + // Cache the governing 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) + await saveZoneState(zone.id, newState, booking?.booking_status || null, { kind, currentNight, totalNights, hasNextBooking }) if (!zone.auto_mode || !automationEnabled) continue // manual override or master switch off — don't touch setpoints if (newState === oldState) continue // no transition — never poll-and-reassert diff --git a/backend/src/routes/status.js b/backend/src/routes/status.js index 4c14129..9055a39 100644 --- a/backend/src/routes/status.js +++ b/backend/src/routes/status.js @@ -9,7 +9,8 @@ export async function statusRoutes(app) { app.get('/api/status', { preHandler: requireCap('view') }, async () => { const hvacDefaults = await getConfig('hvac_defaults', DEFAULT_HVAC_TEMPLATE) const { rows: zones } = await pool.query(` - SELECT z.*, zs.room_state, zs.last_transition_at, zs.last_booking_status + 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 FROM zones z LEFT JOIN zone_state zs ON zs.zone_id = z.id WHERE z.active = TRUE @@ -34,6 +35,7 @@ export async function statusRoutes(app) { zones: zones.map(z => ({ ...z, room_state: z.room_state || 'vacant', + has_next_booking: z.has_next_booking ?? false, target_temp: ['heating_up', 'occupied'].includes(z.room_state) ? (z.occupied_temp ?? hvacDefaults.trv_occupied_temp) : (z.vacant_temp ?? hvacDefaults.trv_vacant_temp), diff --git a/frontend/src/components/ZoneCard.tsx b/frontend/src/components/ZoneCard.tsx index 426923c..3a64169 100644 --- a/frontend/src/components/ZoneCard.tsx +++ b/frontend/src/components/ZoneCard.tsx @@ -1,9 +1,26 @@ -import { Thermometer, BatteryLow, TriangleAlert, Snowflake, Flame, Fan, Droplet, Lock, Heater } from 'lucide-react' +import { Thermometer, BatteryLow, TriangleAlert, Snowflake, Flame, Fan, Droplet, Lock, Heater, PlaneTakeoff, PlaneLanding, BedDouble } from 'lucide-react' import type { ZoneStatus } from '../types' import { ROOM_STATE_LABELS, MHI_FAN_SPEEDS } from '../types' const AC_DEVICE_TYPES = ['mhi_modbus', 'mhi_mqtt', 'midea', 'daikin', 'home_assistant'] +// Departure = takeoff, arrival = landing, stayover = bed (lucide has no "person in +// bed" icon, so BedDouble is the closest stand-in). A departure also shows a second +// landing icon when has_next_booking — same-day-turnover hint (someone's due in later). +function BookingIcons({ zone }: { zone: ZoneStatus }) { + if (!zone.booking_kind) return null + const iconProps = { size: 13, strokeWidth: 1.75 } + return ( + + {zone.booking_kind === 'departure' && } + {zone.booking_kind === 'arrival' && } + {zone.booking_kind === 'stayover' && } + {zone.booking_kind === 'departure' && zone.has_next_booking && } + {zone.total_nights != null && {zone.current_night}/{zone.total_nights}} + + ) +} + const MODE_ICONS: Record = { cool: Snowflake, heat: Flame, @@ -46,6 +63,7 @@ export default function ZoneCard({ zone, onClick }: { zone: ZoneStatus; onClick:
{zone.name} + {unhealthy > 0 && (