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:
jtricerolph 2026-07-09 23:17:00 +00:00
parent bb37fcf501
commit 5462773dd7
2 changed files with 189 additions and 34 deletions

View file

@ -1213,7 +1213,8 @@ async def get_hotel_rate_history(
): ):
"""Per-room-type best available rate over time for a single stay date. """Per-room-type best available rate over time for a single stay date.
Groups by scrape batch so each x-point is one scrape run, not one row. Groups by scrape batch so each x-point is one scrape run, not one row.
Returns series: [{room_type, points: [{t, rate}]}]""" Returns series: [{room_type, points: [{t, rate}]}]
Filters to 2-adult rates only (max_persons=2 or legacy NULL rows)."""
result = await db.execute( result = await db.execute(
text(""" text("""
SELECT SELECT
@ -1226,6 +1227,7 @@ async def get_hotel_rate_history(
AND r.rate_date = :stay_date AND r.rate_date = :stay_date
AND r.rate_gross IS NOT NULL AND r.rate_gross IS NOT NULL
AND r.availability_status = 'available' AND r.availability_status = 'available'
AND (r.max_persons IS NULL OR r.max_persons = 2)
GROUP BY COALESCE(b.started_at, date_trunc('hour', r.scraped_at)), r.room_type GROUP BY COALESCE(b.started_at, date_trunc('hour', r.scraped_at)), r.room_type
ORDER BY scrape_time ORDER BY scrape_time
"""), """),
@ -1234,7 +1236,7 @@ async def get_hotel_rate_history(
by_room: dict = {} by_room: dict = {}
for row in result.mappings(): for row in result.mappings():
rt = (row["room_type"] or "Unknown").split("\n")[0].strip() rt = (row["room_type"] or "Best available").split("\n")[0].strip()
by_room.setdefault(rt, []).append({ by_room.setdefault(rt, []).append({
"t": row["scrape_time"].isoformat() if row["scrape_time"] else None, "t": row["scrape_time"].isoformat() if row["scrape_time"] else None,
"rate": row["best_rate"], "rate": row["best_rate"],
@ -1245,6 +1247,95 @@ async def get_hotel_rate_history(
return {"stay_date": str(stay_date), "series": series} return {"stay_date": str(stay_date), "series": series}
@router.get("/hotels/{hotel_id}/rate-snapshot/{stay_date}")
async def get_hotel_rate_snapshot(
hotel_id: int,
stay_date: date,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""All rate plan variants from the most recent scrape for one hotel+date.
Returns rooms: [{room_type, rooms_left, plans: [{meal, cancel, price, max_persons}]}]"""
result = await db.execute(
text("""
SELECT
r.room_type,
r.breakfast_included,
r.free_cancellation,
r.rate_gross::float AS price,
r.rooms_left,
r.max_persons,
r.availability_status
FROM booking_com_rates r
WHERE r.hotel_id = :hotel_id
AND r.rate_date = :stay_date
AND r.scrape_batch_id = (
SELECT scrape_batch_id
FROM booking_com_rates
WHERE hotel_id = :hotel_id AND rate_date = :stay_date
AND scrape_batch_id IS NOT NULL
ORDER BY scraped_at DESC
LIMIT 1
)
ORDER BY r.room_type, r.breakfast_included, r.free_cancellation DESC, r.rate_gross
"""),
{"hotel_id": hotel_id, "stay_date": stay_date},
)
rows = result.mappings().all()
# If no rate_plan data (old scraper rows), return a simple summary
if not rows:
return {"stay_date": str(stay_date), "rooms": [], "legacy": True}
# Check if this is old single-row data (no rate_plan_id breakdown)
has_breakdown = any(r["room_type"] for r in rows)
if not has_breakdown:
# Legacy: single row per hotel+date from search results scraper
row = rows[0]
return {
"stay_date": str(stay_date),
"legacy": True,
"rooms": [{
"room_type": "Best available",
"rooms_left": row["rooms_left"],
"availability_status": row["availability_status"],
"plans": [{
"meal": "B&B" if row["breakfast_included"] else "Room only",
"cancel": "Free cancellation" if row["free_cancellation"] else "Non-refundable",
"price": row["price"],
"max_persons": row["max_persons"],
}] if row["price"] else [],
}],
}
# Group by room_type, keeping rooms_left from the first occurrence (same per room)
rooms_map: dict = {}
for r in rows:
rt = (r["room_type"] or "Unknown").strip()
if rt not in rooms_map:
rooms_map[rt] = {
"room_type": rt,
"rooms_left": r["rooms_left"],
"availability_status": r["availability_status"],
"plans": [],
}
if r["price"] and (r["max_persons"] is None or r["max_persons"] == 2):
rooms_map[rt]["plans"].append({
"meal": "B&B" if r["breakfast_included"] else "Room only",
"cancel": "Free cancellation" if r["free_cancellation"] else "Non-refundable",
"price": r["price"],
"max_persons": r["max_persons"],
})
return {
"stay_date": str(stay_date),
"legacy": False,
"rooms": list(rooms_map.values()),
}
# ============================================ # ============================================
# SCRAPE HISTORY # SCRAPE HISTORY
# ============================================ # ============================================

View file

@ -1140,18 +1140,25 @@ const MonthSelector: React.FC<{
interface RateHistoryPoint { t: string | null; rate: number } interface RateHistoryPoint { t: string | null; rate: number }
interface RateHistorySeries { room_type: string; points: RateHistoryPoint[] } interface RateHistorySeries { room_type: string; points: RateHistoryPoint[] }
interface RateHistoryModal { hotelId: number; hotelName: string; stayDate: string } 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 CHART_COLORS = ['#1a1a2e', '#c9a84c', '#16a34a', '#dc2626', '#7c3aed', '#0891b2', '#ea580c', '#be185d']
const RateHistoryModalComponent: React.FC<{ modal: RateHistoryModal; onClose: () => void }> = ({ modal, onClose }) => { 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], queryKey: ['hotel-rate-history', modal.hotelId, modal.stayDate],
queryFn: async () => (await api.get(`/competitors/hotels/${modal.hotelId}/rate-history/${modal.stayDate}`)).data, 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(() => { const traces = useMemo(() => {
if (!data?.series) return [] if (!histData?.series) return []
return data.series.map((s, i) => ({ return histData.series.map((s, i) => ({
type: 'scatter' as const, type: 'scatter' as const,
mode: 'lines+markers' as const, mode: 'lines+markers' as const,
name: s.room_type, name: s.room_type,
@ -1161,50 +1168,107 @@ const RateHistoryModalComponent: React.FC<{ modal: RateHistoryModal; onClose: ()
marker: { size: 5 }, marker: { size: 5 },
hovertemplate: `£%{y:.0f}<br>%{x}<extra>${s.room_type}</extra>`, hovertemplate: `£%{y:.0f}<br>%{x}<extra>${s.room_type}</extra>`,
})) }))
}, [data]) }, [histData])
const isLoading = histLoading || snapLoading
return ( return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }} <div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={onClose}> 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()}> 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>
<div style={{ fontWeight: 700, fontSize: 16, color: 'var(--text-dark)' }}>{modal.hotelName}</div> <div style={{ fontWeight: 700, fontSize: 16, color: 'var(--text-dark)' }}>{modal.hotelName}</div>
<div style={{ fontSize: 13, color: 'var(--text-mid)', marginTop: 2 }}> <div style={{ fontSize: 13, color: 'var(--text-mid)', marginTop: 2 }}>{modal.stayDate}</div>
Best available rate history {modal.stayDate}
</div>
</div> </div>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-mid)', padding: 4 }}> <button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-mid)', padding: 4 }}>
<X size={18} strokeWidth={1.75} /> <X size={18} strokeWidth={1.75} />
</button> </button>
</div> </div>
{isLoading ? ( {isLoading ? (
<div style={{ textAlign: 'center', padding: 40, color: 'var(--text-mid)' }}>Loading</div> <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} {/* Current availability snapshot */}
layout={{ {snap && snap.rooms.length > 0 && (
margin: { t: 10, r: 10, b: 50, l: 55 }, <div style={{ marginBottom: 24 }}>
height: 340, <div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 10 }}>
legend: { orientation: 'h', y: -0.2 }, Current availability
xaxis: { title: { text: '' }, tickformat: '%d %b %H:%M', type: 'date' }, </div>
yaxis: { title: { text: 'Rate (£)' }, tickformat: '£,.0f' }, {snap.rooms.map(room => (
plot_bgcolor: 'var(--body-bg)', <div key={room.room_type} style={{ marginBottom: 12, border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
paper_bgcolor: 'var(--card-bg)', <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: 'var(--body-bg)', padding: '8px 12px' }}>
font: { family: 'inherit', size: 11, color: 'var(--text-dark)' }, <span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-dark)' }}>{room.room_type}</span>
hovermode: 'x unified', {room.availability_status === 'sold_out' ? (
}} <span style={{ fontSize: 11, color: '#dc2626', fontWeight: 500 }}>Sold out</span>
config={{ displayModeBar: false, responsive: true }} ) : room.rooms_left != null ? (
style={{ width: '100%' }} <span style={{ fontSize: 11, color: room.rooms_left <= 2 ? '#dc2626' : '#ea580c', fontWeight: 500 }}>
/> {room.rooms_left} left
)} </span>
{data?.series && data.series.length > 0 && ( ) : null}
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 8 }}> </div>
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. {room.plans.length > 0 && (
</div> <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>
</div> </div>