room-planner/backend/src/lib/booking-flow.js
jtricerolph 1e658b6a48 feat: add room-planner app — 3-day HK room view with NewBook integration
NewBook-connected daily housekeeping planner. Replaces the hotelhubmodule-housekeeping-dailylist
WordPress plugin. LXC 120 · 10.10.10.120:3080 · slug: room-planner.

- 3-day booking window (yesterday/today/tomorrow) fetched live from NewBook
- Task completion ticks back to NewBook; room status patches NewBook directly
- 23px border sliver CSS system for adjacent-day booking status
- 3-state filter cycling (off→inclusive→exclusive) for categories and flow types
- Stat filters for outstanding tasks and clean/dirty status
- Rolling 48h activity log with checkout/checkin/status/tasks events
- newbook_pings event bus for future NewBook poller integration
- Room modal with permission-gated guest/rate/notes, task checkboxes, status buttons
- Placeholder sections for future linen-count and routine-tasks modules
- Settings page: task type colours, twin/extra-bed detection, category exclusions
- Mobile-first layout (sidebar desktop, compact top bar mobile)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 13:07:00 +00:00

124 lines
4.7 KiB
JavaScript

// Shared booking-flow classification logic (server-side).
// The same logic is duplicated in frontend/src/lib/booking-flow.ts for client use.
export const STATUS_COLORS = {
arrived: '#3b82f6', // blue
confirmed: '#10b981', // green
unconfirmed: '#f59e0b', // amber
departed: '#a855f7', // purple
cancelled: '#94a3b8', // slate
blocked: '#6b7280', // grey
}
// Extract YYYY-MM-DD from a NewBook datetime string
export function toDateStr(dt) {
if (!dt) return null
return String(dt).slice(0, 10)
}
// Extract HH:MM from a NewBook datetime string
export function toTimeStr(dt) {
if (!dt) return null
const t = String(dt).slice(11, 16)
return t || null
}
// Find all bookings for a given site on a specific date.
// A booking occupies a site on viewDate if: arrival <= viewDate < departure
// (departure date is the check-out day, so the room is vacated on that morning)
export function bookingsForSiteOnDate(bookings, siteId, viewDate) {
return bookings.filter(b => {
if (String(b.site_id) !== String(siteId)) return false
const arrival = toDateStr(b.booking_arrival)
const departure = toDateStr(b.booking_departure)
if (!arrival || !departure) return false
return arrival <= viewDate && departure > viewDate
})
}
// Find the departing booking for a site on viewDate (departure_date === viewDate)
export function departingBookingForSite(bookings, siteId, viewDate) {
return bookings.find(b =>
String(b.site_id) === String(siteId) &&
toDateStr(b.booking_departure) === viewDate
) ?? null
}
// Classify a site's booking state for a given view date.
// Returns a rich descriptor used to build the room card.
export function classifyRoom(site, allBookings, viewDate, yesterday, tomorrow) {
const siteId = String(site.site_id)
// Primary: booking that occupies the room on viewDate (arrival <= viewDate < departure)
const todayBookings = bookingsForSiteOnDate(allBookings, siteId, viewDate)
const departing = departingBookingForSite(allBookings, siteId, viewDate)
// Adjacent day primary occupants (for border slivers)
const prevBookings = bookingsForSiteOnDate(allBookings, siteId, yesterday)
const nextBookings = bookingsForSiteOnDate(allBookings, siteId, tomorrow)
const prevBooking = prevBookings[0] ?? null
const nextBooking = nextBookings[0] ?? null
// Determine flow type
let flowType = 'vacant'
let primaryBooking = todayBookings[0] ?? null
if (primaryBooking) {
const status = (primaryBooking.booking_status || '').toLowerCase()
if (status === 'blocked') {
flowType = 'blocked'
} else {
const arrivalDate = toDateStr(primaryBooking.booking_arrival)
const departureDate = toDateStr(primaryBooking.booking_departure)
const arrivingToday = arrivalDate === viewDate
const departingToday = departureDate === viewDate
if (arrivingToday && departing && String(departing.booking_id) !== String(primaryBooking.booking_id)) {
// A different booking departs same day this one arrives
flowType = 'back-to-back'
} else if (arrivingToday) {
flowType = 'arrive'
} else if (departingToday) {
// Booking departs today but arrival <= viewDate so they were here — shouldn't normally hit
flowType = 'depart'
} else {
flowType = 'stopover'
}
}
} else if (departing) {
// Room had a departing guest but no incoming booking occupies it today
primaryBooking = departing
flowType = 'depart'
}
const arrivalDate = primaryBooking ? toDateStr(primaryBooking.booking_arrival) : null
const departureDate = primaryBooking ? toDateStr(primaryBooking.booking_departure) : null
return {
site_id: siteId,
site_name: site.site_name,
site_status: site.site_status || 'unknown',
category_id: String(site.site_category_id || ''),
category_name: site.site_category_name || '',
category_order: site.site_category_order ?? 0,
site_order: site.site_order ?? 0,
flow_type: flowType,
booking: primaryBooking,
// Span flags — does this booking continue across the day boundary?
spans_previous: !!primaryBooking && !!arrivalDate && arrivalDate < viewDate,
spans_next: !!primaryBooking && !!departureDate && departureDate > viewDate,
// Adjacent day context for border slivers
previous_booking: prevBooking,
next_booking: nextBooking,
previous_status: prevBooking ? (prevBooking.booking_status || '').toLowerCase() : null,
next_status: nextBooking ? (nextBooking.booking_status || '').toLowerCase() : null,
// Departure time info for the wider-border badge
departing_booking: departing,
departing_time: departing ? toTimeStr(departing.booking_departure) : null,
}
}