diff --git a/backend/src/db.js b/backend/src/db.js index 75792e0..a18fe57 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -108,6 +108,10 @@ export async function initDb() { current_night INT, total_nights INT, has_next_booking BOOLEAN NOT NULL DEFAULT FALSE, -- turnover hint: another booking follows a departure today + -- Only set when room_state = 'vacant': nights until the next known arrival + -- within the scheduler's fetch window, or NULL if nothing was found in that + -- window at all (displayed as an open-ended "7+ nights" rather than a count). + vacant_nights INT, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); @@ -195,6 +199,7 @@ export async function initDb() { 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 pool.query(`ALTER TABLE zone_state ADD COLUMN IF NOT EXISTS vacant_nights INT`) await seedDefaults() } diff --git a/backend/src/lib/scheduler.js b/backend/src/lib/scheduler.js index a643787..8fd8db5 100644 --- a/backend/src/lib/scheduler.js +++ b/backend/src/lib/scheduler.js @@ -99,18 +99,19 @@ function bookingDateRange(booking) { 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: +// Same-day turnovers, multi-night stays and upcoming bookings can all be present in +// one site's candidate list (the scheduler 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. +// 2. otherwise the room is genuinely vacant TONIGHT — 'booked' only applies when +// something is arriving today, not merely within the fetch window. The soonest +// upcoming booking (if any) is surfaced separately as vacantNights, a countdown +// to the next arrival, purely informational — it does NOT drive colour/state. // 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) { @@ -128,15 +129,18 @@ function resolveGoverningBooking(candidates, todayISO) { const current = activeToday[0] || null const upcoming = usable - .filter(b => b !== current && bookingDateRange(b).arrivalDate.toISODate() >= todayISO) + .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 } + if (!current) { + // No booking covers today — vacant tonight. vacantNights counts down to the next + // known arrival within the fetch window; null means nothing found in that window + // at all (displayed as an open-ended "7+ nights" rather than a false precise count). + const vacantNights = next + ? Math.round(bookingDateRange(next).arrivalDate.diff(DateTime.fromISO(todayISO, { zone: TZ }), 'days').days) + : null + return { governing: null, kind: null, currentNight: null, totalNights: null, hasNextBooking: false, vacantNights } } const { arrivalDate, departureDate } = bookingDateRange(current) @@ -148,7 +152,7 @@ function resolveGoverningBooking(candidates, todayISO) { : 'stayover' const hasNextBooking = kind === 'departure' && !!next - return { governing, kind, currentNight, totalNights, hasNextBooking } + return { governing: current, kind, currentNight, totalNights, hasNextBooking, vacantNights: null } } // should_heat() — auto mode AND an active booking status AND the room state in @@ -268,13 +272,13 @@ async function getZoneState(zoneId) { } async function saveZoneState(zoneId, roomState, bookingStatus, meta = {}) { - const { kind = null, currentNight = null, totalNights = null, hasNextBooking = false } = meta + const { kind = null, currentNight = null, totalNights = null, hasNextBooking = false, vacantNights = null } = meta await pool.query( `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 + has_next_booking, vacant_nights, last_transition_at, updated_at ) - VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW()) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW()) ON CONFLICT (zone_id) DO UPDATE SET room_state = EXCLUDED.room_state, last_booking_status = EXCLUDED.last_booking_status, @@ -282,9 +286,10 @@ async function saveZoneState(zoneId, roomState, bookingStatus, meta = {}) { current_night = EXCLUDED.current_night, total_nights = EXCLUDED.total_nights, has_next_booking = EXCLUDED.has_next_booking, + vacant_nights = EXCLUDED.vacant_nights, 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, kind, currentNight, totalNights, hasNextBooking] + [zoneId, roomState, bookingStatus, kind, currentNight, totalNights, hasNextBooking, vacantNights] ) } @@ -344,7 +349,7 @@ 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) + const { governing: booking, kind, currentNight, totalNights, hasNextBooking, vacantNights } = resolveGoverningBooking(candidates, today) // 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 @@ -360,7 +365,7 @@ export async function pollOnce() { const prev = await getZoneState(zone.id) const oldState = prev?.room_state || 'vacant' - await saveZoneState(zone.id, newState, booking?.booking_status || null, { kind, currentNight, totalNights, hasNextBooking }) + await saveZoneState(zone.id, newState, booking?.booking_status || null, { kind, currentNight, totalNights, hasNextBooking, vacantNights }) 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 07d3783..c638388 100644 --- a/backend/src/routes/status.js +++ b/backend/src/routes/status.js @@ -22,7 +22,7 @@ export async function statusRoutes(app) { } 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.booking_kind, zs.current_night, zs.total_nights, zs.has_next_booking, zs.vacant_nights FROM zones z LEFT JOIN zone_state zs ON zs.zone_id = z.id WHERE z.active = TRUE diff --git a/frontend/src/components/ZoneCard.tsx b/frontend/src/components/ZoneCard.tsx index 3a64169..faef9fd 100644 --- a/frontend/src/components/ZoneCard.tsx +++ b/frontend/src/components/ZoneCard.tsx @@ -7,18 +7,34 @@ const AC_DEVICE_TYPES = ['mhi_modbus', 'mhi_mqtt', 'midea', 'daikin', 'home_assi // 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). +// A genuinely vacant room (no icon, since nothing's arriving today) instead gets a +// plain night-count-until-next-arrival, or "7+ nights" when nothing's booked at all +// within the scheduler's lookahead window. 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 textStyle = { display: 'inline-flex', alignItems: 'center', gap: 2, marginLeft: 6, fontSize: 11.5, fontWeight: 600, color: 'var(--text-mid)' } as const + + if (zone.booking_kind) { + 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}} + + ) + } + + if (zone.room_state === 'vacant' && zone.zone_type === 'room') { + return ( + + Vacant {zone.vacant_nights != null ? `${zone.vacant_nights} night${zone.vacant_nights === 1 ? '' : 's'}` : '7+ nights'} + + ) + } + + return null } const MODE_ICONS: Record = { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 58dc2e5..f0175e7 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -70,6 +70,9 @@ export interface ZoneStatus extends Zone { current_night: number | null total_nights: number | null has_next_booking: boolean + // Only set when room_state is 'vacant' — nights until the next known arrival within + // the scheduler's fetch window, or null if nothing was found in that window at all. + vacant_nights: number | null devices: DeviceSummary[] }