Add rate snapshot to modal and room-type history chart improvements
Modal now shows two sections:
- Current availability: all room types with rooms-left count, then each rate
plan (meal plan × cancel policy × price) from the latest scrape batch
- Rate history chart: one line per room type, cheapest 2-adult rate per
scrape run (max_persons filter added to exclude 1-adult variants)
New endpoint: GET /competitors/hotels/{id}/rate-snapshot/{date}
Returns all rate plan variants grouped by room type from the latest batch.
Handles legacy single-row data (pre hotel-page scraper) gracefully.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bb37fcf501
commit
5462773dd7
2 changed files with 189 additions and 34 deletions
|
|
@ -1140,18 +1140,25 @@ const MonthSelector: React.FC<{
|
|||
interface RateHistoryPoint { t: string | null; rate: number }
|
||||
interface RateHistorySeries { room_type: string; points: RateHistoryPoint[] }
|
||||
interface RateHistoryModal { hotelId: number; hotelName: string; stayDate: string }
|
||||
interface RatePlan { meal: string; cancel: string; price: number; max_persons: number | null }
|
||||
interface RoomSnapshot { room_type: string; rooms_left: number | null; availability_status: string; plans: RatePlan[] }
|
||||
interface RateSnapshot { stay_date: string; legacy: boolean; rooms: RoomSnapshot[] }
|
||||
|
||||
const CHART_COLORS = ['#1a1a2e', '#c9a84c', '#16a34a', '#dc2626', '#7c3aed', '#0891b2', '#ea580c', '#be185d']
|
||||
|
||||
const RateHistoryModalComponent: React.FC<{ modal: RateHistoryModal; onClose: () => void }> = ({ modal, onClose }) => {
|
||||
const { data, isLoading } = useQuery<{ stay_date: string; series: RateHistorySeries[] }>({
|
||||
const { data: histData, isLoading: histLoading } = useQuery<{ stay_date: string; series: RateHistorySeries[] }>({
|
||||
queryKey: ['hotel-rate-history', modal.hotelId, modal.stayDate],
|
||||
queryFn: async () => (await api.get(`/competitors/hotels/${modal.hotelId}/rate-history/${modal.stayDate}`)).data,
|
||||
})
|
||||
const { data: snap, isLoading: snapLoading } = useQuery<RateSnapshot>({
|
||||
queryKey: ['hotel-rate-snapshot', modal.hotelId, modal.stayDate],
|
||||
queryFn: async () => (await api.get(`/competitors/hotels/${modal.hotelId}/rate-snapshot/${modal.stayDate}`)).data,
|
||||
})
|
||||
|
||||
const traces = useMemo(() => {
|
||||
if (!data?.series) return []
|
||||
return data.series.map((s, i) => ({
|
||||
if (!histData?.series) return []
|
||||
return histData.series.map((s, i) => ({
|
||||
type: 'scatter' as const,
|
||||
mode: 'lines+markers' as const,
|
||||
name: s.room_type,
|
||||
|
|
@ -1161,50 +1168,107 @@ const RateHistoryModalComponent: React.FC<{ modal: RateHistoryModal; onClose: ()
|
|||
marker: { size: 5 },
|
||||
hovertemplate: `£%{y:.0f}<br>%{x}<extra>${s.room_type}</extra>`,
|
||||
}))
|
||||
}, [data])
|
||||
}, [histData])
|
||||
|
||||
const isLoading = histLoading || snapLoading
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
onClick={onClose}>
|
||||
<div style={{ background: 'var(--card-bg)', borderRadius: 12, padding: 24, width: '90%', maxWidth: 760, maxHeight: '85vh', overflow: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}
|
||||
<div style={{ background: 'var(--card-bg)', borderRadius: 12, padding: 24, width: '90%', maxWidth: 820, maxHeight: '90vh', overflow: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 20 }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: 16, color: 'var(--text-dark)' }}>{modal.hotelName}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-mid)', marginTop: 2 }}>
|
||||
Best available rate history — {modal.stayDate}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-mid)', marginTop: 2 }}>{modal.stayDate}</div>
|
||||
</div>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-mid)', padding: 4 }}>
|
||||
<X size={18} strokeWidth={1.75} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: 'var(--text-mid)' }}>Loading…</div>
|
||||
) : !data?.series?.length ? (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: 'var(--text-mid)' }}>No historical data for this date yet.</div>
|
||||
) : (
|
||||
<Plot
|
||||
data={traces}
|
||||
layout={{
|
||||
margin: { t: 10, r: 10, b: 50, l: 55 },
|
||||
height: 340,
|
||||
legend: { orientation: 'h', y: -0.2 },
|
||||
xaxis: { title: { text: '' }, tickformat: '%d %b %H:%M', type: 'date' },
|
||||
yaxis: { title: { text: 'Rate (£)' }, tickformat: '£,.0f' },
|
||||
plot_bgcolor: 'var(--body-bg)',
|
||||
paper_bgcolor: 'var(--card-bg)',
|
||||
font: { family: 'inherit', size: 11, color: 'var(--text-dark)' },
|
||||
hovermode: 'x unified',
|
||||
}}
|
||||
config={{ displayModeBar: false, responsive: true }}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
)}
|
||||
{data?.series && data.series.length > 0 && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 8 }}>
|
||||
Each line is the cheapest available rate for that room type at each scrape run. When the best available room type changes, the lines show which was cheapest at each point.
|
||||
</div>
|
||||
<>
|
||||
{/* Current availability snapshot */}
|
||||
{snap && snap.rooms.length > 0 && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 10 }}>
|
||||
Current availability
|
||||
</div>
|
||||
{snap.rooms.map(room => (
|
||||
<div key={room.room_type} style={{ marginBottom: 12, border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: 'var(--body-bg)', padding: '8px 12px' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-dark)' }}>{room.room_type}</span>
|
||||
{room.availability_status === 'sold_out' ? (
|
||||
<span style={{ fontSize: 11, color: '#dc2626', fontWeight: 500 }}>Sold out</span>
|
||||
) : room.rooms_left != null ? (
|
||||
<span style={{ fontSize: 11, color: room.rooms_left <= 2 ? '#dc2626' : '#ea580c', fontWeight: 500 }}>
|
||||
{room.rooms_left} left
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{room.plans.length > 0 && (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
|
||||
<tbody>
|
||||
{room.plans.map((plan, pi) => (
|
||||
<tr key={pi} style={{ borderTop: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '6px 12px', color: 'var(--text-mid)' }}>{plan.meal}</td>
|
||||
<td style={{ padding: '6px 12px', color: plan.cancel.startsWith('Free') ? '#16a34a' : 'var(--text-mid)' }}>
|
||||
{plan.cancel}
|
||||
</td>
|
||||
<td style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 600, color: 'var(--text-dark)' }}>
|
||||
£{plan.price.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{room.plans.length === 0 && room.availability_status !== 'sold_out' && (
|
||||
<div style={{ padding: '6px 12px', fontSize: 12, color: 'var(--text-mid)' }}>No rates available</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{snap && snap.rooms.length === 0 && (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-mid)', marginBottom: 20, padding: '12px', background: 'var(--body-bg)', borderRadius: 8 }}>
|
||||
No rate data from latest scrape for this date.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rate history chart */}
|
||||
{histData?.series && histData.series.length > 0 && (
|
||||
<>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 10 }}>
|
||||
Rate history — best available per room type
|
||||
</div>
|
||||
<Plot
|
||||
data={traces}
|
||||
layout={{
|
||||
margin: { t: 10, r: 10, b: 50, l: 55 },
|
||||
height: 300,
|
||||
legend: { orientation: 'h', y: -0.25 },
|
||||
xaxis: { title: { text: '' }, tickformat: '%d %b %H:%M', type: 'date' },
|
||||
yaxis: { title: { text: 'Rate (£)' }, tickformat: '£,.0f' },
|
||||
plot_bgcolor: 'var(--body-bg)',
|
||||
paper_bgcolor: 'var(--card-bg)',
|
||||
font: { family: 'inherit', size: 11, color: 'var(--text-dark)' },
|
||||
hovermode: 'x unified',
|
||||
}}
|
||||
config={{ displayModeBar: false, responsive: true }}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{histData?.series && histData.series.length === 0 && (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-mid)', padding: '12px', background: 'var(--body-bg)', borderRadius: 8 }}>
|
||||
No historical rate data yet — history builds up over time as daily scrapes run.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue