From 99ec8b9747bca4d048f492714d9c913b6aa04c68 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Tue, 25 Aug 2026 09:55:35 +0000 Subject: [PATCH] Fix same-day turnover: pick the most-active booking per site, not just the first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pollOnce() previously collapsed multiple bookings overlapping today for one site down to a single candidate before computing room_state, with a weak tie-break (prefer 'arrived' status, else whichever the API happened to return first). On a same-day turnover — departing guest + incoming guest both returned by NewBook's 'staying' list for the same site — if neither was marked 'arrived' yet, this could arbitrarily lock onto the incoming guest's booking (still in 'booked', prep not started) instead of the departing guest's (which should be 'cooling_down'), even though the departing guest was the one actually still checked into the room. Now every candidate booking for a site is evaluated (each zone applies its own offsets, so this only resolves once we have the zone), and the state machine picks whichever produces the most active result: occupied > heating_up > cooling_down > booked > vacant. Zero behaviour change for the common single-booking-per-site case. Co-Authored-By: Claude Sonnet 5 --- backend/src/lib/scheduler.js | 37 ++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/backend/src/lib/scheduler.js b/backend/src/lib/scheduler.js index 39efb54..e5368f9 100644 --- a/backend/src/lib/scheduler.js +++ b/backend/src/lib/scheduler.js @@ -16,6 +16,14 @@ 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 @@ -230,25 +238,32 @@ export async function pollOnce() { 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). 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) + if (!bookingsBySite.has(siteId)) bookingsBySite.set(siteId, []) + bookingsBySite.get(siteId).push(b) } for (const zone of zones) { try { - const booking = bookingsBySite.get(String(zone.newbook_site_id)) || null + const candidates = bookingsBySite.get(String(zone.newbook_site_id)) || [] - // 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 — + 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). await pool.query( `INSERT INTO booking_cache (zone_id, booking_data, fetched_at) VALUES ($1, $2, NOW()) @@ -256,8 +271,6 @@ export async function pollOnce() { [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'