diff --git a/backend/api/bookability.py b/backend/api/bookability.py deleted file mode 100644 index 1d741f7..0000000 --- a/backend/api/bookability.py +++ /dev/null @@ -1,624 +0,0 @@ -""" -Bookability API endpoints -Rate availability matrix and competitor rate comparison -""" -from typing import Optional, List, Dict, Any -from datetime import date, datetime, timedelta -from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import text -from pydantic import BaseModel -import logging -import json - -from database import get_db, SyncSessionLocal -from auth import get_current_user - -router = APIRouter() -logger = logging.getLogger(__name__) - - -# ============================================ -# RESPONSE MODELS -# ============================================ - -class CategoryInfo(BaseModel): - category_id: str - category_name: str - room_count: int - - -class TariffInfo(BaseModel): - name: str - description: Optional[str] = None - rate: Optional[float] = None - average_nightly: Optional[float] = None - available: bool - message: str - sort_order: int = 999 - min_stay: Optional[int] = None - available_for_min_stay: Optional[bool] = None # True if available when queried with min_stay nights - - -class OccupancyInfo(BaseModel): - occupied: int = 0 - available: int = 0 - maintenance: int = 0 - - -class DateRateInfo(BaseModel): - rate_gross: Optional[float] = None - rate_net: Optional[float] = None - tariffs: List[TariffInfo] - tariff_count: int - occupancy: Optional[OccupancyInfo] = None - valid_from: Optional[str] = None - - -class RateMatrixResponse(BaseModel): - categories: List[CategoryInfo] - dates: List[str] - matrix: Dict[str, Dict[str, DateRateInfo]] - date_last_updated: Dict[str, Optional[str]] = {} - - -# ============================================ -# RATE MATRIX ENDPOINT -# ============================================ - -@router.get("/rate-matrix", response_model=RateMatrixResponse) -async def get_rate_matrix( - from_date: Optional[str] = None, - to_date: Optional[str] = None, - category_id: Optional[str] = None, - db: AsyncSession = Depends(get_db), - current_user: dict = Depends(get_current_user) -): - """ - Get rate availability matrix for all tariffs across dates and categories. - - Returns a matrix showing all available tariff options for each room category - and date combination, including availability status and rates. - - Args: - from_date: Start date (YYYY-MM-DD), defaults to today - to_date: End date (YYYY-MM-DD), defaults to today + 30 days - category_id: Optional filter to specific category - - Returns: - RateMatrixResponse with categories, dates, and the matrix data - """ - # Default date range - today = date.today() - start = date.fromisoformat(from_date) if from_date else today - end = date.fromisoformat(to_date) if to_date else today + timedelta(days=30) - - # Validate date range - if end < start: - raise HTTPException(status_code=400, detail="to_date must be after from_date") - if (end - start).days > 366: - raise HTTPException(status_code=400, detail="Date range cannot exceed 366 days") - - # Fetch categories - cat_query = """ - SELECT site_id, site_name, room_count - FROM newbook_room_categories - WHERE is_included = true - """ - params: Dict[str, Any] = {} - - if category_id: - cat_query += " AND site_id = :category_id" - params["category_id"] = category_id - - cat_query += " ORDER BY display_order, site_name" - - cat_result = await db.execute(text(cat_query), params) - categories = [ - CategoryInfo( - category_id=row.site_id, - category_name=row.site_name, - room_count=row.room_count or 0 - ) - for row in cat_result.fetchall() - ] - - if not categories: - return RateMatrixResponse(categories=[], dates=[], matrix={}) - - # Build date list - dates = [] - current = start - while current <= end: - dates.append(current.isoformat()) - current += timedelta(days=1) - - # Fetch rates with tariffs_data (get latest version per category/date) - rates_query = """ - SELECT DISTINCT ON (category_id, rate_date) - category_id, rate_date, rate_gross, rate_net, tariffs_data, valid_from - FROM newbook_current_rates - WHERE rate_date >= :from_date AND rate_date <= :to_date - """ - rates_params: Dict[str, Any] = {"from_date": start, "to_date": end} - - if category_id: - rates_query += " AND category_id = :category_id" - rates_params["category_id"] = category_id - - rates_query += " ORDER BY category_id, rate_date, valid_from DESC" - - rates_result = await db.execute(text(rates_query), rates_params) - rates_rows = rates_result.fetchall() - - # Fetch occupancy data from newbook_occupancy_report_data - occupancy_query = """ - SELECT category_id, date, occupied, available, maintenance - FROM newbook_occupancy_report_data - WHERE date >= :from_date AND date <= :to_date - """ - occupancy_params: Dict[str, Any] = {"from_date": start, "to_date": end} - - if category_id: - occupancy_query += " AND category_id = :category_id" - occupancy_params["category_id"] = category_id - - occupancy_result = await db.execute(text(occupancy_query), occupancy_params) - occupancy_rows = occupancy_result.fetchall() - - # Build occupancy lookup: category_id -> date -> OccupancyInfo - occupancy_map: Dict[str, Dict[str, OccupancyInfo]] = {} - for row in occupancy_rows: - cat_id = row.category_id - occ_date = row.date.isoformat() - if cat_id not in occupancy_map: - occupancy_map[cat_id] = {} - occupancy_map[cat_id][occ_date] = OccupancyInfo( - occupied=row.occupied or 0, - available=row.available or 0, - maintenance=row.maintenance or 0 - ) - - # Build matrix - matrix: Dict[str, Dict[str, DateRateInfo]] = {} - - # Initialize matrix with empty data for all categories and dates - for cat in categories: - matrix[cat.category_id] = {} - for date_str in dates: - # Get occupancy for this category/date if available - occ = occupancy_map.get(cat.category_id, {}).get(date_str) - matrix[cat.category_id][date_str] = DateRateInfo( - rate_gross=None, - rate_net=None, - tariffs=[], - tariff_count=0, - occupancy=occ - ) - - # Populate matrix with actual data - for row in rates_rows: - cat_id = row.category_id - rate_date = row.rate_date.isoformat() - - if cat_id not in matrix or rate_date not in matrix[cat_id]: - continue - - # Parse tariffs_data - tariffs_data = row.tariffs_data or {} - if isinstance(tariffs_data, str): - try: - tariffs_data = json.loads(tariffs_data) - except json.JSONDecodeError: - tariffs_data = {} - - # Build tariff list - tariffs_list = [] - raw_tariffs = tariffs_data.get('tariffs', []) - - for idx, tariff in enumerate(raw_tariffs): - tariffs_list.append(TariffInfo( - name=tariff.get('name', 'Unknown'), - description=tariff.get('description'), - rate=tariff.get('rate'), - average_nightly=tariff.get('average_nightly'), - available=tariff.get('success', False), - message=tariff.get('message', ''), - sort_order=tariff.get('sort_order', idx), - min_stay=tariff.get('min_stay'), - available_for_min_stay=tariff.get('available_for_min_stay') - )) - - # Preserve existing occupancy data - existing_occ = matrix[cat_id][rate_date].occupancy - vf = row.valid_from.isoformat() if row.valid_from else None - - matrix[cat_id][rate_date] = DateRateInfo( - rate_gross=float(row.rate_gross) if row.rate_gross else None, - rate_net=float(row.rate_net) if row.rate_net else None, - tariffs=tariffs_list, - tariff_count=tariffs_data.get('tariff_count', len(tariffs_list)), - occupancy=existing_occ, - valid_from=vf - ) - - # Per-date latest update time (max valid_from across all categories for each date) - date_last_updated: Dict[str, Optional[str]] = {} - for date_str in dates: - latest = None - for cat in categories: - vf = matrix.get(cat.category_id, {}).get(date_str, DateRateInfo(tariffs=[], tariff_count=0)).valid_from - if vf and (latest is None or vf > latest): - latest = vf - date_last_updated[date_str] = latest - - return RateMatrixResponse( - categories=categories, - dates=dates, - matrix=matrix, - date_last_updated=date_last_updated - ) - - -# ============================================ -# RATE MATRIX SUMMARY (lightweight endpoint) -# ============================================ - -@router.get("/rate-matrix/summary") -async def get_rate_matrix_summary( - from_date: Optional[str] = None, - to_date: Optional[str] = None, - db: AsyncSession = Depends(get_db), - current_user: dict = Depends(get_current_user) -): - """ - Get a summary of rate availability issues. - - Returns counts of unavailable tariffs by category and date for quick - identification of potential bookability problems. - """ - today = date.today() - start = date.fromisoformat(from_date) if from_date else today - end = date.fromisoformat(to_date) if to_date else today + timedelta(days=30) - - # Query rates with issues (get latest version per category/date) - result = await db.execute( - text(""" - SELECT DISTINCT ON (category_id, rate_date) - category_id, - rate_date, - tariffs_data - FROM newbook_current_rates - WHERE rate_date >= :from_date AND rate_date <= :to_date - AND tariffs_data IS NOT NULL - ORDER BY category_id, rate_date, valid_from DESC - """), - {"from_date": start, "to_date": end} - ) - - issues = [] - for row in result.fetchall(): - tariffs_data = row.tariffs_data or {} - if isinstance(tariffs_data, str): - try: - tariffs_data = json.loads(tariffs_data) - except json.JSONDecodeError: - continue - - tariffs = tariffs_data.get('tariffs', []) - unavailable = [t for t in tariffs if not t.get('success', False)] - - if unavailable: - issues.append({ - "category_id": row.category_id, - "date": row.rate_date.isoformat(), - "unavailable_count": len(unavailable), - "unavailable_tariffs": [t.get('name') for t in unavailable], - "messages": [t.get('message') for t in unavailable if t.get('message')] - }) - - return { - "from_date": start.isoformat(), - "to_date": end.isoformat(), - "total_issues": len(issues), - "issues": issues - } - - -# ============================================ -# RATE HISTORY ENDPOINT -# ============================================ - -@router.get("/rate-history/{category_id}/{rate_date}") -async def get_rate_history( - category_id: str, - rate_date: str, - db: AsyncSession = Depends(get_db), - current_user: dict = Depends(get_current_user) -): - """ - Get rate change history for a specific category and date. - - Returns all rate snapshots showing how rates evolved over time. - Useful for understanding when rates changed and by how much. - """ - try: - target_date = date.fromisoformat(rate_date) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD") - - result = await db.execute( - text(""" - SELECT - id, - rate_gross, - rate_net, - tariffs_data, - valid_from, - last_verified_at - FROM newbook_current_rates - WHERE category_id = :category_id AND rate_date = :rate_date - ORDER BY valid_from DESC - """), - {"category_id": category_id, "rate_date": target_date} - ) - - history = [] - for row in result.fetchall(): - tariffs_data = row.tariffs_data or {} - if isinstance(tariffs_data, str): - try: - tariffs_data = json.loads(tariffs_data) - except json.JSONDecodeError: - tariffs_data = {} - - tariffs = tariffs_data.get('tariffs', []) - - history.append({ - "id": row.id, - "rate_gross": float(row.rate_gross) if row.rate_gross else None, - "rate_net": float(row.rate_net) if row.rate_net else None, - "valid_from": row.valid_from.isoformat() if row.valid_from else None, - "last_verified_at": row.last_verified_at.isoformat() if row.last_verified_at else None, - "tariff_count": len(tariffs), - "tariffs_available": sum(1 for t in tariffs if t.get('success', False)), - "tariffs_unavailable": sum(1 for t in tariffs if not t.get('success', False)), - "tariffs": [ - { - "name": t.get('name'), - "rate": t.get('rate'), - "available": t.get('success', False), - "message": t.get('message', ''), - "min_stay": t.get('min_stay') - } - for t in tariffs - ] - }) - - return { - "category_id": category_id, - "rate_date": rate_date, - "version_count": len(history), - "history": history - } - - -# ============================================ -# RATE CHANGES SUMMARY -# ============================================ - -@router.get("/rate-changes") -async def get_rate_changes( - days: int = 7, - db: AsyncSession = Depends(get_db), - current_user: dict = Depends(get_current_user) -): - """ - Get summary of rate changes in the last N days. - - Shows which rates changed and when, useful for tracking pricing strategy changes. - """ - cutoff = datetime.now() - timedelta(days=days) - - # Find dates with multiple versions (indicating changes) - result = await db.execute( - text(""" - SELECT - category_id, - rate_date, - COUNT(*) as version_count, - MIN(valid_from) as first_version, - MAX(valid_from) as latest_version - FROM newbook_current_rates - WHERE valid_from >= :cutoff - GROUP BY category_id, rate_date - HAVING COUNT(*) > 1 - ORDER BY MAX(valid_from) DESC - LIMIT 100 - """), - {"cutoff": cutoff} - ) - - changes = [] - for row in result.fetchall(): - changes.append({ - "category_id": row.category_id, - "rate_date": row.rate_date.isoformat(), - "version_count": row.version_count, - "first_version": row.first_version.isoformat() if row.first_version else None, - "latest_version": row.latest_version.isoformat() if row.latest_version else None - }) - - return { - "days": days, - "total_changes": len(changes), - "changes": changes - } - - -# ============================================ -# FETCH RATES TRIGGER (manual refresh) -# ============================================ - -@router.post("/refresh-rates") -async def refresh_rates( - from_date: Optional[str] = None, - to_date: Optional[str] = None, - category_id: Optional[str] = None, - db: AsyncSession = Depends(get_db), - current_user: dict = Depends(get_current_user) -): - """ - Trigger a manual refresh of current rates from Newbook. - - This runs the rate fetch job for the specified date range. - Note: This can be slow as it respects Newbook API rate limits. - """ - from jobs.fetch_current_rates import run_fetch_current_rates - - # For now, just run the standard fetch - # TODO: Add support for custom date range and category filter - try: - await run_fetch_current_rates() - return {"status": "success", "message": "Rates refresh completed"} - except Exception as e: - logger.error(f"Rates refresh failed: {e}") - raise HTTPException(status_code=500, detail=f"Rates refresh failed: {str(e)}") - - -# ============================================ -# SINGLE-DATE RATE REFRESH -# ============================================ - -def _refresh_date_sync(rate_date: date): - """ - Fetch rates for a single date from Newbook and save to DB. - Runs synchronously in a background task. - """ - import asyncio - from decimal import Decimal - from services.newbook_rates_client import NewbookRatesClient - from jobs.fetch_current_rates import save_rate_snapshot - - db = SyncSessionLocal() - try: - # Get config - config_result = db.execute( - text(""" - SELECT config_key, config_value FROM system_config - WHERE config_key IN ('newbook_api_key', 'newbook_username', 'newbook_password', 'newbook_region', 'accommodation_vat_rate') - """) - ) - config = {row.config_key: row.config_value for row in config_result.fetchall()} - - # Central Settings service first, app-local config fallback - from services.central_settings import get_newbook_credentials_sync - creds = get_newbook_credentials_sync() - if not creds: - if not all(k in config for k in ['newbook_api_key', 'newbook_username', 'newbook_password', 'newbook_region']): - logger.error("Newbook credentials not configured for single-date refresh") - return - creds = { - 'api_key': config['newbook_api_key'], - 'username': config['newbook_username'], - 'password': config['newbook_password'], - 'region': config['newbook_region'], - } - - vat_rate = Decimal(config.get('accommodation_vat_rate', '0.20')) - - # Get included categories - cat_result = db.execute( - text("SELECT site_id FROM newbook_room_categories WHERE is_included = true") - ) - included_categories = set(row.site_id for row in cat_result.fetchall()) - - client = NewbookRatesClient( - api_key=creds['api_key'], - username=creds['username'], - password=creds['password'], - region=creds['region'], - vat_rate=vat_rate - ) - - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - async def _fetch(): - async with client: - # Single-night query for this date (all categories) - category_rates = await client._fetch_all_categories_batch( - rate_date, guests_adults=2, guests_children=0 - ) - - # Check for min_stay tariffs needing multi-night verification - dates_by_nights: Dict[int, list] = {} - for cat_id, rates in category_rates.items(): - if cat_id not in included_categories: - continue - for rate in rates: - for tariff in rate.get('tariffs_data', {}).get('tariffs', []): - min_stay = tariff.get('min_stay') - if min_stay and min_stay > 1 and not tariff.get('success', False): - if min_stay not in dates_by_nights: - dates_by_nights[min_stay] = [] - if rate_date not in dates_by_nights[min_stay]: - dates_by_nights[min_stay].append(rate_date) - - # Run multi-night verification if needed - if dates_by_nights: - multi_results = await client.get_multi_night_availability(dates_by_nights) - for cat_id, rates in category_rates.items(): - if cat_id not in included_categories: - continue - for rate in rates: - if rate_date in multi_results: - cat_avail = multi_results[rate_date].get(cat_id, {}) - for tariff in rate.get('tariffs_data', {}).get('tariffs', []): - if tariff.get('min_stay') and tariff['min_stay'] > 1: - tariff['available_for_min_stay'] = cat_avail.get(tariff.get('name', ''), False) - - # Save snapshots - inserted = 0 - for cat_id, rates in category_rates.items(): - if cat_id not in included_categories: - continue - for rate in rates: - result = save_rate_snapshot(db, cat_id, rate['date'], rate) - if result == 'inserted': - inserted += 1 - - return inserted - - inserted = loop.run_until_complete(_fetch()) - db.commit() - logger.info(f"Single-date refresh for {rate_date}: {inserted} new snapshots") - - finally: - loop.close() - - except Exception as e: - logger.error(f"Single-date refresh failed for {rate_date}: {e}", exc_info=True) - db.rollback() - finally: - db.close() - - -@router.post("/refresh-date/{rate_date}") -async def refresh_single_date( - rate_date: str, - current_user: dict = Depends(get_current_user) -): - """ - Trigger a refresh of rates for a single date from Newbook. - Runs synchronously so the client can invalidate its cache immediately on response. - """ - try: - target_date = date.fromisoformat(rate_date) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD") - - import asyncio - loop = asyncio.get_event_loop() - await loop.run_in_executor(None, _refresh_date_sync, target_date) - return {"status": "success", "date": rate_date, "message": f"Rates refreshed for {rate_date}"} diff --git a/backend/api/competitor_rates.py b/backend/api/competitor_rates.py deleted file mode 100644 index 12fffa6..0000000 --- a/backend/api/competitor_rates.py +++ /dev/null @@ -1,940 +0,0 @@ -""" -Competitor Rates API endpoints -Booking.com rate scraping, hotel management, and competitor comparison -""" -from typing import Optional, List, Dict, Any -from datetime import date, datetime, timedelta -from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import text -from pydantic import BaseModel -import logging - -from database import get_db, SyncSessionLocal -from auth import get_current_user - -router = APIRouter() -logger = logging.getLogger(__name__) - - -# ============================================ -# REQUEST/RESPONSE MODELS -# ============================================ - -class ScrapeRequest(BaseModel): - from_date: str - to_date: Optional[str] = None - - -class LocationConfigRequest(BaseModel): - location_name: str - pages_to_scrape: int = 2 - adults: int = 2 - - -class HotelTierUpdate(BaseModel): - tier: str # 'own', 'competitor', 'market' - display_order: Optional[int] = None - - -class HotelResponse(BaseModel): - id: int - booking_com_id: str - name: str - booking_com_url: Optional[str] - star_rating: Optional[float] - review_score: Optional[float] - review_count: Optional[int] - tier: str - display_order: int - notes: Optional[str] - first_seen_at: Optional[datetime] - last_seen_at: Optional[datetime] - - -class RateResponse(BaseModel): - rate_date: str - hotel_id: int - hotel_name: str - tier: str - star_rating: Optional[float] - review_score: Optional[float] - availability_status: str - rate_gross: Optional[float] - room_type: Optional[str] - breakfast_included: Optional[bool] - free_cancellation: Optional[bool] - no_prepayment: Optional[bool] - rooms_left: Optional[int] - scraped_at: Optional[datetime] - - -class ScraperStatusResponse(BaseModel): - enabled: bool - paused: bool - pause_until: Optional[str] - backend: str - location_configured: bool - location_name: Optional[str] - last_scrape: Optional[dict] - - -# ============================================ -# SCRAPER STATUS & CONFIGURATION -# ============================================ - -@router.get("/status", response_model=ScraperStatusResponse) -async def get_scraper_status( - db: AsyncSession = Depends(get_db), - current_user: dict = Depends(get_current_user) -): - """Get current scraper status and configuration.""" - # Get config values - config_result = await db.execute( - text(""" - SELECT config_key, config_value FROM system_config - WHERE config_key IN ( - 'booking_scraper_enabled', - 'booking_scraper_paused', - 'booking_scraper_pause_until', - 'booking_scraper_backend' - ) - """) - ) - config = {row.config_key: row.config_value for row in config_result.fetchall()} - - # Get location config - location_result = await db.execute( - text("SELECT location_name FROM booking_scrape_config WHERE is_active = TRUE LIMIT 1") - ) - location_row = location_result.fetchone() - - # Get last scrape info - last_scrape_result = await db.execute( - text(""" - SELECT batch_id, scrape_type, started_at, completed_at, status, - hotels_found, rates_scraped, error_message - FROM booking_scrape_log - ORDER BY started_at DESC - LIMIT 1 - """) - ) - last_scrape_row = last_scrape_result.fetchone() - last_scrape = None - if last_scrape_row: - last_scrape = { - 'batch_id': str(last_scrape_row.batch_id), - 'scrape_type': last_scrape_row.scrape_type, - 'started_at': last_scrape_row.started_at.isoformat() if last_scrape_row.started_at else None, - 'completed_at': last_scrape_row.completed_at.isoformat() if last_scrape_row.completed_at else None, - 'status': last_scrape_row.status, - 'hotels_found': last_scrape_row.hotels_found, - 'rates_scraped': last_scrape_row.rates_scraped, - 'error_message': last_scrape_row.error_message, - } - - return ScraperStatusResponse( - enabled=config.get('booking_scraper_enabled', 'false') == 'true', - paused=config.get('booking_scraper_paused', 'false') == 'true', - pause_until=config.get('booking_scraper_pause_until'), - backend=config.get('booking_scraper_backend', 'playwright_local'), - location_configured=location_row is not None, - location_name=location_row.location_name if location_row else None, - last_scrape=last_scrape - ) - - -@router.post("/config/location") -async def set_location_config( - config: LocationConfigRequest, - db: AsyncSession = Depends(get_db), - current_user: dict = Depends(get_current_user) -): - """Set the location to scrape for competitor rates.""" - # Deactivate existing configs - await db.execute( - text("UPDATE booking_scrape_config SET is_active = FALSE") - ) - - # Insert new config - await db.execute( - text(""" - INSERT INTO booking_scrape_config (location_name, pages_to_scrape, adults, is_active) - VALUES (:location, :pages, :adults, TRUE) - """), - {'location': config.location_name, 'pages': config.pages_to_scrape, 'adults': config.adults} - ) - await db.commit() - - return {"status": "success", "location": config.location_name} - - -@router.post("/config/enable") -async def enable_scraper( - enabled: bool = True, - db: AsyncSession = Depends(get_db), - current_user: dict = Depends(get_current_user) -): - """Enable or disable the booking.com scraper.""" - await db.execute( - text("UPDATE system_config SET config_value = :val WHERE config_key = 'booking_scraper_enabled'"), - {'val': 'true' if enabled else 'false'} - ) - await db.commit() - return {"status": "success", "enabled": enabled} - - -@router.post("/config/unpause") -async def unpause_scraper( - db: AsyncSession = Depends(get_db), - current_user: dict = Depends(get_current_user) -): - """Manually unpause the scraper (clears blocking pause).""" - await db.execute( - text("UPDATE system_config SET config_value = 'false' WHERE config_key = 'booking_scraper_paused'") - ) - await db.commit() - return {"status": "success", "message": "Scraper unpaused"} - - -# ============================================ -# MANUAL SCRAPE TRIGGER -# ============================================ - -def run_scrape_sync(from_date: date, to_date: date): - """Run scrape in sync context for background task.""" - import asyncio - from services.booking_scraper import run_manual_scrape, cleanup_stale_batches - - db = SyncSessionLocal() - try: - # Clean up any stale batches before starting - cleanup_stale_batches(db, max_age_minutes=60) - - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - result = loop.run_until_complete(run_manual_scrape(db, from_date, to_date)) - logger.info(f"Background scrape completed: {result}") - finally: - loop.close() - except Exception as e: - logger.error(f"Background scrape failed: {e}", exc_info=True) - finally: - db.close() - - -@router.post("/scrape") -async def trigger_manual_scrape( - request: ScrapeRequest, - background_tasks: BackgroundTasks, - db: AsyncSession = Depends(get_db), - current_user: dict = Depends(get_current_user) -): - """ - Trigger a manual scrape for the specified date range. - - Runs in background - check /status for progress. - """ - try: - from_date = date.fromisoformat(request.from_date) - to_date = date.fromisoformat(request.to_date) if request.to_date else from_date - except ValueError as e: - raise HTTPException(status_code=400, detail=f"Invalid date format: {e}") - - if to_date < from_date: - raise HTTPException(status_code=400, detail="to_date must be after from_date") - - if (to_date - from_date).days > 30: - raise HTTPException(status_code=400, detail="Date range cannot exceed 30 days for manual scrape") - - # Check if location is configured - location_result = await db.execute( - text("SELECT id FROM booking_scrape_config WHERE is_active = TRUE LIMIT 1") - ) - if not location_result.fetchone(): - raise HTTPException(status_code=400, detail="No scrape location configured. Set location first.") - - # Check if paused - paused_result = await db.execute( - text("SELECT config_value FROM system_config WHERE config_key = 'booking_scraper_paused'") - ) - paused_row = paused_result.fetchone() - if paused_row and paused_row.config_value == 'true': - raise HTTPException(status_code=400, detail="Scraper is currently paused. Use /unpause first or wait for cooldown.") - - # Start background task - background_tasks.add_task(run_scrape_sync, from_date, to_date) - - return { - "status": "started", - "from_date": from_date.isoformat(), - "to_date": to_date.isoformat(), - "message": "Scrape started in background. Check /status for progress." - } - - -# ============================================ -# HOTELS MANAGEMENT -# ============================================ - -@router.get("/hotels", response_model=List[HotelResponse]) -async def list_hotels( - tier: Optional[str] = None, - db: AsyncSession = Depends(get_db), - current_user: dict = Depends(get_current_user) -): - """ - List all discovered hotels. - - Filter by tier: 'own', 'competitor', 'market', or None for all. - """ - query = """ - SELECT id, booking_com_id, name, booking_com_url, - star_rating, review_score, review_count, - tier, display_order, notes, first_seen_at, last_seen_at - FROM booking_com_hotels - WHERE is_active = TRUE - """ - params = {} - - if tier: - if tier not in ('own', 'competitor', 'market'): - raise HTTPException(status_code=400, detail="Invalid tier. Must be 'own', 'competitor', or 'market'") - query += " AND tier = :tier" - params['tier'] = tier - - query += " ORDER BY display_order, name" - - result = await db.execute(text(query), params) - - return [ - HotelResponse( - id=row.id, - booking_com_id=row.booking_com_id or '', - name=row.name, - booking_com_url=row.booking_com_url, - star_rating=float(row.star_rating) if row.star_rating else None, - review_score=float(row.review_score) if row.review_score else None, - review_count=row.review_count, - tier=row.tier, - display_order=row.display_order, - notes=row.notes, - first_seen_at=row.first_seen_at, - last_seen_at=row.last_seen_at - ) - for row in result.fetchall() - ] - - -@router.put("/hotels/{hotel_id}/tier") -async def update_hotel_tier( - hotel_id: int, - update: HotelTierUpdate, - db: AsyncSession = Depends(get_db), - current_user: dict = Depends(get_current_user) -): - """ - Update a hotel's tier and display order. - - Tiers: - - 'own': Your hotel (for parity checking) - - 'competitor': Main competitors (full tracking) - - 'market': Other hotels (context only) - """ - if update.tier not in ('own', 'competitor', 'market'): - raise HTTPException(status_code=400, detail="Invalid tier") - - # If setting as 'own', clear any existing 'own' hotel - if update.tier == 'own': - await db.execute( - text("UPDATE booking_com_hotels SET tier = 'market' WHERE tier = 'own'") - ) - - # Update the hotel - set_clause = "tier = :tier" - params = {'hotel_id': hotel_id, 'tier': update.tier} - - if update.display_order is not None: - set_clause += ", display_order = :order" - params['order'] = update.display_order - - result = await db.execute( - text(f"UPDATE booking_com_hotels SET {set_clause} WHERE id = :hotel_id RETURNING id"), - params - ) - - if not result.fetchone(): - raise HTTPException(status_code=404, detail="Hotel not found") - - await db.commit() - - # If this is now the own hotel, update system config - if update.tier == 'own': - await db.execute( - text("UPDATE system_config SET config_value = :val WHERE config_key = 'booking_scraper_own_hotel_id'"), - {'val': str(hotel_id)} - ) - await db.commit() - - return {"status": "success", "hotel_id": hotel_id, "tier": update.tier} - - -@router.put("/hotels/{hotel_id}/notes") -async def update_hotel_notes( - hotel_id: int, - notes: str, - db: AsyncSession = Depends(get_db), - current_user: dict = Depends(get_current_user) -): - """Update notes for a hotel.""" - result = await db.execute( - text("UPDATE booking_com_hotels SET notes = :notes WHERE id = :hotel_id RETURNING id"), - {'hotel_id': hotel_id, 'notes': notes} - ) - - if not result.fetchone(): - raise HTTPException(status_code=404, detail="Hotel not found") - - await db.commit() - return {"status": "success"} - - -# ============================================ -# COMPETITOR RATES MATRIX -# ============================================ - -@router.get("/matrix") -async def get_competitor_matrix( - from_date: Optional[str] = None, - to_date: Optional[str] = None, - include_market: bool = False, - db: AsyncSession = Depends(get_db), - current_user: dict = Depends(get_current_user) -): - """ - Get rate comparison matrix for competitors. - - Returns rates for own hotel and competitors, organized by date. - Set include_market=true to also include market tier hotels. - """ - today = date.today() - start = date.fromisoformat(from_date) if from_date else today - end = date.fromisoformat(to_date) if to_date else today + timedelta(days=30) - - if end < start: - raise HTTPException(status_code=400, detail="to_date must be after from_date") - if (end - start).days > 90: - raise HTTPException(status_code=400, detail="Date range cannot exceed 90 days") - - tier_filter = "h.tier IN ('own', 'competitor')" - if include_market: - tier_filter = "h.tier IN ('own', 'competitor', 'market')" - - # Get hotels - hotels_result = await db.execute( - text(f""" - SELECT id, name, tier, display_order, star_rating, review_score, booking_com_url - FROM booking_com_hotels - WHERE is_active = TRUE AND {tier_filter.replace('h.', '')} - ORDER BY display_order, name - """) - ) - hotels = [dict(row._mapping) for row in hotels_result.fetchall()] - - # Get latest rates using the view - rates_result = await db.execute( - text(f""" - SELECT DISTINCT ON (r.hotel_id, r.rate_date) - r.hotel_id, - r.rate_date, - r.availability_status, - r.rate_gross, - r.room_type, - r.breakfast_included, - r.free_cancellation, - r.no_prepayment, - r.rooms_left, - r.scraped_at - FROM booking_com_rates r - JOIN booking_com_hotels h ON r.hotel_id = h.id - WHERE {tier_filter} - AND h.is_active = TRUE - AND r.rate_date >= :from_date AND r.rate_date <= :to_date - ORDER BY r.hotel_id, r.rate_date, r.scraped_at DESC - """), - {'from_date': start, 'to_date': end} - ) - - # Build matrix: hotel_id -> date -> rate data - rates_by_hotel: Dict[int, Dict[str, dict]] = {} - for row in rates_result.fetchall(): - hotel_id = row.hotel_id - rate_date = row.rate_date.isoformat() - - if hotel_id not in rates_by_hotel: - rates_by_hotel[hotel_id] = {} - - rates_by_hotel[hotel_id][rate_date] = { - 'availability_status': row.availability_status, - 'rate_gross': float(row.rate_gross) if row.rate_gross else None, - 'room_type': row.room_type, - 'breakfast_included': row.breakfast_included, - 'free_cancellation': row.free_cancellation, - 'no_prepayment': row.no_prepayment, - 'rooms_left': row.rooms_left, - 'scraped_at': row.scraped_at.isoformat() if row.scraped_at else None, - } - - # Build date list - dates = [] - current = start - while current <= end: - dates.append(current.isoformat()) - current += timedelta(days=1) - - return { - 'from_date': start.isoformat(), - 'to_date': end.isoformat(), - 'dates': dates, - 'hotels': hotels, - 'rates': rates_by_hotel - } - - -# ============================================ -# RATE PARITY (OWN HOTEL VS NEWBOOK) -# ============================================ - -@router.get("/parity") -async def get_rate_parity( - from_date: Optional[str] = None, - to_date: Optional[str] = None, - db: AsyncSession = Depends(get_db), - current_user: dict = Depends(get_current_user) -): - """ - Get rate parity comparison between booking.com and Newbook rates. - - Compares scraped booking.com rates for own hotel against Newbook current rates. - """ - today = date.today() - start = date.fromisoformat(from_date) if from_date else today - end = date.fromisoformat(to_date) if to_date else today + timedelta(days=30) - - # Get own hotel's booking.com rates - booking_rates_result = await db.execute( - text(""" - SELECT DISTINCT ON (r.rate_date) - r.rate_date, - r.rate_gross as booking_rate, - r.availability_status, - r.room_type as booking_room_type, - r.scraped_at - FROM booking_com_rates r - JOIN booking_com_hotels h ON r.hotel_id = h.id - WHERE h.tier = 'own' - AND r.rate_date >= :from_date AND r.rate_date <= :to_date - ORDER BY r.rate_date, r.scraped_at DESC - """), - {'from_date': start, 'to_date': end} - ) - booking_rates = {row.rate_date: dict(row._mapping) for row in booking_rates_result.fetchall()} - - # Get Newbook rates (best rate per date across categories) - newbook_rates_result = await db.execute( - text(""" - SELECT DISTINCT ON (rate_date) - rate_date, - rate_gross as newbook_rate, - category_id - FROM newbook_current_rates - WHERE rate_date >= :from_date AND rate_date <= :to_date - ORDER BY rate_date, valid_from DESC - """), - {'from_date': start, 'to_date': end} - ) - newbook_rates = {row.rate_date: dict(row._mapping) for row in newbook_rates_result.fetchall()} - - # Compare rates - parity_issues = [] - all_dates = set(booking_rates.keys()) | set(newbook_rates.keys()) - - for rate_date in sorted(all_dates): - booking = booking_rates.get(rate_date) - newbook = newbook_rates.get(rate_date) - - if not booking or not newbook: - continue - - booking_rate = booking.get('booking_rate') - newbook_rate = newbook.get('newbook_rate') - - if not booking_rate or not newbook_rate: - continue - - diff_pct = ((float(booking_rate) - float(newbook_rate)) / float(newbook_rate)) * 100 - - if abs(diff_pct) > 1: # More than 1% difference - parity_issues.append({ - 'rate_date': rate_date.isoformat(), - 'booking_rate': float(booking_rate), - 'newbook_rate': float(newbook_rate), - 'difference_pct': round(diff_pct, 2), - 'alert_type': 'higher' if diff_pct > 0 else 'lower', - 'booking_room_type': booking.get('booking_room_type'), - 'availability_status': booking.get('availability_status'), - }) - - return { - 'from_date': start.isoformat(), - 'to_date': end.isoformat(), - 'issues_count': len(parity_issues), - 'issues': parity_issues - } - - -# ============================================ -# PARITY ALERTS -# ============================================ - -@router.get("/parity/alerts") -async def get_parity_alerts( - status: Optional[str] = None, - db: AsyncSession = Depends(get_db), - current_user: dict = Depends(get_current_user) -): - """Get rate parity alerts.""" - query = """ - SELECT id, rate_date, room_category, newbook_rate, booking_com_rate, - difference_pct, alert_type, alert_status, created_at, - acknowledged_at, acknowledged_by, notes - FROM rate_parity_alerts - """ - params = {} - - if status: - query += " WHERE alert_status = :status" - params['status'] = status - - query += " ORDER BY rate_date DESC, created_at DESC LIMIT 100" - - result = await db.execute(text(query), params) - - return [ - { - 'id': row.id, - 'rate_date': row.rate_date.isoformat(), - 'room_category': row.room_category, - 'newbook_rate': float(row.newbook_rate) if row.newbook_rate else None, - 'booking_com_rate': float(row.booking_com_rate) if row.booking_com_rate else None, - 'difference_pct': float(row.difference_pct) if row.difference_pct else None, - 'alert_type': row.alert_type, - 'alert_status': row.alert_status, - 'created_at': row.created_at.isoformat() if row.created_at else None, - 'acknowledged_at': row.acknowledged_at.isoformat() if row.acknowledged_at else None, - 'acknowledged_by': row.acknowledged_by, - 'notes': row.notes, - } - for row in result.fetchall() - ] - - -@router.put("/parity/alerts/{alert_id}/acknowledge") -async def acknowledge_parity_alert( - alert_id: int, - db: AsyncSession = Depends(get_db), - current_user: dict = Depends(get_current_user) -): - """Acknowledge a parity alert.""" - result = await db.execute( - text(""" - UPDATE rate_parity_alerts - SET alert_status = 'acknowledged', - acknowledged_at = NOW(), - acknowledged_by = :username - WHERE id = :alert_id - RETURNING id - """), - {'alert_id': alert_id, 'username': current_user.get('username', 'unknown')} - ) - - if not result.fetchone(): - raise HTTPException(status_code=404, detail="Alert not found") - - await db.commit() - return {"status": "success"} - - -# ============================================ -# QUEUE STATUS -# ============================================ - -@router.get("/queue-status") -async def get_queue_status( - db: AsyncSession = Depends(get_db), - current_user: dict = Depends(get_current_user) -): - """Get current scrape queue status.""" - result = await db.execute( - text(""" - SELECT - status, - COUNT(*) as count, - MIN(rate_date) as earliest_date, - MAX(rate_date) as latest_date - FROM booking_scrape_queue - GROUP BY status - """) - ) - status_counts = {row.status: { - 'count': row.count, - 'earliest': row.earliest_date.isoformat() if row.earliest_date else None, - 'latest': row.latest_date.isoformat() if row.latest_date else None, - } for row in result.fetchall()} - - # Get retry items (failed but under max_attempts) - retry_result = await db.execute( - text(""" - SELECT COUNT(*) as count - FROM booking_scrape_queue - WHERE status = 'pending' AND attempts > 0 - """) - ) - retry_count = retry_result.fetchone().count - - return { - 'statuses': status_counts, - 'retries_pending': retry_count, - 'total_pending': status_counts.get('pending', {}).get('count', 0), - 'total_completed': status_counts.get('completed', {}).get('count', 0), - 'total_failed': status_counts.get('failed', {}).get('count', 0), - } - - -# ============================================ -# SCHEDULE INFO -# ============================================ - -@router.get("/schedule-info") -async def get_schedule_info( - db: AsyncSession = Depends(get_db), - current_user: dict = Depends(get_current_user) -): - """Get information about the scraping schedule.""" - # Get configured time - time_result = await db.execute( - text("SELECT config_value FROM system_config WHERE config_key = 'booking_scraper_daily_time'") - ) - time_row = time_result.fetchone() - daily_time = time_row.config_value if time_row and time_row.config_value else '05:30' - - # Calculate what today's schedule would look like - from jobs.scrape_booking_rates import get_high_priority_dates, get_medium_priority_dates, get_low_priority_dates - high = get_high_priority_dates() - medium = get_medium_priority_dates() - low = get_low_priority_dates() - - today = date.today() - weekday_name = today.strftime('%A') - - return { - 'daily_time': daily_time, - 'today': today.isoformat(), - 'weekday': weekday_name, - 'tiers': { - 'high': { - 'description': 'Next 30 days (scraped first)', - 'dates_today': len(high), - 'range': f'{high[0].isoformat()} to {high[-1].isoformat()}' if high else None, - }, - 'medium': { - 'description': 'Days 31-180 (scraped after high priority)', - 'dates_today': len(medium), - 'range': f'{medium[0].isoformat()} to {medium[-1].isoformat()}' if medium else None, - }, - 'low': { - 'description': 'Days 181-365 (scraped last, or until rate limit)', - 'dates_today': len(low), - 'range': f'{low[0].isoformat()} to {low[-1].isoformat()}' if low else None, - }, - }, - 'total_dates_today': len(set(high + medium + low)), - } - - -# ============================================ -# SCRAPE COVERAGE (365-day view) -# ============================================ - -@router.get("/scrape-coverage") -async def get_scrape_coverage( - db: AsyncSession = Depends(get_db), - current_user: dict = Depends(get_current_user) -): - """ - Get 365-day scrape coverage showing last scraped time - and next expected scrape for every date. - """ - today = date.today() - end = today + timedelta(days=365) - - # Get latest scraped_at per date (across all hotels) - result = await db.execute( - text(""" - SELECT rate_date, MAX(scraped_at) as last_scraped - FROM booking_com_rates - WHERE rate_date >= :from_date AND rate_date <= :to_date - GROUP BY rate_date - """), - {'from_date': today, 'to_date': end} - ) - scraped_map = {row.rate_date: row.last_scraped for row in result.fetchall()} - - # Compute tier and next scrape for each date - from jobs.scrape_booking_rates import compute_next_scrape_for_date - - coverage = [] - for offset in range(366): - d = today + timedelta(days=offset) - tier, next_scrape = compute_next_scrape_for_date(d) - last_scraped = scraped_map.get(d) - - coverage.append({ - 'date': d.isoformat(), - 'tier': tier, - 'last_scraped': last_scraped.isoformat() if last_scraped else None, - 'next_expected': next_scrape.isoformat() if next_scrape else None, - }) - - return { - 'today': today.isoformat(), - 'coverage': coverage, - } - - -# ============================================ -# BOOKING.COM AVAILABILITY CHECK (for Bookability page) -# ============================================ - -@router.get("/booking-availability") -async def get_booking_availability( - from_date: Optional[str] = None, - to_date: Optional[str] = None, - db: AsyncSession = Depends(get_db), - current_user: dict = Depends(get_current_user) -): - """ - Check own hotel's availability on booking.com. - - Returns a simple summary: for each date in the range, whether the own hotel - appears available on booking.com based on the latest scrape data. - """ - today = date.today() - start = date.fromisoformat(from_date) if from_date else today - end = date.fromisoformat(to_date) if to_date else today + timedelta(days=30) - - # Get own hotel's latest scraped availability - result = await db.execute( - text(""" - SELECT DISTINCT ON (r.rate_date) - r.rate_date, - r.availability_status, - r.rate_gross, - r.scraped_at - FROM booking_com_rates r - JOIN booking_com_hotels h ON r.hotel_id = h.id - WHERE h.tier = 'own' - AND r.rate_date >= :from_date AND r.rate_date <= :to_date - ORDER BY r.rate_date, r.scraped_at DESC - """), - {'from_date': start, 'to_date': end} - ) - rows = result.fetchall() - - if not rows: - return { - 'has_own_hotel': False, - 'dates_checked': 0, - 'dates_available': 0, - 'dates_sold_out': 0, - 'dates_no_data': 0, - 'latest_scrape': None, - 'dates': {}, - } - - dates_map = {} - dates_available = 0 - dates_sold_out = 0 - dates_no_data = 0 - latest_scrape = None - - for row in rows: - status = row.availability_status - dates_map[row.rate_date.isoformat()] = { - 'status': status, - 'rate': float(row.rate_gross) if row.rate_gross else None, - } - if status == 'available': - dates_available += 1 - elif status == 'sold_out': - dates_sold_out += 1 - else: - dates_no_data += 1 - - if row.scraped_at and (not latest_scrape or row.scraped_at > latest_scrape): - latest_scrape = row.scraped_at - - return { - 'has_own_hotel': True, - 'dates_checked': len(rows), - 'dates_available': dates_available, - 'dates_sold_out': dates_sold_out, - 'dates_no_data': dates_no_data, - 'latest_scrape': latest_scrape.isoformat() if latest_scrape else None, - 'dates': dates_map, - } - - -# ============================================ -# SCRAPE HISTORY -# ============================================ - -@router.get("/scrape-history") -async def get_scrape_history( - limit: int = 20, - db: AsyncSession = Depends(get_db), - current_user: dict = Depends(get_current_user) -): - """Get recent scrape batch history.""" - result = await db.execute( - text(""" - SELECT batch_id, scrape_type, started_at, completed_at, status, - dates_queued, dates_completed, dates_failed, - hotels_found, rates_scraped, error_message, - blocked_at, resume_after - FROM booking_scrape_log - ORDER BY started_at DESC - LIMIT :limit - """), - {'limit': limit} - ) - - return [ - { - 'batch_id': str(row.batch_id), - 'scrape_type': row.scrape_type, - 'started_at': row.started_at.isoformat() if row.started_at else None, - 'completed_at': row.completed_at.isoformat() if row.completed_at else None, - 'status': row.status, - 'dates_queued': row.dates_queued, - 'dates_completed': row.dates_completed, - 'dates_failed': row.dates_failed, - 'hotels_found': row.hotels_found, - 'rates_scraped': row.rates_scraped, - 'error_message': row.error_message, - 'blocked_at': row.blocked_at.isoformat() if row.blocked_at else None, - 'resume_after': row.resume_after.isoformat() if row.resume_after else None, - } - for row in result.fetchall() - ] diff --git a/backend/main.py b/backend/main.py index 64674a9..0227698 100644 --- a/backend/main.py +++ b/backend/main.py @@ -20,8 +20,7 @@ from database import async_engine from api import ( forecast, sync, export, budget, accuracy, evolution, crossref, explain, config, historical, resos, backtest, sync_bookings, - resos_sync, reports, special_dates, backup, public, bookability, - competitor_rates, ai_insights, + resos_sync, reports, special_dates, backup, public, ai_insights, ) from scheduler import start_scheduler, shutdown_scheduler @@ -151,8 +150,6 @@ app.include_router(reports.router, prefix="/reports", tags=[ app.include_router(special_dates.router, prefix="/settings", tags=["Settings"]) app.include_router(backup.router, prefix="/backup", tags=["Backup & Restore"]) app.include_router(public.router, prefix="/public", tags=["Public API"]) -app.include_router(bookability.router, prefix="/bookability", tags=["Bookability"]) -app.include_router(competitor_rates.router, prefix="/competitor-rates", tags=["Competitor Rates"]) app.include_router(ai_insights.router, prefix="/ai-insights", tags=["AI Insights"]) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 69aed7a..1e6adc2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,8 +4,6 @@ import Layout from './components/Layout' import Dashboard from './pages/Dashboard' import Forecasts from './pages/Forecasts' import History from './pages/History' -import Bookability from './pages/Bookability' -import CompetitorRates from './pages/CompetitorRates' import Accuracy from './pages/Accuracy' import Settings from './pages/Settings' @@ -20,8 +18,6 @@ export default function App() { } /> } /> } /> - } /> - } /> } /> } /> } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 9e8286f..de69058 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,6 +1,6 @@ import { NavLink } from 'react-router-dom' import { - TrendingUp, BarChart2, Calendar, Target, Globe, History, + TrendingUp, BarChart2, Target, History, Settings, Bot, } from 'lucide-react' import { useAuth } from './AuthGate' @@ -13,8 +13,6 @@ const NAV = [ { to: '/dashboard', label: 'Dashboard', icon: Bot, cap: 'view' }, { to: '/forecasts', label: 'Forecasts', icon: TrendingUp, cap: 'view' }, { to: '/history', label: 'History', icon: History, cap: 'view' }, - { to: '/bookability', label: 'Bookability', icon: Calendar, cap: 'view_bookability' }, - { to: '/competitor-rates', label: 'Competitors', icon: Globe, cap: 'view_competitor_rates' }, { to: '/accuracy', label: 'Accuracy', icon: Target, cap: 'view_accuracy' }, { to: '/settings', label: 'Settings', icon: Settings, cap: 'settings' }, ] diff --git a/frontend/src/pages/Bookability.tsx b/frontend/src/pages/Bookability.tsx deleted file mode 100644 index 3fb67cd..0000000 --- a/frontend/src/pages/Bookability.tsx +++ /dev/null @@ -1,1094 +0,0 @@ -import React, { useState, useMemo } from 'react' -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { Link } from 'react-router-dom' -import api from '../api' - -// Format Date as YYYY-MM-DD using local time (avoids UTC/DST shift from toISOString) -const fmtDate = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` - -// Types -interface CategoryInfo { - category_id: string - category_name: string - room_count: number -} - -interface TariffInfo { - name: string - description?: string - rate: number | null - average_nightly?: number - available: boolean - message: string - sort_order?: number - min_stay?: number | null - available_for_min_stay?: boolean | null // True if available when queried with min_stay nights -} - -interface OccupancyInfo { - occupied: number - available: number - maintenance: number -} - -interface DateRateInfo { - rate_gross: number | null - rate_net: number | null - tariffs: TariffInfo[] - tariff_count: number - occupancy?: OccupancyInfo - valid_from?: string | null -} - -interface RateMatrixData { - categories: CategoryInfo[] - dates: string[] - matrix: Record> - date_last_updated?: Record -} - -// Helper functions -const formatDateShort = (dateStr: string): string => { - const date = new Date(dateStr + 'T00:00:00') - return date.toLocaleDateString('en-GB', { day: 'numeric' }) -} - -const formatDayOfWeek = (dateStr: string): string => { - const date = new Date(dateStr + 'T00:00:00') - return date.toLocaleDateString('en-GB', { weekday: 'short' }) -} - -const formatLastUpdated = (isoStr: string | null | undefined): string => { - if (!isoStr) return '' - const d = new Date(isoStr) - const now = new Date() - const isToday = d.toDateString() === now.toDateString() - return isToday - ? d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) - : d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short' }) + ' ' + d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) -} - -const isWeekend = (dateStr: string): boolean => { - const date = new Date(dateStr + 'T00:00:00') - const day = date.getDay() - return day === 0 || day === 6 -} - -const formatCurrency = (value: number | null): string => { - if (value === null || value === undefined) return '-' - return new Intl.NumberFormat('en-GB', { - style: 'currency', - currency: 'GBP', - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }).format(value) -} - -// Inline style helpers (replacing theme utilities) -const mergeStyles = (...styles: React.CSSProperties[]): React.CSSProperties => - Object.assign({}, ...styles) - -const buttonStyle = (variant: 'primary' | 'secondary' | 'outline', size?: 'small'): React.CSSProperties => { - const base: React.CSSProperties = { - border: 'none', - borderRadius: '6px', - cursor: 'pointer', - fontWeight: 500, - padding: size === 'small' ? '4px 10px' : '8px 16px', - fontSize: size === 'small' ? '13px' : '14px', - lineHeight: 1.4, - transition: 'all 0.15s', - } - if (variant === 'primary') return { ...base, background: 'var(--gold)', color: '#fff' } - if (variant === 'secondary') return { ...base, background: 'var(--navy)', color: '#fff' } - // outline - return { ...base, background: 'transparent', color: 'var(--text-dark)', border: '1px solid var(--card-border)' } -} - -const badgeStyle = (variant: 'success' | 'error' | 'warning' | 'info'): React.CSSProperties => { - const map: Record = { - success: { background: '#dcfce7', color: '#16a34a', padding: '2px 8px', borderRadius: '99px', fontSize: '12px', fontWeight: 600 }, - error: { background: '#fee2e2', color: '#dc2626', padding: '2px 8px', borderRadius: '99px', fontSize: '12px', fontWeight: 600 }, - warning: { background: '#fef3c7', color: '#d97706', padding: '2px 8px', borderRadius: '99px', fontSize: '12px', fontWeight: 600 }, - info: { background: '#dbeafe', color: '#2563eb', padding: '2px 8px', borderRadius: '99px', fontSize: '12px', fontWeight: 600 }, - } - return map[variant] || map.info -} - -// Components -const MonthSelector: React.FC<{ - value: string - onChange: (value: string) => void -}> = ({ value, onChange }) => { - const handlePrevMonth = () => { - const [year, month] = value.split('-').map(Number) - const date = new Date(year, month - 2, 1) - onChange(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`) - } - - const handleNextMonth = () => { - const [year, month] = value.split('-').map(Number) - const date = new Date(year, month, 1) - onChange(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`) - } - - // Generate month options (current month + next 12 months) - const monthOptions = useMemo(() => { - const options: { value: string; label: string }[] = [] - const now = new Date() - for (let i = 0; i < 13; i++) { - const date = new Date(now.getFullYear(), now.getMonth() + i, 1) - const monthValue = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` - const label = date.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' }) - options.push({ value: monthValue, label }) - } - return options - }, []) - - return ( -
- - - -
- ) -} - -// Helper to collect unique tariff names across all dates for a category, preserving Newbook order -const getAllTariffNames = (rateData: Record, dates: string[]): string[] => { - const tariffMap = new Map() - for (const dateStr of dates) { - const data = rateData[dateStr] - if (data?.tariffs) { - for (const tariff of data.tariffs) { - if (!tariffMap.has(tariff.name)) { - tariffMap.set(tariff.name, tariff.sort_order ?? 999) - } - } - } - } - return Array.from(tariffMap.entries()) - .sort((a, b) => a[1] - b[1]) - .map(([name]) => name) -} - -// Helper to format scrape age -const formatScrapeAge = (isoStr: string | null): string => { - if (!isoStr) return '' - const diff = Date.now() - new Date(isoStr).getTime() - const mins = Math.floor(diff / 60000) - if (mins < 60) return `${mins}m ago` - const hours = Math.floor(mins / 60) - if (hours < 24) return `${hours}h ago` - const days = Math.floor(hours / 24) - return `${days}d ago` -} - -interface BookingAvailabilityData { - has_own_hotel: boolean - dates_checked: number - dates_available: number - dates_sold_out: number - dates_no_data: number - latest_scrape: string | null - dates: Record -} - -const LoadingSpinner: React.FC = () => ( -
-
- Loading rate data... -
-) - -const ErrorMessage: React.FC<{ message: string }> = ({ message }) => ( -
- ! - {message} -
-) - -// Main Component -const Bookability: React.FC = () => { - const queryClient = useQueryClient() - const [selectedMonth, setSelectedMonth] = useState(() => { - const today = new Date() - return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}` - }) - const [refreshingDate, setRefreshingDate] = useState(null) - - // Calculate date range from selected month - const { fromDate, toDate } = useMemo(() => { - const [year, month] = selectedMonth.split('-').map(Number) - const start = new Date(year, month - 1, 1) - const end = new Date(year, month, 0) // Last day of month - return { - fromDate: fmtDate(start), - toDate: fmtDate(end), - } - }, [selectedMonth]) - - // Single-date refresh mutation - const dateRefreshM = useMutation({ - mutationFn: async (d: string) => { - setRefreshingDate(d) - const res = await api.post(`/bookability/refresh-date/${d}`) - return res.data - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['rate-matrix'] }) - setRefreshingDate(null) - }, - onError: () => setRefreshingDate(null), - }) - - // Fetch Booking.com availability data - const { data: bookingData } = useQuery({ - queryKey: ['booking-availability', fromDate, toDate], - queryFn: async () => { - const params = new URLSearchParams({ from_date: fromDate, to_date: toDate }) - const res = await api.get(`/competitor-rates/booking-availability?${params}`) - return res.data - }, - staleTime: 5 * 60 * 1000, - }) - - // Fetch rate matrix data - const { data, isLoading, error, refetch } = useQuery({ - queryKey: ['rate-matrix', fromDate, toDate], - queryFn: async () => { - const params = new URLSearchParams({ from_date: fromDate, to_date: toDate }) - const res = await api.get(`/bookability/rate-matrix?${params}`) - return res.data - }, - }) - - // Calculate summary stats - focus on unbookable dates (rooms available but no rates) - const summary = useMemo(() => { - if (!data) return null - - let totalDateCategories = 0 - let unbookableDateCategories = 0 - const unbookableIssues: { category: string; date: string; roomsLeft: number }[] = [] - - for (const cat of data.categories) { - const catData = data.matrix[cat.category_id] - if (!catData) continue - - for (const dateStr of data.dates) { - const dayData = catData[dateStr] - if (!dayData) continue - - // Check if rooms are available (bookable = available - maintenance - occupied) - const occ = dayData.occupancy - const bookableRooms = occ ? occ.available - occ.maintenance : 0 - const roomsLeft = occ ? bookableRooms - occ.occupied : 0 - const hasRoomsAvailable = roomsLeft > 0 - - // Only count dates where rooms are available - if (hasRoomsAvailable) { - totalDateCategories++ - - // Check if ANY tariff is available for booking - // A tariff is "bookable" if: - // - available: true (single-night available), OR - // - has min_stay > 1 AND available_for_min_stay: true (verified via multi-night query) - const hasAnyAvailableRate = dayData.tariffs?.some(t => { - if (t.available) return true - // If has min_stay requirement and verified available for that stay length - if (t.min_stay && t.min_stay > 1 && t.available_for_min_stay === true) return true - return false - }) ?? false - - if (!hasAnyAvailableRate && dayData.tariffs && dayData.tariffs.length > 0) { - // Rooms available but no rates bookable - this is a problem! - unbookableDateCategories++ - unbookableIssues.push({ - category: cat.category_name, - date: dateStr, - roomsLeft: roomsLeft, - }) - } - } - } - } - - return { - totalDateCategories, - unbookableDateCategories, - unbookablePercent: totalDateCategories > 0 - ? ((unbookableDateCategories / totalDateCategories) * 100).toFixed(1) - : '0', - issues: unbookableIssues.slice(0, 10), - hasMoreIssues: unbookableIssues.length > 10, - totalIssues: unbookableIssues.length, - } - }, [data]) - - return ( -
- {/* Header */} -
-
-
-

