rates/frontend/src/pages/Bookability.tsx
jtricerolph 808fa6f8b2 Fade past dates in Bookability matrix
Apply 45% opacity to all column cells where stay_date < today —
date header, category fill, occupancy, and all tariff cells —
matching the same visual treatment as the Direct Rates table.

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

1295 lines
46 KiB
TypeScript

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<string, Record<string, DateRateInfo>>
date_last_updated?: Record<string, string | null>
date_last_changed?: Record<string, string | null>
}
// 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<string, React.CSSProperties> = {
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 (
<div style={styles.monthSelector}>
<button onClick={handlePrevMonth} style={buttonStyle('outline', 'small')}>
&larr;
</button>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
style={styles.monthDropdown}
>
{monthOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<button onClick={handleNextMonth} style={buttonStyle('outline', 'small')}>
&rarr;
</button>
</div>
)
}
// Helper to collect unique tariff names across all dates for a category, preserving Newbook order
const getAllTariffNames = (rateData: Record<string, DateRateInfo>, dates: string[]): string[] => {
const tariffMap = new Map<string, number>()
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<string, { status: string; rate: number | null }>
}
interface HistoryTarget {
categoryId: string
categoryName: string
date: string
}
const LoadingSpinner: React.FC = () => (
<div style={styles.loading}>
<div style={styles.spinner} />
<span>Loading rate data...</span>
</div>
)
const ErrorMessage: React.FC<{ message: string }> = ({ message }) => (
<div style={styles.error}>
<span style={styles.errorIcon}>!</span>
{message}
</div>
)
// 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<string | null>(null)
const [history, setHistory] = useState<HistoryTarget | null>(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<BookingAvailabilityData>({
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<RateMatrixData>({
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 (
<div style={styles.container}>
{/* Header */}
<div style={styles.header}>
<div style={styles.headerTop}>
<div>
<h1 style={styles.title}>Rate Availability</h1>
<p style={styles.subtitle}>
View tariff availability across all room categories
</p>
</div>
<div style={styles.headerActions}>
<MonthSelector value={selectedMonth} onChange={setSelectedMonth} />
<button
onClick={() => refetch()}
style={buttonStyle('outline', 'small')}
disabled={isLoading}
>
Refresh
</button>
</div>
</div>
{/* Summary Stats */}
{summary && (
<div style={styles.summaryBar}>
<div style={styles.summaryItem}>
<span style={styles.summaryValue}>{data?.categories.length || 0}</span>
<span style={styles.summaryLabel}>Room Types</span>
</div>
<div style={styles.summaryItem}>
<span style={styles.summaryValue}>{data?.dates.length || 0}</span>
<span style={styles.summaryLabel}>Days</span>
</div>
<div style={styles.summaryItem}>
<span style={mergeStyles(
styles.summaryValue,
summary.unbookableDateCategories > 0 ? { color: 'var(--danger)' } : { color: 'var(--success)' }
)}>
{summary.unbookableDateCategories}
</span>
<span style={styles.summaryLabel}>Unbookable</span>
</div>
<div style={styles.summaryItem}>
<span style={badgeStyle(summary.unbookableDateCategories > 0 ? 'warning' : 'success')}>
{summary.unbookablePercent}% blocked
</span>
</div>
</div>
)}
</div>
{/* Content */}
<div style={styles.content}>
{isLoading && <LoadingSpinner />}
{error && <ErrorMessage message={(error as Error).message} />}
{data && data.categories.length === 0 && (
<div style={styles.noData}>
No room categories configured. Please set up room categories in Settings.
</div>
)}
{data && data.categories.length > 0 && (
<div style={styles.unifiedCard}>
<div style={styles.tableContainer}>
<table style={styles.table}>
<thead>
<tr>
<th style={mergeStyles(styles.th, styles.stickyCol, styles.stickyHeader)}>Tariff</th>
{data.dates.map(dateStr => (
<th
key={dateStr}
style={mergeStyles(
styles.th,
styles.dateHeader,
styles.stickyHeader,
isWeekend(dateStr) ? styles.weekendHeader : {},
isPast(dateStr) ? { opacity: 0.45 } : {}
)}
>
<div style={styles.dateHeaderContent}>
<span style={styles.dayOfWeek}>{formatDayOfWeek(dateStr)}</span>
<span style={styles.dayNum}>{formatDateShort(dateStr)}</span>
<button
onClick={() => dateRefreshM.mutate(dateStr)}
disabled={refreshingDate !== null}
style={mergeStyles(
styles.scrapeBtn,
refreshingDate === dateStr ? styles.scrapeBtnActive : {}
)}
title={`Refresh ${dateStr}`}
>
{refreshingDate === dateStr ? '...' : '↻'}
</button>
{data.date_last_updated?.[dateStr] && (
<span
style={styles.lastUpdated}
title={[
`Checked: ${new Date(data.date_last_updated[dateStr]!).toLocaleString('en-GB')}`,
data.date_last_changed?.[dateStr]
? `Changed: ${new Date(data.date_last_changed[dateStr]!).toLocaleString('en-GB')}`
: null,
].filter(Boolean).join('\n')}
>
{formatLastUpdated(data.date_last_updated[dateStr])}
</span>
)}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{data.categories.map(category => {
const rateData = data.matrix[category.category_id] || {}
const tariffNames = getAllTariffNames(rateData, data.dates)
return (
<React.Fragment key={category.category_id}>
{/* Category header row */}
<tr>
<td style={mergeStyles(styles.categoryHeaderRow, styles.stickyCol, { zIndex: 15, overflow: 'visible', textOverflow: 'clip', width: 'auto', minWidth: '160px' })}>
{category.category_name}
<span style={styles.roomCount}> ({category.room_count} rooms)</span>
</td>
{data.dates.map(dateStr => (
<td key={dateStr} style={mergeStyles(styles.categoryHeaderFill, isPast(dateStr) ? { opacity: 0.45 } : {})} />
))}
</tr>
{/* Occupancy row */}
<tr>
<td style={mergeStyles(styles.td, styles.stickyCol, styles.occupancyLabel)}>
Occupancy
</td>
{data.dates.map(dateStr => {
const dayData = rateData[dateStr]
const occ = dayData?.occupancy
if (!occ) {
return (
<td
key={dateStr}
style={mergeStyles(
styles.td,
styles.occupancyCell,
isWeekend(dateStr) ? styles.weekendCell : {},
{ cursor: 'pointer' },
isPast(dateStr) ? { opacity: 0.45 } : {}
)}
title="Click for history"
onClick={() => setHistory({ categoryId: category.category_id, categoryName: category.category_name, date: dateStr })}
>
-
</td>
)
}
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 (
<td
key={dateStr}
style={mergeStyles(
styles.td,
styles.occupancyCell,
isWeekend(dateStr) ? styles.weekendCell : {},
getOccStyle(),
{ cursor: 'pointer' },
isPast(dateStr) ? { opacity: 0.45 } : {}
)}
title={`${occ.occupied} of ${bookableRooms} bookable rooms occupied${hasOffline ? ` (${occ.maintenance} offline)` : ''} - ${roomsLeft} left · click for history`}
onClick={() => setHistory({ categoryId: category.category_id, categoryName: category.category_name, date: dateStr })}
>
<span style={styles.occupancyText}>
{occ.occupied}/{occ.available}
{hasOffline && <span style={styles.maintenanceBadge}>({occ.maintenance})</span>}
</span>
</td>
)
})}
</tr>
{/* Tariff rows */}
{tariffNames.length === 0 ? (
<tr>
<td
colSpan={data.dates.length + 1}
style={{ ...styles.td, color: 'var(--text-mid)', textAlign: 'center' }}
>
No rate data available for this period
</td>
</tr>
) : (
tariffNames.map(tariffName => (
<tr key={tariffName}>
<td style={mergeStyles(styles.td, styles.stickyCol, styles.tariffNameCell)}>
{tariffName}
</td>
{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 (
<td
key={dateStr}
style={mergeStyles(
styles.td,
styles.tariffCell,
isWeekend(dateStr) ? styles.weekendCell : {},
noRoomsAvailable ? styles.cellNoRooms : styles.cellNoData,
isPast(dateStr) ? { opacity: 0.45 } : {}
)}
title={noRoomsAvailable ? 'No rooms available · click for history' : 'Click for history'}
onClick={() => setHistory({ categoryId: category.category_id, categoryName: category.category_name, date: dateStr })}
>
-
</td>
)
}
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 ? (
<span style={styles.minStayBadge} title={`Minimum ${tariff.min_stay} nights`}>
{tariff.min_stay}
</span>
) : 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 (
<td
key={dateStr}
style={mergeStyles(
styles.td,
styles.tariffCell,
isWeekend(dateStr) ? styles.weekendCell : {},
getCellStyle(),
!isEffectivelyAvailable && !noRoomsAvailable ? { textDecoration: 'line-through' } : {},
isPast(dateStr) ? { opacity: 0.45 } : {}
)}
title={`${getTooltip()} · click for history`}
onClick={() => setHistory({ categoryId: category.category_id, categoryName: category.category_name, date: dateStr })}
>
<span style={styles.cellContent}>
{tariff.rate !== null ? formatCurrency(tariff.rate) : (isEffectivelyAvailable ? 'Y' : 'N')}
{minStayBadge}
</span>
</td>
)
})}
</tr>
))
)}
</React.Fragment>
)
})}
{/* Booking.com section */}
{bookingData && bookingData.has_own_hotel && (
<React.Fragment>
<tr>
<td style={mergeStyles(styles.categoryHeaderRow, styles.stickyCol, { zIndex: 15, overflow: 'visible', textOverflow: 'clip', width: 'auto', minWidth: '160px' })}>
Booking.com
<span style={styles.roomCount}>
{' '}{bookingData.latest_scrape ? `Scraped ${formatScrapeAge(bookingData.latest_scrape)}` : 'No scrape data'}
{' · '}
<Link to="/market" style={{ color: 'var(--navy)', textDecoration: 'none', fontSize: '13px' }}>
View details
</Link>
</span>
</td>
{data.dates.map(dateStr => (
<td key={dateStr} style={styles.categoryHeaderFill} />
))}
</tr>
<tr>
<td style={mergeStyles(styles.td, styles.stickyCol, styles.tariffNameCell)}>
Best Available
</td>
{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 (
<td
key={dateStr}
style={mergeStyles(
styles.td,
styles.tariffCell,
isWeekend(dateStr) ? styles.weekendCell : {},
getCellStyle()
)}
title={
!entry ? 'No data'
: isAvailable ? `Booking.com: ${formatCurrency(entry.rate)}`
: isSoldOut ? 'Sold out on Booking.com'
: 'No data'
}
>
{!entry ? '-'
: isAvailable && entry.rate ? formatCurrency(entry.rate)
: isSoldOut ? 'Sold'
: '-'}
</td>
)
})}
</tr>
</React.Fragment>
)}
</tbody>
</table>
</div>
</div>
)}
</div>
{/* Issues Panel */}
{summary && summary.unbookableDateCategories > 0 && (
<div style={styles.issuesPanel}>
<h3 style={styles.issuesTitle}>
Unbookable Dates ({summary.totalIssues})
</h3>
<p style={styles.issuesSubtitle}>
Dates with rooms available but no rates bookable
</p>
<div style={styles.issuesList}>
{summary.issues.map((issue, idx) => (
<div key={idx} style={styles.issueItem}>
<span style={styles.issueCategory}>{issue.category}</span>
<span style={styles.issueDate}>{issue.date}</span>
<span style={styles.issueMessage}>{issue.roomsLeft} room{issue.roomsLeft !== 1 ? 's' : ''} available, no rates</span>
</div>
))}
{summary.hasMoreIssues && (
<div style={styles.moreIssues}>
+{summary.totalIssues - 10} more issues
</div>
)}
</div>
</div>
)}
{/* Legend */}
<div style={styles.legend}>
<span style={styles.legendTitle}>Legend:</span>
<span style={mergeStyles(styles.legendItem, styles.cellAvailable)}>Available</span>
<span style={mergeStyles(styles.legendItem, styles.cellUnavailable)}>Unavailable</span>
<span style={mergeStyles(styles.legendItem, styles.cellNoRooms)}>No Rooms</span>
<span style={mergeStyles(styles.legendItem, styles.cellNoData)}>No Data</span>
<span style={{ marginLeft: 'auto', fontSize: 11, color: 'var(--text-mid)' }}>Click any cell for rate &amp; occupancy history</span>
</div>
{history && <OccHistoryModal target={history} onClose={() => setHistory(null)} />}
</div>
)
}
// Styles
const styles: Record<string, React.CSSProperties> = {
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 (
<div
style={{ position: 'fixed', inset: 0, background: 'rgba(26,26,46,0.55)', zIndex: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={onClose}
>
<div
className="card"
style={{ width: 'min(780px, 92vw)', maxHeight: '88vh', overflow: 'auto' }}
onClick={e => e.stopPropagation()}
>
<div className="card-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 600 }}>{target.categoryName} {target.date}</span>
<button style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-mid)' }} onClick={onClose}>
<X size={16} strokeWidth={1.75} />
</button>
</div>
<div style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 24 }}>
{isLoading && <div className="loading-state"><div className="spinner" />Loading history</div>}
{!isLoading && (
<>
{/* Rate history */}
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-mid)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 8 }}>
Rate History
{rateData?.version_count ? <span style={{ fontWeight: 400, marginLeft: 8 }}>({rateData.version_count} change{rateData.version_count !== 1 ? 's' : ''})</span> : null}
</div>
{!rateTraces.length ? (
<div style={{ padding: '20px 0', color: 'var(--text-mid)', fontSize: 13, textAlign: 'center' }}>No rate history recorded yet.</div>
) : (
<>
<Plot
data={rateTraces}
layout={{
...PLOT_BASE,
height: 240,
showlegend: true,
legend: { orientation: 'h' as const, y: -0.35, font: { size: 11 } },
yaxis: { ...PLOT_BASE.yaxis, tickprefix: '£', title: { text: '£ / night' } },
}}
config={{ displayModeBar: false, responsive: true }}
style={{ width: '100%' }}
/>
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 2 }}>
Green circle = available &nbsp;·&nbsp; Red = unavailable at that snapshot
</div>
</>
)}
</div>
{/* Occupancy history */}
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-mid)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 8 }}>
Occupancy Pick-up
{occData?.snapshots.length ? <span style={{ fontWeight: 400, marginLeft: 8 }}>({occData.snapshots.length} snapshot{occData.snapshots.length !== 1 ? 's' : ''})</span> : null}
</div>
{!occTrace ? (
<div style={{ padding: '20px 0', color: 'var(--text-mid)', fontSize: 13, textAlign: 'center' }}>
No occupancy snapshots yet pick-up history starts accumulating from first sync after this deployment.
</div>
) : (
<Plot
data={[occTrace]}
layout={{
...PLOT_BASE,
height: 180,
showlegend: false,
yaxis: { ...PLOT_BASE.yaxis, range: [0, 100], ticksuffix: '%', title: { text: 'Occ %' } },
}}
config={{ displayModeBar: false, responsive: true }}
style={{ width: '100%' }}
/>
)}
</div>
</>
)}
</div>
</div>
</div>
)
}
export default Bookability