import React, { useState, useMemo } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { Link } from 'react-router-dom' import Plot from 'react-plotly.js' import { X } from 'lucide-react' import api from '../api' // Format Date as YYYY-MM-DD using local time (avoids UTC/DST shift from toISOString) const fmtDate = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` // Types interface CategoryInfo { category_id: string category_name: string room_count: number } interface TariffInfo { name: string description?: string rate: number | null average_nightly?: number available: boolean message: string sort_order?: number min_stay?: number | null available_for_min_stay?: boolean | null // True if available when queried with min_stay nights } interface OccupancyInfo { occupied: number available: number maintenance: number } interface DateRateInfo { rate_gross: number | null rate_net: number | null tariffs: TariffInfo[] tariff_count: number occupancy?: OccupancyInfo valid_from?: string | null } interface RateMatrixData { categories: CategoryInfo[] dates: string[] matrix: Record> date_last_updated?: Record date_last_changed?: Record } // Helper functions const formatDateShort = (dateStr: string): string => { const date = new Date(dateStr + 'T00:00:00') return date.toLocaleDateString('en-GB', { day: 'numeric' }) } const formatDayOfWeek = (dateStr: string): string => { const date = new Date(dateStr + 'T00:00:00') return date.toLocaleDateString('en-GB', { weekday: 'short' }) } const formatLastUpdated = (isoStr: string | null | undefined): string => { if (!isoStr) return '' const d = new Date(isoStr) const now = new Date() const isToday = d.toDateString() === now.toDateString() return isToday ? d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short' }) + ' ' + d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) } const isWeekend = (dateStr: string): boolean => { const date = new Date(dateStr + 'T00:00:00') const day = date.getDay() return day === 0 || day === 6 } const todayStr = fmtDate(new Date()) const isPast = (dateStr: string): boolean => dateStr < todayStr const formatCurrency = (value: number | null): string => { if (value === null || value === undefined) return '-' return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP', minimumFractionDigits: 0, maximumFractionDigits: 0, }).format(value) } // Inline style helpers (replacing theme utilities) const mergeStyles = (...styles: React.CSSProperties[]): React.CSSProperties => Object.assign({}, ...styles) const buttonStyle = (variant: 'primary' | 'secondary' | 'outline', size?: 'small'): React.CSSProperties => { const base: React.CSSProperties = { border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 500, padding: size === 'small' ? '4px 10px' : '8px 16px', fontSize: size === 'small' ? '13px' : '14px', lineHeight: 1.4, transition: 'all 0.15s', } if (variant === 'primary') return { ...base, background: 'var(--gold)', color: '#fff' } if (variant === 'secondary') return { ...base, background: 'var(--navy)', color: '#fff' } // outline return { ...base, background: 'transparent', color: 'var(--text-dark)', border: '1px solid var(--card-border)' } } const badgeStyle = (variant: 'success' | 'error' | 'warning' | 'info'): React.CSSProperties => { const map: Record = { success: { background: '#dcfce7', color: '#16a34a', padding: '2px 8px', borderRadius: '99px', fontSize: '12px', fontWeight: 600 }, error: { background: '#fee2e2', color: '#dc2626', padding: '2px 8px', borderRadius: '99px', fontSize: '12px', fontWeight: 600 }, warning: { background: '#fef3c7', color: '#d97706', padding: '2px 8px', borderRadius: '99px', fontSize: '12px', fontWeight: 600 }, info: { background: '#dbeafe', color: '#2563eb', padding: '2px 8px', borderRadius: '99px', fontSize: '12px', fontWeight: 600 }, } return map[variant] || map.info } // Components const MonthSelector: React.FC<{ value: string onChange: (value: string) => void }> = ({ value, onChange }) => { const handlePrevMonth = () => { const [year, month] = value.split('-').map(Number) const date = new Date(year, month - 2, 1) onChange(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`) } const handleNextMonth = () => { const [year, month] = value.split('-').map(Number) const date = new Date(year, month, 1) onChange(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`) } // Generate month options (current month + next 12 months) const monthOptions = useMemo(() => { const options: { value: string; label: string }[] = [] const now = new Date() for (let i = 0; i < 13; i++) { const date = new Date(now.getFullYear(), now.getMonth() + i, 1) const monthValue = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` const label = date.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' }) options.push({ value: monthValue, label }) } return options }, []) return (
) } // Helper to collect unique tariff names across all dates for a category, preserving Newbook order const getAllTariffNames = (rateData: Record, dates: string[]): string[] => { const tariffMap = new Map() for (const dateStr of dates) { const data = rateData[dateStr] if (data?.tariffs) { for (const tariff of data.tariffs) { if (!tariffMap.has(tariff.name)) { tariffMap.set(tariff.name, tariff.sort_order ?? 999) } } } } return Array.from(tariffMap.entries()) .sort((a, b) => a[1] - b[1]) .map(([name]) => name) } // Helper to format scrape age const formatScrapeAge = (isoStr: string | null): string => { if (!isoStr) return '' const diff = Date.now() - new Date(isoStr).getTime() const mins = Math.floor(diff / 60000) if (mins < 60) return `${mins}m ago` const hours = Math.floor(mins / 60) if (hours < 24) return `${hours}h ago` const days = Math.floor(hours / 24) return `${days}d ago` } interface BookingAvailabilityData { has_own_hotel: boolean dates_checked: number dates_available: number dates_sold_out: number dates_no_data: number latest_scrape: string | null dates: Record } interface HistoryTarget { categoryId: string categoryName: string date: string } const LoadingSpinner: React.FC = () => (
Loading rate data...
) const ErrorMessage: React.FC<{ message: string }> = ({ message }) => (
! {message}
) // Main Component const Bookability: React.FC = () => { const queryClient = useQueryClient() const [selectedMonth, setSelectedMonth] = useState(() => { const today = new Date() return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}` }) const [refreshingDate, setRefreshingDate] = useState(null) const [history, setHistory] = useState(null) // Calculate date range from selected month const { fromDate, toDate } = useMemo(() => { const [year, month] = selectedMonth.split('-').map(Number) const start = new Date(year, month - 1, 1) const end = new Date(year, month, 0) // Last day of month return { fromDate: fmtDate(start), toDate: fmtDate(end), } }, [selectedMonth]) // Single-date refresh mutation const dateRefreshM = useMutation({ mutationFn: async (d: string) => { setRefreshingDate(d) const res = await api.post(`/bookability/refresh-date/${d}`) return res.data }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['rate-matrix'] }) setRefreshingDate(null) }, onError: () => setRefreshingDate(null), }) // Fetch Booking.com availability data const { data: bookingData } = useQuery({ queryKey: ['booking-availability', fromDate, toDate], queryFn: async () => { const params = new URLSearchParams({ from_date: fromDate, to_date: toDate }) const res = await api.get(`/competitors/booking-availability?${params}`) return res.data }, staleTime: 5 * 60 * 1000, }) // Fetch rate matrix data const { data, isLoading, error, refetch } = useQuery({ queryKey: ['rate-matrix', fromDate, toDate], queryFn: async () => { const params = new URLSearchParams({ from_date: fromDate, to_date: toDate }) const res = await api.get(`/bookability/rate-matrix?${params}`) return res.data }, }) // Calculate summary stats - focus on unbookable dates (rooms available but no rates) const summary = useMemo(() => { if (!data) return null let totalDateCategories = 0 let unbookableDateCategories = 0 const unbookableIssues: { category: string; date: string; roomsLeft: number }[] = [] for (const cat of data.categories) { const catData = data.matrix[cat.category_id] if (!catData) continue for (const dateStr of data.dates) { const dayData = catData[dateStr] if (!dayData) continue // Check if rooms are available (bookable = available - maintenance - occupied) const occ = dayData.occupancy const bookableRooms = occ ? occ.available - occ.maintenance : 0 const roomsLeft = occ ? bookableRooms - occ.occupied : 0 const hasRoomsAvailable = roomsLeft > 0 // Only count dates where rooms are available if (hasRoomsAvailable) { totalDateCategories++ // Check if ANY tariff is available for booking // A tariff is "bookable" if: // - available: true (single-night available), OR // - has min_stay > 1 AND available_for_min_stay: true (verified via multi-night query) const hasAnyAvailableRate = dayData.tariffs?.some(t => { if (t.available) return true // If has min_stay requirement and verified available for that stay length if (t.min_stay && t.min_stay > 1 && t.available_for_min_stay === true) return true return false }) ?? false if (!hasAnyAvailableRate && dayData.tariffs && dayData.tariffs.length > 0) { // Rooms available but no rates bookable - this is a problem! unbookableDateCategories++ unbookableIssues.push({ category: cat.category_name, date: dateStr, roomsLeft: roomsLeft, }) } } } } return { totalDateCategories, unbookableDateCategories, unbookablePercent: totalDateCategories > 0 ? ((unbookableDateCategories / totalDateCategories) * 100).toFixed(1) : '0', issues: unbookableIssues.slice(0, 10), hasMoreIssues: unbookableIssues.length > 10, totalIssues: unbookableIssues.length, } }, [data]) return (
{/* Header */}

