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. // 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' // NewBook status strings vary by account; we normalise and check for 'depart' /
// / 'checkedin' to identify in-room guests. Everything else in the 'staying' list is // 'checkedout' (already checked out — room's free, whatever the date window says),
// treated as "arriving today" — confirmed but not yet checked in. // 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) { function classifyBookingStatus(rawStatus) {
const s = String(rawStatus ?? '').toLowerCase().replace(/[\s_\-]/g, '') const s = String(rawStatus ?? '').toLowerCase().replace(/[\s_\-]/g, '')
if (s.includes('arriv') || s.includes('inhouse') || s.includes('checkedin')) return 'occupied' if (s.includes('depart') || s.includes('checkedout')) return 'departed'
return 'arriving' if (s.includes('arriv') || s.includes('inhouse') || s.includes('checkedin')) return 'inhouse'
return 'confirmed'
} }
async function fetchRoomOccupancy() { async function fetchRoomOccupancy() {
@ -52,23 +55,35 @@ async function fetchRoomOccupancy() {
// filter locally against each booking's own arrival/departure dates instead. // filter locally against each booking's own arrival/departure dates instead.
const bookings = await fetchBookings(yesterday, today) const bookings = await fetchBookings(yesterday, today)
const occupied = new Set() const occupied = new Set()
const departing = new Set()
const arriving = new Set() const arriving = new Set()
for (const b of bookings) { for (const b of bookings) {
const siteId = b.booking_site_id ?? b.site_id const siteId = b.booking_site_id ?? b.site_id
if (siteId == null) continue if (siteId == null) continue
const arrival = String(b.booking_arrival ?? '').slice(0, 10) const arrival = String(b.booking_arrival ?? '').slice(0, 10)
const departure = String(b.booking_departure ?? '').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 // 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 if (arrival > today || departure < today) continue
const id = String(siteId) const id = String(siteId)
if (classifyBookingStatus(b.booking_status) === 'occupied') occupied.add(id) const kind = classifyBookingStatus(b.booking_status)
else arriving.add(id) 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) for (const id of occupied) arriving.delete(id)
return { occupied, arriving } return { occupied, departing, arriving }
} }
export async function taskRoutes(app) { 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 // "Unoccupied rooms only": keep only rooms-category tasks whose NewBook room
// has no in-house (arrived) booking right now. // has no in-house (arrived) booking right now.
if (q.unoccupied === 'true') { if (q.unoccupied === 'true') {
let occupied let occupied, departing
try { try {
({ occupied } = await fetchRoomOccupancy()) ({ occupied, departing } = await fetchRoomOccupancy())
} catch (err) { } catch (err) {
return reply.status(502).send({ error: `NewBook error: ${err.message}` }) 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) 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 // 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) => { app.get('/api/occupancy', { preHandler: requireCap('view') }, async (req, reply) => {
try { try {
const { occupied, arriving } = await fetchRoomOccupancy() const { occupied, departing, arriving } = await fetchRoomOccupancy()
return { return {
date: new Date().toISOString().slice(0, 10), date: new Date().toISOString().slice(0, 10),
occupied_site_ids: [...occupied], occupied_site_ids: [...occupied],
departing_site_ids: [...departing],
arriving_site_ids: [...arriving], arriving_site_ids: [...arriving],
} }
} catch (err) { } catch (err) {

View file

@ -91,7 +91,7 @@ export function resolveTask(id: number, body: {
export function addComment(id: number, note: string, addToTemplate = false): Promise<{ ok: boolean; added_to_template: boolean }> { export function addComment(id: number, note: string, addToTemplate = false): Promise<{ ok: boolean; added_to_template: boolean }> {
return request(`/tasks/${id}/comments`, { method: 'POST', body: JSON.stringify({ note, add_to_template: addToTemplate }) }) return request(`/tasks/${id}/comments`, { method: 'POST', body: JSON.stringify({ note, add_to_template: addToTemplate }) })
} }
export function fetchOccupancy(): Promise<{ date: string; occupied_site_ids: string[]; arriving_site_ids: string[] }> { export function fetchOccupancy(): Promise<{ date: string; occupied_site_ids: string[]; departing_site_ids: string[]; arriving_site_ids: string[] }> {
return request('/occupancy') return request('/occupancy')
} }

View file

@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { Plus, RefreshCw, Camera, BedDouble, Layers, ChevronDown, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react' import { Plus, RefreshCw, Camera, BedDouble, LogOut, Layers, ChevronDown, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'
import type { Task, Category, Location, AppConfig, TaskStatus, Priority } from '../types' import type { Task, Category, Location, AppConfig, TaskStatus, Priority } from '../types'
import { STATUS_LABELS, can } from '../types' import { STATUS_LABELS, can } from '../types'
import { fetchTasks, fetchLocations, fetchConfig, fetchOccupancy } from '../api' import { fetchTasks, fetchLocations, fetchConfig, fetchOccupancy } from '../api'
@ -20,6 +20,7 @@ export default function Summary() {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [occupiedSiteIds, setOccupiedSiteIds] = useState<Set<string>>(new Set()) const [occupiedSiteIds, setOccupiedSiteIds] = useState<Set<string>>(new Set())
const [departingSiteIds, setDepartingSiteIds] = useState<Set<string>>(new Set())
const [arrivingSiteIds, setArrivingSiteIds] = useState<Set<string>>(new Set()) const [arrivingSiteIds, setArrivingSiteIds] = useState<Set<string>>(new Set())
const [statusFilter, setStatusFilter] = useState<TaskStatus | null>(null) const [statusFilter, setStatusFilter] = useState<TaskStatus | null>(null)
@ -70,6 +71,7 @@ export default function Summary() {
.then(([t, occ]) => { .then(([t, occ]) => {
setTasks(t) setTasks(t)
setOccupiedSiteIds(occ ? new Set(occ.occupied_site_ids) : new Set()) setOccupiedSiteIds(occ ? new Set(occ.occupied_site_ids) : new Set())
setDepartingSiteIds(occ ? new Set(occ.departing_site_ids) : new Set())
setArrivingSiteIds(occ ? new Set(occ.arriving_site_ids) : new Set()) setArrivingSiteIds(occ ? new Set(occ.arriving_site_ids) : new Set())
setError(null) setError(null)
}) })
@ -115,17 +117,19 @@ export default function Summary() {
return c return c
}, [tasks]) }, [tasks])
// Rooms chip counts: occupied = guest in room, arriving = booked today not yet in // Rooms chip counts: occupied = guest staying, departing = guest checking out
// today (accessible later), arriving = booked today not yet in
const roomsCounts = useMemo(() => { const roomsCounts = useMemo(() => {
if (!roomsView) return null if (!roomsView) return null
let occupied = 0, arriving = 0 let occupied = 0, departing = 0, arriving = 0
for (const t of tasks) { for (const t of tasks) {
const sid = t.newbook_site_id ? String(t.newbook_site_id) : null const sid = t.newbook_site_id ? String(t.newbook_site_id) : null
if (sid && occupiedSiteIds.has(sid)) occupied++ if (sid && occupiedSiteIds.has(sid)) occupied++
else if (sid && departingSiteIds.has(sid)) departing++
else if (sid && arrivingSiteIds.has(sid)) arriving++ else if (sid && arrivingSiteIds.has(sid)) arriving++
} }
return { total: tasks.length, occupied, arriving, free: tasks.length - occupied - arriving } return { total: tasks.length, occupied, departing, arriving, free: tasks.length - occupied - departing - arriving }
}, [tasks, roomsView, occupiedSiteIds, arrivingSiteIds]) }, [tasks, roomsView, occupiedSiteIds, departingSiteIds, arrivingSiteIds])
const sortedTasks = useMemo(() => { const sortedTasks = useMemo(() => {
if (sortMode === 'default') return tasks if (sortMode === 'default') return tasks
@ -226,7 +230,7 @@ export default function Summary() {
> >
<BedDouble size={13} strokeWidth={1.75} /> <BedDouble size={13} strokeWidth={1.75} />
{roomsView && roomsCounts {roomsView && roomsCounts
? `Rooms (${roomsCounts.free} free${roomsCounts.arriving ? ` • ${roomsCounts.arriving} arriving` : ''}${roomsCounts.occupied ? ` • ${roomsCounts.occupied} occupied` : ''})` ? `Rooms (${roomsCounts.free} free${roomsCounts.arriving ? ` • ${roomsCounts.arriving} arriving` : ''}${roomsCounts.departing ? ` • ${roomsCounts.departing} departing` : ''}${roomsCounts.occupied ? ` • ${roomsCounts.occupied} occupied` : ''})`
: 'Rooms Accessible View'} : 'Rooms Accessible View'}
</button> </button>
)} )}
@ -253,18 +257,23 @@ export default function Summary() {
const renderTask = (t: Task) => { const renderTask = (t: Task) => {
const sid = t.newbook_site_id ? String(t.newbook_site_id) : null const sid = t.newbook_site_id ? String(t.newbook_site_id) : null
const isOccupied = roomsView && !!sid && occupiedSiteIds.has(sid) const isOccupied = roomsView && !!sid && occupiedSiteIds.has(sid)
const isArriving = roomsView && !!sid && !isOccupied && arrivingSiteIds.has(sid) const isDeparting = roomsView && !!sid && !isOccupied && departingSiteIds.has(sid)
const isArriving = roomsView && !!sid && !isOccupied && !isDeparting && arrivingSiteIds.has(sid)
return ( return (
<div <div
key={t.id} key={t.id}
className={`card task-card ${t.priority === 'urgent' ? 'urgent' : t.priority === 'high' ? 'high' : ''} ${t.unusable ? 'unusable' : ''}`} className={`card task-card ${t.priority === 'urgent' ? 'urgent' : t.priority === 'high' ? 'high' : ''} ${t.unusable ? 'unusable' : ''}`}
style={isOccupied ? { opacity: 0.5, filter: 'grayscale(0.3)' } : undefined} // Occupied (stayover) rooms grey out hard — not accessible today. Departing
// rooms are lightly dimmed only: guest is still in there right now, but the
// room frees up later today so it's worth flagging rather than hiding.
style={isOccupied ? { opacity: 0.5, filter: 'grayscale(0.3)' } : isDeparting ? { opacity: 0.8 } : undefined}
onClick={() => setOpenTask(t.id)} onClick={() => setOpenTask(t.id)}
> >
<div className="task-card-main"> <div className="task-card-main">
<div className="task-card-title"> <div className="task-card-title">
{t.title} {t.title}
{isOccupied && <span className="badge badge-outline"><BedDouble size={10} strokeWidth={1.75} /> Occupied</span>} {isOccupied && <span className="badge badge-outline"><BedDouble size={10} strokeWidth={1.75} /> Occupied</span>}
{isDeparting && <span className="badge" style={{ background: '#ede9fe', color: '#6d28d9', border: '1px solid #c4b5fd' }}><LogOut size={10} strokeWidth={1.75} /> Departing</span>}
{isArriving && <span className="badge" style={{ background: '#fef3c7', color: '#92400e', border: '1px solid #fcd34d' }}><BedDouble size={10} strokeWidth={1.75} /> Arrival today</span>} {isArriving && <span className="badge" style={{ background: '#fef3c7', color: '#92400e', border: '1px solid #fcd34d' }}><BedDouble size={10} strokeWidth={1.75} /> Arrival today</span>}
{t.unusable && <UnusableBadge />} {t.unusable && <UnusableBadge />}
{t.template_id && <span className="badge badge-outline">Recurring</span>} {t.template_id && <span className="badge badge-outline">Recurring</span>}