Rate Availability

-

- View tariff availability across all room categories -

-
-
- - -
-
- - {/* Summary Stats */} - {summary && ( -
-
- {data?.categories.length || 0} - Room Types -
-
- {data?.dates.length || 0} - Days -
-
- 0 ? { color: 'var(--danger)' } : { color: 'var(--success)' } - )}> - {summary.unbookableDateCategories} - - Unbookable -
-
- 0 ? 'warning' : 'success')}> - {summary.unbookablePercent}% blocked - -
-
- )} -
- - {/* Content */} -
- {isLoading && } - {error && } - {data && data.categories.length === 0 && ( -
- No room categories configured. Please set up room categories in Settings. -
- )} - {data && data.categories.length > 0 && ( -
-
- - - - - {data.dates.map(dateStr => ( - - ))} - - - - {data.categories.map(category => { - const rateData = data.matrix[category.category_id] || {} - const tariffNames = getAllTariffNames(rateData, data.dates) - return ( - - {/* Category header row */} - - - {data.dates.map(dateStr => ( - - {/* Occupancy row */} - - - {data.dates.map(dateStr => { - const dayData = rateData[dateStr] - const occ = dayData?.occupancy - if (!occ) { - return ( - - ) - } - const bookableRooms = occ.available - occ.maintenance - const roomsLeft = bookableRooms - occ.occupied - const isFull = roomsLeft <= 0 - const occPercent = bookableRooms > 0 - ? Math.round((occ.occupied / bookableRooms) * 100) - : 100 - const isHighOcc = occPercent >= 80 && !isFull - const hasOffline = occ.maintenance > 0 - const getOccStyle = () => { - if (isFull) return styles.occupancyFull - if (isHighOcc) return styles.occupancyHigh - return styles.occupancyAvailable - } - return ( - - ) - })} - - {/* Tariff rows */} - {tariffNames.length === 0 ? ( - - - - ) : ( - tariffNames.map(tariffName => ( - - - {data.dates.map(dateStr => { - const dayData = rateData[dateStr] - const tariff = dayData?.tariffs?.find(t => t.name === tariffName) - const occupancy = dayData?.occupancy - const noRoomsAvailable = occupancy && - (occupancy.available - occupancy.maintenance - occupancy.occupied) <= 0 - - if (!tariff) { - return ( - - ) - } - - const isEffectivelyAvailable = tariff.available || - (tariff.min_stay && tariff.min_stay > 1 && tariff.available_for_min_stay === true) - const minStayBadge = isEffectivelyAvailable && !noRoomsAvailable && tariff.min_stay && tariff.min_stay > 1 ? ( - - {tariff.min_stay} - - ) : null - const getCellStyle = () => { - if (noRoomsAvailable) return styles.cellNoRooms - if (isEffectivelyAvailable) return styles.cellAvailable - return styles.cellUnavailable - } - const getTooltip = () => { - if (noRoomsAvailable) return `${tariffName}: No rooms available` - if (isEffectivelyAvailable) { - const minStayNote = tariff.min_stay && tariff.min_stay > 1 ? ` (Min ${tariff.min_stay} nights)` : '' - return `${tariffName}: ${formatCurrency(tariff.rate)}${minStayNote}` - } - return `${tariffName}: ${tariff.message || 'Not available'}` - } - - return ( - - ) - })} - - )) - )} - - ) - })} - {/* Booking.com section */} - {bookingData && bookingData.has_own_hotel && ( - - - - {data.dates.map(dateStr => ( - - - - {data.dates.map(dateStr => { - const entry = bookingData.dates[dateStr] - const isAvailable = entry?.status === 'available' - const isSoldOut = entry?.status === 'sold_out' - const getCellStyle = () => { - if (!entry) return styles.cellNoData - if (isAvailable && entry.rate) return styles.cellAvailable - if (isSoldOut) return styles.bookingCellSoldOut - return styles.cellNoData - } - return ( - - ) - })} - - - )} - -
Tariff -
- {formatDayOfWeek(dateStr)} - {formatDateShort(dateStr)} - - {data.date_last_updated?.[dateStr] && ( - - {formatLastUpdated(data.date_last_updated[dateStr])} - - )} -
-
- {category.category_name} - ({category.room_count} rooms) - - ))} -
- Occupancy - - - - - - {occ.occupied}/{occ.available} - {hasOffline && ({occ.maintenance})} - -
- No rate data available for this period -
- {tariffName} - - - - - - {tariff.rate !== null ? formatCurrency(tariff.rate) : (isEffectivelyAvailable ? 'Y' : 'N')} - {minStayBadge} - -
- Booking.com - - {' '}{bookingData.latest_scrape ? `Scraped ${formatScrapeAge(bookingData.latest_scrape)}` : 'No scrape data'} - {' · '} - - View details - - - - ))} -
- Best Available - - {!entry ? '-' - : isAvailable && entry.rate ? formatCurrency(entry.rate) - : isSoldOut ? 'Sold' - : '-'} -
-
-
- )} -
- - {/* Issues Panel */} - {summary && summary.unbookableDateCategories > 0 && ( -
-

- Unbookable Dates ({summary.totalIssues}) -

-

- Dates with rooms available but no rates bookable -

-
- {summary.issues.map((issue, idx) => ( -
- {issue.category} - {issue.date} - {issue.roomsLeft} room{issue.roomsLeft !== 1 ? 's' : ''} available, no rates -
- ))} - {summary.hasMoreIssues && ( -
- +{summary.totalIssues - 10} more issues -
- )} -
-
- )} - - {/* Legend */} -
- Legend: - Available - Unavailable - No Rooms - No Data -
-
- ) -} - -// Styles -const styles: Record = { - container: { - padding: '24px', - maxWidth: '100%', - margin: '0 auto', - }, - header: { - marginBottom: '24px', - }, - headerTop: { - display: 'flex', - justifyContent: 'space-between', - alignItems: 'flex-start', - marginBottom: '16px', - flexWrap: 'wrap', - gap: '16px', - }, - title: { - fontSize: '24px', - fontWeight: 700, - color: 'var(--text-dark)', - margin: 0, - }, - subtitle: { - fontSize: '13px', - color: 'var(--text-mid)', - margin: '4px 0 0', - }, - headerActions: { - display: 'flex', - alignItems: 'center', - gap: '16px', - }, - monthSelector: { - display: 'flex', - alignItems: 'center', - gap: '8px', - }, - monthDropdown: { - fontSize: '14px', - fontWeight: 500, - color: 'var(--text-dark)', - padding: '4px 8px', - borderRadius: '6px', - border: '1px solid var(--card-border)', - background: 'var(--card-bg)', - cursor: 'pointer', - minWidth: '160px', - }, - summaryBar: { - display: 'flex', - gap: '24px', - padding: '16px', - background: 'var(--card-bg)', - borderRadius: '10px', - boxShadow: 'var(--shadow-sm)', - flexWrap: 'wrap', - }, - summaryItem: { - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - gap: '4px', - }, - summaryValue: { - fontSize: '20px', - fontWeight: 700, - color: 'var(--text-dark)', - }, - summaryLabel: { - fontSize: '11px', - color: 'var(--text-mid)', - textTransform: 'uppercase', - }, - content: { - display: 'flex', - flexDirection: 'column', - gap: '24px', - }, - unifiedCard: { - background: 'var(--card-bg)', - borderRadius: '10px', - padding: '16px', - boxShadow: 'var(--shadow-md)', - }, - categoryHeaderRow: { - fontWeight: 600, - fontSize: '14px', - color: 'var(--text-dark)', - background: 'var(--body-bg)', - padding: '8px 16px', - borderTop: '2px solid var(--card-border)', - textAlign: 'left' as const, - whiteSpace: 'nowrap' as const, - }, - categoryHeaderFill: { - background: 'var(--body-bg)', - borderTop: '2px solid var(--card-border)', - padding: 0, - }, - stickyHeader: { - position: 'sticky' as const, - top: 0, - zIndex: 20, - background: 'var(--card-bg)', - }, - roomCount: { - fontSize: '13px', - fontWeight: 400, - color: 'var(--text-mid)', - }, - tableContainer: { - overflowX: 'auto', - maxWidth: '100%', - }, - table: { - width: '100%', - borderCollapse: 'collapse', - fontSize: '13px', - minWidth: '800px', - tableLayout: 'fixed' as const, - }, - th: { - padding: '8px', - borderBottom: '2px solid var(--card-border)', - textAlign: 'center', - fontWeight: 600, - color: 'var(--text-dark)', - whiteSpace: 'nowrap', - background: 'var(--card-bg)', - }, - td: { - padding: '8px', - borderBottom: '1px solid var(--card-border)', - textAlign: 'center', - whiteSpace: 'nowrap', - }, - stickyCol: { - position: 'sticky', - left: 0, - background: 'var(--card-bg)', - zIndex: 10, - textAlign: 'left', - width: '160px', - borderRight: '1px solid var(--card-border)', - overflow: 'hidden', - textOverflow: 'ellipsis', - }, - dateHeader: { - width: '56px', - padding: '4px', - }, - dateHeaderContent: { - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - gap: '2px', - }, - dayOfWeek: { - fontSize: '11px', - color: 'var(--text-mid)', - }, - dayNum: { - fontSize: '13px', - fontWeight: 600, - }, - lastUpdated: { - fontSize: '9px', - color: 'var(--text-mid)', - opacity: 0.7, - lineHeight: 1, - }, - weekendHeader: { - background: 'var(--body-bg)', - }, - weekendCell: { - borderLeft: '2px solid var(--card-border)', - }, - tariffNameCell: { - fontWeight: 500, - overflow: 'hidden', - textOverflow: 'ellipsis', - }, - tariffCell: { - cursor: 'pointer', - position: 'relative', - transition: 'background 0.1s', - fontSize: '11px', - }, - cellContent: { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - gap: '2px', - }, - minStayBadge: { - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - width: '14px', - height: '14px', - borderRadius: '50%', - background: '#d97706', - color: '#fff', - fontSize: '9px', - fontWeight: 700, - marginLeft: '2px', - flexShrink: 0, - }, - cellAvailable: { - background: '#dcfce7', - color: 'var(--success)', - }, - cellUnavailable: { - background: '#fee2e2', - color: 'var(--danger)', - textDecoration: 'line-through', - }, - cellNoRooms: { - background: '#e0e0e0', - color: 'var(--text-mid)', - }, - cellNoData: { - background: 'var(--body-bg)', - color: 'var(--text-mid)', - }, - bookingCellSoldOut: { - background: '#fee2e2', - color: 'var(--danger)', - fontWeight: 500, - }, - occupancyLabel: { - fontWeight: 600, - color: 'var(--navy)', - background: '#f0f4ff', - }, - occupancyCell: { - fontSize: '11px', - color: 'var(--text-mid)', - background: 'var(--body-bg)', - }, - occupancyText: { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - gap: '2px', - }, - maintenanceBadge: { - color: '#d97706', - marginLeft: '2px', - }, - occupancyAvailable: { - background: '#dcfce7', - color: 'var(--success)', - fontWeight: 500, - }, - occupancyHigh: { - background: '#fef3c7', - color: '#d97706', - fontWeight: 500, - }, - occupancyFull: { - background: '#fee2e2', - color: 'var(--danger)', - fontWeight: 500, - }, - loading: { - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'center', - padding: '48px', - gap: '16px', - color: 'var(--text-mid)', - }, - spinner: { - width: '40px', - height: '40px', - border: '3px solid var(--card-border)', - borderTop: '3px solid var(--navy)', - borderRadius: '50%', - animation: 'spin 1s linear infinite', - }, - error: { - display: 'flex', - alignItems: 'center', - gap: '8px', - padding: '24px', - background: '#fee2e2', - color: 'var(--danger)', - borderRadius: '10px', - }, - errorIcon: { - width: '24px', - height: '24px', - borderRadius: '50%', - background: 'var(--danger)', - color: '#fff', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - fontWeight: 700, - }, - noData: { - textAlign: 'center', - padding: '32px', - color: 'var(--text-mid)', - }, - issuesPanel: { - marginTop: '24px', - background: '#fef3c7', - borderRadius: '10px', - padding: '24px', - }, - issuesTitle: { - fontSize: '14px', - fontWeight: 600, - color: '#d97706', - marginBottom: '4px', - }, - issuesSubtitle: { - fontSize: '13px', - color: 'var(--text-mid)', - marginBottom: '16px', - }, - issuesList: { - display: 'flex', - flexDirection: 'column', - gap: '8px', - }, - issueItem: { - display: 'flex', - gap: '8px', - fontSize: '13px', - flexWrap: 'wrap', - }, - issueCategory: { - fontWeight: 600, - color: 'var(--text-dark)', - }, - issueDate: { - color: 'var(--text-mid)', - }, - issueMessage: { - color: 'var(--danger)', - fontStyle: 'italic', - }, - moreIssues: { - color: 'var(--text-mid)', - fontStyle: 'italic', - marginTop: '8px', - }, - legend: { - display: 'flex', - alignItems: 'center', - gap: '16px', - marginTop: '24px', - padding: '16px', - background: 'var(--card-bg)', - borderRadius: '10px', - fontSize: '13px', - }, - legendTitle: { - fontWeight: 600, - color: 'var(--text-dark)', - }, - legendItem: { - padding: '4px 8px', - borderRadius: '4px', - fontSize: '11px', - }, - scrapeBtn: { - background: 'none', - border: '1px solid var(--card-border)', - borderRadius: '4px', - cursor: 'pointer', - fontSize: '10px', - lineHeight: 1, - padding: '2px 4px', - color: 'var(--text-mid)', - opacity: 0.6, - transition: 'opacity 0.15s', - }, - scrapeBtnActive: { - opacity: 1, - color: 'var(--navy)', - borderColor: 'var(--navy)', - }, -} - -export default Bookability diff --git a/frontend/src/pages/CompetitorRates.tsx b/frontend/src/pages/CompetitorRates.tsx deleted file mode 100644 index 3b3cf3e..0000000 --- a/frontend/src/pages/CompetitorRates.tsx +++ /dev/null @@ -1,1827 +0,0 @@ -import React, { useState, useMemo, useCallback } from 'react' -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import api from '../api' - -// Format Date as YYYY-MM-DD using local time (avoids UTC/DST shift from toISOString) -const fmtDate = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` - -// ============================================ -// TYPES -// ============================================ - -interface ScraperStatus { - enabled: boolean - paused: boolean - pause_until: string | null - backend: string - location_configured: boolean - location_name: string | null - last_scrape: { - batch_id: string - scrape_type: string - started_at: string | null - completed_at: string | null - status: string - hotels_found: number | null - rates_scraped: number | null - error_message: string | null - } | null -} - -interface Hotel { - id: number - booking_com_id: string - name: string - booking_com_url: string | null - star_rating: number | null - review_score: number | null - review_count: number | null - tier: 'own' | 'competitor' | 'market' - display_order: number - notes: string | null - first_seen_at: string | null - last_seen_at: string | null -} - -interface RateMatrixResponse { - from_date: string - to_date: string - dates: string[] - hotels: { - id: number - name: string - tier: string - display_order: number - star_rating: number | null - review_score: number | null - booking_com_url: string | null - }[] - rates: Record> -} - -interface ScheduleInfo { - daily_time: string - today: string - weekday: string - tiers: { - high: { description: string; dates_today: number; range: string | null } - medium: { description: string; dates_today: number; range: string | null } - low: { description: string; dates_today: number; range: string | null } - } - total_dates_today: number -} - -interface QueueStatus { - statuses: Record - retries_pending: number - total_pending: number - total_completed: number - total_failed: number -} - -interface CoverageEntry { - date: string - tier: 'high' | 'medium' | 'low' | 'none' - last_scraped: string | null - next_expected: string | null -} - -interface CoverageResponse { - today: string - coverage: CoverageEntry[] -} - -interface ScrapeHistoryEntry { - batch_id: string - scrape_type: string - started_at: string | null - completed_at: string | null - status: string - dates_queued: number | null - dates_completed: number | null - dates_failed: number | null - hotels_found: number | null - rates_scraped: number | null - error_message: string | null - blocked_at: string | null - resume_after: string | null -} - -// ============================================ -// HELPERS -// ============================================ - -const formatCurrency = (value: number | null): string => { - if (value === null || value === undefined) return '-' - return new Intl.NumberFormat('en-GB', { - style: 'currency', - currency: 'GBP', - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }).format(value) -} - -const formatDateShort = (dateStr: string): string => { - const date = new Date(dateStr + 'T00:00:00') - return date.toLocaleDateString('en-GB', { day: 'numeric' }) -} - -const formatDayOfWeek = (dateStr: string): string => { - const date = new Date(dateStr + 'T00:00:00') - return date.toLocaleDateString('en-GB', { weekday: 'short' }) -} - -const isWeekend = (dateStr: string): boolean => { - const date = new Date(dateStr + 'T00:00:00') - const day = date.getDay() - return day === 0 || day === 6 -} - -const formatDateTime = (iso: string | null): string => { - if (!iso) return '-' - const d = new Date(iso) - return d.toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) -} - -const formatScrapeAge = (iso: string | null): string => { - if (!iso) return '' - const scraped = new Date(iso) - const now = new Date() - const diffMs = now.getTime() - scraped.getTime() - const diffMins = Math.floor(diffMs / 60000) - if (diffMins < 60) return `${diffMins}m ago` - const diffHours = Math.floor(diffMins / 60) - if (diffHours < 24) return `${diffHours}h ago` - const diffDays = Math.floor(diffHours / 24) - return `${diffDays}d ago` -} - -const tierColor = (tier: string) => { - switch (tier) { - case 'own': return '#2563eb' - case 'competitor': return '#d97706' - case 'market': return '#64748b' - default: return '#64748b' - } -} - -// ============================================ -// INLINE STYLE HELPERS (replacing theme utilities) -// ============================================ - -const mergeStyles = (...s: React.CSSProperties[]): React.CSSProperties => - Object.assign({}, ...s) - -const buttonStyle = (variant: 'primary' | 'secondary' | 'outline', size?: 'small'): React.CSSProperties => { - const base: React.CSSProperties = { - border: 'none', - borderRadius: '6px', - cursor: 'pointer', - fontWeight: 500, - padding: size === 'small' ? '4px 10px' : '8px 16px', - fontSize: size === 'small' ? '13px' : '14px', - lineHeight: 1.4, - transition: 'all 0.15s', - } - if (variant === 'primary') return { ...base, background: 'var(--gold)', color: '#fff' } - if (variant === 'secondary') return { ...base, background: 'var(--navy)', color: '#fff' } - return { ...base, background: 'transparent', color: 'var(--text-dark)', border: '1px solid var(--card-border)' } -} - -const badgeStyle = (variant: 'success' | 'error' | 'warning' | 'info'): React.CSSProperties => { - const map: Record = { - success: { background: '#dcfce7', color: '#16a34a', padding: '2px 8px', borderRadius: '99px', fontSize: '12px', fontWeight: 600 }, - error: { background: '#fee2e2', color: '#dc2626', padding: '2px 8px', borderRadius: '99px', fontSize: '12px', fontWeight: 600 }, - warning: { background: '#fef3c7', color: '#d97706', padding: '2px 8px', borderRadius: '99px', fontSize: '12px', fontWeight: 600 }, - info: { background: '#dbeafe', color: '#2563eb', padding: '2px 8px', borderRadius: '99px', fontSize: '12px', fontWeight: 600 }, - } - return map[variant] || map.info -} - -const inputStyle: React.CSSProperties = { - width: '100%', - padding: '8px 10px', - borderRadius: '6px', - border: '1px solid var(--card-border)', - fontSize: '14px', - color: 'var(--text-dark)', - background: 'var(--card-bg)', - boxSizing: 'border-box', -} - -const inputLabelStyle: React.CSSProperties = { - display: 'block', - fontSize: '12px', - fontWeight: 600, - color: 'var(--text-mid)', - marginBottom: '4px', - textTransform: 'uppercase', - letterSpacing: '0.04em', -} - -// ============================================ -// TABS -// ============================================ - -type TabId = 'matrix' | 'hotels' | 'settings' - -// ============================================ -// STATUS PANEL -// ============================================ - -const StatusPanel: React.FC<{ status: ScraperStatus | undefined, isLoading: boolean }> = ({ status, isLoading }) => { - if (isLoading) return
Loading status...
- if (!status) return null - - return ( -
-
- Scraper - - {status.enabled ? 'Enabled' : 'Disabled'} - -
- {status.paused && ( -
- Status - - Paused{status.pause_until ? ` until ${formatDateTime(status.pause_until)}` : ''} - -
- )} -
- Location - - {status.location_name || 'Not configured'} - -
-
- Backend - {status.backend} -
- {status.last_scrape && ( -
- Last Scrape - - {status.last_scrape.status} - - - {formatDateTime(status.last_scrape.completed_at || status.last_scrape.started_at)} - {status.last_scrape.hotels_found ? ` | ${status.last_scrape.hotels_found} hotels, ${status.last_scrape.rates_scraped} rates` : ''} - -
- )} -
- ) -} - -// ============================================ -// SETTINGS TAB -// ============================================ - -const SettingsTab: React.FC = () => { - const queryClient = useQueryClient() - const [locationName, setLocationName] = useState('') - const [pages, setPages] = useState(2) - const [adults, setAdults] = useState(2) - const [scrapeFrom, setScrapeFrom] = useState(() => fmtDate(new Date())) - const [scrapeTo, setScrapeTo] = useState(() => { - const d = new Date() - d.setDate(d.getDate() + 7) - return fmtDate(d) - }) - - const { data: status } = useQuery({ - queryKey: ['scraper-status'], - queryFn: async () => (await api.get('/competitor-rates/status')).data, - }) - - const { data: history } = useQuery({ - queryKey: ['scrape-history'], - queryFn: async () => (await api.get('/competitor-rates/scrape-history?limit=10')).data, - }) - - const { data: scheduleInfo } = useQuery({ - queryKey: ['schedule-info'], - queryFn: async () => (await api.get('/competitor-rates/schedule-info')).data, - }) - - const { data: queueStatus } = useQuery({ - queryKey: ['queue-status'], - queryFn: async () => (await api.get('/competitor-rates/queue-status')).data, - refetchInterval: 30000, - }) - - const { data: coverage } = useQuery({ - queryKey: ['scrape-coverage'], - queryFn: async () => (await api.get('/competitor-rates/scrape-coverage')).data, - staleTime: 60000, - }) - - const setLocationMutation = useMutation({ - mutationFn: async () => { - return (await api.post('/competitor-rates/config/location', { - location_name: locationName, - pages_to_scrape: pages, - adults: adults, - })).data - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['scraper-status'] }) - setLocationName('') - }, - }) - - const enableMutation = useMutation({ - mutationFn: async (enabled: boolean) => { - return (await api.post(`/competitor-rates/config/enable?enabled=${enabled}`)).data - }, - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['scraper-status'] }), - }) - - const unpauseMutation = useMutation({ - mutationFn: async () => (await api.post('/competitor-rates/config/unpause')).data, - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['scraper-status'] }), - }) - - const scrapeMutation = useMutation({ - mutationFn: async () => { - return (await api.post('/competitor-rates/scrape', { - from_date: scrapeFrom, - to_date: scrapeTo, - })).data - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['scraper-status'] }) - queryClient.invalidateQueries({ queryKey: ['scrape-history'] }) - }, - }) - - return ( -
- {/* Location Configuration */} -
-