Rate Availability

View tariff availability across all room categories

{/* Summary Stats */} {summary && (
{data?.categories.length || 0} Room Types
{data?.dates.length || 0} Days
0 ? { color: 'var(--danger)' } : { color: 'var(--success)' } )}> {summary.unbookableDateCategories} Unbookable
0 ? 'warning' : 'success')}> {summary.unbookablePercent}% blocked
)}
{/* Content */}
{isLoading && } {error && } {data && data.categories.length === 0 && (
No room categories configured. Please set up room categories in Settings.
)} {data && data.categories.length > 0 && (
{data.dates.map(dateStr => ( ))} {data.categories.map(category => { const rateData = data.matrix[category.category_id] || {} const tariffNames = getAllTariffNames(rateData, data.dates) return ( {/* Category header row */} {data.dates.map(dateStr => ( {/* Occupancy row */} {data.dates.map(dateStr => { const dayData = rateData[dateStr] const occ = dayData?.occupancy if (!occ) { return ( ) } const bookableRooms = occ.available - occ.maintenance const roomsLeft = bookableRooms - occ.occupied const isFull = roomsLeft <= 0 const occPercent = bookableRooms > 0 ? Math.round((occ.occupied / bookableRooms) * 100) : 100 const isHighOcc = occPercent >= 80 && !isFull const hasOffline = occ.maintenance > 0 const getOccStyle = () => { if (isFull) return styles.occupancyFull if (isHighOcc) return styles.occupancyHigh return styles.occupancyAvailable } return ( ) })} {/* Tariff rows */} {tariffNames.length === 0 ? ( ) : ( tariffNames.map(tariffName => ( {data.dates.map(dateStr => { const dayData = rateData[dateStr] const tariff = dayData?.tariffs?.find(t => t.name === tariffName) const occupancy = dayData?.occupancy const noRoomsAvailable = occupancy && (occupancy.available - occupancy.maintenance - occupancy.occupied) <= 0 if (!tariff) { return ( ) } const isEffectivelyAvailable = tariff.available || (tariff.min_stay && tariff.min_stay > 1 && tariff.available_for_min_stay === true) const minStayBadge = isEffectivelyAvailable && !noRoomsAvailable && tariff.min_stay && tariff.min_stay > 1 ? ( {tariff.min_stay} ) : null const getCellStyle = () => { if (noRoomsAvailable) return styles.cellNoRooms if (isEffectivelyAvailable) return styles.cellAvailable return styles.cellUnavailable } const getTooltip = () => { if (noRoomsAvailable) return `${tariffName}: No rooms available` if (isEffectivelyAvailable) { const minStayNote = tariff.min_stay && tariff.min_stay > 1 ? ` (Min ${tariff.min_stay} nights)` : '' return `${tariffName}: ${formatCurrency(tariff.rate)}${minStayNote}` } return `${tariffName}: ${tariff.message || 'Not available'}` } return ( ) })} )) )} ) })} {/* Booking.com section */} {bookingData && bookingData.has_own_hotel && ( {data.dates.map(dateStr => ( {data.dates.map(dateStr => { const entry = bookingData.dates[dateStr] const isAvailable = entry?.status === 'available' const isSoldOut = entry?.status === 'sold_out' const getCellStyle = () => { if (!entry) return styles.cellNoData if (isAvailable && entry.rate) return styles.cellAvailable if (isSoldOut) return styles.bookingCellSoldOut return styles.cellNoData } return ( ) })} )}
Tariff
{formatDayOfWeek(dateStr)} {formatDateShort(dateStr)} {data.date_last_updated?.[dateStr] && ( {formatLastUpdated(data.date_last_updated[dateStr])} )}
{category.category_name} ({category.room_count} rooms) ))}
Occupancy setHistory({ categoryId: category.category_id, categoryName: category.category_name, date: dateStr })} > - setHistory({ categoryId: category.category_id, categoryName: category.category_name, date: dateStr })} > {occ.occupied}/{occ.available} {hasOffline && ({occ.maintenance})}
No rate data available for this period
{tariffName} setHistory({ categoryId: category.category_id, categoryName: category.category_name, date: dateStr })} > - setHistory({ categoryId: category.category_id, categoryName: category.category_name, date: dateStr })} > {tariff.rate !== null ? formatCurrency(tariff.rate) : (isEffectivelyAvailable ? 'Y' : 'N')} {minStayBadge}
Booking.com {' '}{bookingData.latest_scrape ? `Scraped ${formatScrapeAge(bookingData.latest_scrape)}` : 'No scrape data'} {' · '} View details ))}
Best Available {!entry ? '-' : isAvailable && entry.rate ? formatCurrency(entry.rate) : isSoldOut ? 'Sold' : '-'}
)}
{/* Issues Panel */} {summary && summary.unbookableDateCategories > 0 && (

Unbookable Dates ({summary.totalIssues})

Dates with rooms available but no rates bookable

{summary.issues.map((issue, idx) => (
{issue.category} {issue.date} {issue.roomsLeft} room{issue.roomsLeft !== 1 ? 's' : ''} available, no rates
))} {summary.hasMoreIssues && (
+{summary.totalIssues - 10} more issues
)}
)} {/* Legend */}
Legend: Available Unavailable No Rooms No Data Click any cell for rate & occupancy history
{history && setHistory(null)} />}
) } // Styles const styles: Record = { container: { padding: '24px', maxWidth: '100%', margin: '0 auto', }, header: { marginBottom: '24px', }, headerTop: { display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '16px', flexWrap: 'wrap', gap: '16px', }, title: { fontSize: '24px', fontWeight: 700, color: 'var(--text-dark)', margin: 0, }, subtitle: { fontSize: '13px', color: 'var(--text-mid)', margin: '4px 0 0', }, headerActions: { display: 'flex', alignItems: 'center', gap: '16px', }, monthSelector: { display: 'flex', alignItems: 'center', gap: '8px', }, monthDropdown: { fontSize: '14px', fontWeight: 500, color: 'var(--text-dark)', padding: '4px 8px', borderRadius: '6px', border: '1px solid var(--card-border)', background: 'var(--card-bg)', cursor: 'pointer', minWidth: '160px', }, summaryBar: { display: 'flex', gap: '24px', padding: '16px', background: 'var(--card-bg)', borderRadius: '10px', boxShadow: 'var(--shadow-sm)', flexWrap: 'wrap', }, summaryItem: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '4px', }, summaryValue: { fontSize: '20px', fontWeight: 700, color: 'var(--text-dark)', }, summaryLabel: { fontSize: '11px', color: 'var(--text-mid)', textTransform: 'uppercase', }, content: { display: 'flex', flexDirection: 'column', gap: '24px', }, unifiedCard: { background: 'var(--card-bg)', borderRadius: '10px', padding: '16px', boxShadow: 'var(--shadow-md)', }, categoryHeaderRow: { fontWeight: 600, fontSize: '14px', color: 'var(--text-dark)', background: 'var(--body-bg)', padding: '8px 16px', borderTop: '2px solid var(--card-border)', textAlign: 'left' as const, whiteSpace: 'nowrap' as const, }, categoryHeaderFill: { background: 'var(--body-bg)', borderTop: '2px solid var(--card-border)', padding: 0, }, stickyHeader: { position: 'sticky' as const, top: 0, zIndex: 20, background: 'var(--card-bg)', }, roomCount: { fontSize: '13px', fontWeight: 400, color: 'var(--text-mid)', }, tableContainer: { overflowX: 'auto', maxWidth: '100%', }, table: { width: '100%', borderCollapse: 'collapse', fontSize: '13px', minWidth: '800px', tableLayout: 'fixed' as const, }, th: { padding: '8px', borderBottom: '2px solid var(--card-border)', textAlign: 'center', fontWeight: 600, color: 'var(--text-dark)', whiteSpace: 'nowrap', background: 'var(--card-bg)', }, td: { padding: '8px', borderBottom: '1px solid var(--card-border)', textAlign: 'center', whiteSpace: 'nowrap', }, stickyCol: { position: 'sticky', left: 0, background: 'var(--card-bg)', zIndex: 10, textAlign: 'left', width: '160px', borderRight: '1px solid var(--card-border)', overflow: 'hidden', textOverflow: 'ellipsis', }, dateHeader: { width: '56px', padding: '4px', }, dateHeaderContent: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '2px', }, dayOfWeek: { fontSize: '11px', color: 'var(--text-mid)', }, dayNum: { fontSize: '13px', fontWeight: 600, }, lastUpdated: { fontSize: '9px', color: 'var(--text-mid)', opacity: 0.7, lineHeight: 1, }, weekendHeader: { background: 'var(--body-bg)', }, weekendCell: { borderLeft: '2px solid var(--card-border)', }, tariffNameCell: { fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', }, tariffCell: { cursor: 'pointer', position: 'relative', transition: 'background 0.1s', fontSize: '11px', }, cellContent: { display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '2px', }, minStayBadge: { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: '14px', height: '14px', borderRadius: '50%', background: '#d97706', color: '#fff', fontSize: '9px', fontWeight: 700, marginLeft: '2px', flexShrink: 0, }, cellAvailable: { background: '#dcfce7', color: 'var(--success)', }, cellUnavailable: { background: '#fee2e2', color: 'var(--danger)', textDecoration: 'line-through', }, cellNoRooms: { background: '#e0e0e0', color: 'var(--text-mid)', }, cellNoData: { background: 'var(--body-bg)', color: 'var(--text-mid)', }, bookingCellSoldOut: { background: '#fee2e2', color: 'var(--danger)', fontWeight: 500, }, occupancyLabel: { fontWeight: 600, color: 'var(--navy)', background: '#f0f4ff', }, occupancyCell: { fontSize: '11px', color: 'var(--text-mid)', background: 'var(--body-bg)', }, occupancyText: { display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '2px', }, maintenanceBadge: { color: '#d97706', marginLeft: '2px', }, occupancyAvailable: { background: '#dcfce7', color: 'var(--success)', fontWeight: 500, }, occupancyHigh: { background: '#fef3c7', color: '#d97706', fontWeight: 500, }, occupancyFull: { background: '#fee2e2', color: 'var(--danger)', fontWeight: 500, }, loading: { display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '48px', gap: '16px', color: 'var(--text-mid)', }, spinner: { width: '40px', height: '40px', border: '3px solid var(--card-border)', borderTop: '3px solid var(--navy)', borderRadius: '50%', animation: 'spin 1s linear infinite', }, error: { display: 'flex', alignItems: 'center', gap: '8px', padding: '24px', background: '#fee2e2', color: 'var(--danger)', borderRadius: '10px', }, errorIcon: { width: '24px', height: '24px', borderRadius: '50%', background: 'var(--danger)', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700, }, noData: { textAlign: 'center', padding: '32px', color: 'var(--text-mid)', }, issuesPanel: { marginTop: '24px', background: '#fef3c7', borderRadius: '10px', padding: '24px', }, issuesTitle: { fontSize: '14px', fontWeight: 600, color: '#d97706', marginBottom: '4px', }, issuesSubtitle: { fontSize: '13px', color: 'var(--text-mid)', marginBottom: '16px', }, issuesList: { display: 'flex', flexDirection: 'column', gap: '8px', }, issueItem: { display: 'flex', gap: '8px', fontSize: '13px', flexWrap: 'wrap', }, issueCategory: { fontWeight: 600, color: 'var(--text-dark)', }, issueDate: { color: 'var(--text-mid)', }, issueMessage: { color: 'var(--danger)', fontStyle: 'italic', }, moreIssues: { color: 'var(--text-mid)', fontStyle: 'italic', marginTop: '8px', }, legend: { display: 'flex', alignItems: 'center', gap: '16px', marginTop: '24px', padding: '16px', background: 'var(--card-bg)', borderRadius: '10px', fontSize: '13px', }, legendTitle: { fontWeight: 600, color: 'var(--text-dark)', }, legendItem: { padding: '4px 8px', borderRadius: '4px', fontSize: '11px', }, scrapeBtn: { background: 'none', border: '1px solid var(--card-border)', borderRadius: '4px', cursor: 'pointer', fontSize: '10px', lineHeight: 1, padding: '2px 4px', color: 'var(--text-mid)', opacity: 0.6, transition: 'opacity 0.15s', }, scrapeBtnActive: { opacity: 1, color: 'var(--navy)', borderColor: 'var(--navy)', }, } // ─── Occupancy + Rate History Modal ────────────────────────────────────────── interface RateHistorySnapshot { valid_from: string tariffs: { name: string; rate: number | null; available: boolean; min_stay?: number | null }[] } interface OccSnapshot { valid_from: string occupied: number available: number occupancy_pct: number | null } const PLOT_BASE = { paper_bgcolor: 'transparent', plot_bgcolor: 'transparent', font: { family: 'Inter, system-ui, sans-serif', size: 12, color: '#60748b' }, margin: { t: 10, r: 24, b: 48, l: 52 }, xaxis: { gridcolor: '#e5e9f0', zeroline: false }, yaxis: { gridcolor: '#e5e9f0', zeroline: false }, } const TRACE_COLORS = ['#2563eb', '#d97706', '#7c3aed', '#059669', '#0891b2', '#be185d'] function OccHistoryModal({ target, onClose }: { target: HistoryTarget; onClose: () => void }) { const { data: rateData, isLoading: rateLoading } = useQuery<{ history: RateHistorySnapshot[] version_count: number }>({ queryKey: ['bookability-rate-history', target.categoryId, target.date], queryFn: () => api.get(`/bookability/rate-history/${target.categoryId}/${target.date}`).then(r => r.data), }) const { data: occData, isLoading: occLoading } = useQuery<{ snapshots: OccSnapshot[] }>({ queryKey: ['bookability-occ-history', target.categoryId, target.date], queryFn: () => api.get(`/bookability/occupancy-history/${target.categoryId}/${target.date}`).then(r => r.data), }) const rateTraces = useMemo(() => { const history = rateData?.history ?? [] if (!history.length) return [] const tariffNames = [...new Set(history.flatMap(h => h.tariffs.map(t => t.name)))] return tariffNames.map((name, i) => { const pts = history .map(h => ({ x: h.valid_from, t: h.tariffs.find(t => t.name === name) })) .filter(p => p.t?.rate != null) return { x: pts.map(p => p.x), y: pts.map(p => p.t!.rate), type: 'scatter' as const, mode: 'lines+markers' as const, name, line: { color: TRACE_COLORS[i % TRACE_COLORS.length], shape: 'hv' as const, width: 2 }, marker: { size: 8, color: pts.map(p => p.t!.available ? '#16a34a' : '#dc2626'), symbol: pts.map(p => (p.t!.available ? 'circle' : 'x') as any), line: { color: TRACE_COLORS[i % TRACE_COLORS.length], width: 1 }, }, } }) }, [rateData]) const occTrace = useMemo(() => { const snaps = occData?.snapshots ?? [] if (!snaps.length) return null return { x: snaps.map(s => s.valid_from), y: snaps.map(s => s.occupancy_pct), type: 'scatter' as const, mode: 'lines+markers' as const, name: 'Occupancy %', fill: 'tozeroy' as const, fillcolor: 'rgba(201,168,76,0.12)', line: { color: 'var(--gold, #c9a84c)', shape: 'hv' as const, width: 2 }, marker: { size: 5, color: '#c9a84c' }, } }, [occData]) const isLoading = rateLoading || occLoading return (
e.stopPropagation()} >
{target.categoryName} — {target.date}
{isLoading &&
Loading history…
} {!isLoading && ( <> {/* Rate history */}
Rate History {rateData?.version_count ? ({rateData.version_count} change{rateData.version_count !== 1 ? 's' : ''}) : null}
{!rateTraces.length ? (
No rate history recorded yet.
) : ( <>
Green circle = available  ·  Red ✕ = unavailable at that snapshot
)}
{/* Occupancy history */}
Occupancy Pick-up {occData?.snapshots.length ? ({occData.snapshots.length} snapshot{occData.snapshots.length !== 1 ? 's' : ''}) : null}
{!occTrace ? (
No occupancy snapshots yet — pick-up history starts accumulating from first sync after this deployment.
) : ( )}
)}
) } export default Bookability