import { useState } from 'react' import { useQuery } from '@tanstack/react-query' import Plot from 'react-plotly.js' import { TrendingUp, TrendingDown, Minus, AlertTriangle, ChevronDown } from 'lucide-react' import api from '../api' const fmtDate = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` interface AnalysisHotel { hotel_id: number hotel_name: string last_scraped: string | null date_count: number } interface StrategyLabel { label: string advance_discount_pct: number weekend_premium_pct: number avg_sold_out_rate_pct: number peak_months: string[] } interface HotelAnalysis { strategy: StrategyLabel advance_curve: { days_ahead: number; avg_price: number; sample_count: number }[] dow_breakdown: { dow: number; dow_name: string; avg_price: number; count: number }[] sold_out_pattern: { stay_date: string; sold_out_pct: number }[] } interface TimelineEntry { scraped_at: string room_id: string rate_id: string room_label: string rate_label: string price_incl: number | null availability: number } interface ComparisonRow { hotel_id: number hotel_name: string our_rate: number | null their_rate: number | null price_index: number | null days_checked: number } const DOW = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] 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: 16, b: 48, l: 48 }, showlegend: false, xaxis: { gridcolor: '#e5e9f0', zeroline: false }, yaxis: { gridcolor: '#e5e9f0', zeroline: false }, } function strategyIcon(label: string) { if (label.includes('Discount')) return if (label.includes('Premium')) return if (label.includes('Yield')) return return } function priceIndexClass(idx: number | null) { if (idx == null) return 'badge badge-neutral' if (idx > 105) return 'badge badge-success' if (idx < 85) return 'badge badge-danger' if (idx < 95) return 'badge badge-warning' return 'badge badge-neutral' } export default function RateAnalysis() { const [selectedHotel, setSelectedHotel] = useState(null) const [timelineDate, setTimelineDate] = useState(fmtDate(new Date(Date.now() + 30 * 86400000))) const [compFrom, setCompFrom] = useState(fmtDate(new Date())) const [compTo, setCompTo] = useState(fmtDate(new Date(Date.now() + 29 * 86400000))) const { data: hotels } = useQuery({ queryKey: ['analysis-hotels'], queryFn: () => api.get('/analysis/hotels').then(r => r.data), }) const { data: analysis, isLoading: analysisLoading } = useQuery({ queryKey: ['analysis-hotel', selectedHotel], queryFn: () => api.get(`/analysis/hotel/${selectedHotel}`).then(r => r.data), enabled: !!selectedHotel, }) const { data: timeline } = useQuery({ queryKey: ['analysis-timeline', selectedHotel, timelineDate], queryFn: () => api.get(`/analysis/hotel/${selectedHotel}/timeline`, { params: { date: timelineDate } }).then(r => r.data), enabled: !!selectedHotel, }) const { data: comparison, isLoading: compLoading } = useQuery({ queryKey: ['analysis-comparison', compFrom, compTo], queryFn: () => api.get('/analysis/comparison', { params: { from_date: compFrom, to_date: compTo } }).then(r => r.data), enabled: !!(compFrom && compTo), }) const presets = [ { label: '7d', days: 7 }, { label: '14d', days: 14 }, { label: '30d', days: 30 }, ] return (
Rate Analysis
Competitor pricing structure and advance purchase behaviour
{/* Comparison table — full width, no hotel needed */}
Market Comparison
{presets.map(p => ( ))} setCompFrom(e.target.value)} /> to setCompTo(e.target.value)} />
{compLoading ? (
Loading…
) : (
{(comparison || []).length === 0 && ( )} {(comparison || []).map(row => ( ))}
Competitor Our Avg Rate Their Avg Rate Price Index Dates Checked
No comparison data available.
{row.our_rate ? `£${Number(row.our_rate).toFixed(2)}` : '—'} {row.their_rate ? `£${Number(row.their_rate).toFixed(2)}` : '—'} {row.price_index != null ? ( {row.price_index.toFixed(0)} ) : '—'} {row.days_checked}
)}
{/* Hotel selector for deep analysis */}
{selectedHotel && analysisLoading && (
Loading analysis…
)} {selectedHotel && !analysisLoading && analysis && (
{/* Strategy card */}
Pricing Strategy
{strategyIcon(analysis.strategy.label)} {analysis.strategy.label}
{analysis.strategy.peak_months.length > 0 && ( )}
{/* Advance purchase curve */}
Advance Purchase Curve
{analysis.advance_curve.length > 0 ? ( p.days_ahead), y: analysis.advance_curve.map(p => p.avg_price), line: { color: '#c9a84c', width: 2 }, marker: { size: 4, color: '#c9a84c' }, hovertemplate: '%{x} days ahead: £%{y:.2f}', }]} layout={{ ...PLOT_LAYOUT_BASE, xaxis: { ...PLOT_LAYOUT_BASE.xaxis, title: { text: 'Days ahead', font: { size: 11 } }, autorange: 'reversed' }, yaxis: { ...PLOT_LAYOUT_BASE.yaxis, title: { text: 'Avg price (£)', font: { size: 11 } }, tickprefix: '£' }, }} config={{ displayModeBar: false, responsive: true }} useResizeHandler /> ) : (
Not enough data yet.
)}
{/* DOW breakdown */}
Day-of-Week Breakdown
{analysis.dow_breakdown.length > 0 ? ( d.dow_name), y: analysis.dow_breakdown.map(d => d.avg_price), marker: { color: analysis.dow_breakdown.map(d => d.dow >= 5 ? '#c9a84c' : '#3b82f6' ), }, hovertemplate: '%{x}: £%{y:.2f}', }]} layout={{ ...PLOT_LAYOUT_BASE, yaxis: { ...PLOT_LAYOUT_BASE.yaxis, tickprefix: '£' }, }} config={{ displayModeBar: false, responsive: true }} useResizeHandler /> ) : (
Not enough data yet.
)}
{/* Rate timeline */}
Rate Timeline — How Rates Changed for One Date setTimelineDate(e.target.value)} />
{(timeline || []).length === 0 ? (
No timeline data for this date.
) : ( )}
)} {!selectedHotel && (
Select a competitor above to view their pricing strategy and advance purchase curve.
)}
) } function StatChip({ label, value, hint }: { label: string; value: string; hint: string }) { return (
{label} {value} {hint && {hint}}
) } function buildTimelineTraces(entries: TimelineEntry[]) { const byRoom: Record = {} for (const e of entries) { const key = e.room_label || e.room_id if (!byRoom[key]) byRoom[key] = [] byRoom[key].push(e) } const colors = ['#c9a84c', '#3b82f6', '#10b981', '#ef4444', '#8b5cf6', '#f59e0b'] return Object.entries(byRoom).map(([room, pts], i) => ({ type: 'scatter' as const, mode: 'lines+markers' as const, name: room, x: pts.map(p => p.scraped_at), y: pts.map(p => p.price_incl ?? null), line: { color: colors[i % colors.length], width: 2 }, marker: { size: 5, color: colors[i % colors.length] }, hovertemplate: `${room}: £%{y:.2f}`, })) }