Location Configuration

-

- Set the location for competitor rate scraping. - {status?.location_name && ( - <> Currently: {status.location_name} - )} -

-
-
- - setLocationName(e.target.value)} - placeholder="e.g. Bowness-on-Windermere" - style={inputStyle} - /> -
-
- - setPages(parseInt(e.target.value) || 2)} - min={1} - max={5} - style={inputStyle} - /> -
-
- - setAdults(parseInt(e.target.value) || 2)} - min={1} - max={4} - style={inputStyle} - /> -
-
- - {setLocationMutation.isError && ( -

- {(setLocationMutation.error as any)?.response?.data?.detail || 'Failed to set location'} -

- )} -
- - {/* Scraper Controls */} -
-

Scraper Controls

-
- - {status?.paused && ( - - )} -
-
- - {/* Schedule Info */} -
-

Automatic Schedule

- {scheduleInfo ? ( -
-

- Runs daily at {scheduleInfo.daily_time} ({scheduleInfo.weekday}) -

-
- {Object.entries(scheduleInfo.tiers).map(([key, tier]) => ( -
-
- {key} - 0 ? 'info' : 'warning')}> - {tier.dates_today} dates - -
-

{tier.description}

- {tier.range && ( -

{tier.range}

- )} -
- ))} -
-

