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'