Restore guestline-monitor features: room stock/occupancy, rate history, room stats

- dates endpoint now returns total_avail, benchmark rate (tier-resolved),
  min-stay nights, max_rooms (summed per-room stock) and friendly labels
- rooms endpoint groups by room with stock, occupancy, best/bench rate and
  nested rate plans; injects known-but-absent rooms; honours room_order
- new /history/{stay_date} endpoint: per room/rate series, benchmark mode,
  or overall availability + cheapest rate per scrape run
- new /stats endpoint: per-room stock + windowed avg rate / current occ /
  est. final occ (look-back window so past dates give completed bookings)
- DirectRates page: occupancy pills, avail x/stock, Room Stats tab,
  history chart modal (plotly), expandable rate plans with history links
- fix discovery trigger crashing (run_until_complete inside running loop)
- fix migration script reading wrong column name (engine_profile)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-06 06:50:37 +00:00
parent 74ae94671b
commit 20d939de00
3 changed files with 908 additions and 173 deletions

View file

@ -1,9 +1,10 @@
import React, { useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import Plot from 'react-plotly.js'
import {
Building2, RefreshCw, Plus, Settings, ChevronDown, ChevronRight,
AlertCircle, CheckCircle, Clock,
Building2, RefreshCw, Plus, ChevronDown, ChevronRight,
Clock, LineChart, X,
} from 'lucide-react'
import api from '../api'
import { useAuth } from '../components/AuthGate'
@ -12,8 +13,11 @@ import { can } from '../types'
const fmtDate = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
const TABS = ['overview', 'hotel', 'manage'] as const
const TABS = ['overview', 'hotel', 'stats', 'manage'] as const
type Tab = typeof TABS[number]
const TAB_LABELS: Record<Tab, string> = {
overview: 'Overview', hotel: 'Hotel Detail', stats: 'Room Stats', manage: 'Manage',
}
interface DirectHotel {
id: number
@ -27,30 +31,115 @@ interface DirectHotel {
interface DateRow {
stay_date: string
total_avail: number
cheapest_rate: number | null
bench_rate: number | null
bench_calculated: boolean
has_availability: boolean
min_stay_nights: number | null
has_min_stay: boolean
scraped_at: string | null
}
interface RoomRow {
room_id: string
rate_id: string
room_label: string
rate_label: string
availability: number
price_incl: number | null
min_stay_nights: number | null
bench_rate: number | null
interface DatesResponse {
hotel_name: string
max_rooms: number | null
benchmark_room: string | null
benchmark_rate: string | null
room_labels: Record<string, string>
rate_labels: Record<string, string>
dates: DateRow[]
}
const fmt = (v: number | null) => v != null ? `£${v.toFixed(2)}` : '—'
interface RateEntry {
rate_id: string
rate_label: string
price_incl: number | null
price_excl: number | null
min_stay_nights: number | null
}
interface RoomGroup {
room_id: string
room_label: string
availability: number | null
stock: number | null
best_rate: number | null
bench_rate: number | null
bench_calculated: boolean
min_stay_nights: number | null
unavailable: boolean
rates: RateEntry[]
}
interface HistoryPoint {
scraped_at: string
availability?: number
price_incl: number | null
calculated?: boolean
}
interface StatsWindow {
dates: number
avg_price: number | null
avg_occ: number | null
past_dates: number
est_final_occ: number | null
}
interface RoomStats {
stock: number | null
room_label: string
windows: Record<string, StatsWindow | null>
}
interface HistoryTarget {
date: string
roomId?: string
rateId?: string
mode: 'overall' | 'benchmark' | 'rate'
title: string
}
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const fmt = (v: number | null | undefined) => v != null ? `£${Number(v).toFixed(2)}` : '—'
const age = (ts: string | null) => {
if (!ts) return 'Never'
const h = Math.round((Date.now() - new Date(ts).getTime()) / 3600000)
return h < 24 ? `${h}h ago` : `${Math.round(h / 24)}d ago`
}
const occColor = (pct: number) =>
pct >= 80 ? 'var(--danger)' : pct >= 50 ? 'var(--warning)' : 'var(--success)'
function OccupancyPill({ avail, stock }: { avail: number; stock: number | null }) {
if (!stock) return null
const pct = Math.round((1 - avail / stock) * 100)
return (
<span className="badge" style={{
background: pct >= 80 ? '#fee2e2' : pct >= 50 ? '#fef3c7' : '#dcfce7',
color: occColor(pct),
}}>
{pct}% sold
</span>
)
}
function OccBar({ pct }: { pct: number | null }) {
if (pct == null) return null
return (
<span style={{
display: 'inline-block', width: 70, height: 6, background: 'var(--border)',
borderRadius: 3, verticalAlign: 'middle', marginLeft: 6,
}}>
<span style={{
display: 'block', height: '100%', borderRadius: 3,
width: `${Math.min(100, pct)}%`, background: occColor(pct),
}} />
</span>
)
}
export default function DirectRates() {
const { hotelId } = useParams<{ hotelId?: string }>()
const navigate = useNavigate()
@ -62,13 +151,14 @@ export default function DirectRates() {
const [expandedDate, setExpandedDate] = useState<string | null>(null)
const [fromDate, setFromDate] = useState(fmtDate(new Date()))
const [toDate, setToDate] = useState(fmtDate(new Date(Date.now() + 89 * 86400000)))
const [history, setHistory] = useState<HistoryTarget | null>(null)
const { data: hotels, isLoading: hotelsLoading } = useQuery<DirectHotel[]>({
queryKey: ['direct-hotels'],
queryFn: () => api.get('/direct/hotels').then(r => r.data),
})
const { data: dates, isLoading: datesLoading } = useQuery<{ dates: DateRow[]; hotel_name: string }>({
const { data: dates, isLoading: datesLoading } = useQuery<DatesResponse>({
queryKey: ['direct-dates', selectedHotel, fromDate, toDate],
queryFn: () => api.get(`/direct/hotels/${selectedHotel}/dates`, {
params: { from_date: fromDate, to_date: toDate }
@ -76,7 +166,7 @@ export default function DirectRates() {
enabled: !!selectedHotel && tab === 'hotel',
})
const { data: roomData } = useQuery<{ rooms: RoomRow[]; bench_price: number | null }>({
const { data: roomData } = useQuery<{ rooms: RoomGroup[]; bench_price: number | null }>({
queryKey: ['direct-rooms', selectedHotel, expandedDate],
queryFn: () => api.get(`/direct/hotels/${selectedHotel}/date/${expandedDate}/rooms`).then(r => r.data),
enabled: !!selectedHotel && !!expandedDate,
@ -90,7 +180,6 @@ export default function DirectRates() {
const selectHotel = (id: number) => {
setSelectedHotel(id)
setTab('hotel')
setExpandedDate(null)
navigate(`/direct/${id}`)
}
@ -113,7 +202,7 @@ export default function DirectRates() {
{TABS.map(t => (
<button key={t} className={`sub-nav-item${tab === t ? ' active' : ''}`}
onClick={() => setTab(t)}>
{t === 'overview' ? 'Overview' : t === 'hotel' ? 'Hotel Detail' : 'Manage'}
{TAB_LABELS[t]}
</button>
))}
</div>
@ -122,7 +211,7 @@ export default function DirectRates() {
<OverviewTab
hotels={hotels || []}
loading={hotelsLoading}
onSelectHotel={selectHotel}
onSelectHotel={id => { selectHotel(id); setTab('hotel') }}
onScrape={id => scrapeMutation.mutate(id)}
scraping={scrapeMutation.isPending}
/>
@ -133,8 +222,7 @@ export default function DirectRates() {
hotels={hotels || []}
selectedHotel={selectedHotel}
onSelectHotel={selectHotel}
dates={dates?.dates || []}
hotelName={dates?.hotel_name}
data={dates}
datesLoading={datesLoading}
fromDate={fromDate}
toDate={toDate}
@ -144,6 +232,15 @@ export default function DirectRates() {
expandedDate={expandedDate}
setExpandedDate={setExpandedDate}
roomData={roomData}
onShowHistory={setHistory}
/>
)}
{tab === 'stats' && (
<RoomStatsTab
hotels={hotels || []}
selectedHotel={selectedHotel}
onSelectHotel={selectHotel}
/>
)}
@ -153,6 +250,10 @@ export default function DirectRates() {
{tab === 'manage' && !canManage && (
<div className="empty-state">You don't have permission to manage competitor hotels.</div>
)}
{history && selectedHotel && (
<HistoryModal hotelId={selectedHotel} target={history} onClose={() => setHistory(null)} />
)}
</div>
)
}
@ -222,28 +323,58 @@ function OverviewTab({ hotels, loading, onSelectHotel, onScrape, scraping }: {
)
}
// ─── Hotel selector row ───────────────────────────────────────────────────────
function HotelSelector({ hotels, selectedHotel, onSelectHotel }: {
hotels: DirectHotel[]
selectedHotel: number | null
onSelectHotel: (id: number) => void
}) {
return (
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>Hotel</label>
<select
style={{ width: 220 }}
value={selectedHotel || ''}
onChange={e => onSelectHotel(parseInt(e.target.value))}
>
<option value="">Select a hotel</option>
{hotels.map(h => (
<option key={h.id} value={h.id}>{h.name}</option>
))}
</select>
</div>
)
}
// ─── Hotel Detail Tab ─────────────────────────────────────────────────────────
function HotelDetailTab({ hotels, selectedHotel, onSelectHotel, dates, hotelName, datesLoading,
fromDate, toDate, setFromDate, setToDate, presets, expandedDate, setExpandedDate, roomData }: any) {
function HotelDetailTab({ hotels, selectedHotel, onSelectHotel, data, datesLoading,
fromDate, toDate, setFromDate, setToDate, presets, expandedDate, setExpandedDate,
roomData, onShowHistory }: {
hotels: DirectHotel[]
selectedHotel: number | null
onSelectHotel: (id: number) => void
data: DatesResponse | undefined
datesLoading: boolean
fromDate: string
toDate: string
setFromDate: (v: string) => void
setToDate: (v: string) => void
presets: { label: string; days: number }[]
expandedDate: string | null
setExpandedDate: (v: string | null) => void
roomData: { rooms: RoomGroup[]; bench_price: number | null } | undefined
onShowHistory: (t: HistoryTarget) => void
}) {
const dates = data?.dates || []
const maxRooms = data?.max_rooms || null
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Controls */}
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>Hotel</label>
<select
style={{ width: 220 }}
value={selectedHotel || ''}
onChange={e => onSelectHotel(parseInt(e.target.value))}
>
<option value="">Select a hotel</option>
{hotels.map((h: DirectHotel) => (
<option key={h.id} value={h.id}>{h.name}</option>
))}
</select>
</div>
<HotelSelector hotels={hotels} selectedHotel={selectedHotel} onSelectHotel={onSelectHotel} />
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>From</label>
<input type="date" style={{ width: 140 }} value={fromDate} onChange={e => setFromDate(e.target.value)} />
@ -253,7 +384,7 @@ function HotelDetailTab({ hotels, selectedHotel, onSelectHotel, dates, hotelName
<input type="date" style={{ width: 140 }} value={toDate} onChange={e => setToDate(e.target.value)} />
</div>
<div style={{ display: 'flex', gap: 6 }}>
{presets.map((p: any) => (
{presets.map(p => (
<button key={p.label} className="btn btn-outline btn-sm"
onClick={() => {
const from = new Date(); const to = new Date(Date.now() + p.days * 86400000)
@ -273,104 +404,414 @@ function HotelDetailTab({ hotels, selectedHotel, onSelectHotel, dates, hotelName
{selectedHotel && !datesLoading && dates.length > 0 && (
<div className="card">
<div className="card-header">{hotelName} {dates.length} dates</div>
<div className="card-header">
{data?.hotel_name} {dates.length} dates
{maxRooms ? <span style={{ fontWeight: 400, color: 'var(--text-mid)', fontSize: 12, marginLeft: 8 }}>· est. {maxRooms} rooms total stock</span> : null}
</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th></th>
<th>Date</th>
<th>Cheapest Rate</th>
<th>Availability</th>
<th>Min-Stay</th>
<th>Day</th>
<th>Avail</th>
<th>Occupancy</th>
<th>Best Rate</th>
<th>Benchmark</th>
<th>Scraped</th>
<th></th>
</tr>
</thead>
<tbody>
{dates.map((d: DateRow) => (
<React.Fragment key={d.stay_date}>
<tr
onClick={() => setExpandedDate(expandedDate === d.stay_date ? null : d.stay_date)}
style={{ cursor: 'pointer' }}
>
<td style={{ width: 24, color: 'var(--text-mid)' }}>
{expandedDate === d.stay_date
? <ChevronDown size={14} strokeWidth={1.75} />
: <ChevronRight size={14} strokeWidth={1.75} />}
</td>
<td>{d.stay_date}</td>
<td style={{ fontWeight: 600 }}>
{d.cheapest_rate ? `£${Number(d.cheapest_rate).toFixed(2)}` : '—'}
</td>
<td>
{d.has_availability
? <CheckCircle size={14} strokeWidth={1.75} color="var(--success)" />
: <AlertCircle size={14} strokeWidth={1.75} color="var(--danger)" />}
</td>
<td>
{d.has_min_stay
? <span className="badge badge-warning">Min-stay</span>
: null}
</td>
<td style={{ fontSize: 11, color: 'var(--text-mid)' }}>{age(d.scraped_at)}</td>
</tr>
{expandedDate === d.stay_date && roomData && (
<tr>
<td colSpan={6} style={{ background: '#f8fafc', padding: '8px 16px' }}>
<RoomBreakdown rooms={roomData.rooms} benchPrice={roomData.bench_price} />
{dates.map(d => {
const day = new Date(d.stay_date + 'T12:00:00')
const dow = DAYS[day.getDay()]
const isWeekend = day.getDay() === 0 || day.getDay() === 5 || day.getDay() === 6
const isMinStay = !!(d.min_stay_nights && d.min_stay_nights > 1)
const isFull = d.total_avail === 0 && !isMinStay
return (
<React.Fragment key={d.stay_date}>
<tr
onClick={() => setExpandedDate(expandedDate === d.stay_date ? null : d.stay_date)}
style={{ cursor: 'pointer' }}
>
<td style={{ width: 24, color: 'var(--text-mid)' }}>
{expandedDate === d.stay_date
? <ChevronDown size={14} strokeWidth={1.75} />
: <ChevronRight size={14} strokeWidth={1.75} />}
</td>
<td style={{ fontWeight: 600 }}>{d.stay_date}</td>
<td style={{ color: isWeekend ? 'var(--gold)' : 'var(--text-mid)', fontSize: 12 }}>{dow}</td>
<td>
{isFull
? <span className="badge badge-danger">FULL</span>
: isMinStay
? <span className="badge badge-warning">{d.min_stay_nights}N min</span>
: <strong>{d.total_avail}</strong>}
</td>
<td>
{isFull
? <span className="badge badge-danger">Sold Out</span>
: isMinStay
? <span className="badge badge-warning">Min stay</span>
: <OccupancyPill avail={d.total_avail} stock={maxRooms} />}
</td>
<td style={{ fontWeight: 600 }}>
{(isFull || isMinStay) && d.cheapest_rate != null
? <span><span style={{ color: 'var(--text-mid)', fontSize: 11 }}>est. </span><em>{fmt(d.cheapest_rate)}</em></span>
: fmt(d.cheapest_rate)}
</td>
<td style={{ color: 'var(--text-mid)' }}>
{d.bench_rate != null
? (d.bench_calculated || isMinStay ? <em>{fmt(d.bench_rate)}*</em> : fmt(d.bench_rate))
: '—'}
</td>
<td style={{ fontSize: 11, color: 'var(--text-mid)' }}>{age(d.scraped_at)}</td>
<td style={{ width: 60 }}>
<button
className="btn btn-outline btn-sm"
title="Rate & availability history"
onClick={e => {
e.stopPropagation()
onShowHistory({ date: d.stay_date, mode: 'overall', title: `History — ${d.stay_date}` })
}}
>
<LineChart size={12} strokeWidth={1.75} />
</button>
</td>
</tr>
)}
</React.Fragment>
))}
{expandedDate === d.stay_date && roomData && (
<tr>
<td colSpan={9} style={{ background: '#f8fafc', padding: '8px 16px' }}>
<RoomBreakdown
rooms={roomData.rooms}
date={d.stay_date}
onShowHistory={onShowHistory}
/>
</td>
</tr>
)}
</React.Fragment>
)
})}
</tbody>
</table>
</div>
<div style={{ padding: '8px 16px', fontSize: 11, color: 'var(--text-mid)' }}>
* benchmark estimated from another room type via tier offsets. Occupancy is measured against the
highest availability ever observed per room (estimated stock).
</div>
</div>
)}
</div>
)
}
function RoomBreakdown({ rooms, benchPrice }: { rooms: RoomRow[]; benchPrice: number | null }) {
function RoomBreakdown({ rooms, date, onShowHistory }: {
rooms: RoomGroup[]
date: string
onShowHistory: (t: HistoryTarget) => void
}) {
const [expandedRoom, setExpandedRoom] = useState<string | null>(null)
return (
<table style={{ width: '100%', fontSize: 12 }}>
<thead>
<tr>
<th></th>
<th>Room</th>
<th>Rate Plan</th>
<th>Avail</th>
<th>Price</th>
<th>Occupancy</th>
<th>Best Rate</th>
<th>Bench Rate</th>
<th>Min-Stay</th>
</tr>
</thead>
<tbody>
{rooms.map((r, i) => (
<tr key={i}>
<td>{r.room_label}</td>
<td style={{ color: 'var(--text-mid)' }}>{r.rate_label}</td>
<td>{r.availability > 0 ? <CheckCircle size={12} strokeWidth={1.75} color="var(--success)" /> : <AlertCircle size={12} strokeWidth={1.75} color="var(--danger)" />}</td>
<td style={{ fontWeight: 600 }}>{fmt(r.price_incl)}</td>
<td style={{ color: 'var(--text-mid)' }}>{fmt(r.bench_rate)}</td>
<td>{r.min_stay_nights && r.min_stay_nights > 1 ? <span className="badge badge-warning">{r.min_stay_nights}N</span> : null}</td>
</tr>
))}
{rooms.map(r => {
const roomFull = !r.unavailable && r.availability === 0
const minStay = !!(r.min_stay_nights && r.min_stay_nights > 1)
const expanded = expandedRoom === r.room_id
return (
<React.Fragment key={r.room_id}>
<tr
onClick={() => !r.unavailable && setExpandedRoom(expanded ? null : r.room_id)}
style={{ cursor: r.unavailable ? 'default' : 'pointer' }}
>
<td style={{ width: 20, color: 'var(--text-mid)' }}>
{!r.unavailable && (expanded
? <ChevronDown size={12} strokeWidth={1.75} />
: <ChevronRight size={12} strokeWidth={1.75} />)}
</td>
<td style={{ fontWeight: 600 }}>{r.room_label}</td>
<td>
{r.unavailable
? <span className="badge badge-neutral">N/A</span>
: roomFull
? <span className="badge badge-danger">FULL</span>
: minStay
? <span className="badge badge-warning">{r.min_stay_nights}N min</span>
: <span><strong>{r.availability}</strong>{r.stock ? <span style={{ color: 'var(--text-mid)' }}> / {r.stock}</span> : null}</span>}
</td>
<td>
{roomFull
? <span className="badge badge-danger">Sold Out</span>
: (!r.unavailable && !minStay && r.availability != null)
? <OccupancyPill avail={r.availability} stock={r.stock} />
: null}
</td>
<td style={{ fontWeight: 600 }}>
{(roomFull || minStay) && r.best_rate != null
? <span><span style={{ color: 'var(--text-mid)', fontSize: 11 }}>est. </span><em>{fmt(r.best_rate)}</em></span>
: fmt(r.best_rate)}
</td>
<td style={{ color: 'var(--text-mid)' }}>
{r.bench_rate != null
? (r.bench_calculated ? <em>{fmt(r.bench_rate)}*</em> : fmt(r.bench_rate))
: '—'}
</td>
<td>{minStay ? <span className="badge badge-warning">{r.min_stay_nights}N</span> : null}</td>
</tr>
{expanded && r.rates.map(rate => (
<tr key={rate.rate_id} style={{ background: '#f1f5f9' }}>
<td></td>
<td style={{ paddingLeft: 20, color: 'var(--text-mid)' }}>{rate.rate_label}</td>
<td colSpan={2}></td>
<td>{fmt(rate.price_incl)} incl <span style={{ color: 'var(--text-mid)' }}>/ {fmt(rate.price_excl)} excl</span></td>
<td></td>
<td>
<button
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--gold)', fontSize: 11, display: 'inline-flex', alignItems: 'center', gap: 4, padding: 0 }}
onClick={e => {
e.stopPropagation()
onShowHistory({
date, roomId: r.room_id, rateId: rate.rate_id, mode: 'rate',
title: `${r.room_label}${rate.rate_label}${date}`,
})
}}
>
<LineChart size={11} strokeWidth={1.75} />
history
</button>
</td>
</tr>
))}
</React.Fragment>
)
})}
</tbody>
</table>
)
}
// ─── History Modal ────────────────────────────────────────────────────────────
const PLOT_LAYOUT_BASE = {
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
font: { family: 'Inter, system-ui, sans-serif', size: 12, color: '#60748b' },
margin: { t: 20, r: 48, b: 48, l: 48 },
xaxis: { gridcolor: '#e5e9f0', zeroline: false },
yaxis: { gridcolor: '#e5e9f0', zeroline: false },
}
function HistoryModal({ hotelId, target, onClose }: {
hotelId: number
target: HistoryTarget
onClose: () => void
}) {
const { data: series, isLoading } = useQuery<HistoryPoint[]>({
queryKey: ['direct-history', hotelId, target.date, target.roomId, target.rateId, target.mode],
queryFn: () => api.get(`/direct/hotels/${hotelId}/history/${target.date}`, {
params: {
...(target.roomId ? { room_id: target.roomId } : {}),
...(target.rateId ? { rate_id: target.rateId } : {}),
...(target.mode === 'benchmark' ? { mode: 'benchmark' } : {}),
},
}).then(r => r.data),
})
const x = (series || []).map(p => p.scraped_at)
const priceTrace = {
x, y: (series || []).map(p => p.price_incl ?? null),
type: 'scatter' as const, mode: 'lines+markers' as const,
name: target.mode === 'benchmark' ? 'Benchmark rate' : target.mode === 'rate' ? 'Rate' : 'Cheapest rate',
line: { color: '#c9a84c', width: 2 },
marker: { size: 6 },
}
const traces: any[] = [priceTrace]
if (target.mode !== 'benchmark') {
traces.push({
x, y: (series || []).map(p => p.availability ?? null),
type: 'scatter' as const, mode: 'lines+markers' as const,
name: 'Availability', yaxis: 'y2',
line: { color: '#2563eb', width: 2, dash: 'dot' },
marker: { size: 5 },
})
}
return (
<div
style={{
position: 'fixed', inset: 0, background: 'rgba(26,26,46,0.55)', zIndex: 100,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
onClick={onClose}
>
<div
className="card"
style={{ width: 'min(720px, 92vw)', maxHeight: '85vh', overflow: 'auto' }}
onClick={e => e.stopPropagation()}
>
<div className="card-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
{target.title}
<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 }}>
{isLoading && <div className="loading-state"><div className="spinner" />Loading</div>}
{!isLoading && (!series || series.length === 0) && (
<div className="empty-state">No history recorded yet for this date.</div>
)}
{!isLoading && series && series.length > 0 && (
<Plot
data={traces}
layout={{
...PLOT_LAYOUT_BASE,
height: 320,
showlegend: target.mode !== 'benchmark',
legend: { orientation: 'h' as const, y: -0.25 },
yaxis: { ...PLOT_LAYOUT_BASE.yaxis, title: { text: '£ per night' } },
yaxis2: {
overlaying: 'y' as const, side: 'right' as const,
gridcolor: 'transparent', zeroline: false,
title: { text: 'rooms available' }, rangemode: 'tozero' as const,
},
}}
config={{ displayModeBar: false, responsive: true }}
style={{ width: '100%' }}
/>
)}
{target.mode === 'benchmark' && series?.some(p => p.calculated) && (
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 8 }}>
Some points are estimated from other room types via tier offsets.
</div>
)}
</div>
</div>
</div>
)
}
// ─── Room Stats Tab ───────────────────────────────────────────────────────────
const STAT_WINDOWS = [
{ key: 'this_week', label: 'This Week (7 days)' },
{ key: 'this_month', label: 'This Month (30 days)' },
{ key: 'next_6mo', label: 'Next 6 Months' },
{ key: 'next_12mo', label: 'Next 12 Months' },
]
function RoomStatsTab({ hotels, selectedHotel, onSelectHotel }: {
hotels: DirectHotel[]
selectedHotel: number | null
onSelectHotel: (id: number) => void
}) {
const { data: stats, isLoading } = useQuery<Record<string, RoomStats>>({
queryKey: ['direct-stats', selectedHotel],
queryFn: () => api.get(`/direct/hotels/${selectedHotel}/stats`).then(r => r.data),
enabled: !!selectedHotel,
})
const roomIds = stats
? Object.keys(stats).sort((a, b) => (stats[b].stock || 0) - (stats[a].stock || 0))
: []
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-end' }}>
<HotelSelector hotels={hotels} selectedHotel={selectedHotel} onSelectHotel={onSelectHotel} />
</div>
<div style={{ fontSize: 12, color: 'var(--text-mid)', maxWidth: 720 }}>
Per-room breakdown on the benchmark rate. <strong>Stock</strong> is the highest availability ever
observed for the room. <strong>Current Occ</strong> compares the latest snapshot against stock across
upcoming dates. <strong>Est. Final Occ</strong> uses the equivalent look-back window past dates no
longer change, so they reflect completed bookings.
</div>
{!selectedHotel && <div className="empty-state">Select a hotel to view room stats.</div>}
{selectedHotel && isLoading && <div className="loading-state"><div className="spinner" />Loading</div>}
{selectedHotel && !isLoading && roomIds.length === 0 && (
<div className="empty-state">No data yet run a scrape first.</div>
)}
{selectedHotel && !isLoading && roomIds.length > 0 && STAT_WINDOWS.map(win => (
<div className="card" key={win.key}>
<div className="card-header">{win.label}</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Room Type</th>
<th>Stock</th>
<th>Avg Rate</th>
<th>Current Occ %</th>
<th>Est. Final Occ %</th>
<th>Dates</th>
</tr>
</thead>
<tbody>
{roomIds.map(rid => {
const room = stats![rid]
const w = room.windows[win.key]
if (!w) return (
<tr key={rid}>
<td style={{ fontWeight: 600 }}>{room.room_label}</td>
<td style={{ color: 'var(--text-mid)' }}>{room.stock ?? '—'}</td>
<td colSpan={4} style={{ color: 'var(--text-mid)' }}>No data</td>
</tr>
)
return (
<tr key={rid}>
<td style={{ fontWeight: 600 }}>{room.room_label}</td>
<td style={{ color: 'var(--text-mid)' }}>{room.stock ?? '—'}</td>
<td>{w.avg_price != null ? fmt(w.avg_price) : '—'}</td>
<td>
{w.avg_occ != null
? <span><span style={{ color: occColor(w.avg_occ), fontWeight: 600 }}>{w.avg_occ}%</span><OccBar pct={w.avg_occ} /></span>
: '—'}
</td>
<td>
{w.est_final_occ != null
? <span><span style={{ color: occColor(w.est_final_occ), fontWeight: 600 }}>{w.est_final_occ}%</span><OccBar pct={w.est_final_occ} /></span>
: <span style={{ color: 'var(--text-mid)', fontSize: 11 }}>no past dates</span>}
</td>
<td style={{ color: 'var(--text-mid)', fontSize: 12 }}>
{w.dates} ahead{w.past_dates ? ` · ${w.past_dates} past` : ''}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
))}
</div>
)
}
// ─── Manage Tab ───────────────────────────────────────────────────────────────
function ManageTab({ hotels, onRefresh }: { hotels: DirectHotel[]; onRefresh: () => void }) {
const [showAdd, setShowAdd] = useState(false)
const [detectUrl, setDetectUrl] = useState('')
const [detected, setDetected] = useState<any>(null)
const [detecting, setDetecting] = useState(false)
const [newName, setNewName] = useState('')
const [extraParams, setExtraParams] = useState<Record<string, string>>({})
const [profiles, setProfiles] = useState<any[]>([])
const qc = useQueryClient()
const { data: profileList } = useQuery({