- Total today: {scheduleInfo.total_dates_today} dates -

-
- ) : ( -

Loading schedule...

- )} - - {/* Queue Status */} - {queueStatus && (queueStatus.total_pending > 0 || queueStatus.total_failed > 0) && ( -
-

Queue

-
- {queueStatus.total_pending > 0 && ( - {queueStatus.total_pending} pending - )} - {queueStatus.retries_pending > 0 && ( - {queueStatus.retries_pending} retries - )} - {queueStatus.total_completed > 0 && ( - {queueStatus.total_completed} done - )} - {queueStatus.total_failed > 0 && ( - {queueStatus.total_failed} failed - )} -
-
- )} -
- - {/* Manual Scrape */} -
-

Manual Scrape

-

- Trigger a one-off scrape for a date range. Runs in background. -

-
-
- - setScrapeFrom(e.target.value)} - style={inputStyle} - /> -
-
- - setScrapeTo(e.target.value)} - style={inputStyle} - /> -
-
- - {!status?.location_configured && ( -

Configure a location first

- )} - {scrapeMutation.isSuccess && ( -

- Scrape started! Check status for progress. -

- )} - {scrapeMutation.isError && ( -

- {(scrapeMutation.error as any)?.response?.data?.detail || 'Failed to start scrape'} -

- )} -
- - {/* Scrape History */} -
-

Scrape History

- {history && history.length > 0 ? ( -
- - - - - - - - - - - - - {history.map(entry => ( - - - - - - - - - ))} - -
TypeStartedStatusHotelsRatesError
{entry.scrape_type}{formatDateTime(entry.started_at)} - - {entry.status} - - {entry.hotels_found ?? '-'}{entry.rates_scraped ?? '-'} - {entry.error_message || '-'} -
-
- ) : ( -

No scrape history yet

- )} -
- - {/* Scrape Coverage - 365 day view */} -
-

Scrape Coverage (365 days)

-

- Each cell is a date. Color shows freshness of data; letter shows priority (H=high, M=medium, L=low). -

- {coverage ? :

Loading coverage...

} -
-
- ) -} - -// ============================================ -// COVERAGE GRID -// ============================================ - -const freshnessColor = (lastScraped: string | null): React.CSSProperties => { - if (!lastScraped) return { background: '#e8e8e8', color: '#64748b' } - const hours = (Date.now() - new Date(lastScraped).getTime()) / 3600000 - if (hours < 24) return { background: '#c6efce', color: '#1a7a2e' } // green - fresh - if (hours < 72) return { background: '#fff3cd', color: '#856404' } // yellow - 1-3 days - if (hours < 168) return { background: '#ffe0b2', color: '#e65100' } // orange - 3-7 days - if (hours < 336) return { background: '#f8d7da', color: '#721c24' } // red - 7-14 days - return { background: '#c62828', color: '#ffffff' } // dark red - >14 days -} - -const tierLabel = (tier: string) => { - switch (tier) { - case 'high': return 'H' - case 'medium': return 'M' - case 'low': return 'L' - default: return '-' - } -} - -const CoverageGrid: React.FC<{ coverage: CoverageResponse }> = ({ coverage }) => { - // Group by month - const months = useMemo(() => { - const grouped: Record = {} - for (const entry of coverage.coverage) { - const monthKey = entry.date.substring(0, 7) // YYYY-MM - if (!grouped[monthKey]) grouped[monthKey] = [] - grouped[monthKey].push(entry) - } - return Object.entries(grouped) - }, [coverage.coverage]) - - const formatMonthLabel = (monthKey: string) => { - const [y, m] = monthKey.split('-') - const d = new Date(parseInt(y), parseInt(m) - 1, 1) - return d.toLocaleDateString('en-GB', { month: 'short', year: 'numeric' }) - } - - const formatDateLabel = (dateStr: string) => { - const d = new Date(dateStr + 'T00:00:00') - return d.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric' }) - } - - const formatAge = (iso: string | null): string => { - if (!iso) return 'Never scraped' - const hours = (Date.now() - new Date(iso).getTime()) / 3600000 - if (hours < 1) return `${Math.floor(hours * 60)}m ago` - if (hours < 24) return `${Math.floor(hours)}h ago` - return `${Math.floor(hours / 24)}d ago` - } - - return ( -
- {/* Legend */} -
- {'<'}24h - 1-3d - 3-7d - 7-14d - {'>'} 14d - Never - - H=High M=Medium L=Low priority - -
- {months.map(([monthKey, entries]) => ( -
-
{formatMonthLabel(monthKey)}
-
- {entries.map(entry => ( -
- - {new Date(entry.date + 'T00:00:00').getDate()} - - - {tierLabel(entry.tier)} - -
- ))} -
-
- ))} -
- ) -} - -// ============================================ -// HOTELS TAB -// ============================================ - -const HotelsTab: React.FC = () => { - const queryClient = useQueryClient() - const [tierFilter, setTierFilter] = useState('') - - const { data: hotels, isLoading } = useQuery({ - queryKey: ['competitor-hotels', tierFilter], - queryFn: async () => { - const params = tierFilter ? `?tier=${tierFilter}` : '' - return (await api.get(`/competitor-rates/hotels${params}`)).data - }, - }) - - const tierMutation = useMutation({ - mutationFn: async ({ hotelId, tier }: { hotelId: number, tier: string }) => { - return (await api.put(`/competitor-rates/hotels/${hotelId}/tier`, { tier })).data - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['competitor-hotels'] }) - }, - }) - - const grouped = useMemo(() => { - if (!hotels) return { own: [], competitor: [], market: [] } - return { - own: hotels.filter(h => h.tier === 'own'), - competitor: hotels.filter(h => h.tier === 'competitor'), - market: hotels.filter(h => h.tier === 'market'), - } - }, [hotels]) - - const HotelCard: React.FC<{ hotel: Hotel }> = ({ hotel }) => ( -
-
-
- {hotel.name} -
- {hotel.star_rating && {hotel.star_rating} stars} - {hotel.review_score && Score: {hotel.review_score}} - {hotel.review_count && ({hotel.review_count} reviews)} -
-
-
- -
-
-
- - ID: {hotel.booking_com_id} - - {hotel.last_seen_at && ( - - Last seen: {formatDateTime(hotel.last_seen_at)} - - )} -
-
- ) - - if (isLoading) { - return ( -
-
- Loading hotels... -
- ) - } - - return ( -
- {/* Filter */} -
- Filter: - {['', 'own', 'competitor', 'market'].map(t => ( - - ))} -
- - {/* Hotels */} - {!hotels || hotels.length === 0 ? ( -
-

No Hotels Discovered

-

- Run a scrape to discover hotels in your configured location. -

-
- ) : ( -
- {/* Own Hotel */} - {grouped.own.length > 0 && ( -
-

- Your Hotel ({grouped.own.length}) -

- {grouped.own.map(h => )} -
- )} - - {/* Competitors */} - {grouped.competitor.length > 0 && ( -
-

- Competitors ({grouped.competitor.length}) -

- {grouped.competitor.map(h => )} -
- )} - - {/* Market */} - {grouped.market.length > 0 && ( -
-

- Market ({grouped.market.length}) -

- {grouped.market.map(h => )} -
- )} -
- )} -
- ) -} - -// ============================================ -// RATE MATRIX TAB -// ============================================ - -const MonthSelector: React.FC<{ - value: string - onChange: (value: string) => void -}> = ({ value, onChange }) => { - const handlePrevMonth = () => { - const [year, month] = value.split('-').map(Number) - const date = new Date(year, month - 2, 1) - onChange(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`) - } - - const handleNextMonth = () => { - const [year, month] = value.split('-').map(Number) - const date = new Date(year, month, 1) - onChange(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`) - } - - const monthOptions = useMemo(() => { - const options: { value: string; label: string }[] = [] - const now = new Date() - for (let i = 0; i < 13; i++) { - const date = new Date(now.getFullYear(), now.getMonth() + i, 1) - const monthValue = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` - const label = date.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' }) - options.push({ value: monthValue, label }) - } - return options - }, []) - - return ( -
- - - -
- ) -} - -const RateMatrixTab: React.FC = () => { - const [selectedMonth, setSelectedMonth] = useState(() => { - const today = new Date() - return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}` - }) - const [includeMarket, setIncludeMarket] = useState(false) - const [hoveredCell, setHoveredCell] = useState<{ row: number; col: number } | null>(null) - const [scrapingDate, setScrapingDate] = useState(null) - const queryClient = useQueryClient() - - const dateScrapeM = useMutation({ - mutationFn: async (d: string) => { - setScrapingDate(d) - return (await api.post('/competitor-rates/scrape', { from_date: d, to_date: d })).data - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['competitor-matrix'] }) - queryClient.invalidateQueries({ queryKey: ['scraper-status'] }) - setScrapingDate(null) - }, - onError: () => setScrapingDate(null), - }) - - const onCellEnter = useCallback((row: number, col: number) => { - setHoveredCell({ row, col }) - }, []) - const onCellLeave = useCallback(() => setHoveredCell(null), []) - - const { fromDate, toDate } = useMemo(() => { - const [year, month] = selectedMonth.split('-').map(Number) - return { - fromDate: fmtDate(new Date(year, month - 1, 1)), - toDate: fmtDate(new Date(year, month, 0)), - } - }, [selectedMonth]) - - const { data, isLoading, error } = useQuery({ - queryKey: ['competitor-matrix', fromDate, toDate, includeMarket], - queryFn: async () => { - const params = new URLSearchParams({ - from_date: fromDate, - to_date: toDate, - include_market: includeMarket.toString(), - }) - return (await api.get(`/competitor-rates/matrix?${params}`)).data - }, - }) - - const dates = data?.dates || [] - const rates = data?.rates || {} - - // Sort hotels: own first, then competitor, then market - const hotels = useMemo(() => { - const tierPriority: Record = { own: 0, competitor: 1, market: 2 } - return [...(data?.hotels || [])].sort((a, b) => { - const ta = tierPriority[a.tier] ?? 9 - const tb = tierPriority[b.tier] ?? 9 - if (ta !== tb) return ta - tb - return (a.display_order ?? 999) - (b.display_order ?? 999) - }) - }, [data?.hotels]) - - // Compute latest scraped_at per date column across all hotels - const scrapedAtByDate = useMemo(() => { - const result: Record = {} - for (const d of dates) { - let latest: string | null = null - for (const hotel of hotels) { - const rate = (rates[hotel.id] || {})[d] - if (rate?.scraped_at) { - if (!latest || rate.scraped_at > latest) { - latest = rate.scraped_at - } - } - } - result[d] = latest - } - return result - }, [dates, hotels, rates]) - - if (isLoading) { - return ( -
-
- Loading rate matrix... -
- ) - } - - if (error) { - return ( -
- {(error as any)?.response?.data?.detail || 'Failed to load rate matrix'} -
- ) - } - - return ( -
- {/* Controls */} -
- - -
- - {hotels.length === 0 ? ( -
-

No Rate Data

-

- Run a scrape and categorize hotels as competitors to see rate comparisons. -

-
- ) : ( -
- - - - - {dates.map((d, colIdx) => { - const scrapeAge = formatScrapeAge(scrapedAtByDate[d]) - const isColHovered = hoveredCell?.col === colIdx - return ( - - ) - })} - - - - {hotels.map((hotel, rowIdx) => { - const hotelRates = rates[hotel.id] || {} - const isRowHovered = hoveredCell?.row === rowIdx - return ( - - - {dates.map((d, colIdx) => { - const rate = hotelRates[d] - const isAvailable = rate?.availability_status === 'available' - const isSoldOut = rate?.availability_status === 'sold_out' - - let cellStyle: React.CSSProperties = styles.matrixCellEmpty - if (rate) { - if (isAvailable && rate.rate_gross) { - cellStyle = styles.matrixCellAvailable - } else if (isSoldOut) { - cellStyle = styles.matrixCellSoldOut - } else { - cellStyle = styles.matrixCellNoRate - } - } - - const tooltip = rate ? [ - rate.room_type, - rate.breakfast_included ? 'Breakfast incl.' : null, - rate.free_cancellation ? 'Free cancel' : null, - rate.rooms_left ? `${rate.rooms_left} left` : null, - ].filter(Boolean).join(' | ') : '' - - // Build booking.com link: strip existing date/guest params, add ours - let bookingUrl: string | null = null - if (hotel.booking_com_url) { - const checkin = d - const co = new Date(d + 'T00:00:00') - co.setDate(co.getDate() + 1) - const checkout = fmtDate(co) - try { - const url = new URL(hotel.booking_com_url) - const stripParams = ['checkin', 'checkout', 'group_adults', 'group_children', 'req_adults', 'req_children', 'no_rooms'] - stripParams.forEach(p => url.searchParams.delete(p)) - url.searchParams.set('checkin', checkin) - url.searchParams.set('checkout', checkout) - url.searchParams.set('group_adults', '2') - bookingUrl = url.toString() - } catch { - // Fallback if URL parsing fails - bookingUrl = hotel.booking_com_url - } - } - - const cellContent = rate ? ( - isAvailable && rate.rate_gross - ? formatCurrency(rate.rate_gross) - : isSoldOut - ? 'Sold' - : '-' - ) : '' - - const isRowH = hoveredCell?.row === rowIdx - const isColH = hoveredCell?.col === colIdx - const isCellH = isRowH && isColH - - return ( - - ) - })} - - ) - })} - -
Hotel -
- {formatDayOfWeek(d)} - {formatDateShort(d)} - {scrapeAge ? ( - {scrapeAge} - ) : null} - -
-
-
- - {hotel.name} - {hotel.star_rating && ( - {hotel.star_rating}* - )} -
-
onCellEnter(rowIdx, colIdx)} - onMouseLeave={onCellLeave} - > - {bookingUrl ? ( - - {cellContent} - - ) : cellContent} -
-
- )} - - {/* Legend */} -
- Legend: - Available - Sold Out - No Rate - No Data - - Own - Competitor - Market - -
-
- ) -} - -// ============================================ -// MAIN COMPONENT -// ============================================ - -const CompetitorRates: React.FC = () => { - const [activeTab, setActiveTab] = useState('matrix') - - const { data: status, isLoading: statusLoading } = useQuery({ - queryKey: ['scraper-status'], - queryFn: async () => (await api.get('/competitor-rates/status')).data, - refetchInterval: 30000, - }) - - const tabs: { id: TabId; label: string }[] = [ - { id: 'matrix', label: 'Rate Matrix' }, - { id: 'hotels', label: 'Hotels' }, - { id: 'settings', label: 'Scraper Settings' }, - ] - - return ( -
- {/* Header */} -
-
-

Competitor Rates

-

- Compare rates across competitor hotels from Booking.com -

-
-
- - {/* Status Bar */} - - - {/* Tabs */} -
- {tabs.map(tab => ( - - ))} -
- - {/* Tab Content */} -
- {activeTab === 'matrix' && } - {activeTab === 'hotels' && } - {activeTab === 'settings' && } -
-
- ) -} - -// ============================================ -// STYLES -// ============================================ - -const styles: Record = { - container: { - padding: '24px', - maxWidth: '100%', - margin: '0 auto', - }, - pageHeader: { - marginBottom: '16px', - }, - title: { - fontSize: '24px', - fontWeight: 700, - color: 'var(--text-dark)', - margin: 0, - }, - subtitle: { - fontSize: '13px', - color: 'var(--text-mid)', - margin: '4px 0 0', - }, - - // Status bar - statusBar: { - display: 'flex', - gap: '24px', - padding: '16px', - background: 'var(--card-bg)', - borderRadius: '10px', - boxShadow: 'var(--shadow-sm)', - marginBottom: '16px', - flexWrap: 'wrap', - alignItems: 'center', - }, - statusItem: { - display: 'flex', - alignItems: 'center', - gap: '8px', - }, - statusLabel: { - fontSize: '11px', - color: 'var(--text-mid)', - textTransform: 'uppercase', - fontWeight: 500, - }, - - // Tabs - tabBar: { - display: 'flex', - gap: '4px', - borderBottom: '2px solid var(--card-border)', - marginBottom: '24px', - }, - tab: { - padding: '8px 24px', - background: 'transparent', - border: 'none', - borderBottom: '2px solid transparent', - cursor: 'pointer', - fontSize: '13px', - fontWeight: 500, - color: 'var(--text-mid)', - marginBottom: '-2px', - transition: 'all 0.2s', - }, - tabActive: { - color: 'var(--navy)', - borderBottomColor: 'var(--navy)', - fontWeight: 600, - }, - tabContent: { - minHeight: '300px', - }, - - // Cards - card: { - background: 'var(--card-bg)', - borderRadius: '10px', - padding: '24px', - boxShadow: 'var(--shadow-md)', - }, - cardTitle: { - fontSize: '16px', - fontWeight: 600, - color: 'var(--text-dark)', - margin: '0 0 4px', - }, - cardDescription: { - fontSize: '13px', - color: 'var(--text-mid)', - margin: '0 0 16px', - }, - - // Settings - settingsGrid: { - display: 'grid', - gridTemplateColumns: 'repeat(auto-fit, minmax(350px, 1fr))', - gap: '24px', - }, - formRow: { - display: 'flex', - gap: '16px', - flexWrap: 'wrap', - }, - formGroup: { - flex: 1, - minWidth: '200px', - }, - formGroupSmall: { - width: '80px', - }, - controlRow: { - display: 'flex', - gap: '16px', - flexWrap: 'wrap', - }, - errorText: { - color: 'var(--danger)', - fontSize: '13px', - marginTop: '8px', - }, - hintText: { - color: 'var(--text-mid)', - fontSize: '11px', - marginTop: '8px', - fontStyle: 'italic', - }, - - // Hotels - filterRow: { - display: 'flex', - alignItems: 'center', - gap: '8px', - marginBottom: '24px', - flexWrap: 'wrap', - }, - filterLabel: { - fontSize: '13px', - fontWeight: 500, - color: 'var(--text-mid)', - }, - tierSection: { - marginBottom: '24px', - }, - tierHeader: { - fontSize: '14px', - fontWeight: 600, - margin: '0 0 8px', - }, - hotelCard: { - background: 'var(--card-bg)', - borderRadius: '10px', - padding: '16px', - boxShadow: 'var(--shadow-sm)', - marginBottom: '8px', - }, - hotelHeader: { - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - gap: '16px', - }, - hotelInfo: { - flex: 1, - }, - hotelName: { - fontSize: '13px', - fontWeight: 600, - color: 'var(--text-dark)', - }, - hotelMeta: { - display: 'flex', - gap: '16px', - fontSize: '11px', - color: 'var(--text-mid)', - marginTop: '4px', - }, - hotelActions: { - display: 'flex', - gap: '8px', - }, - tierSelect: { - padding: '4px 8px', - borderRadius: '6px', - border: '1px solid var(--card-border)', - fontSize: '11px', - cursor: 'pointer', - background: 'var(--card-bg)', - }, - hotelFooter: { - display: 'flex', - justifyContent: 'space-between', - marginTop: '8px', - paddingTop: '8px', - borderTop: '1px solid var(--card-border)', - }, - emptyState: { - textAlign: 'center', - padding: '48px', - background: 'var(--card-bg)', - borderRadius: '10px', - boxShadow: 'var(--shadow-sm)', - }, - - // Rate Matrix - matrixControls: { - display: 'flex', - alignItems: 'center', - gap: '24px', - marginBottom: '24px', - flexWrap: 'wrap', - }, - monthSelector: { - display: 'flex', - alignItems: 'center', - gap: '8px', - }, - monthDropdown: { - fontSize: '14px', - fontWeight: 500, - color: 'var(--text-dark)', - padding: '4px 8px', - borderRadius: '6px', - border: '1px solid var(--card-border)', - background: 'var(--card-bg)', - cursor: 'pointer', - minWidth: '160px', - }, - checkboxLabel: { - display: 'flex', - alignItems: 'center', - gap: '8px', - fontSize: '13px', - color: 'var(--text-mid)', - cursor: 'pointer', - }, - matrixContainer: { - overflowX: 'auto', - background: 'var(--card-bg)', - borderRadius: '10px', - boxShadow: 'var(--shadow-md)', - }, - matrixTable: { - width: '100%', - borderCollapse: 'collapse', - fontSize: '11px', - minWidth: '800px', - }, - matrixTh: { - padding: '8px', - borderBottom: '2px solid var(--card-border)', - textAlign: 'center', - fontWeight: 600, - color: 'var(--text-dark)', - whiteSpace: 'nowrap', - background: 'var(--card-bg)', - fontSize: '11px', - }, - matrixTd: { - padding: '4px 8px', - borderBottom: '1px solid var(--card-border)', - textAlign: 'center', - whiteSpace: 'nowrap', - fontSize: '11px', - }, - stickyCol: { - position: 'sticky', - left: 0, - background: 'var(--card-bg)', - zIndex: 10, - textAlign: 'left', - minWidth: '180px', - maxWidth: '220px', - borderRight: '1px solid var(--card-border)', - }, - hotelNameCell: { - fontWeight: 500, - overflow: 'hidden', - textOverflow: 'ellipsis', - }, - matrixHotelInfo: { - display: 'flex', - alignItems: 'center', - gap: '8px', - }, - tierDot: { - width: '8px', - height: '8px', - borderRadius: '50%', - flexShrink: 0, - display: 'inline-block', - }, - matrixHotelName: { - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - }, - matrixStars: { - color: '#d97706', - fontSize: '11px', - flexShrink: 0, - }, - dateHeader: { - minWidth: '50px', - padding: '4px', - }, - dateHeaderContent: { - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - gap: '2px', - }, - dayOfWeek: { - fontSize: '11px', - color: 'var(--text-mid)', - }, - dayNum: { - fontSize: '13px', - fontWeight: 600, - }, - scrapeAge: { - fontSize: '9px', - color: 'var(--success)', - fontWeight: 400, - opacity: 0.8, - lineHeight: 1, - }, - weekendHeader: { - background: 'var(--body-bg)', - }, - weekendCell: { - borderLeft: '2px solid var(--card-border)', - }, - matrixCellAvailable: { - background: '#dcfce7', - color: 'var(--success)', - fontWeight: 600, - }, - matrixCellSoldOut: { - background: '#fee2e2', - color: 'var(--danger)', - }, - matrixCellNoRate: { - background: '#fef3c7', - color: '#d97706', - }, - matrixCellEmpty: { - background: 'var(--body-bg)', - color: 'var(--text-mid)', - }, - matrixCellLink: { - color: 'inherit', - textDecoration: 'none', - display: 'block', - width: '100%', - height: '100%', - } as React.CSSProperties, - crosshairHighlight: { - boxShadow: 'inset 0 0 0 1px #1a1a2e33', - background: '#1a1a2e08', - }, - crosshairCell: { - boxShadow: 'inset 0 0 0 2px var(--navy)', - }, - crosshairRow: { - boxShadow: 'inset 0 0 0 1px #1a1a2e33', - background: '#1a1a2e08', - }, - crosshairCol: { - boxShadow: 'inset 0 0 0 1px #1a1a2e33', - background: '#1a1a2e08', - }, - scrapeBtn: { - background: 'none', - border: '1px solid var(--card-border)', - borderRadius: '4px', - cursor: 'pointer', - fontSize: '10px', - lineHeight: 1, - padding: '2px 4px', - color: 'var(--text-mid)', - opacity: 0.6, - transition: 'opacity 0.15s', - }, - scrapeBtnActive: { - opacity: 1, - color: 'var(--navy)', - borderColor: 'var(--navy)', - }, - - // Shared - table: { - width: '100%', - borderCollapse: 'collapse', - fontSize: '13px', - }, - th: { - padding: '8px', - borderBottom: '2px solid var(--card-border)', - textAlign: 'left', - fontWeight: 600, - color: 'var(--text-dark)', - whiteSpace: 'nowrap', - fontSize: '11px', - }, - td: { - padding: '8px', - borderBottom: '1px solid var(--card-border)', - fontSize: '13px', - }, - historyTable: { - overflowX: 'auto', - }, - noData: { - textAlign: 'center', - padding: '24px', - color: 'var(--text-mid)', - fontSize: '13px', - }, - legend: { - display: 'flex', - alignItems: 'center', - gap: '16px', - marginTop: '24px', - padding: '16px', - background: 'var(--card-bg)', - borderRadius: '10px', - fontSize: '13px', - flexWrap: 'wrap', - }, - legendTitle: { - fontWeight: 600, - color: 'var(--text-dark)', - }, - legendItem: { - padding: '4px 8px', - borderRadius: '4px', - fontSize: '11px', - }, - loading: { - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'center', - padding: '48px', - gap: '16px', - color: 'var(--text-mid)', - }, - spinner: { - width: '40px', - height: '40px', - border: '3px solid var(--card-border)', - borderTop: '3px solid var(--navy)', - borderRadius: '50%', - animation: 'spin 1s linear infinite', - }, - errorBox: { - padding: '24px', - background: '#fee2e2', - color: 'var(--danger)', - borderRadius: '10px', - }, - - // Schedule - scheduleGrid: { - display: 'flex', - flexDirection: 'column', - gap: '8px', - }, - scheduleTier: { - padding: '8px', - background: 'var(--body-bg)', - borderRadius: '6px', - }, - scheduleTierHeader: { - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - }, - scheduleTierName: { - fontSize: '13px', - fontWeight: 600, - color: 'var(--text-dark)', - textTransform: 'capitalize', - }, - scheduleTierDesc: { - fontSize: '11px', - color: 'var(--text-mid)', - margin: '4px 0 0', - }, - scheduleTierRange: { - fontSize: '11px', - color: 'var(--text-mid)', - margin: '2px 0 0', - fontFamily: 'monospace', - }, - queuePanel: { - padding: '8px', - background: 'var(--body-bg)', - borderRadius: '6px', - }, - queueStats: { - display: 'flex', - gap: '8px', - flexWrap: 'wrap', - }, - - // Coverage grid - coverageContainer: { - display: 'flex', - flexDirection: 'column', - gap: '16px', - }, - coverageLegend: { - display: 'flex', - alignItems: 'center', - gap: '8px', - fontSize: '11px', - flexWrap: 'wrap', - }, - coverageLegendItem: { - padding: '2px 8px', - borderRadius: '4px', - fontSize: '11px', - }, - coverageMonth: { - display: 'flex', - alignItems: 'flex-start', - gap: '8px', - }, - coverageMonthLabel: { - fontSize: '11px', - fontWeight: 600, - color: 'var(--text-dark)', - minWidth: '70px', - paddingTop: '3px', - flexShrink: 0, - }, - coverageCells: { - display: 'flex', - flexWrap: 'wrap', - gap: '3px', - }, - coverageCell: { - width: '32px', - height: '28px', - borderRadius: '3px', - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'center', - cursor: 'default', - lineHeight: 1, - border: '1px solid rgba(0,0,0,0.06)', - }, - coverageCellDay: { - fontSize: '9px', - fontWeight: 600, - }, - coverageCellTier: { - fontSize: '7px', - opacity: 0.7, - }, -} - -export default CompetitorRates