Fix: a booking arriving in a few days showed 'booked' instead of 'vacant'
resolveGoverningBooking() was letting an upcoming (not-today) booking govern colour, so any room with something booked within the 7-day lookahead showed green 'Booked' regardless of how far off the arrival was. 'booked' should only apply when someone's actually arriving tonight. Now a booking only governs state/colour when it covers today (arriving today, mid-stay, or departing today) — otherwise the room is genuinely vacant. The soonest upcoming booking (if any) is surfaced separately as vacant_nights, a countdown to the next arrival, purely informational and no longer tied to colour. Dashboard shows 'Vacant N nights' when something's booked within the window, or 'Vacant 7+ nights' when nothing was found in the fetched window at all. New zone_state.vacant_nights column, threaded through saveZoneState/pollOnce/status.js. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
915fc0039c
commit
dc8c9df719
5 changed files with 60 additions and 31 deletions
|
|
@ -108,6 +108,10 @@ export async function initDb() {
|
||||||
current_night INT,
|
current_night INT,
|
||||||
total_nights INT,
|
total_nights INT,
|
||||||
has_next_booking BOOLEAN NOT NULL DEFAULT FALSE, -- turnover hint: another booking follows a departure today
|
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()
|
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 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 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 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()
|
await seedDefaults()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -99,18 +99,19 @@ function bookingDateRange(booking) {
|
||||||
return { arrivalDate: arrival.startOf('day'), departureDate: departure.startOf('day') }
|
return { arrivalDate: arrival.startOf('day'), departureDate: departure.startOf('day') }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same-day turnovers, multi-night stays and future-but-not-yet-relevant bookings can
|
// Same-day turnovers, multi-night stays and upcoming bookings can all be present in
|
||||||
// all be present in one site's candidate list (the scheduler now fetches yesterday..+7
|
// one site's candidate list (the scheduler fetches yesterday..+7 days) — there's no
|
||||||
// days) — there's no single "the" booking without resolving against today's date:
|
// single "the" booking without resolving against today's date:
|
||||||
// 1. "active today" = any non-cancelled booking whose [arrival, departure] window
|
// 1. "active today" = any non-cancelled booking whose [arrival, departure] window
|
||||||
// covers today, preferring one NOT YET marked departed — so a departing guest's
|
// covers today, preferring one NOT YET marked departed — so a departing guest's
|
||||||
// booking keeps governing (blue -> purple) right up until checkout is confirmed
|
// 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
|
// 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
|
// departed it drops out of contention and the incoming booking (itself
|
||||||
// active-today, since its arrival date is today) naturally takes over.
|
// active-today, since its arrival date is today) naturally takes over.
|
||||||
// 2. otherwise, the soonest upcoming booking (arrival >= today) governs — purely for
|
// 2. otherwise the room is genuinely vacant TONIGHT — 'booked' only applies when
|
||||||
// colour ('booked' ahead of its own prep window). No kind/night-count is shown
|
// something is arriving today, not merely within the fetch window. The soonest
|
||||||
// for it, since today isn't actually one of its nights yet.
|
// 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
|
// The 'departure'-kind case also flags hasNextBooking when a distinct booking is
|
||||||
// already lined up, for the dashboard's takeoff+landing turnover hint.
|
// already lined up, for the dashboard's takeoff+landing turnover hint.
|
||||||
function resolveGoverningBooking(candidates, todayISO) {
|
function resolveGoverningBooking(candidates, todayISO) {
|
||||||
|
|
@ -128,15 +129,18 @@ function resolveGoverningBooking(candidates, todayISO) {
|
||||||
const current = activeToday[0] || null
|
const current = activeToday[0] || null
|
||||||
|
|
||||||
const upcoming = usable
|
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)
|
.sort((a, b) => bookingDateRange(a).arrivalDate - bookingDateRange(b).arrivalDate)
|
||||||
const next = upcoming[0] || null
|
const next = upcoming[0] || null
|
||||||
|
|
||||||
const governing = current || next
|
if (!current) {
|
||||||
if (!governing || governing !== current) {
|
// No booking covers today — vacant tonight. vacantNights counts down to the next
|
||||||
// No booking at all, or the only candidate is booked ahead (today isn't one of
|
// known arrival within the fetch window; null means nothing found in that window
|
||||||
// its nights yet) — no icon/night-count either way.
|
// at all (displayed as an open-ended "7+ nights" rather than a false precise count).
|
||||||
return { governing, kind: null, currentNight: null, totalNights: null, hasNextBooking: false }
|
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)
|
const { arrivalDate, departureDate } = bookingDateRange(current)
|
||||||
|
|
@ -148,7 +152,7 @@ function resolveGoverningBooking(candidates, todayISO) {
|
||||||
: 'stayover'
|
: 'stayover'
|
||||||
const hasNextBooking = kind === 'departure' && !!next
|
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
|
// 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 = {}) {
|
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(
|
await pool.query(
|
||||||
`INSERT INTO zone_state (
|
`INSERT INTO zone_state (
|
||||||
zone_id, room_state, last_booking_status, booking_kind, current_night, total_nights,
|
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
|
ON CONFLICT (zone_id) DO UPDATE SET
|
||||||
room_state = EXCLUDED.room_state,
|
room_state = EXCLUDED.room_state,
|
||||||
last_booking_status = EXCLUDED.last_booking_status,
|
last_booking_status = EXCLUDED.last_booking_status,
|
||||||
|
|
@ -282,9 +286,10 @@ async function saveZoneState(zoneId, roomState, bookingStatus, meta = {}) {
|
||||||
current_night = EXCLUDED.current_night,
|
current_night = EXCLUDED.current_night,
|
||||||
total_nights = EXCLUDED.total_nights,
|
total_nights = EXCLUDED.total_nights,
|
||||||
has_next_booking = EXCLUDED.has_next_booking,
|
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,
|
last_transition_at = CASE WHEN zone_state.room_state != EXCLUDED.room_state THEN NOW() ELSE zone_state.last_transition_at END,
|
||||||
updated_at = NOW()`,
|
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) {
|
for (const zone of zones) {
|
||||||
try {
|
try {
|
||||||
const candidates = bookingsBySite.get(String(zone.newbook_site_id)) || []
|
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
|
// 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
|
// 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 prev = await getZoneState(zone.id)
|
||||||
const oldState = prev?.room_state || 'vacant'
|
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 (!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
|
if (newState === oldState) continue // no transition — never poll-and-reassert
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ export async function statusRoutes(app) {
|
||||||
}
|
}
|
||||||
const { rows: zones } = await pool.query(`
|
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
|
zs.booking_kind, zs.current_night, zs.total_nights, zs.has_next_booking, zs.vacant_nights
|
||||||
FROM zones z
|
FROM zones z
|
||||||
LEFT JOIN zone_state zs ON zs.zone_id = z.id
|
LEFT JOIN zone_state zs ON zs.zone_id = z.id
|
||||||
WHERE z.active = TRUE
|
WHERE z.active = TRUE
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,16 @@ const AC_DEVICE_TYPES = ['mhi_modbus', 'mhi_mqtt', 'midea', 'daikin', 'home_assi
|
||||||
// Departure = takeoff, arrival = landing, stayover = bed (lucide has no "person in
|
// 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
|
// 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).
|
// 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 }) {
|
function BookingIcons({ zone }: { zone: ZoneStatus }) {
|
||||||
if (!zone.booking_kind) return null
|
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 }
|
const iconProps = { size: 13, strokeWidth: 1.75 }
|
||||||
return (
|
return (
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 2, marginLeft: 6, fontSize: 11.5, fontWeight: 600, color: 'var(--text-mid)' }}>
|
<span style={textStyle}>
|
||||||
{zone.booking_kind === 'departure' && <PlaneTakeoff {...iconProps} />}
|
{zone.booking_kind === 'departure' && <PlaneTakeoff {...iconProps} />}
|
||||||
{zone.booking_kind === 'arrival' && <PlaneLanding {...iconProps} />}
|
{zone.booking_kind === 'arrival' && <PlaneLanding {...iconProps} />}
|
||||||
{zone.booking_kind === 'stayover' && <BedDouble {...iconProps} />}
|
{zone.booking_kind === 'stayover' && <BedDouble {...iconProps} />}
|
||||||
|
|
@ -21,6 +26,17 @@ function BookingIcons({ zone }: { zone: ZoneStatus }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (zone.room_state === 'vacant' && zone.zone_type === 'room') {
|
||||||
|
return (
|
||||||
|
<span style={textStyle}>
|
||||||
|
Vacant {zone.vacant_nights != null ? `${zone.vacant_nights} night${zone.vacant_nights === 1 ? '' : 's'}` : '7+ nights'}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const MODE_ICONS: Record<string, typeof Snowflake> = {
|
const MODE_ICONS: Record<string, typeof Snowflake> = {
|
||||||
cool: Snowflake,
|
cool: Snowflake,
|
||||||
heat: Flame,
|
heat: Flame,
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,9 @@ export interface ZoneStatus extends Zone {
|
||||||
current_night: number | null
|
current_night: number | null
|
||||||
total_nights: number | null
|
total_nights: number | null
|
||||||
has_next_booking: boolean
|
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[]
|
devices: DeviceSummary[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue