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 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-06 06:50:37 +00:00
parent 74ae94671b
commit 20d939de00
3 changed files with 908 additions and 173 deletions

View file

@ -1,7 +1,6 @@
""" """
Direct booking engine API hotel management, discovery, scrape control, rate data. Direct booking engine API hotel management, discovery, scrape control, rate data.
""" """
import asyncio
import json import json
import logging import logging
from datetime import date, timedelta from datetime import date, timedelta
@ -11,7 +10,7 @@ from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import text from sqlalchemy import text
from database import AsyncSessionLocal, SyncSessionLocal from database import AsyncSessionLocal
from auth import get_current_user, require_cap from auth import get_current_user, require_cap
from services.direct_profiles import PROFILES, detect_profile, get_profile from services.direct_profiles import PROFILES, detect_profile, get_profile
from services.direct_scraper import run_discovery, get_discovery_status, run_scrape from services.direct_scraper import run_discovery, get_discovery_status, run_scrape
@ -45,6 +44,37 @@ class DetectRequest(BaseModel):
url: str 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 ──────────────────────────────────────────────────────────────── # ─── Profiles ────────────────────────────────────────────────────────────────
@router.get("/profiles") @router.get("/profiles")
@ -161,10 +191,7 @@ async def trigger_discovery(hotel_id: int, background_tasks: BackgroundTasks, us
if not hotel: if not hotel:
raise HTTPException(status_code=404, detail="Hotel not found") raise HTTPException(status_code=404, detail="Hotel not found")
background_tasks.add_task( background_tasks.add_task(run_discovery, hotel_id, hotel["profile_name"], hotel["params"])
asyncio.get_event_loop().run_until_complete,
run_discovery(hotel_id, hotel["profile_name"], hotel["params"])
)
return {"status": "discovery started", "hotel_id": hotel_id} return {"status": "discovery started", "hotel_id": hotel_id}
@ -220,37 +247,79 @@ async def hotel_dates(
if not hotel: if not hotel:
raise HTTPException(status_code=404, detail="Hotel not found") 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( rates_result = await db.execute(
text(""" text("""
SELECT SELECT DISTINCT ON (stay_date, room_id, rate_id)
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, stay_date, room_id, rate_id, availability,
price_incl, min_stay_nights, scraped_at price_incl, min_stay_nights, scraped_at
FROM direct_rates FROM direct_rates
WHERE hotel_id = :hid WHERE hotel_id = :hid
AND stay_date BETWEEN :fd AND :td AND stay_date BETWEEN :fd AND :td
ORDER BY room_id, rate_id, scraped_at DESC ORDER BY stay_date, room_id, rate_id, scraped_at DESC
) r
GROUP BY r.stay_date
ORDER BY r.stay_date
"""), """),
{"hid": hotel_id, "fd": from_date, "td": to_date} {"hid": hotel_id, "fd": from_date, "td": to_date}
) )
dates = [dict(r) for r in rates_result.mappings().all()] rows = rates_result.mappings().all()
# 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 { return {
"hotel_id": hotel_id, "hotel_id": hotel_id,
"hotel_name": hotel["name"], "hotel_name": hotel["name"],
"from_date": str(from_date), "from_date": str(from_date),
"to_date": str(to_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, "dates": dates,
} }
@ -264,7 +333,7 @@ async def hotel_date_rooms(
require_cap(user, "view_direct_rates") require_cap(user, "view_direct_rates")
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
cfg_row = await db.execute( 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} {"id": hotel_id}
) )
hotel = cfg_row.mappings().fetchone() hotel = cfg_row.mappings().fetchone()
@ -283,55 +352,280 @@ async def hotel_date_rooms(
"""), """),
{"hid": hotel_id, "sd": rate_date} {"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()]
# 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()}
# Resolve tier-normalised benchmark rates
room_labels = hotel["room_labels"] or {} room_labels = hotel["room_labels"] or {}
rate_labels = hotel["rate_labels"] or {} rate_labels = hotel["rate_labels"] or {}
tier_offsets = hotel["tier_offsets"] or {} tier_offsets = hotel["tier_offsets"] or {}
tier_base_room = hotel["tier_base_room"] benchmark_room = hotel["benchmark_room"] or ""
benchmark_room = hotel["benchmark_room"] benchmark_rate = hotel["benchmark_rate"] or ""
benchmark_rate = hotel["benchmark_rate"]
# 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) 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
enriched = [] # Group flat room×rate rows into one entry per room
for r in rooms: rooms: dict = {}
r["room_label"] = room_labels.get(r["room_id"], r["room_id"]) for r in rows:
r["rate_label"] = rate_labels.get(r["rate_id"], r["rate_id"]) rid = r["room_id"]
# Derive bench_rate for this room from tier offsets room = rooms.setdefault(rid, {
r["bench_rate"] = None "room_id": rid,
if bench_price is not None and tier_offsets and tier_base_room: "room_label": room_labels.get(rid, rid),
room_offset = tier_offsets.get(r["room_id"]) "availability": r["availability"],
bench_offset = tier_offsets.get(benchmark_room, 0) "stock": room_stock.get(rid),
if room_offset is not None: "best_rate": None,
r["bench_rate"] = round(bench_price + (room_offset - bench_offset), 2) "bench_rate": None,
enriched.append(r) "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"],
})
# 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 { return {
"hotel_id": hotel_id, "hotel_id": hotel_id,
"date": str(rate_date), "date": str(rate_date),
"bench_price": bench_price, "bench_price": bench_price,
"rooms": enriched, "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

View file

@ -10,6 +10,7 @@ import sys
from datetime import datetime, timezone from datetime import datetime, timezone
import psycopg2 import psycopg2
import psycopg2.extras
ARCHIVE_DIR = os.environ.get( ARCHIVE_DIR = os.environ.get(
"GUESTLINE_ARCHIVE", "GUESTLINE_ARCHIVE",
@ -37,7 +38,7 @@ def migrate():
src = sqlite3.connect(CONFIG_DB) src = sqlite3.connect(CONFIG_DB)
src.row_factory = dict_row src.row_factory = dict_row
hotels = src.execute( 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, room_order, benchmark_room, benchmark_rate,
tier_base_room, tier_offsets, scrape_enabled, last_scraped_at tier_base_room, tier_offsets, scrape_enabled, last_scraped_at
FROM hotels ORDER BY id""" FROM hotels ORDER BY id"""
@ -65,7 +66,7 @@ def migrate():
RETURNING id""", RETURNING id""",
( (
h["name"], h["name"],
h["profile"], h["engine_profile"],
params, params,
h["room_labels"] or "{}", h["room_labels"] or "{}",
h["rate_labels"] or "{}", h["rate_labels"] or "{}",
@ -163,5 +164,4 @@ def migrate():
if __name__ == "__main__": if __name__ == "__main__":
import psycopg2.extras
migrate() migrate()

View file

@ -1,9 +1,10 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import Plot from 'react-plotly.js'
import { import {
Building2, RefreshCw, Plus, Settings, ChevronDown, ChevronRight, Building2, RefreshCw, Plus, ChevronDown, ChevronRight,
AlertCircle, CheckCircle, Clock, Clock, LineChart, X,
} from 'lucide-react' } from 'lucide-react'
import api from '../api' import api from '../api'
import { useAuth } from '../components/AuthGate' import { useAuth } from '../components/AuthGate'
@ -12,8 +13,11 @@ import { can } from '../types'
const fmtDate = (d: Date) => const fmtDate = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` `${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] type Tab = typeof TABS[number]
const TAB_LABELS: Record<Tab, string> = {
overview: 'Overview', hotel: 'Hotel Detail', stats: 'Room Stats', manage: 'Manage',
}
interface DirectHotel { interface DirectHotel {
id: number id: number
@ -27,30 +31,115 @@ interface DirectHotel {
interface DateRow { interface DateRow {
stay_date: string stay_date: string
total_avail: number
cheapest_rate: number | null cheapest_rate: number | null
bench_rate: number | null
bench_calculated: boolean
has_availability: boolean has_availability: boolean
min_stay_nights: number | null
has_min_stay: boolean has_min_stay: boolean
scraped_at: string | null scraped_at: string | null
} }
interface RoomRow { interface DatesResponse {
room_id: string hotel_name: string
rate_id: string max_rooms: number | null
room_label: string benchmark_room: string | null
rate_label: string benchmark_rate: string | null
availability: number room_labels: Record<string, string>
price_incl: number | null rate_labels: Record<string, string>
min_stay_nights: number | null dates: DateRow[]
bench_rate: number | null
} }
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<string, StatsWindow | null>
}
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) => { const age = (ts: string | null) => {
if (!ts) return 'Never' if (!ts) return 'Never'
const h = Math.round((Date.now() - new Date(ts).getTime()) / 3600000) const h = Math.round((Date.now() - new Date(ts).getTime()) / 3600000)
return h < 24 ? `${h}h ago` : `${Math.round(h / 24)}d ago` 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 (
<span className="badge" style={{
background: pct >= 80 ? '#fee2e2' : pct >= 50 ? '#fef3c7' : '#dcfce7',
color: occColor(pct),
}}>
{pct}% sold
</span>
)
}
function OccBar({ pct }: { pct: number | null }) {
if (pct == null) return null
return (
<span style={{
display: 'inline-block', width: 70, height: 6, background: 'var(--border)',
borderRadius: 3, verticalAlign: 'middle', marginLeft: 6,
}}>
<span style={{
display: 'block', height: '100%', borderRadius: 3,
width: `${Math.min(100, pct)}%`, background: occColor(pct),
}} />
</span>
)
}
export default function DirectRates() { export default function DirectRates() {
const { hotelId } = useParams<{ hotelId?: string }>() const { hotelId } = useParams<{ hotelId?: string }>()
const navigate = useNavigate() const navigate = useNavigate()
@ -62,13 +151,14 @@ export default function DirectRates() {
const [expandedDate, setExpandedDate] = useState<string | null>(null) const [expandedDate, setExpandedDate] = useState<string | null>(null)
const [fromDate, setFromDate] = useState(fmtDate(new Date())) const [fromDate, setFromDate] = useState(fmtDate(new Date()))
const [toDate, setToDate] = useState(fmtDate(new Date(Date.now() + 89 * 86400000))) const [toDate, setToDate] = useState(fmtDate(new Date(Date.now() + 89 * 86400000)))
const [history, setHistory] = useState<HistoryTarget | null>(null)
const { data: hotels, isLoading: hotelsLoading } = useQuery<DirectHotel[]>({ const { data: hotels, isLoading: hotelsLoading } = useQuery<DirectHotel[]>({
queryKey: ['direct-hotels'], queryKey: ['direct-hotels'],
queryFn: () => api.get('/direct/hotels').then(r => r.data), 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<DatesResponse>({
queryKey: ['direct-dates', selectedHotel, fromDate, toDate], queryKey: ['direct-dates', selectedHotel, fromDate, toDate],
queryFn: () => api.get(`/direct/hotels/${selectedHotel}/dates`, { queryFn: () => api.get(`/direct/hotels/${selectedHotel}/dates`, {
params: { from_date: fromDate, to_date: toDate } params: { from_date: fromDate, to_date: toDate }
@ -76,7 +166,7 @@ export default function DirectRates() {
enabled: !!selectedHotel && tab === 'hotel', 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], queryKey: ['direct-rooms', selectedHotel, expandedDate],
queryFn: () => api.get(`/direct/hotels/${selectedHotel}/date/${expandedDate}/rooms`).then(r => r.data), queryFn: () => api.get(`/direct/hotels/${selectedHotel}/date/${expandedDate}/rooms`).then(r => r.data),
enabled: !!selectedHotel && !!expandedDate, enabled: !!selectedHotel && !!expandedDate,
@ -90,7 +180,6 @@ export default function DirectRates() {
const selectHotel = (id: number) => { const selectHotel = (id: number) => {
setSelectedHotel(id) setSelectedHotel(id)
setTab('hotel')
setExpandedDate(null) setExpandedDate(null)
navigate(`/direct/${id}`) navigate(`/direct/${id}`)
} }
@ -113,7 +202,7 @@ export default function DirectRates() {
{TABS.map(t => ( {TABS.map(t => (
<button key={t} className={`sub-nav-item${tab === t ? ' active' : ''}`} <button key={t} className={`sub-nav-item${tab === t ? ' active' : ''}`}
onClick={() => setTab(t)}> onClick={() => setTab(t)}>
{t === 'overview' ? 'Overview' : t === 'hotel' ? 'Hotel Detail' : 'Manage'} {TAB_LABELS[t]}
</button> </button>
))} ))}
</div> </div>
@ -122,7 +211,7 @@ export default function DirectRates() {
<OverviewTab <OverviewTab
hotels={hotels || []} hotels={hotels || []}
loading={hotelsLoading} loading={hotelsLoading}
onSelectHotel={selectHotel} onSelectHotel={id => { selectHotel(id); setTab('hotel') }}
onScrape={id => scrapeMutation.mutate(id)} onScrape={id => scrapeMutation.mutate(id)}
scraping={scrapeMutation.isPending} scraping={scrapeMutation.isPending}
/> />
@ -133,8 +222,7 @@ export default function DirectRates() {
hotels={hotels || []} hotels={hotels || []}
selectedHotel={selectedHotel} selectedHotel={selectedHotel}
onSelectHotel={selectHotel} onSelectHotel={selectHotel}
dates={dates?.dates || []} data={dates}
hotelName={dates?.hotel_name}
datesLoading={datesLoading} datesLoading={datesLoading}
fromDate={fromDate} fromDate={fromDate}
toDate={toDate} toDate={toDate}
@ -144,6 +232,15 @@ export default function DirectRates() {
expandedDate={expandedDate} expandedDate={expandedDate}
setExpandedDate={setExpandedDate} setExpandedDate={setExpandedDate}
roomData={roomData} roomData={roomData}
onShowHistory={setHistory}
/>
)}
{tab === 'stats' && (
<RoomStatsTab
hotels={hotels || []}
selectedHotel={selectedHotel}
onSelectHotel={selectHotel}
/> />
)} )}
@ -153,6 +250,10 @@ export default function DirectRates() {
{tab === 'manage' && !canManage && ( {tab === 'manage' && !canManage && (
<div className="empty-state">You don't have permission to manage competitor hotels.</div> <div className="empty-state">You don't have permission to manage competitor hotels.</div>
)} )}
{history && selectedHotel && (
<HistoryModal hotelId={selectedHotel} target={history} onClose={() => setHistory(null)} />
)}
</div> </div>
) )
} }
@ -222,15 +323,14 @@ function OverviewTab({ hotels, loading, onSelectHotel, onScrape, scraping }: {
) )
} }
// ─── Hotel Detail Tab ───────────────────────────────────────────────────────── // ─── Hotel selector row ───────────────────────────────────────────────────────
function HotelDetailTab({ hotels, selectedHotel, onSelectHotel, dates, hotelName, datesLoading,
fromDate, toDate, setFromDate, setToDate, presets, expandedDate, setExpandedDate, roomData }: any) {
function HotelSelector({ hotels, selectedHotel, onSelectHotel }: {
hotels: DirectHotel[]
selectedHotel: number | null
onSelectHotel: (id: number) => void
}) {
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Controls */}
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div> <div>
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>Hotel</label> <label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>Hotel</label>
<select <select
@ -239,11 +339,42 @@ function HotelDetailTab({ hotels, selectedHotel, onSelectHotel, dates, hotelName
onChange={e => onSelectHotel(parseInt(e.target.value))} onChange={e => onSelectHotel(parseInt(e.target.value))}
> >
<option value="">Select a hotel</option> <option value="">Select a hotel</option>
{hotels.map((h: DirectHotel) => ( {hotels.map(h => (
<option key={h.id} value={h.id}>{h.name}</option> <option key={h.id} value={h.id}>{h.name}</option>
))} ))}
</select> </select>
</div> </div>
)
}
// ─── Hotel Detail Tab ─────────────────────────────────────────────────────────
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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Controls */}
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<HotelSelector hotels={hotels} selectedHotel={selectedHotel} onSelectHotel={onSelectHotel} />
<div> <div>
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>From</label> <label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>From</label>
<input type="date" style={{ width: 140 }} value={fromDate} onChange={e => setFromDate(e.target.value)} /> <input type="date" style={{ width: 140 }} value={fromDate} onChange={e => setFromDate(e.target.value)} />
@ -253,7 +384,7 @@ function HotelDetailTab({ hotels, selectedHotel, onSelectHotel, dates, hotelName
<input type="date" style={{ width: 140 }} value={toDate} onChange={e => setToDate(e.target.value)} /> <input type="date" style={{ width: 140 }} value={toDate} onChange={e => setToDate(e.target.value)} />
</div> </div>
<div style={{ display: 'flex', gap: 6 }}> <div style={{ display: 'flex', gap: 6 }}>
{presets.map((p: any) => ( {presets.map(p => (
<button key={p.label} className="btn btn-outline btn-sm" <button key={p.label} className="btn btn-outline btn-sm"
onClick={() => { onClick={() => {
const from = new Date(); const to = new Date(Date.now() + p.days * 86400000) const from = new Date(); const to = new Date(Date.now() + p.days * 86400000)
@ -273,21 +404,33 @@ function HotelDetailTab({ hotels, selectedHotel, onSelectHotel, dates, hotelName
{selectedHotel && !datesLoading && dates.length > 0 && ( {selectedHotel && !datesLoading && dates.length > 0 && (
<div className="card"> <div className="card">
<div className="card-header">{hotelName} {dates.length} dates</div> <div className="card-header">
{data?.hotel_name} {dates.length} dates
{maxRooms ? <span style={{ fontWeight: 400, color: 'var(--text-mid)', fontSize: 12, marginLeft: 8 }}>· est. {maxRooms} rooms total stock</span> : null}
</div>
<div className="table-wrap"> <div className="table-wrap">
<table> <table>
<thead> <thead>
<tr> <tr>
<th></th> <th></th>
<th>Date</th> <th>Date</th>
<th>Cheapest Rate</th> <th>Day</th>
<th>Availability</th> <th>Avail</th>
<th>Min-Stay</th> <th>Occupancy</th>
<th>Best Rate</th>
<th>Benchmark</th>
<th>Scraped</th> <th>Scraped</th>
<th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{dates.map((d: DateRow) => ( {dates.map(d => {
const day = new Date(d.stay_date + 'T12:00:00')
const dow = DAYS[day.getDay()]
const isWeekend = day.getDay() === 0 || day.getDay() === 5 || day.getDay() === 6
const isMinStay = !!(d.min_stay_nights && d.min_stay_nights > 1)
const isFull = d.total_avail === 0 && !isMinStay
return (
<React.Fragment key={d.stay_date}> <React.Fragment key={d.stay_date}>
<tr <tr
onClick={() => setExpandedDate(expandedDate === d.stay_date ? null : d.stay_date)} onClick={() => setExpandedDate(expandedDate === d.stay_date ? null : d.stay_date)}
@ -298,79 +441,377 @@ function HotelDetailTab({ hotels, selectedHotel, onSelectHotel, dates, hotelName
? <ChevronDown size={14} strokeWidth={1.75} /> ? <ChevronDown size={14} strokeWidth={1.75} />
: <ChevronRight size={14} strokeWidth={1.75} />} : <ChevronRight size={14} strokeWidth={1.75} />}
</td> </td>
<td>{d.stay_date}</td> <td style={{ fontWeight: 600 }}>{d.stay_date}</td>
<td style={{ color: isWeekend ? 'var(--gold)' : 'var(--text-mid)', fontSize: 12 }}>{dow}</td>
<td>
{isFull
? <span className="badge badge-danger">FULL</span>
: isMinStay
? <span className="badge badge-warning">{d.min_stay_nights}N min</span>
: <strong>{d.total_avail}</strong>}
</td>
<td>
{isFull
? <span className="badge badge-danger">Sold Out</span>
: isMinStay
? <span className="badge badge-warning">Min stay</span>
: <OccupancyPill avail={d.total_avail} stock={maxRooms} />}
</td>
<td style={{ fontWeight: 600 }}> <td style={{ fontWeight: 600 }}>
{d.cheapest_rate ? `£${Number(d.cheapest_rate).toFixed(2)}` : '—'} {(isFull || isMinStay) && d.cheapest_rate != null
? <span><span style={{ color: 'var(--text-mid)', fontSize: 11 }}>est. </span><em>{fmt(d.cheapest_rate)}</em></span>
: fmt(d.cheapest_rate)}
</td> </td>
<td> <td style={{ color: 'var(--text-mid)' }}>
{d.has_availability {d.bench_rate != null
? <CheckCircle size={14} strokeWidth={1.75} color="var(--success)" /> ? (d.bench_calculated || isMinStay ? <em>{fmt(d.bench_rate)}*</em> : fmt(d.bench_rate))
: <AlertCircle size={14} strokeWidth={1.75} color="var(--danger)" />} : '—'}
</td>
<td>
{d.has_min_stay
? <span className="badge badge-warning">Min-stay</span>
: null}
</td> </td>
<td style={{ fontSize: 11, color: 'var(--text-mid)' }}>{age(d.scraped_at)}</td> <td style={{ fontSize: 11, color: 'var(--text-mid)' }}>{age(d.scraped_at)}</td>
<td style={{ width: 60 }}>
<button
className="btn btn-outline btn-sm"
title="Rate & availability history"
onClick={e => {
e.stopPropagation()
onShowHistory({ date: d.stay_date, mode: 'overall', title: `History — ${d.stay_date}` })
}}
>
<LineChart size={12} strokeWidth={1.75} />
</button>
</td>
</tr> </tr>
{expandedDate === d.stay_date && roomData && ( {expandedDate === d.stay_date && roomData && (
<tr> <tr>
<td colSpan={6} style={{ background: '#f8fafc', padding: '8px 16px' }}> <td colSpan={9} style={{ background: '#f8fafc', padding: '8px 16px' }}>
<RoomBreakdown rooms={roomData.rooms} benchPrice={roomData.bench_price} /> <RoomBreakdown
rooms={roomData.rooms}
date={d.stay_date}
onShowHistory={onShowHistory}
/>
</td> </td>
</tr> </tr>
)} )}
</React.Fragment> </React.Fragment>
))} )
})}
</tbody> </tbody>
</table> </table>
</div> </div>
<div style={{ padding: '8px 16px', fontSize: 11, color: 'var(--text-mid)' }}>
* benchmark estimated from another room type via tier offsets. Occupancy is measured against the
highest availability ever observed per room (estimated stock).
</div>
</div> </div>
)} )}
</div> </div>
) )
} }
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<string | null>(null)
return ( return (
<table style={{ width: '100%', fontSize: 12 }}> <table style={{ width: '100%', fontSize: 12 }}>
<thead> <thead>
<tr> <tr>
<th></th>
<th>Room</th> <th>Room</th>
<th>Rate Plan</th>
<th>Avail</th> <th>Avail</th>
<th>Price</th> <th>Occupancy</th>
<th>Best Rate</th>
<th>Bench Rate</th> <th>Bench Rate</th>
<th>Min-Stay</th> <th>Min-Stay</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rooms.map((r, i) => ( {rooms.map(r => {
<tr key={i}> const roomFull = !r.unavailable && r.availability === 0
<td>{r.room_label}</td> const minStay = !!(r.min_stay_nights && r.min_stay_nights > 1)
<td style={{ color: 'var(--text-mid)' }}>{r.rate_label}</td> const expanded = expandedRoom === r.room_id
<td>{r.availability > 0 ? <CheckCircle size={12} strokeWidth={1.75} color="var(--success)" /> : <AlertCircle size={12} strokeWidth={1.75} color="var(--danger)" />}</td> return (
<td style={{ fontWeight: 600 }}>{fmt(r.price_incl)}</td> <React.Fragment key={r.room_id}>
<td style={{ color: 'var(--text-mid)' }}>{fmt(r.bench_rate)}</td> <tr
<td>{r.min_stay_nights && r.min_stay_nights > 1 ? <span className="badge badge-warning">{r.min_stay_nights}N</span> : null}</td> onClick={() => !r.unavailable && setExpandedRoom(expanded ? null : r.room_id)}
style={{ cursor: r.unavailable ? 'default' : 'pointer' }}
>
<td style={{ width: 20, color: 'var(--text-mid)' }}>
{!r.unavailable && (expanded
? <ChevronDown size={12} strokeWidth={1.75} />
: <ChevronRight size={12} strokeWidth={1.75} />)}
</td>
<td style={{ fontWeight: 600 }}>{r.room_label}</td>
<td>
{r.unavailable
? <span className="badge badge-neutral">N/A</span>
: roomFull
? <span className="badge badge-danger">FULL</span>
: minStay
? <span className="badge badge-warning">{r.min_stay_nights}N min</span>
: <span><strong>{r.availability}</strong>{r.stock ? <span style={{ color: 'var(--text-mid)' }}> / {r.stock}</span> : null}</span>}
</td>
<td>
{roomFull
? <span className="badge badge-danger">Sold Out</span>
: (!r.unavailable && !minStay && r.availability != null)
? <OccupancyPill avail={r.availability} stock={r.stock} />
: null}
</td>
<td style={{ fontWeight: 600 }}>
{(roomFull || minStay) && r.best_rate != null
? <span><span style={{ color: 'var(--text-mid)', fontSize: 11 }}>est. </span><em>{fmt(r.best_rate)}</em></span>
: fmt(r.best_rate)}
</td>
<td style={{ color: 'var(--text-mid)' }}>
{r.bench_rate != null
? (r.bench_calculated ? <em>{fmt(r.bench_rate)}*</em> : fmt(r.bench_rate))
: '—'}
</td>
<td>{minStay ? <span className="badge badge-warning">{r.min_stay_nights}N</span> : null}</td>
</tr>
{expanded && r.rates.map(rate => (
<tr key={rate.rate_id} style={{ background: '#f1f5f9' }}>
<td></td>
<td style={{ paddingLeft: 20, color: 'var(--text-mid)' }}>{rate.rate_label}</td>
<td colSpan={2}></td>
<td>{fmt(rate.price_incl)} incl <span style={{ color: 'var(--text-mid)' }}>/ {fmt(rate.price_excl)} excl</span></td>
<td></td>
<td>
<button
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--gold)', fontSize: 11, display: 'inline-flex', alignItems: 'center', gap: 4, padding: 0 }}
onClick={e => {
e.stopPropagation()
onShowHistory({
date, roomId: r.room_id, rateId: rate.rate_id, mode: 'rate',
title: `${r.room_label}${rate.rate_label}${date}`,
})
}}
>
<LineChart size={11} strokeWidth={1.75} />
history
</button>
</td>
</tr> </tr>
))} ))}
</React.Fragment>
)
})}
</tbody> </tbody>
</table> </table>
) )
} }
// ─── 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<HistoryPoint[]>({
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 (
<div
style={{
position: 'fixed', inset: 0, background: 'rgba(26,26,46,0.55)', zIndex: 100,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
onClick={onClose}
>
<div
className="card"
style={{ width: 'min(720px, 92vw)', maxHeight: '85vh', overflow: 'auto' }}
onClick={e => e.stopPropagation()}
>
<div className="card-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
{target.title}
<button style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-mid)' }} onClick={onClose}>
<X size={16} strokeWidth={1.75} />
</button>
</div>
<div style={{ padding: 16 }}>
{isLoading && <div className="loading-state"><div className="spinner" />Loading</div>}
{!isLoading && (!series || series.length === 0) && (
<div className="empty-state">No history recorded yet for this date.</div>
)}
{!isLoading && series && series.length > 0 && (
<Plot
data={traces}
layout={{
...PLOT_LAYOUT_BASE,
height: 320,
showlegend: target.mode !== 'benchmark',
legend: { orientation: 'h' as const, y: -0.25 },
yaxis: { ...PLOT_LAYOUT_BASE.yaxis, title: { text: '£ per night' } },
yaxis2: {
overlaying: 'y' as const, side: 'right' as const,
gridcolor: 'transparent', zeroline: false,
title: { text: 'rooms available' }, rangemode: 'tozero' as const,
},
}}
config={{ displayModeBar: false, responsive: true }}
style={{ width: '100%' }}
/>
)}
{target.mode === 'benchmark' && series?.some(p => p.calculated) && (
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 8 }}>
Some points are estimated from other room types via tier offsets.
</div>
)}
</div>
</div>
</div>
)
}
// ─── 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<Record<string, RoomStats>>({
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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-end' }}>
<HotelSelector hotels={hotels} selectedHotel={selectedHotel} onSelectHotel={onSelectHotel} />
</div>
<div style={{ fontSize: 12, color: 'var(--text-mid)', maxWidth: 720 }}>
Per-room breakdown on the benchmark rate. <strong>Stock</strong> is the highest availability ever
observed for the room. <strong>Current Occ</strong> compares the latest snapshot against stock across
upcoming dates. <strong>Est. Final Occ</strong> uses the equivalent look-back window past dates no
longer change, so they reflect completed bookings.
</div>
{!selectedHotel && <div className="empty-state">Select a hotel to view room stats.</div>}
{selectedHotel && isLoading && <div className="loading-state"><div className="spinner" />Loading</div>}
{selectedHotel && !isLoading && roomIds.length === 0 && (
<div className="empty-state">No data yet run a scrape first.</div>
)}
{selectedHotel && !isLoading && roomIds.length > 0 && STAT_WINDOWS.map(win => (
<div className="card" key={win.key}>
<div className="card-header">{win.label}</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Room Type</th>
<th>Stock</th>
<th>Avg Rate</th>
<th>Current Occ %</th>
<th>Est. Final Occ %</th>
<th>Dates</th>
</tr>
</thead>
<tbody>
{roomIds.map(rid => {
const room = stats![rid]
const w = room.windows[win.key]
if (!w) return (
<tr key={rid}>
<td style={{ fontWeight: 600 }}>{room.room_label}</td>
<td style={{ color: 'var(--text-mid)' }}>{room.stock ?? '—'}</td>
<td colSpan={4} style={{ color: 'var(--text-mid)' }}>No data</td>
</tr>
)
return (
<tr key={rid}>
<td style={{ fontWeight: 600 }}>{room.room_label}</td>
<td style={{ color: 'var(--text-mid)' }}>{room.stock ?? '—'}</td>
<td>{w.avg_price != null ? fmt(w.avg_price) : '—'}</td>
<td>
{w.avg_occ != null
? <span><span style={{ color: occColor(w.avg_occ), fontWeight: 600 }}>{w.avg_occ}%</span><OccBar pct={w.avg_occ} /></span>
: '—'}
</td>
<td>
{w.est_final_occ != null
? <span><span style={{ color: occColor(w.est_final_occ), fontWeight: 600 }}>{w.est_final_occ}%</span><OccBar pct={w.est_final_occ} /></span>
: <span style={{ color: 'var(--text-mid)', fontSize: 11 }}>no past dates</span>}
</td>
<td style={{ color: 'var(--text-mid)', fontSize: 12 }}>
{w.dates} ahead{w.past_dates ? ` · ${w.past_dates} past` : ''}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
))}
</div>
)
}
// ─── Manage Tab ─────────────────────────────────────────────────────────────── // ─── Manage Tab ───────────────────────────────────────────────────────────────
function ManageTab({ hotels, onRefresh }: { hotels: DirectHotel[]; onRefresh: () => void }) { function ManageTab({ hotels, onRefresh }: { hotels: DirectHotel[]; onRefresh: () => void }) {
const [showAdd, setShowAdd] = useState(false) const [showAdd, setShowAdd] = useState(false)
const [detectUrl, setDetectUrl] = useState('') const [detectUrl, setDetectUrl] = useState('')
const [detected, setDetected] = useState<any>(null) const [detected, setDetected] = useState<any>(null)
const [detecting, setDetecting] = useState(false)
const [newName, setNewName] = useState('') const [newName, setNewName] = useState('')
const [extraParams, setExtraParams] = useState<Record<string, string>>({}) const [extraParams, setExtraParams] = useState<Record<string, string>>({})
const [profiles, setProfiles] = useState<any[]>([])
const qc = useQueryClient() const qc = useQueryClient()
const { data: profileList } = useQuery({ const { data: profileList } = useQuery({