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.
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
# ============================================