From 5462773dd7562996858d910801a9c2b3b0b91ac4 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Thu, 9 Jul 2026 23:17:00 +0000 Subject: [PATCH] Add rate snapshot to modal and room-type history chart improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/api/competitors.py | 95 +++++++++++++++++++++- frontend/src/pages/MarketView.tsx | 128 ++++++++++++++++++++++-------- 2 files changed, 189 insertions(+), 34 deletions(-) diff --git a/backend/api/competitors.py b/backend/api/competitors.py index fd490ab..c913661 100644 --- a/backend/api/competitors.py +++ b/backend/api/competitors.py @@ -1213,7 +1213,8 @@ async def get_hotel_rate_history( ): """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. - 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( text(""" SELECT @@ -1226,6 +1227,7 @@ async def get_hotel_rate_history( AND r.rate_date = :stay_date AND r.rate_gross IS NOT NULL 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 ORDER BY scrape_time """), @@ -1234,7 +1236,7 @@ async def get_hotel_rate_history( by_room: dict = {} 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({ "t": row["scrape_time"].isoformat() if row["scrape_time"] else None, "rate": row["best_rate"], @@ -1245,6 +1247,95 @@ async def get_hotel_rate_history( 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 # ============================================ diff --git a/frontend/src/pages/MarketView.tsx b/frontend/src/pages/MarketView.tsx index 1544c75..8713c93 100644 --- a/frontend/src/pages/MarketView.tsx +++ b/frontend/src/pages/MarketView.tsx @@ -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({ + 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}
%{x}${s.room_type}`, })) - }, [data]) + }, [histData]) + + const isLoading = histLoading || snapLoading return (
-
e.stopPropagation()}> -
+
{modal.hotelName}
-
- Best available rate history — {modal.stayDate} -
+
{modal.stayDate}
+ {isLoading ? (
Loading…
- ) : !data?.series?.length ? ( -
No historical data for this date yet.
) : ( - - )} - {data?.series && data.series.length > 0 && ( -
- 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. -
+ <> + {/* Current availability snapshot */} + {snap && snap.rooms.length > 0 && ( +
+
+ Current availability +
+ {snap.rooms.map(room => ( +
+
+ {room.room_type} + {room.availability_status === 'sold_out' ? ( + Sold out + ) : room.rooms_left != null ? ( + + {room.rooms_left} left + + ) : null} +
+ {room.plans.length > 0 && ( + + + {room.plans.map((plan, pi) => ( + + + + + + ))} + +
{plan.meal} + {plan.cancel} + + £{plan.price.toLocaleString()} +
+ )} + {room.plans.length === 0 && room.availability_status !== 'sold_out' && ( +
No rates available
+ )} +
+ ))} +
+ )} + {snap && snap.rooms.length === 0 && ( +
+ No rate data from latest scrape for this date. +
+ )} + + {/* Rate history chart */} + {histData?.series && histData.series.length > 0 && ( + <> +
+ Rate history — best available per room type +
+ + + )} + {histData?.series && histData.series.length === 0 && ( +
+ No historical rate data yet — history builds up over time as daily scrapes run. +
+ )} + )}