diff --git a/backend/src/lib/booking-flow.js b/backend/src/lib/booking-flow.js index 153a64b..ec9133d 100644 --- a/backend/src/lib/booking-flow.js +++ b/backend/src/lib/booking-flow.js @@ -51,80 +51,95 @@ export function classifyRoom(site, allBookings, viewDate, yesterday, tomorrow) { // Primary: booking that occupies the room on viewDate (arrival <= viewDate < departure) const todayBookings = bookingsForSiteOnDate(allBookings, siteId, viewDate) - const departing = departingBookingForSite(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) + // Prefer bookings arriving TODAY over stopovers — handles the case where NewBook + // still shows a previous stay as active when a new booking has already arrived. + const todayArriving = todayBookings.filter(b => toDateStr(b.booking_arrival) === viewDate) + let primaryBooking = todayArriving[0] ?? todayBookings[0] ?? null - const prevBooking = prevBookings[0] ?? null - const nextBooking = nextBookings[0] ?? null + // If an arriving booking is primary AND there's also a non-arriving booking in + // todayBookings, treat that as the departing context (B2B where departure date + // hasn't been updated in NewBook yet). + let effectiveDeparting = departing + if (!effectiveDeparting && todayArriving.length > 0 && todayBookings.length > todayArriving.length) { + effectiveDeparting = todayBookings.find(b => toDateStr(b.booking_arrival) !== viewDate) ?? null + } + + // Adjacent day primary occupants (for border brackets) + const prevBooking = bookingsForSiteOnDate(allBookings, siteId, yesterday)[0] ?? null + const nextBooking = bookingsForSiteOnDate(allBookings, siteId, tomorrow)[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 + const arrDate = toDateStr(primaryBooking.booking_arrival) + const deptDate = toDateStr(primaryBooking.booking_departure) + const arrivingToday = arrDate === viewDate + const departingToday = deptDate === viewDate - if (arrivingToday && departing && String(departing.booking_id) !== String(primaryBooking.booking_id)) { - // A different booking departs same day this one arrives + if (arrivingToday && effectiveDeparting && + String(effectiveDeparting.booking_id) !== String(primaryBooking.booking_id)) { 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 + } else if (effectiveDeparting) { + primaryBooking = effectiveDeparting flowType = 'depart' } - const arrivalDate = primaryBooking ? toDateStr(primaryBooking.booking_arrival) : null + const arrivalDate = primaryBooking ? toDateStr(primaryBooking.booking_arrival) : null const departureDate = primaryBooking ? toDateStr(primaryBooking.booking_departure) : null - // NewBook may return category info as flat fields (site_category_id, category_id) - // or as a nested object. Resolve whichever is present. + // spans_next: booking is ALSO active tomorrow (departure strictly after tomorrow). + // Using > tomorrow (not > viewDate) so a booking departing tomorrow does NOT extend + // the card right — it ends tonight and its right border should be visible. + const spansNext = !!primaryBooking && !!departureDate && departureDate > tomorrow + + // Previous-day bracket: if the previous booking departs TODAY, force "departed" + // colour regardless of whether NewBook has updated its status yet. + const rawPrevStatus = prevBooking ? (prevBooking.booking_status || '').toLowerCase() : null + const prevStatus = (rawPrevStatus && toDateStr(prevBooking?.booking_departure) === viewDate) + ? 'departed' + : rawPrevStatus + + // NewBook may return category info as flat fields or a nested object. const catId = site.site_category_id ?? site.category_id ?? site.category?.id ?? null const catName = site.site_category_name ?? site.category_name ?? site.category?.name ?? '' const catOrd = site.site_category_order ?? site.category_order ?? site.category?.order ?? 0 return { - site_id: siteId, - site_name: site.site_name, - site_status: site.site_status || 'unknown', - category_id: catId != null ? String(catId) : '', - category_name: catName, - category_order: catOrd, - site_order: site.site_order ?? 0, + site_id: siteId, + site_name: site.site_name, + site_status: site.site_status || 'unknown', + category_id: catId != null ? String(catId) : '', + category_name: catName, + category_order: catOrd, + site_order: site.site_order ?? 0, - flow_type: flowType, - booking: primaryBooking, + 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, + spans_previous: !!primaryBooking && !!arrivalDate && arrivalDate < viewDate, + spans_next: spansNext, - // Adjacent day context for border slivers previous_booking: prevBooking, next_booking: nextBooking, - previous_status: prevBooking ? (prevBooking.booking_status || '').toLowerCase() : null, + previous_status: prevStatus, 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, + departing_booking: effectiveDeparting, + departing_time: effectiveDeparting ? toTimeStr(effectiveDeparting.booking_departure) : null, } } diff --git a/frontend/src/components/RoomCard.tsx b/frontend/src/components/RoomCard.tsx index 4551c5f..f5a6008 100644 --- a/frontend/src/components/RoomCard.tsx +++ b/frontend/src/components/RoomCard.tsx @@ -16,10 +16,17 @@ interface Props { const I = { strokeWidth: 1.75 } +// Parse any date(-time) string as a UTC midnight value, ignoring any time component. +// Date.parse on "YYYY-MM-DD HH:MM:SS" is timezone-ambiguous in browsers; this is not. +function utcDay(s: string): number { + const d = s.slice(0, 10) + return Date.UTC(+d.slice(0, 4), +d.slice(5, 7) - 1, +d.slice(8, 10)) +} + function nightsInfo(arrival: string, departure: string, viewDate: string) { - const msDay = 86400000 - const total = Math.round((Date.parse(departure) - Date.parse(arrival)) / msDay) - const current = Math.round((Date.parse(viewDate) - Date.parse(arrival)) / msDay) + 1 + const msDay = 86400000 + const total = Math.round((utcDay(departure) - utcDay(arrival)) / msDay) + const current = Math.round((utcDay(viewDate) - utcDay(arrival)) / msDay) + 1 return { current: Math.max(1, Math.min(current, total)), total: Math.max(1, total) } }