Widen booking fetch window, resolve governing booking per-night, add turnover icons/nights to dashboard
Backend:
- fetchBookings() window widened from today-only to yesterday..+7 days, so a
departing booking (arrived any number of nights ago) and upcoming bookings are
both in view — the previous today-only fetch couldn't see a departing guest's
arrival date at all.
- New resolveGoverningBooking(): replaces the single-booking-per-site pick with a
two-tier resolution — (1) any non-cancelled booking whose stay covers today,
preferring one not yet marked 'departed' (so a departing guest keeps governing
blue->purple right up until checkout is confirmed, even with a same-day arrival
also active); (2) otherwise the soonest upcoming booking, purely for colour
('booked' ahead of its own prep window). Also computes booking_kind
(arrival/departure/stayover), current/total night count, and a has_next_booking
turnover hint (for departure days with another booking already lined up).
- determineRoomState() no longer forces 'vacant' for a future-dated arrival — that
case is now handled by resolveGoverningBooking() deciding whether a booking is
relevant to today at all; once picked as governing it always resolves to at
least 'booked'.
- zone_state table: new booking_kind/current_night/total_nights/has_next_booking
columns, persisted every poll alongside room_state and exposed via GET /api/status.
Frontend:
- ZoneCard: room name is now followed by a takeoff (departure) / landing (arrival)
/ bed (stayover) icon plus a current/total nights figure, sourced from the new
status fields. A departure additionally shows a landing icon when has_next_booking,
hinting at a same-day turnover before the room's actually marked departed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
99ec8b9747
commit
48ffebc08d
5 changed files with 138 additions and 37 deletions
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue