Add a Departing state to the Rooms Accessible View

A guest checking out today but not yet gone was previously lumped in
with ordinary stayovers — fully greyed out, no way to tell "the guest is
gone in 20 minutes" apart from "the guest isn't leaving at all". Split
that out into its own bucket:

Backend: classifyBookingStatus() now also recognises 'depart'/'checkedout'
statuses (already checked out — room's free regardless of the date
window). fetchRoomOccupancy() returns occupied/departing/arriving; an
in-house booking whose departure date is today lands in 'departing'
instead of 'occupied'. The ?unoccupied=true task filter and /api/occupancy
both updated for the new three-way split.

Frontend: departing rooms get a light dim (opacity only, no greyscale)
instead of the hard grey-out, plus a purple 'Departing' badge — matching
the booked=green/occupied=blue/departing=purple colour language hvac
already uses for NewBook room states. Chip label breakdown now reads
"X free • Y arriving • Z departing • W occupied".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-09-03 09:33:57 +00:00
parent 96674c58c8
commit 8ddb99d2d7
3 changed files with 54 additions and 25 deletions

View file

@ -33,13 +33,16 @@ function stripCosts(task) {
}
// Returns guests physically in the room right now vs those booked to arrive today.
// NewBook status strings vary by account; we normalise and check for 'arriv' / 'inhouse'
// / 'checkedin' to identify in-room guests. Everything else in the 'staying' list is
// treated as "arriving today" — confirmed but not yet checked in.
// NewBook status strings vary by account; we normalise and check for 'depart' /
// 'checkedout' (already checked out — room's free, whatever the date window says),
// then 'arriv' / 'inhouse' / 'checkedin' to identify in-room guests. Everything else
// in the 'staying' list is treated as "arriving today" — confirmed but not yet
// checked in.
function classifyBookingStatus(rawStatus) {
const s = String(rawStatus ?? '').toLowerCase().replace(/[\s_\-]/g, '')
if (s.includes('arriv') || s.includes('inhouse') || s.includes('checkedin')) return 'occupied'
return 'arriving'
if (s.includes('depart') || s.includes('checkedout')) return 'departed'
if (s.includes('arriv') || s.includes('inhouse') || s.includes('checkedin')) return 'inhouse'
return 'confirmed'
}
async function fetchRoomOccupancy() {
@ -52,23 +55,35 @@ async function fetchRoomOccupancy() {
// filter locally against each booking's own arrival/departure dates instead.
const bookings = await fetchBookings(yesterday, today)
const occupied = new Set()
const departing = new Set()
const arriving = new Set()
for (const b of bookings) {
const siteId = b.booking_site_id ?? b.site_id
if (siteId == null) continue
const arrival = String(b.booking_arrival ?? '').slice(0, 10)
const departure = String(b.booking_departure ?? '').slice(0, 10)
// Only bookings whose stay actually covers today belong in either bucket — the
// Only bookings whose stay actually covers today belong in any bucket — the
// wider window also pulls back yesterday's now-departed booking, which shouldn't
// count towards either set.
// count towards any of them.
if (arrival > today || departure < today) continue
const id = String(siteId)
if (classifyBookingStatus(b.booking_status) === 'occupied') occupied.add(id)
else arriving.add(id)
const kind = classifyBookingStatus(b.booking_status)
if (kind === 'departed') continue // already checked out — room's free now
if (kind === 'inhouse') {
// In the room right now: still a stayover if checking out later, or
// "departing" (checking out today but not yet gone) if this is their last day.
if (departure === today) departing.add(id)
else occupied.add(id)
} else {
arriving.add(id)
}
}
// A site that is both (e.g. multi-booking day) should be treated as occupied
// A site can have more than one booking on a turnover day (e.g. a departing guest
// plus a same-day incoming one) — physically-present-right-now always wins over a
// booking that's merely confirmed to arrive later.
for (const id of departing) { occupied.delete(id); arriving.delete(id) }
for (const id of occupied) arriving.delete(id)
return { occupied, arriving }
return { occupied, departing, arriving }
}
export async function taskRoutes(app) {
@ -106,13 +121,16 @@ export async function taskRoutes(app) {
// "Unoccupied rooms only": keep only rooms-category tasks whose NewBook room
// has no in-house (arrived) booking right now.
if (q.unoccupied === 'true') {
let occupied
let occupied, departing
try {
({ occupied } = await fetchRoomOccupancy())
({ occupied, departing } = await fetchRoomOccupancy())
} catch (err) {
return reply.status(502).send({ error: `NewBook error: ${err.message}` })
}
rows = rows.filter(t => t.is_rooms && t.newbook_site_id && !occupied.has(t.newbook_site_id))
// A "departing today" room is still guest-occupied right now, so it counts as
// occupied for this filter even though the Rooms Accessible View shows it
// separately.
rows = rows.filter(t => t.is_rooms && t.newbook_site_id && !occupied.has(t.newbook_site_id) && !departing.has(t.newbook_site_id))
}
if (!hasCap(req, 'costs')) rows = rows.map(stripCosts)
@ -334,13 +352,15 @@ export async function taskRoutes(app) {
})
// GET /api/occupancy — room state from NewBook for the Rooms Accessible View
// Returns two lists: occupied (guest in room) and arriving (booked today, not yet checked in)
// Returns three lists: occupied (staying, guest in room), departing (guest in room
// but checking out today — accessible later) and arriving (booked today, not yet checked in)
app.get('/api/occupancy', { preHandler: requireCap('view') }, async (req, reply) => {
try {
const { occupied, arriving } = await fetchRoomOccupancy()
const { occupied, departing, arriving } = await fetchRoomOccupancy()
return {
date: new Date().toISOString().slice(0, 10),
occupied_site_ids: [...occupied],
departing_site_ids: [...departing],
arriving_site_ids: [...arriving],
}
} catch (err) {