From 20d939de0003d38a9b795116720882bac30f3072 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Mon, 6 Jul 2026 06:50:37 +0000 Subject: [PATCH] Restore guestline-monitor features: room stock/occupancy, rate history, room stats - dates endpoint now returns total_avail, benchmark rate (tier-resolved), min-stay nights, max_rooms (summed per-room stock) and friendly labels - rooms endpoint groups by room with stock, occupancy, best/bench rate and nested rate plans; injects known-but-absent rooms; honours room_order - new /history/{stay_date} endpoint: per room/rate series, benchmark mode, or overall availability + cheapest rate per scrape run - new /stats endpoint: per-room stock + windowed avg rate / current occ / est. final occ (look-back window so past dates give completed bookings) - DirectRates page: occupancy pills, avail x/stock, Room Stats tab, history chart modal (plotly), expandable rate plans with history links - fix discovery trigger crashing (run_until_complete inside running loop) - fix migration script reading wrong column name (engine_profile) Co-Authored-By: Claude Fable 5 --- backend/api/direct.py | 456 +++++++++++++++++---- backend/migrate_direct_data.py | 6 +- frontend/src/pages/DirectRates.tsx | 619 ++++++++++++++++++++++++----- 3 files changed, 908 insertions(+), 173 deletions(-) diff --git a/backend/api/direct.py b/backend/api/direct.py index 06802db..8be2cc4 100644 --- a/backend/api/direct.py +++ b/backend/api/direct.py @@ -1,7 +1,6 @@ """ Direct booking engine API — hotel management, discovery, scrape control, rate data. """ -import asyncio import json import logging from datetime import date, timedelta @@ -11,7 +10,7 @@ from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks from pydantic import BaseModel from sqlalchemy import text -from database import AsyncSessionLocal, SyncSessionLocal +from database import AsyncSessionLocal from auth import get_current_user, require_cap from services.direct_profiles import PROFILES, detect_profile, get_profile from services.direct_scraper import run_discovery, get_discovery_status, run_scrape @@ -45,6 +44,37 @@ class DetectRequest(BaseModel): url: str +# ─── Benchmark helpers (ported from guestline-monitor) ────────────────────── + +def _resolve_bench(prices: dict, bench_room: str, offsets: dict): + """ + Derive the benchmark room's price from whatever room prices are available. + `prices` maps room_id -> price on the benchmark rate plan. + All offsets are relative to tier_base_room; bench = room_price - room_offset + bench_offset. + Returns (price, is_calculated). + """ + if prices.get(bench_room) is not None: + return float(prices[bench_room]), False + + bench_offset = offsets.get(bench_room, 0) + for room_id, room_offset in offsets.items(): + if room_id == bench_room: + continue + if prices.get(room_id) is not None: + return float(prices[room_id]) - room_offset + bench_offset, True + + for v in prices.values(): + if v is not None: + return float(v), True + + return None, False + + +def _room_stock(rows) -> dict: + """room_id -> MAX(availability) ever seen = estimated stock.""" + return {r["room_id"]: r["stock"] for r in rows} + + # ─── Profiles ──────────────────────────────────────────────────────────────── @router.get("/profiles") @@ -161,10 +191,7 @@ async def trigger_discovery(hotel_id: int, background_tasks: BackgroundTasks, us if not hotel: raise HTTPException(status_code=404, detail="Hotel not found") - background_tasks.add_task( - asyncio.get_event_loop().run_until_complete, - run_discovery(hotel_id, hotel["profile_name"], hotel["params"]) - ) + background_tasks.add_task(run_discovery, hotel_id, hotel["profile_name"], hotel["params"]) return {"status": "discovery started", "hotel_id": hotel_id} @@ -220,39 +247,81 @@ async def hotel_dates( if not hotel: raise HTTPException(status_code=404, detail="Hotel not found") - # Latest snapshot per date: cheapest available price_incl + # Latest snapshot per (date, room, rate) rates_result = await db.execute( text(""" - SELECT - r.stay_date, - MIN(r.price_incl) FILTER (WHERE r.availability > 0) AS cheapest_rate, - BOOL_OR(r.availability > 0) AS has_availability, - BOOL_OR(r.min_stay_nights IS NOT NULL - AND r.min_stay_nights > 1) AS has_min_stay, - MAX(r.scraped_at) AS scraped_at - FROM ( - SELECT DISTINCT ON (room_id, rate_id) - stay_date, room_id, rate_id, availability, - price_incl, min_stay_nights, scraped_at - FROM direct_rates - WHERE hotel_id = :hid - AND stay_date BETWEEN :fd AND :td - ORDER BY room_id, rate_id, scraped_at DESC - ) r - GROUP BY r.stay_date - ORDER BY r.stay_date + SELECT DISTINCT ON (stay_date, room_id, rate_id) + stay_date, room_id, rate_id, availability, + price_incl, min_stay_nights, scraped_at + FROM direct_rates + WHERE hotel_id = :hid + AND stay_date BETWEEN :fd AND :td + ORDER BY stay_date, room_id, rate_id, scraped_at DESC """), {"hid": hotel_id, "fd": from_date, "td": to_date} ) - dates = [dict(r) for r in rates_result.mappings().all()] + rows = rates_result.mappings().all() - return { - "hotel_id": hotel_id, - "hotel_name": hotel["name"], - "from_date": str(from_date), - "to_date": str(to_date), - "dates": dates, - } + # Stock = max availability ever seen per room (across all history) + stock_result = await db.execute( + text("""SELECT room_id, MAX(availability) AS stock + FROM direct_rates WHERE hotel_id = :hid GROUP BY room_id"""), + {"hid": hotel_id} + ) + room_stock = {r["room_id"]: r["stock"] for r in stock_result.mappings().all()} + + bench_room = hotel["benchmark_room"] or "" + bench_rate = hotel["benchmark_rate"] or "" + offsets = hotel["tier_offsets"] or {} + max_rooms = sum(v for v in room_stock.values() if v) or None + + # Group by date; availability counted once per room (max across its rate plans) + by_date: dict = {} + for r in rows: + d = by_date.setdefault(r["stay_date"], { + "room_avail": {}, "bench_prices": {}, "prices": [], + "min_stay": None, "scraped_at": None, + }) + room_avail = d["room_avail"] + room_avail[r["room_id"]] = max(room_avail.get(r["room_id"], 0), r["availability"] or 0) + if r["price_incl"] is not None and (r["availability"] or 0) > 0: + d["prices"].append(float(r["price_incl"])) + if r["rate_id"] == bench_rate and r["price_incl"] is not None: + d["bench_prices"][r["room_id"]] = r["price_incl"] + if r["min_stay_nights"] and (d["min_stay"] is None or r["min_stay_nights"] > d["min_stay"]): + d["min_stay"] = r["min_stay_nights"] + if d["scraped_at"] is None or r["scraped_at"] > d["scraped_at"]: + d["scraped_at"] = r["scraped_at"] + + dates = [] + for stay_date in sorted(by_date): + d = by_date[stay_date] + total_avail = sum(d["room_avail"].values()) + bench_price, bench_calc = _resolve_bench(d["bench_prices"], bench_room, offsets) + dates.append({ + "stay_date": stay_date, + "total_avail": total_avail, + "cheapest_rate": min(d["prices"]) if d["prices"] else None, + "bench_rate": round(bench_price, 2) if bench_price is not None else None, + "bench_calculated": bench_calc, + "has_availability": total_avail > 0, + "min_stay_nights": d["min_stay"], + "has_min_stay": bool(d["min_stay"] and d["min_stay"] > 1), + "scraped_at": d["scraped_at"], + }) + + return { + "hotel_id": hotel_id, + "hotel_name": hotel["name"], + "from_date": str(from_date), + "to_date": str(to_date), + "max_rooms": max_rooms, + "room_labels": hotel["room_labels"] or {}, + "rate_labels": hotel["rate_labels"] or {}, + "benchmark_room": bench_room or None, + "benchmark_rate": bench_rate or None, + "dates": dates, + } @router.get("/hotels/{hotel_id}/date/{rate_date}/rooms") @@ -264,7 +333,7 @@ async def hotel_date_rooms( require_cap(user, "view_direct_rates") async with AsyncSessionLocal() as db: cfg_row = await db.execute( - text("SELECT room_labels, rate_labels, tier_offsets, tier_base_room, benchmark_room, benchmark_rate FROM direct_competitor_hotels WHERE id = :id"), + text("SELECT room_labels, rate_labels, room_order, tier_offsets, tier_base_room, benchmark_room, benchmark_rate FROM direct_competitor_hotels WHERE id = :id"), {"id": hotel_id} ) hotel = cfg_row.mappings().fetchone() @@ -283,55 +352,280 @@ async def hotel_date_rooms( """), {"hid": hotel_id, "sd": rate_date} ) - rooms = [dict(r) for r in rooms_result.mappings().all()] + rows = [dict(r) for r in rooms_result.mappings().all()] - # Resolve tier-normalised benchmark rates - room_labels = hotel["room_labels"] or {} - rate_labels = hotel["rate_labels"] or {} - tier_offsets = hotel["tier_offsets"] or {} - tier_base_room = hotel["tier_base_room"] - benchmark_room = hotel["benchmark_room"] - benchmark_rate = hotel["benchmark_rate"] + # Stock = max availability ever seen per room + stock_result = await db.execute( + text("""SELECT room_id, MAX(availability) AS stock + FROM direct_rates WHERE hotel_id = :hid GROUP BY room_id"""), + {"hid": hotel_id} + ) + room_stock = {r["room_id"]: r["stock"] for r in stock_result.mappings().all()} - # Find benchmark price - bench_price = None - if benchmark_room and benchmark_rate: - bench_match = next( - (r for r in rooms - if r["room_id"] == benchmark_room and r["rate_id"] == benchmark_rate - and r["availability"] > 0 and r["price_incl"]), - None - ) - if bench_match: - bench_price = float(bench_match["price_incl"]) - elif tier_base_room and tier_offsets: - base_match = next( - (r for r in rooms - if r["room_id"] == tier_base_room and r["availability"] > 0 and r["price_incl"]), - None - ) - if base_match: - base_price = float(base_match["price_incl"]) - bench_offset = tier_offsets.get(benchmark_room, 0) - base_offset = tier_offsets.get(tier_base_room, 0) - bench_price = base_price - base_offset + bench_offset + room_labels = hotel["room_labels"] or {} + rate_labels = hotel["rate_labels"] or {} + tier_offsets = hotel["tier_offsets"] or {} + benchmark_room = hotel["benchmark_room"] or "" + benchmark_rate = hotel["benchmark_rate"] or "" + bench_offset = tier_offsets.get(benchmark_room, 0) - enriched = [] - for r in rooms: - r["room_label"] = room_labels.get(r["room_id"], r["room_id"]) - r["rate_label"] = rate_labels.get(r["rate_id"], r["rate_id"]) - # Derive bench_rate for this room from tier offsets - r["bench_rate"] = None - if bench_price is not None and tier_offsets and tier_base_room: - room_offset = tier_offsets.get(r["room_id"]) - bench_offset = tier_offsets.get(benchmark_room, 0) - if room_offset is not None: - r["bench_rate"] = round(bench_price + (room_offset - bench_offset), 2) - enriched.append(r) + # Group flat room×rate rows into one entry per room + rooms: dict = {} + for r in rows: + rid = r["room_id"] + room = rooms.setdefault(rid, { + "room_id": rid, + "room_label": room_labels.get(rid, rid), + "availability": r["availability"], + "stock": room_stock.get(rid), + "best_rate": None, + "bench_rate": None, + "bench_calculated": False, + "min_stay_nights": r["min_stay_nights"], + "unavailable": False, + "rates": [], + }) + room["availability"] = max(room["availability"] or 0, r["availability"] or 0) + if r["price_incl"] is not None: + p = float(r["price_incl"]) + if room["best_rate"] is None or p < room["best_rate"]: + room["best_rate"] = p + if r["min_stay_nights"] and not room["min_stay_nights"]: + room["min_stay_nights"] = r["min_stay_nights"] + room["rates"].append({ + "rate_id": r["rate_id"], + "rate_label": rate_labels.get(r["rate_id"], r["rate_id"]), + "price_incl": r["price_incl"], + "price_excl": r["price_excl"], + "min_stay_nights": r["min_stay_nights"], + }) - return { - "hotel_id": hotel_id, - "date": str(rate_date), - "bench_price": bench_price, - "rooms": enriched, + # Resolve the benchmark room's price today from whatever rooms are present + bench_prices = {} + for rid, room in rooms.items(): + p = next((rt["price_incl"] for rt in room["rates"] if rt["rate_id"] == benchmark_rate), None) + if p is not None: + bench_prices[rid] = p + bench_price, _ = _resolve_bench(bench_prices, benchmark_room, tier_offsets) + + # Per-room benchmark: direct quote if present, else estimate via tier offsets + for rid, room in rooms.items(): + direct = bench_prices.get(rid) + room_offset = tier_offsets.get(rid) + if direct is not None: + room["bench_rate"] = round(float(direct), 2) + elif bench_price is not None and room_offset is not None: + room["bench_rate"] = round(bench_price + (room_offset - bench_offset), 2) + room["bench_calculated"] = True + + # Inject known rooms absent from this date's snapshot (no availability at all) + for rid in set(room_stock) - set(rooms): + room_offset = tier_offsets.get(rid) + derived = round(bench_price + (room_offset - bench_offset), 2) \ + if bench_price is not None and room_offset is not None else None + rooms[rid] = { + "room_id": rid, + "room_label": room_labels.get(rid, rid), + "availability": None, + "stock": room_stock.get(rid), + "best_rate": None, + "bench_rate": derived, + "bench_calculated": derived is not None, + "min_stay_nights": None, + "unavailable": True, + "rates": [], } + + # Sort by configured room_order, then by stock (largest first) + room_order = hotel["room_order"] or [] + + def sort_key(r): + try: + return (0, room_order.index(r["room_id"]), 0) + except ValueError: + return (1, -(r["stock"] or 0), 0) + + return { + "hotel_id": hotel_id, + "date": str(rate_date), + "bench_price": bench_price, + "rooms": sorted(rooms.values(), key=sort_key), + } + + +# ─── Rate history ───────────────────────────────────────────────────────────── + +@router.get("/hotels/{hotel_id}/history/{stay_date}") +async def rate_history( + hotel_id: int, + stay_date: date, + room_id: Optional[str] = None, + rate_id: Optional[str] = None, + mode: Optional[str] = None, + user=Depends(get_current_user) +): + """ + Time series of tracked rates for one stay date, across scrape runs. + - room_id + rate_id: history for that exact room/rate combo + - mode=benchmark: benchmark-room price history (tier-resolved) + - default: total availability + cheapest rate per scrape + """ + require_cap(user, "view_direct_rates") + async with AsyncSessionLocal() as db: + cfg_row = await db.execute( + text("SELECT benchmark_room, benchmark_rate, tier_offsets FROM direct_competitor_hotels WHERE id = :id"), + {"id": hotel_id} + ) + hotel = cfg_row.mappings().fetchone() + if not hotel: + raise HTTPException(status_code=404, detail="Hotel not found") + + if room_id and rate_id: + result = await db.execute( + text("""SELECT scraped_at, availability, price_incl + FROM direct_rates + WHERE hotel_id = :hid AND stay_date = :sd + AND room_id = :room AND rate_id = :rate + ORDER BY scraped_at"""), + {"hid": hotel_id, "sd": stay_date, "room": room_id, "rate": rate_id} + ) + return [dict(r) for r in result.mappings().all()] + + result = await db.execute( + text("""SELECT scraped_at, room_id, rate_id, availability, price_incl + FROM direct_rates + WHERE hotel_id = :hid AND stay_date = :sd + ORDER BY scraped_at"""), + {"hid": hotel_id, "sd": stay_date} + ) + rows = result.mappings().all() + + bench_room = hotel["benchmark_room"] or "" + bench_rate = hotel["benchmark_rate"] or "" + offsets = hotel["tier_offsets"] or {} + + # Group by scrape timestamp + by_scrape: dict = {} + for r in rows: + s = by_scrape.setdefault(r["scraped_at"], {"room_avail": {}, "bench_prices": {}, "prices": []}) + s["room_avail"][r["room_id"]] = max(s["room_avail"].get(r["room_id"], 0), r["availability"] or 0) + if r["price_incl"] is not None: + s["prices"].append(float(r["price_incl"])) + if r["rate_id"] == bench_rate: + s["bench_prices"][r["room_id"]] = r["price_incl"] + + series = [] + for scraped_at in sorted(by_scrape): + s = by_scrape[scraped_at] + if mode == "benchmark": + price, calc = _resolve_bench(s["bench_prices"], bench_room, offsets) + series.append({ + "scraped_at": scraped_at, + "price_incl": round(price, 2) if price is not None else None, + "calculated": calc, + }) + else: + series.append({ + "scraped_at": scraped_at, + "availability": sum(s["room_avail"].values()), + "price_incl": min(s["prices"]) if s["prices"] else None, + }) + return series + + +# ─── Per-room stats ─────────────────────────────────────────────────────────── + +STAT_WINDOWS = { + "this_week": 7, + "this_month": 30, + "next_6mo": 182, + "next_12mo": 365, +} + + +@router.get("/hotels/{hotel_id}/stats") +async def hotel_stats(hotel_id: int, user=Depends(get_current_user)): + """ + Per-room stats on the benchmark rate across time windows: stock (max + availability ever seen), avg rate, current occupancy vs stock, and + estimated final occupancy from the equivalent look-back window (past + dates no longer change, so they reflect completed bookings). + """ + require_cap(user, "view_direct_rates") + today = date.today() + + async with AsyncSessionLocal() as db: + cfg_row = await db.execute( + text("SELECT room_labels, benchmark_rate FROM direct_competitor_hotels WHERE id = :id"), + {"id": hotel_id} + ) + hotel = cfg_row.mappings().fetchone() + if not hotel: + raise HTTPException(status_code=404, detail="Hotel not found") + + stock_result = await db.execute( + text("""SELECT room_id, MAX(availability) AS stock + FROM direct_rates WHERE hotel_id = :hid GROUP BY room_id"""), + {"hid": hotel_id} + ) + stock = {r["room_id"]: r["stock"] for r in stock_result.mappings().all()} + + bench_rate = hotel["benchmark_rate"] or "" + result = await db.execute( + text("""SELECT DISTINCT ON (stay_date, room_id) + stay_date, room_id, availability, price_incl + FROM direct_rates + WHERE hotel_id = :hid AND rate_id = :rate + AND stay_date BETWEEN :fd AND :td + ORDER BY stay_date, room_id, scraped_at DESC"""), + {"hid": hotel_id, "rate": bench_rate, + "fd": today - timedelta(days=365), "td": today + timedelta(days=365)} + ) + rows = result.mappings().all() + + room_labels = hotel["room_labels"] or {} + by_room: dict = {} + for r in rows: + by_room.setdefault(r["room_id"], []).append(r) + + result_out = {} + for room_id, room_stock_val in stock.items(): + room_rows = by_room.get(room_id, []) + room_stock_safe = room_stock_val or 1 + windows = {} + for wname, days in STAT_WINDOWS.items(): + future = [r for r in room_rows if today <= r["stay_date"] < today + timedelta(days=days)] + past = [r for r in room_rows if today - timedelta(days=days) <= r["stay_date"] < today] + + if not future and not past: + windows[wname] = None + continue + + prices = [float(r["price_incl"]) for r in future if r["price_incl"] is not None] + avg_price = round(sum(prices) / len(prices), 2) if prices else None + + avg_occ = None + if future: + avg_avail = sum(r["availability"] or 0 for r in future) / len(future) + avg_occ = round((1 - avg_avail / room_stock_safe) * 100, 1) + + est_final_occ = None + if past: + past_sold = sum(room_stock_safe - (r["availability"] or 0) for r in past) + est_final_occ = round(past_sold / (len(past) * room_stock_safe) * 100, 1) + + windows[wname] = { + "dates": len(future), + "avg_price": avg_price, + "avg_occ": avg_occ, + "past_dates": len(past), + "est_final_occ": est_final_occ, + } + + result_out[room_id] = { + "stock": room_stock_val, + "room_label": room_labels.get(room_id, room_id), + "windows": windows, + } + + return result_out diff --git a/backend/migrate_direct_data.py b/backend/migrate_direct_data.py index e0de24d..606d471 100644 --- a/backend/migrate_direct_data.py +++ b/backend/migrate_direct_data.py @@ -10,6 +10,7 @@ import sys from datetime import datetime, timezone import psycopg2 +import psycopg2.extras ARCHIVE_DIR = os.environ.get( "GUESTLINE_ARCHIVE", @@ -37,7 +38,7 @@ def migrate(): src = sqlite3.connect(CONFIG_DB) src.row_factory = dict_row hotels = src.execute( - """SELECT id, name, profile, params, room_labels, rate_labels, + """SELECT id, name, engine_profile, params, room_labels, rate_labels, room_order, benchmark_room, benchmark_rate, tier_base_room, tier_offsets, scrape_enabled, last_scraped_at FROM hotels ORDER BY id""" @@ -65,7 +66,7 @@ def migrate(): RETURNING id""", ( h["name"], - h["profile"], + h["engine_profile"], params, h["room_labels"] or "{}", h["rate_labels"] or "{}", @@ -163,5 +164,4 @@ def migrate(): if __name__ == "__main__": - import psycopg2.extras migrate() diff --git a/frontend/src/pages/DirectRates.tsx b/frontend/src/pages/DirectRates.tsx index 6652d9d..34fb610 100644 --- a/frontend/src/pages/DirectRates.tsx +++ b/frontend/src/pages/DirectRates.tsx @@ -1,9 +1,10 @@ import React, { useState } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import Plot from 'react-plotly.js' import { - Building2, RefreshCw, Plus, Settings, ChevronDown, ChevronRight, - AlertCircle, CheckCircle, Clock, + Building2, RefreshCw, Plus, ChevronDown, ChevronRight, + Clock, LineChart, X, } from 'lucide-react' import api from '../api' import { useAuth } from '../components/AuthGate' @@ -12,8 +13,11 @@ import { can } from '../types' const fmtDate = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` -const TABS = ['overview', 'hotel', 'manage'] as const +const TABS = ['overview', 'hotel', 'stats', 'manage'] as const type Tab = typeof TABS[number] +const TAB_LABELS: Record = { + overview: 'Overview', hotel: 'Hotel Detail', stats: 'Room Stats', manage: 'Manage', +} interface DirectHotel { id: number @@ -27,30 +31,115 @@ interface DirectHotel { interface DateRow { stay_date: string + total_avail: number cheapest_rate: number | null + bench_rate: number | null + bench_calculated: boolean has_availability: boolean + min_stay_nights: number | null has_min_stay: boolean scraped_at: string | null } -interface RoomRow { - room_id: string - rate_id: string - room_label: string - rate_label: string - availability: number - price_incl: number | null - min_stay_nights: number | null - bench_rate: number | null +interface DatesResponse { + hotel_name: string + max_rooms: number | null + benchmark_room: string | null + benchmark_rate: string | null + room_labels: Record + rate_labels: Record + dates: DateRow[] } -const fmt = (v: number | null) => v != null ? `£${v.toFixed(2)}` : '—' +interface RateEntry { + rate_id: string + rate_label: string + price_incl: number | null + price_excl: number | null + min_stay_nights: number | null +} + +interface RoomGroup { + room_id: string + room_label: string + availability: number | null + stock: number | null + best_rate: number | null + bench_rate: number | null + bench_calculated: boolean + min_stay_nights: number | null + unavailable: boolean + rates: RateEntry[] +} + +interface HistoryPoint { + scraped_at: string + availability?: number + price_incl: number | null + calculated?: boolean +} + +interface StatsWindow { + dates: number + avg_price: number | null + avg_occ: number | null + past_dates: number + est_final_occ: number | null +} + +interface RoomStats { + stock: number | null + room_label: string + windows: Record +} + +interface HistoryTarget { + date: string + roomId?: string + rateId?: string + mode: 'overall' | 'benchmark' | 'rate' + title: string +} + +const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] +const fmt = (v: number | null | undefined) => v != null ? `£${Number(v).toFixed(2)}` : '—' const age = (ts: string | null) => { if (!ts) return 'Never' const h = Math.round((Date.now() - new Date(ts).getTime()) / 3600000) return h < 24 ? `${h}h ago` : `${Math.round(h / 24)}d ago` } +const occColor = (pct: number) => + pct >= 80 ? 'var(--danger)' : pct >= 50 ? 'var(--warning)' : 'var(--success)' + +function OccupancyPill({ avail, stock }: { avail: number; stock: number | null }) { + if (!stock) return null + const pct = Math.round((1 - avail / stock) * 100) + return ( + = 80 ? '#fee2e2' : pct >= 50 ? '#fef3c7' : '#dcfce7', + color: occColor(pct), + }}> + {pct}% sold + + ) +} + +function OccBar({ pct }: { pct: number | null }) { + if (pct == null) return null + return ( + + + + ) +} + export default function DirectRates() { const { hotelId } = useParams<{ hotelId?: string }>() const navigate = useNavigate() @@ -62,13 +151,14 @@ export default function DirectRates() { const [expandedDate, setExpandedDate] = useState(null) const [fromDate, setFromDate] = useState(fmtDate(new Date())) const [toDate, setToDate] = useState(fmtDate(new Date(Date.now() + 89 * 86400000))) + const [history, setHistory] = useState(null) const { data: hotels, isLoading: hotelsLoading } = useQuery({ queryKey: ['direct-hotels'], queryFn: () => api.get('/direct/hotels').then(r => r.data), }) - const { data: dates, isLoading: datesLoading } = useQuery<{ dates: DateRow[]; hotel_name: string }>({ + const { data: dates, isLoading: datesLoading } = useQuery({ queryKey: ['direct-dates', selectedHotel, fromDate, toDate], queryFn: () => api.get(`/direct/hotels/${selectedHotel}/dates`, { params: { from_date: fromDate, to_date: toDate } @@ -76,7 +166,7 @@ export default function DirectRates() { enabled: !!selectedHotel && tab === 'hotel', }) - const { data: roomData } = useQuery<{ rooms: RoomRow[]; bench_price: number | null }>({ + const { data: roomData } = useQuery<{ rooms: RoomGroup[]; bench_price: number | null }>({ queryKey: ['direct-rooms', selectedHotel, expandedDate], queryFn: () => api.get(`/direct/hotels/${selectedHotel}/date/${expandedDate}/rooms`).then(r => r.data), enabled: !!selectedHotel && !!expandedDate, @@ -90,7 +180,6 @@ export default function DirectRates() { const selectHotel = (id: number) => { setSelectedHotel(id) - setTab('hotel') setExpandedDate(null) navigate(`/direct/${id}`) } @@ -113,7 +202,7 @@ export default function DirectRates() { {TABS.map(t => ( ))} @@ -122,7 +211,7 @@ export default function DirectRates() { { selectHotel(id); setTab('hotel') }} onScrape={id => scrapeMutation.mutate(id)} scraping={scrapeMutation.isPending} /> @@ -133,8 +222,7 @@ export default function DirectRates() { hotels={hotels || []} selectedHotel={selectedHotel} onSelectHotel={selectHotel} - dates={dates?.dates || []} - hotelName={dates?.hotel_name} + data={dates} datesLoading={datesLoading} fromDate={fromDate} toDate={toDate} @@ -144,6 +232,15 @@ export default function DirectRates() { expandedDate={expandedDate} setExpandedDate={setExpandedDate} roomData={roomData} + onShowHistory={setHistory} + /> + )} + + {tab === 'stats' && ( + )} @@ -153,6 +250,10 @@ export default function DirectRates() { {tab === 'manage' && !canManage && (
You don't have permission to manage competitor hotels.
)} + + {history && selectedHotel && ( + setHistory(null)} /> + )} ) } @@ -222,28 +323,58 @@ function OverviewTab({ hotels, loading, onSelectHotel, onScrape, scraping }: { ) } +// ─── Hotel selector row ─────────────────────────────────────────────────────── + +function HotelSelector({ hotels, selectedHotel, onSelectHotel }: { + hotels: DirectHotel[] + selectedHotel: number | null + onSelectHotel: (id: number) => void +}) { + return ( +
+ + +
+ ) +} + // ─── Hotel Detail Tab ───────────────────────────────────────────────────────── -function HotelDetailTab({ hotels, selectedHotel, onSelectHotel, dates, hotelName, datesLoading, - fromDate, toDate, setFromDate, setToDate, presets, expandedDate, setExpandedDate, roomData }: any) { +function HotelDetailTab({ hotels, selectedHotel, onSelectHotel, data, datesLoading, + fromDate, toDate, setFromDate, setToDate, presets, expandedDate, setExpandedDate, + roomData, onShowHistory }: { + hotels: DirectHotel[] + selectedHotel: number | null + onSelectHotel: (id: number) => void + data: DatesResponse | undefined + datesLoading: boolean + fromDate: string + toDate: string + setFromDate: (v: string) => void + setToDate: (v: string) => void + presets: { label: string; days: number }[] + expandedDate: string | null + setExpandedDate: (v: string | null) => void + roomData: { rooms: RoomGroup[]; bench_price: number | null } | undefined + onShowHistory: (t: HistoryTarget) => void +}) { + const dates = data?.dates || [] + const maxRooms = data?.max_rooms || null return (
{/* Controls */}
-
- - -
+
setFromDate(e.target.value)} /> @@ -253,7 +384,7 @@ function HotelDetailTab({ hotels, selectedHotel, onSelectHotel, dates, hotelName setToDate(e.target.value)} />
- {presets.map((p: any) => ( + {presets.map(p => (
) } -function RoomBreakdown({ rooms, benchPrice }: { rooms: RoomRow[]; benchPrice: number | null }) { +function RoomBreakdown({ rooms, date, onShowHistory }: { + rooms: RoomGroup[] + date: string + onShowHistory: (t: HistoryTarget) => void +}) { + const [expandedRoom, setExpandedRoom] = useState(null) + return ( + - - + + - {rooms.map((r, i) => ( - - - - - - - - - ))} + {rooms.map(r => { + const roomFull = !r.unavailable && r.availability === 0 + const minStay = !!(r.min_stay_nights && r.min_stay_nights > 1) + const expanded = expandedRoom === r.room_id + return ( + + !r.unavailable && setExpandedRoom(expanded ? null : r.room_id)} + style={{ cursor: r.unavailable ? 'default' : 'pointer' }} + > + + + + + + + + + {expanded && r.rates.map(rate => ( + + + + + + + + + ))} + + ) + })}
RoomRate Plan AvailPriceOccupancyBest Rate Bench Rate Min-Stay
{r.room_label}{r.rate_label}{r.availability > 0 ? : }{fmt(r.price_incl)}{fmt(r.bench_rate)}{r.min_stay_nights && r.min_stay_nights > 1 ? {r.min_stay_nights}N : null}
+ {!r.unavailable && (expanded + ? + : )} + {r.room_label} + {r.unavailable + ? N/A + : roomFull + ? FULL + : minStay + ? {r.min_stay_nights}N min + : {r.availability}{r.stock ? / {r.stock} : null}} + + {roomFull + ? Sold Out + : (!r.unavailable && !minStay && r.availability != null) + ? + : null} + + {(roomFull || minStay) && r.best_rate != null + ? est. {fmt(r.best_rate)} + : fmt(r.best_rate)} + + {r.bench_rate != null + ? (r.bench_calculated ? {fmt(r.bench_rate)}* : fmt(r.bench_rate)) + : '—'} + {minStay ? {r.min_stay_nights}N : null}
{rate.rate_label}{fmt(rate.price_incl)} incl / {fmt(rate.price_excl)} excl + +
) } +// ─── History Modal ──────────────────────────────────────────────────────────── + +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: 48, b: 48, l: 48 }, + xaxis: { gridcolor: '#e5e9f0', zeroline: false }, + yaxis: { gridcolor: '#e5e9f0', zeroline: false }, +} + +function HistoryModal({ hotelId, target, onClose }: { + hotelId: number + target: HistoryTarget + onClose: () => void +}) { + const { data: series, isLoading } = useQuery({ + queryKey: ['direct-history', hotelId, target.date, target.roomId, target.rateId, target.mode], + queryFn: () => api.get(`/direct/hotels/${hotelId}/history/${target.date}`, { + params: { + ...(target.roomId ? { room_id: target.roomId } : {}), + ...(target.rateId ? { rate_id: target.rateId } : {}), + ...(target.mode === 'benchmark' ? { mode: 'benchmark' } : {}), + }, + }).then(r => r.data), + }) + + const x = (series || []).map(p => p.scraped_at) + const priceTrace = { + x, y: (series || []).map(p => p.price_incl ?? null), + type: 'scatter' as const, mode: 'lines+markers' as const, + name: target.mode === 'benchmark' ? 'Benchmark rate' : target.mode === 'rate' ? 'Rate' : 'Cheapest rate', + line: { color: '#c9a84c', width: 2 }, + marker: { size: 6 }, + } + const traces: any[] = [priceTrace] + if (target.mode !== 'benchmark') { + traces.push({ + x, y: (series || []).map(p => p.availability ?? null), + type: 'scatter' as const, mode: 'lines+markers' as const, + name: 'Availability', yaxis: 'y2', + line: { color: '#2563eb', width: 2, dash: 'dot' }, + marker: { size: 5 }, + }) + } + + return ( +
+
e.stopPropagation()} + > +
+ {target.title} + +
+
+ {isLoading &&
Loading…
} + {!isLoading && (!series || series.length === 0) && ( +
No history recorded yet for this date.
+ )} + {!isLoading && series && series.length > 0 && ( + + )} + {target.mode === 'benchmark' && series?.some(p => p.calculated) && ( +
+ Some points are estimated from other room types via tier offsets. +
+ )} +
+
+
+ ) +} + +// ─── Room Stats Tab ─────────────────────────────────────────────────────────── + +const STAT_WINDOWS = [ + { key: 'this_week', label: 'This Week (7 days)' }, + { key: 'this_month', label: 'This Month (30 days)' }, + { key: 'next_6mo', label: 'Next 6 Months' }, + { key: 'next_12mo', label: 'Next 12 Months' }, +] + +function RoomStatsTab({ hotels, selectedHotel, onSelectHotel }: { + hotels: DirectHotel[] + selectedHotel: number | null + onSelectHotel: (id: number) => void +}) { + const { data: stats, isLoading } = useQuery>({ + queryKey: ['direct-stats', selectedHotel], + queryFn: () => api.get(`/direct/hotels/${selectedHotel}/stats`).then(r => r.data), + enabled: !!selectedHotel, + }) + + const roomIds = stats + ? Object.keys(stats).sort((a, b) => (stats[b].stock || 0) - (stats[a].stock || 0)) + : [] + + return ( +
+
+ +
+ +
+ Per-room breakdown on the benchmark rate. Stock is the highest availability ever + observed for the room. Current Occ compares the latest snapshot against stock across + upcoming dates. Est. Final Occ uses the equivalent look-back window — past dates no + longer change, so they reflect completed bookings. +
+ + {!selectedHotel &&
Select a hotel to view room stats.
} + {selectedHotel && isLoading &&
Loading…
} + {selectedHotel && !isLoading && roomIds.length === 0 && ( +
No data yet — run a scrape first.
+ )} + + {selectedHotel && !isLoading && roomIds.length > 0 && STAT_WINDOWS.map(win => ( +
+
{win.label}
+
+ + + + + + + + + + + + + {roomIds.map(rid => { + const room = stats![rid] + const w = room.windows[win.key] + if (!w) return ( + + + + + + ) + return ( + + + + + + + + + ) + })} + +
Room TypeStockAvg RateCurrent Occ %Est. Final Occ %Dates
{room.room_label}{room.stock ?? '—'}No data
{room.room_label}{room.stock ?? '—'}{w.avg_price != null ? fmt(w.avg_price) : '—'} + {w.avg_occ != null + ? {w.avg_occ}% + : '—'} + + {w.est_final_occ != null + ? {w.est_final_occ}% + : no past dates} + + {w.dates} ahead{w.past_dates ? ` · ${w.past_dates} past` : ''} +
+
+
+ ))} +
+ ) +} + // ─── Manage Tab ─────────────────────────────────────────────────────────────── function ManageTab({ hotels, onRefresh }: { hotels: DirectHotel[]; onRefresh: () => void }) { const [showAdd, setShowAdd] = useState(false) const [detectUrl, setDetectUrl] = useState('') const [detected, setDetected] = useState(null) - const [detecting, setDetecting] = useState(false) const [newName, setNewName] = useState('') const [extraParams, setExtraParams] = useState>({}) - const [profiles, setProfiles] = useState([]) const qc = useQueryClient() const { data: profileList } = useQuery({