Download app.py from CodeAntidote/Hotel-Rate-Radar: direct link, hf CLI and curl.
- Browser
- Download file 83.6 kB
-
https://huggingface.co/spaces/CodeAntidote/Hotel-Rate-Radar/resolve/main/app.py
- Command line
-
hf download hf://spaces/CodeAntidote/Hotel-Rate-Radar/app.py
-
curl -L -o app.py https://huggingface.co/spaces/CodeAntidote/Hotel-Rate-Radar/resolve/main/app.py
83.6 kB
| import os | |
| import json | |
| from statistics import mean | |
| from verified_rates import shop, HOTELS | |
| from html import escape | |
| import math | |
| import time as time_module | |
| import tempfile | |
| from concurrent.futures import ThreadPoolExecutor, as_completed | |
| from datetime import datetime, date, time, timedelta, timezone | |
| from difflib import SequenceMatcher | |
| from statistics import median | |
| from zoneinfo import ZoneInfo | |
| import gradio as gr | |
| import pandas as pd | |
| import requests | |
| APP_NAME = "Hotel Rate Radar" | |
| DEFAULT_HOTEL = "Surf City Inn & Suites" | |
| DEFAULT_MARKET = "Santa Cruz, CA" | |
| DEFAULT_LAT = 36.9741 | |
| DEFAULT_LON = -122.0308 | |
| DEFAULT_TARGET_CLASS = 2 | |
| PRIMARY_COMPETITOR = "Hotel Solares" | |
| PREFERRED_COMPETITORS = [ | |
| "Hotel Solares", | |
| "Riverside Inn & Suites Santa Cruz", | |
| "Capri Motel", | |
| "Aqua Inn", | |
| ] | |
| TOTAL_ROOMS = 70 | |
| TZ = ZoneInfo("America/Los_Angeles") | |
| # Strategic Santa Cruz road points. These are current flow samples, not future traffic forecasts. | |
| TRAFFIC_POINTS = { | |
| "Hwy 17 Summit": (37.1267, -121.9840), | |
| "Hwy 17 Santa Cruz approach": (37.0038, -122.0308), | |
| "Hwy 1 East": (36.9740, -121.9890), | |
| "Hwy 1 West": (36.9800, -122.0550), | |
| } | |
| DEFAULT_OTA = pd.DataFrame( | |
| [ | |
| ["Hotel direct", None], | |
| ["Google Hotels", None], | |
| ["Booking.com", None], | |
| ["Expedia", None], | |
| ["Hotels.com", None], | |
| ["Agoda", None], | |
| ["Priceline", None], | |
| ], | |
| columns=["Channel", "Base rate ($)"], | |
| ) | |
| DEFAULT_COMPS = pd.DataFrame( | |
| [ | |
| ["Hotel Solares", None], | |
| ["Riverside Inn & Suites Santa Cruz", None], | |
| ["Capri Motel", None], | |
| ["Aqua Inn", None], | |
| ], | |
| columns=["Comparable hotel", "Base rate ($)"], | |
| ) | |
| ROOM_PROFILE = pd.DataFrame( | |
| [ | |
| ["1 Queen", 4, None, None, 0], | |
| ["King Kitchen (KK)", 4, None, None, 0], | |
| ["2 Queen Plus", 11, None, None, 0], | |
| ["2 Queen Kitchen (2QK)", 5, None, None, 0], | |
| ["King Queen Suite (KQS)", 33, None, None, 0], | |
| ["3 Queen", 13, None, None, 0], | |
| ], | |
| columns=["Room type", "Total rooms", "Rooms left", "Current rate ($)", "Manual adjustment ($)"], | |
| ) | |
| # Reference spacing learned from the long-running room-rate pattern the owner shared. | |
| # These are relative offsets, not hard-coded prices for any date. | |
| ROOM_RATE_OFFSETS = { | |
| "1 Queen": 0, | |
| "King Kitchen (KK)": 4, | |
| "2 Queen Plus": 10, | |
| "King Queen Suite (KQS)": 14, | |
| "2 Queen Kitchen (2QK)": 24, | |
| "3 Queen": 40, | |
| } | |
| RATE_CACHE = {} | |
| RATE_CACHE_TTL_SECONDS = 60 * 45 | |
| TRAFFIC_CACHE = {"at": 0, "value": None} | |
| TRAFFIC_CACHE_TTL_SECONDS = 60 * 10 | |
| WEATHER_CACHE = {"at": 0, "value": None} | |
| WEATHER_CACHE_TTL_SECONDS = 60 * 20 | |
| def _safe_float(value, default=None): | |
| try: | |
| if value is None or (isinstance(value, float) and math.isnan(value)): | |
| return default | |
| if isinstance(value, str): | |
| value = value.replace("$", "").replace(",", "").strip() | |
| if not value: | |
| return default | |
| parsed = float(value) | |
| return parsed if math.isfinite(parsed) else default | |
| except (TypeError, ValueError): | |
| return default | |
| def _extract_rates(df, rate_col): | |
| if df is None: | |
| return [] | |
| if not isinstance(df, pd.DataFrame): | |
| df = pd.DataFrame(df) | |
| if rate_col not in df.columns: | |
| return [] | |
| vals = [] | |
| for value in df[rate_col].tolist(): | |
| number = _safe_float(value) | |
| if number is not None and 20 <= number <= 3000: | |
| vals.append(number) | |
| return vals | |
| def _price_from_rate_object(rate_obj): | |
| if not isinstance(rate_obj, dict): | |
| return None | |
| # Prefer pre-tax/base rate so comparisons are as apples-to-apples as possible. | |
| for key in ("extracted_before_taxes_fees", "extracted_lowest"): | |
| value = _safe_float(rate_obj.get(key)) | |
| if value is not None and 20 <= value <= 3000: | |
| return value | |
| return None | |
| def _property_price(prop): | |
| if not isinstance(prop, dict): | |
| return None | |
| rate = _price_from_rate_object(prop.get("rate_per_night")) | |
| if rate is not None: | |
| return rate | |
| value = _safe_float(prop.get("extracted_price")) | |
| if value is not None and 20 <= value <= 3000: | |
| return value | |
| return None | |
| def _normalise_name(value): | |
| return " ".join("".join(ch.lower() if ch.isalnum() else " " for ch in str(value or "")).split()) | |
| def _name_similarity(a, b): | |
| a_n = _normalise_name(a) | |
| b_n = _normalise_name(b) | |
| if not a_n or not b_n: | |
| return 0.0 | |
| if a_n in b_n or b_n in a_n: | |
| return 0.95 | |
| return SequenceMatcher(None, a_n, b_n).ratio() | |
| def _distance_miles(lat1, lon1, lat2, lon2): | |
| try: | |
| from math import radians, sin, cos, sqrt, atan2 | |
| r = 3958.7613 | |
| dlat = radians(float(lat2) - float(lat1)) | |
| dlon = radians(float(lon2) - float(lon1)) | |
| a = sin(dlat / 2) ** 2 + cos(radians(float(lat1))) * cos(radians(float(lat2))) * sin(dlon / 2) ** 2 | |
| return 2 * r * atan2(sqrt(a), sqrt(1 - a)) | |
| except Exception: | |
| return None | |
| def parse_target_date(value): | |
| if isinstance(value, datetime): | |
| return value.date() | |
| if isinstance(value, date): | |
| return value | |
| if isinstance(value, (int, float)): | |
| return datetime.fromtimestamp(value, TZ).date() | |
| text = str(value or "").strip() | |
| if not text: | |
| raise ValueError("No date selected") | |
| return pd.to_datetime(text).date() | |
| def clamp(v, lo, hi): | |
| return max(lo, min(hi, v)) | |
| def get_weather_map(): | |
| """Live Open-Meteo daily forecast, up to its current forecast horizon.""" | |
| now = time_module.time() | |
| if WEATHER_CACHE["value"] is not None and now - WEATHER_CACHE["at"] < WEATHER_CACHE_TTL_SECONDS: | |
| return WEATHER_CACHE["value"] | |
| try: | |
| params = { | |
| "latitude": DEFAULT_LAT, | |
| "longitude": DEFAULT_LON, | |
| "daily": "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max", | |
| "temperature_unit": "fahrenheit", | |
| "timezone": "America/Los_Angeles", | |
| "forecast_days": 16, | |
| } | |
| response = requests.get("https://api.open-meteo.com/v1/forecast", params=params, timeout=10) | |
| response.raise_for_status() | |
| daily = response.json().get("daily", {}) | |
| out = {} | |
| dates = daily.get("time", []) | |
| for i, day in enumerate(dates): | |
| out[day] = { | |
| "max_f": (daily.get("temperature_2m_max") or [None] * len(dates))[i], | |
| "min_f": (daily.get("temperature_2m_min") or [None] * len(dates))[i], | |
| "precip_pct": (daily.get("precipitation_probability_max") or [None] * len(dates))[i], | |
| "weather_code": (daily.get("weather_code") or [None] * len(dates))[i], | |
| } | |
| WEATHER_CACHE.update({"at": now, "value": out}) | |
| return out | |
| except Exception: | |
| return {} | |
| def weather_adjustment(weather): | |
| if not weather: | |
| return 0.0, "No reliable live weather forecast available for this date" | |
| high = _safe_float(weather.get("max_f"), 65) | |
| rain = _safe_float(weather.get("precip_pct"), 0) | |
| code = int(_safe_float(weather.get("weather_code"), 0)) | |
| adj = 0.0 | |
| if 68 <= high <= 82 and rain <= 20: | |
| adj += 0.04 | |
| elif high >= 63 and rain <= 30: | |
| adj += 0.02 | |
| if rain >= 70 or code in {65, 67, 82, 95, 96, 99}: | |
| adj -= 0.05 | |
| elif rain >= 45: | |
| adj -= 0.025 | |
| return adj, f"{high:.0f}°F high, {rain:.0f}% max precipitation" | |
| def get_ticketmaster_events_range(start_date, end_date): | |
| key = os.getenv("TICKETMASTER_API_KEY", "").strip() | |
| if not key: | |
| return {}, "Ticketmaster secret not configured" | |
| try: | |
| local_start = datetime.combine(start_date, time.min, TZ) | |
| local_end = datetime.combine(end_date, time.max.replace(microsecond=0), TZ) | |
| params = { | |
| "apikey": key, | |
| "latlong": f"{DEFAULT_LAT},{DEFAULT_LON}", | |
| "radius": 25, | |
| "unit": "miles", | |
| "startDateTime": local_start.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), | |
| "endDateTime": local_end.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), | |
| "size": 200, | |
| "sort": "date,asc", | |
| } | |
| response = requests.get("https://app.ticketmaster.com/discovery/v2/events.json", params=params, timeout=15) | |
| response.raise_for_status() | |
| events = response.json().get("_embedded", {}).get("events", []) | |
| grouped = {} | |
| for event in events: | |
| day = event.get("dates", {}).get("start", {}).get("localDate") | |
| if not day: | |
| continue | |
| venues = event.get("_embedded", {}).get("venues", []) | |
| venue = venues[0].get("name", "") if venues else "" | |
| classification = "" | |
| classes = event.get("classifications", []) | |
| if classes: | |
| classification = classes[0].get("segment", {}).get("name", "") | |
| grouped.setdefault(day, []).append({ | |
| "name": event.get("name", "Event"), | |
| "venue": venue, | |
| "classification": classification, | |
| "end_time": event.get("dates", {}).get("end", {}).get("dateTime"), | |
| }) | |
| return grouped, "Live Ticketmaster data" | |
| except Exception as exc: | |
| return {}, f"Ticketmaster unavailable: {type(exc).__name__}" | |
| def event_adjustment(events, manual_event_level): | |
| level_map = { | |
| "None / normal day": 0.00, | |
| "Small local activity": 0.02, | |
| "Moderate event demand": 0.06, | |
| "Large event / graduation / festival": 0.12, | |
| "Exceptional sell-out demand": 0.18, | |
| } | |
| manual_adj = level_map.get(manual_event_level, 0.0) | |
| if not events: | |
| return manual_adj, [] | |
| score = 0.0 | |
| highlights = [] | |
| for event in events[:20]: | |
| classification = (event.get("classification") or "").lower() | |
| name = event.get("name", "Event") | |
| venue = event.get("venue", "") | |
| weight = 0.010 | |
| if any(k in classification for k in ["music", "sports", "arts"]): | |
| weight = 0.017 | |
| text = f"{name}" + (f" — {venue}" if venue else "") | |
| if any(k in text.lower() for k in ["boardwalk", "kaiser permanente arena", "uc santa cruz", "ucsc", "graduation", "commencement", "festival"]): | |
| weight += 0.018 | |
| score += weight | |
| highlights.append(text) | |
| live_adj = min(score, 0.16) | |
| return max(manual_adj, live_adj), highlights[:8] | |
| def get_tomtom_traffic(): | |
| """Live Santa Cruz traffic flow. Cached briefly to reduce quota usage.""" | |
| key = os.getenv("TOMTOM_API_KEY", "").strip() | |
| if not key: | |
| return None, [], "TomTom secret not configured" | |
| now = time_module.time() | |
| if TRAFFIC_CACHE["value"] is not None and now - TRAFFIC_CACHE["at"] < TRAFFIC_CACHE_TTL_SECONDS: | |
| return TRAFFIC_CACHE["value"] | |
| samples = [] | |
| details = [] | |
| closures = [] | |
| try: | |
| endpoint = "https://api.tomtom.com/traffic/services/4/flowSegmentData/absolute/10/json" | |
| for name, (lat, lon) in TRAFFIC_POINTS.items(): | |
| params = {"key": key, "point": f"{lat},{lon}", "unit": "mph", "thickness": 1} | |
| response = requests.get(endpoint, params=params, timeout=8) | |
| response.raise_for_status() | |
| data = response.json().get("flowSegmentData", {}) | |
| current = _safe_float(data.get("currentSpeed")) | |
| free = _safe_float(data.get("freeFlowSpeed")) | |
| confidence = _safe_float(data.get("confidence")) | |
| closed = bool(data.get("roadClosure", False)) | |
| if current is not None and free and free > 0: | |
| congestion = 1.0 - max(0.0, min(1.0, current / free)) | |
| # Downweight low-confidence road samples. | |
| quality = clamp(confidence if confidence is not None else 0.65, 0.25, 1.0) | |
| samples.append((congestion, quality)) | |
| details.append(f"{name}: {current:.0f}/{free:.0f} mph" + (" • closure" if closed else "")) | |
| if closed: | |
| closures.append(name) | |
| if not samples: | |
| result = (None, details, "TomTom returned no usable flow segments") | |
| else: | |
| weighted = sum(c * q for c, q in samples) / sum(q for _, q in samples) | |
| result = (weighted, details, "Live TomTom flow data" + (f" • closures: {', '.join(closures)}" if closures else "")) | |
| TRAFFIC_CACHE.update({"at": now, "value": result}) | |
| return result | |
| except Exception as exc: | |
| return None, details, f"TomTom unavailable: {type(exc).__name__}" | |
| def traffic_adjustment(auto_congestion, manual_level, target_date): | |
| level_map = { | |
| "Normal": 0.00, | |
| "Busier than normal": 0.015, | |
| "Heavy inbound traffic": 0.03, | |
| "Severe inbound congestion": 0.045, | |
| } | |
| today = datetime.now(TZ).date() | |
| # Current road speeds are live observations, not a forecast. Only let them change today's rate. | |
| if target_date != today: | |
| return 0.0, "Live traffic shown as current context only; no future-date rate impact" | |
| if auto_congestion is None: | |
| return level_map.get(manual_level, 0.0), f"Manual current-day traffic fallback: {manual_level}" | |
| adj = min(max(auto_congestion - 0.10, 0) * 0.12, 0.04) | |
| return adj, f"Sampled road speeds are {auto_congestion * 100:.0f}% below free-flow on average" | |
| def _parse_google_hotel_sources(target_prop, reference_rate=None): | |
| """Return one lowest comparable base-room offer per booking source. | |
| Google Hotels property details can expose many room/package prices for the same | |
| provider. Those are useful for shopping, but they must not all be averaged as | |
| if they were separate base-room quotes. We keep only the lowest offer per | |
| provider and reject offers that are implausibly far from the property's lowest | |
| comparable rate. | |
| """ | |
| rows = [] | |
| if not isinstance(target_prop, dict): | |
| return rows | |
| ref = _safe_float(reference_rate) | |
| if ref is None: | |
| ref = _property_price(target_prop) | |
| for item in (target_prop.get("prices") or []) + (target_prop.get("featured_prices") or []): | |
| if not isinstance(item, dict): | |
| continue | |
| source = item.get("source") or "Google Hotels source" | |
| rate = _price_from_rate_object(item.get("rate_per_night")) | |
| if rate is None: | |
| continue | |
| rows.append({"Channel": str(source).strip(), "Base rate ($)": round(rate, 2)}) | |
| property_rate = _property_price(target_prop) | |
| if property_rate is not None: | |
| rows.append({"Channel": "Google Hotels lowest", "Base rate ($)": round(property_rate, 2)}) | |
| if ref is None: | |
| ref = property_rate | |
| # One observation per provider: take that provider's lowest comparable quote. | |
| dedup = {} | |
| for row in rows: | |
| source = str(row["Channel"]).strip() | |
| price = _safe_float(row["Base rate ($)"]) | |
| if not source or price is None: | |
| continue | |
| key = _normalise_name(source) | |
| if key not in dedup or price < dedup[key]["Base rate ($)"]: | |
| dedup[key] = {"Channel": source, "Base rate ($)": round(price, 2)} | |
| cleaned = list(dedup.values()) | |
| if ref is not None and 20 <= ref <= 3000: | |
| # Base-room provider quotes should cluster fairly close to the property's | |
| # lowest public price. Higher suites/packages are deliberately excluded. | |
| low = max(20.0, ref * 0.70) | |
| high = max(ref * 1.40, ref + 40.0) | |
| filtered = [r for r in cleaned if low <= float(r["Base rate ($)"]) <= high] | |
| # Never lose the property-level lowest quote because of a malformed source. | |
| if filtered: | |
| cleaned = filtered | |
| priority = ["official", "surf city", "google hotels", "priceline", "kayak", "booking", "expedia", "hotels com", "agoda"] | |
| def rank(row): | |
| name = _normalise_name(row["Channel"]) | |
| for i, token in enumerate(priority): | |
| if token in name: | |
| return i | |
| return len(priority) | |
| return sorted(cleaned, key=lambda r: (rank(r), r["Base rate ($)"])) | |
| def _is_primary_competitor(name): | |
| return _name_similarity(name, PRIMARY_COMPETITOR) >= 0.62 | |
| def _hotel_result_from_json(data, hotel_name, max_comps=6): | |
| properties = [] | |
| for prop in data.get("properties", []) or []: | |
| if isinstance(prop, dict) and str(prop.get("type", "hotel")).lower() == "hotel": | |
| properties.append(prop) | |
| for ad in data.get("ads", []) or []: | |
| if isinstance(ad, dict): | |
| properties.append(ad) | |
| priced = [prop for prop in properties if _property_price(prop) is not None] | |
| target = None | |
| target_score = 0.0 | |
| for prop in priced: | |
| score = _name_similarity(hotel_name, prop.get("name", "")) | |
| if score > target_score: | |
| target, target_score = prop, score | |
| if target_score < 0.55: | |
| target = None | |
| target_price = _property_price(target) if target else None | |
| target_lat = DEFAULT_LAT | |
| target_lon = DEFAULT_LON | |
| if target: | |
| coords = target.get("gps_coordinates", {}) or {} | |
| target_lat = _safe_float(coords.get("latitude"), DEFAULT_LAT) | |
| target_lon = _safe_float(coords.get("longitude"), DEFAULT_LON) | |
| competitors = [] | |
| for prop in priced: | |
| if target is not None and prop is target: | |
| continue | |
| name = str(prop.get("name", "Hotel")) | |
| if target is not None and _name_similarity(hotel_name, name) >= 0.85: | |
| continue | |
| price = _property_price(prop) | |
| if price is None: | |
| continue | |
| coords = prop.get("gps_coordinates", {}) or {} | |
| lat = _safe_float(coords.get("latitude")) | |
| lon = _safe_float(coords.get("longitude")) | |
| distance = _distance_miles(target_lat, target_lon, lat, lon) if lat is not None and lon is not None else None | |
| hotel_class = _safe_float(prop.get("hotel_class")) | |
| is_primary = _is_primary_competitor(name) | |
| preferred_match = max((_name_similarity(name, p) for p in PREFERRED_COMPETITORS), default=0.0) | |
| # Prefer the owner's real comp set. If Google gives us too few of them, we | |
| # may use another nearby budget peer, but not a luxury/property-package outlier. | |
| if hotel_class is not None and hotel_class >= 4 and not is_primary: | |
| continue | |
| if preferred_match < 0.58 and not is_primary: | |
| if distance is None or distance > 4.0: | |
| continue | |
| if hotel_class is not None and abs(hotel_class - DEFAULT_TARGET_CLASS) > 1: | |
| continue | |
| # Guard against a suite/package price being mistaken for a hotel's base rate. | |
| if target_price is not None: | |
| lo = max(20.0, target_price * 0.55) | |
| hi = max(target_price * 1.65, target_price + 55.0) | |
| if not (lo <= price <= hi): | |
| continue | |
| class_gap = abs(hotel_class - DEFAULT_TARGET_CLASS) if hotel_class is not None else 0.75 | |
| role = "Primary comp" if is_primary else ("Preferred comp" if preferred_match >= 0.58 else "Secondary 2-star/budget comp") | |
| competitors.append({ | |
| "Comparable hotel": name, | |
| "Base rate ($)": round(price, 2), | |
| "Role": role, | |
| "Distance (mi)": round(distance, 1) if distance is not None else None, | |
| "Rating": _safe_float(prop.get("overall_rating")), | |
| "Hotel class": int(hotel_class) if hotel_class is not None else None, | |
| "_primary": 0 if is_primary else 1, | |
| "_preferred": 0 if preferred_match >= 0.58 else 1, | |
| "_distance": distance if distance is not None else 99, | |
| "_class_gap": class_gap, | |
| }) | |
| competitors.sort(key=lambda x: (x["_primary"], x["_preferred"], x["_class_gap"], x["_distance"])) | |
| chosen = competitors[:max_comps] | |
| for item in chosen: | |
| for k in ("_primary", "_preferred", "_distance", "_class_gap"): | |
| item.pop(k, None) | |
| own_sources = _parse_google_hotel_sources(target, target_price) | |
| return { | |
| "target_found": target is not None, | |
| "matched_name": target.get("name") if target else None, | |
| "match_score": round(target_score, 3), | |
| "target_lowest": target_price, | |
| "property_token": target.get("property_token") if target else None, | |
| "own_sources": own_sources, | |
| "competitors": chosen, | |
| "search_metadata": data.get("search_metadata", {}), | |
| } | |
| def get_live_google_hotel_rates(target_date, hotel_name, market, max_comps=4, use_cache=True, deep_sources=True, fresh=False): | |
| result, status=shop(target_date, fresh=fresh) | |
| status += "\n" + "\n".join(f"- **{a['Hotel']}**: {a['Status']}" for a in result['audit']) | |
| return result,status | |
| def get_live_google_hotel_rates_30(start_date, hotel_name, market, max_comps=4, fresh=False): | |
| results={}; notes=[] | |
| # Sequential calls bound provider pressure; explicit UI opt-in guards quota. | |
| for i in range(30): | |
| day=start_date+timedelta(days=i) | |
| value,status=get_live_google_hotel_rates(day,hotel_name,market,fresh=fresh) | |
| results[day.isoformat()]=value | |
| notes.append(f"{day}: {sum(bool(a['Offers']) for a in value['audit'])}/5 hotels with matched offers" + "\n" + "\n".join(f"- {a['Hotel']}: {a['Status']}" for a in value['audit'])) | |
| return results,"\n".join(notes) | |
| def _market_anchor(own_df, comp_df, owner_direct_rate): | |
| own_rates=_extract_rates(own_df,"Base rate ($)") | |
| comp_rates=_extract_rates(comp_df,"Base rate ($)") | |
| own_avg=mean(own_rates) if own_rates else None | |
| comp_avg=mean(comp_rates) if comp_rates else None | |
| manual=_safe_float(owner_direct_rate) | |
| manual=manual if manual is not None and manual > 0 else None | |
| # Manual direct rate is an explicit fallback, never an observed market quote. | |
| anchor=comp_avg if comp_avg is not None else own_avg if own_avg is not None else manual | |
| label=f"Arithmetic mean of {len(comp_rates)}/4 competitor starting rates" if comp_rates else "Surf City advertised-rate average" if own_rates else "Owner-entered fallback (not observed)" | |
| return anchor,own_avg,None,comp_avg,label,own_rates,comp_rates | |
| def room_type_recommendations(base_bar, room_profile_df, occupancy, floor, ceiling, direct_discount, current_base_rate=None, late_high_traffic=False): | |
| """ | |
| Build the room ladder from the property's real pricing pattern. | |
| The ladder uses relative spacing between room types rather than fixed percentages. | |
| When the base rate rises sharply, the spacing widens as it historically does on | |
| peak-demand dates. Inventory pressure is used only when Rooms left is actually | |
| entered; blank inventory is never invented from overall occupancy. | |
| """ | |
| if room_profile_df is None: | |
| room_profile_df = ROOM_PROFILE.copy() | |
| if not isinstance(room_profile_df, pd.DataFrame): | |
| room_profile_df = pd.DataFrame(room_profile_df) | |
| if room_profile_df.empty: | |
| room_profile_df = ROOM_PROFILE.copy() | |
| # At normal rates, use the normal room offsets. As base price rises, widen the | |
| # ladder; this mirrors the long-running pattern in the reference screenshots. | |
| spread_factor = clamp(1.0 + max(float(base_bar) - 100.0, 0.0) / 70.0, 1.0, 3.5) | |
| current_base = _safe_float(current_base_rate) | |
| rows = [] | |
| for _, row in room_profile_df.iterrows(): | |
| room_type = str(row.get("Room type", "Room")).strip() | |
| total = int(_safe_float(row.get("Total rooms"), 0) or 0) | |
| if total <= 0: | |
| continue | |
| offset = float(ROOM_RATE_OFFSETS.get(room_type, 0)) | |
| manual_adj = _safe_float(row.get("Manual adjustment ($)"), 0.0) | |
| # Owner can type an exact current room price. If blank, show a current-rate | |
| # ladder derived from the owner's current base rate. | |
| current_entered = _safe_float(row.get("Current rate ($)")) | |
| if current_entered is not None: | |
| current_rate = current_entered | |
| current_note = "entered" | |
| else: | |
| current_rate = None | |
| current_note = "not entered" | |
| # Do not estimate room scarcity from the overall occupancy slider. | |
| left_raw = _safe_float(row.get("Rooms left")) | |
| if left_raw is None: | |
| left = None | |
| availability_adj = 0.0 | |
| availability_note = "Not entered" | |
| else: | |
| left = int(round(clamp(left_raw, 0, total))) | |
| remaining_ratio = left / total if total else 1.0 | |
| if left <= 0: | |
| availability_adj = 0.0 | |
| availability_note = "Sold out" | |
| elif remaining_ratio <= 0.10: | |
| availability_adj = 0.10 | |
| availability_note = f"{left}/{total} left" | |
| elif remaining_ratio <= 0.25: | |
| availability_adj = 0.06 | |
| availability_note = f"{left}/{total} left" | |
| elif remaining_ratio <= 0.40: | |
| availability_adj = 0.03 | |
| availability_note = f"{left}/{total} left" | |
| elif remaining_ratio >= 0.80: | |
| availability_adj = -0.02 | |
| availability_note = f"{left}/{total} left" | |
| else: | |
| availability_adj = 0.0 | |
| availability_note = f"{left}/{total} left" | |
| if late_high_traffic and left is not None and 0 < left <= total * .25: | |
| availability_adj = -0.03 | |
| availability_note = f"{left}/{total} left; late/high-traffic policy −3%" | |
| if left_raw is not None and left <= 0: | |
| rec_bar = None | |
| direct = None | |
| low = None | |
| high = None | |
| else: | |
| reference_offsets = { | |
| "1 Queen": (0,0,0), "King Kitchen (KK)": (4,30,6), | |
| "2 Queen Plus": (10,50,60), "King Queen Suite (KQS)": (14,70,90), | |
| "2 Queen Kitchen (2QK)": (24,76,106), "3 Queen": (40,100,140)} | |
| a,b,c = reference_offsets.get(room_type,(offset,offset,offset)) | |
| base = float(base_bar) | |
| reference_offset = a if base <= 85 else (a+(b-a)*min(1,(base-85)/114) if base <=199 else b+(c-b)*min(1,(base-199)/70)) | |
| ladder_rate = base + reference_offset + manual_adj | |
| ladder_rate *= (1.0 + availability_adj) | |
| rec_bar = round(clamp(ladder_rate, floor, ceiling)) | |
| direct = round(clamp(rec_bar * (1 - float(direct_discount) / 100.0), floor, ceiling)) | |
| low = round(clamp(rec_bar * 0.94, floor, ceiling)) | |
| high = round(clamp(rec_bar * 1.07, floor, ceiling)) | |
| rows.append({ | |
| "Room type": room_type, | |
| "Inventory": total, | |
| "Rooms left": "—" if left is None else left, | |
| "Current rate": f"${current_rate:.0f}" if current_rate is not None else "—", | |
| "Current basis": current_note, | |
| "Reference offset": f"+${offset:.0f}", | |
| "Availability impact": f"{availability_adj:+.0%}" if left_raw is not None and left > 0 else "—", | |
| "Recommended BAR": f"${rec_bar}" if rec_bar is not None else "SOLD OUT", | |
| "Direct rate": f"${direct}" if direct is not None else "—", | |
| "Working range": f"${low}–${high}" if low is not None else "—", | |
| "Availability": availability_note, | |
| }) | |
| return pd.DataFrame(rows) | |
| def occupancy_adjustment(occupancy): | |
| if occupancy is None: return 0.0 | |
| occ = (float(occupancy) if occupancy is not None else 65.0) | |
| if occ < 40: | |
| return -0.10 | |
| if occ < 55: | |
| return -0.06 | |
| if occ < 70: | |
| return 0.00 | |
| if occ < 80: | |
| return 0.05 | |
| if occ < 90: | |
| return 0.10 | |
| if occ < 96: | |
| return 0.17 | |
| return 0.24 | |
| def pickup_adjustment(pickup_points): | |
| if pickup_points is None: return 0.0 | |
| p = (float(pickup_points) if pickup_points is not None else 0.0) | |
| if p <= -5: | |
| return -0.05 | |
| if p < 2: | |
| return 0.00 | |
| if p < 6: | |
| return 0.03 | |
| if p < 12: | |
| return 0.06 | |
| return 0.10 | |
| def day_adjustment(target_date): | |
| wd = target_date.weekday() | |
| if wd == 4: | |
| return 0.06 | |
| if wd == 5: | |
| return 0.10 | |
| if wd == 6: | |
| return 0.02 | |
| return 0.00 | |
| def lead_time_adjustment(target_date, occupancy): | |
| today = datetime.now(TZ).date() | |
| lead = (target_date - today).days | |
| if occupancy is None: return 0.0, lead | |
| occ = (float(occupancy) if occupancy is not None else 65.0) | |
| if lead < 0: | |
| return 0.0, lead | |
| if lead <= 2: | |
| if occ >= 90: | |
| return 0.08, lead | |
| if occ < 55: | |
| return -0.10, lead | |
| if lead <= 7 and occ >= 82: | |
| return 0.05, lead | |
| return 0.0, lead | |
| def _build_rate_inputs(live_result, manual_ota_df, manual_comp_df, owner_direct_rate): | |
| source = "Manual fallback" | |
| live_own = list((live_result or {}).get("own_sources") or []) | |
| live_comps = list((live_result or {}).get("competitors") or []) | |
| if live_own: | |
| own_df = pd.DataFrame(live_own) | |
| source = "Automatic Google Hotels rate shop" | |
| else: | |
| own_df = manual_ota_df.copy() if isinstance(manual_ota_df, pd.DataFrame) else pd.DataFrame(manual_ota_df or []) | |
| if live_comps: | |
| comp_df = pd.DataFrame(live_comps) | |
| source = "Automatic Google Hotels rate shop" | |
| else: | |
| comp_df = manual_comp_df.copy() if isinstance(manual_comp_df, pd.DataFrame) else pd.DataFrame(manual_comp_df or []) | |
| if "Channel" not in own_df.columns: | |
| own_df = pd.DataFrame(columns=["Channel", "Base rate ($)"]) | |
| if "Base rate ($)" not in own_df.columns: | |
| own_df["Base rate ($)"] = None | |
| if "Comparable hotel" not in comp_df.columns: | |
| comp_df = pd.DataFrame(columns=["Comparable hotel", "Base rate ($)"]) | |
| if "Base rate ($)" not in comp_df.columns: | |
| comp_df["Base rate ($)"] = None | |
| own_rates = _extract_rates(own_df, "Base rate ($)") | |
| comp_rates = _extract_rates(comp_df, "Base rate ($)") | |
| return own_df, comp_df, own_rates, comp_rates, source | |
| def rate_movement_html(current_rate, competitor_rate, recommended, low, high, title="Rate movement"): | |
| current_rate = _safe_float(current_rate) | |
| competitor_rate = _safe_float(competitor_rate) | |
| recommended = _safe_float(recommended) | |
| low = _safe_float(low) | |
| high = _safe_float(high) | |
| vals = [v for v in [current_rate, competitor_rate, recommended, low, high] if v is not None] | |
| if not vals or recommended is None: | |
| return "" | |
| axis_min = min(vals) | |
| axis_max = max(vals) | |
| span = max(axis_max - axis_min, 10) | |
| pad = max(5, span * 0.12) | |
| axis_min -= pad | |
| axis_max += pad | |
| def pct(v): | |
| if v is None: | |
| return None | |
| return clamp((float(v) - axis_min) / (axis_max - axis_min) * 100.0, 2.0, 98.0) | |
| base = current_rate if current_rate is not None else competitor_rate | |
| delta = recommended - base if base is not None else 0 | |
| if delta > 0.99: | |
| arrow, action, cls = "↑", f"Raise ${delta:.0f}", "move-up" | |
| elif delta < -0.99: | |
| arrow, action, cls = "↓", f"Lower ${abs(delta):.0f}", "move-down" | |
| else: | |
| arrow, action, cls = "→", "Hold rate", "move-flat" | |
| markers = [] | |
| if current_rate is not None: | |
| markers.append( | |
| f'<div class="rr-marker rr-current" style="left:{pct(current_rate):.1f}%">' | |
| f'<div class="rr-dot"></div><div class="rr-label">Current rate <b>${current_rate:.0f}</b></div></div>' | |
| ) | |
| if competitor_rate is not None: | |
| markers.append( | |
| f'<div class="rr-marker rr-comp" style="left:{pct(competitor_rate):.1f}%">' | |
| f'<div class="rr-dot"></div><div class="rr-label">Comp avg <b>${competitor_rate:.0f}</b></div></div>' | |
| ) | |
| markers.append( | |
| f'<div class="rr-marker rr-rec" style="left:{pct(recommended):.1f}%">' | |
| f'<div class="rr-callout {cls}"><span>{arrow}</span> ${recommended:.0f}<small>{action}</small></div>' | |
| f'<div class="rr-pin"></div></div>' | |
| ) | |
| range_text = "" | |
| if low is not None and high is not None: | |
| range_text = f'<div class="rr-range"><span>${low:.0f}</span><span>Suggested range</span><span>${high:.0f}</span></div>' | |
| return ( | |
| f'<div class="rate-movement">' | |
| f'<div class="rr-title">{escape(title)}</div>' | |
| f'{range_text}' | |
| f'<div class="rr-track"><div class="rr-line"></div>{"".join(markers)}</div>' | |
| f'</div>' | |
| ) | |
| def daadu_note(rate): | |
| try: | |
| value = float(rate) | |
| except Exception: | |
| return "" | |
| if value < 100: | |
| return '<div class="daadu-note daadu-sad">Daadu not happy 😅</div>' | |
| return '<div class="daadu-note daadu-happy">Daadu is happy 😄</div>' | |
| def money_or_na(value): | |
| return f"${value:.0f}" if value is not None else "—" | |
| def hour_to_label(hour_value): | |
| hour = int(_safe_float(hour_value, 15)) % 24 | |
| return datetime(2000, 1, 1, hour, 0).strftime("%I:00 %p") | |
| def parse_hour_value(hour_value): | |
| if isinstance(hour_value, (int, float)): | |
| return int(hour_value) % 24 | |
| raw = str(hour_value or "").strip() | |
| if raw.isdigit(): | |
| return int(raw) % 24 | |
| try: | |
| return datetime.strptime(raw, "%I:%M %p").hour | |
| except ValueError: | |
| try: | |
| return datetime.strptime(raw, "%H:%M").hour | |
| except ValueError as exc: | |
| raise ValueError("Select a valid pricing time") from exc | |
| HOUR_CHOICES = [hour_to_label(h) for h in range(24)] | |
| def hourly_context(target_date, mode, hour, restaurant_close, event_end, closure_discount, now=None, late_reduction=2.0): | |
| now = now or datetime.now(TZ) | |
| parsed_hour = parse_hour_value(hour) | |
| selected = now if mode == "Live now" and target_date == now.date() else datetime.combine( | |
| target_date, time(parsed_hour), TZ | |
| ) | |
| live = mode == "Live now" and target_date == now.date() | |
| note = f"Pricing time: {selected.strftime('%Y-%m-%d %I:%M %p %Z')}. " | |
| note += "Current traffic may influence this rate. " if live else "Scenario: current traffic excluded; manual traffic estimate used. " | |
| closed = [] | |
| # A clock time before 06:00 belongs to the end of this evening's trading window. | |
| for label, value in (("Restaurants", restaurant_close), ("Local event", event_end)): | |
| if value and str(value).strip(): | |
| try: | |
| closing = time.fromisoformat(str(value).strip()) | |
| except ValueError as exc: | |
| raise ValueError(f"{label}: enter closing time as HH:MM or leave blank") from exc | |
| end = datetime.combine(target_date, closing, TZ) | |
| if closing.hour < 6: | |
| end += timedelta(days=1) | |
| if selected >= end: | |
| closed.append(label) | |
| late_hours = max(0, selected.hour + selected.minute / 60 - 18) | |
| late_discount = min(10.0, late_hours * max(0, float(late_reduction))) / 100 | |
| adjustment = -min(0.20, late_discount + (abs(float(closure_discount)) / 100 if closed else 0.0)) | |
| note += f"Late-day policy: {late_reduction:g}% per hour after 6 PM, capped at 10%; applied reduction {late_discount:.1%}. " | |
| if closed: | |
| note += f"Entered closing times passed: {', '.join(closed)}; manual adjustment {adjustment:+.1%}. " | |
| else: | |
| note += "No entered closing time has passed. " | |
| note += "Opening hours are manual; unknown event end times are not guessed." | |
| return {"selected": selected, "live": live, "adjustment": adjustment, "note": note} | |
| def compute_night( | |
| target_date, | |
| live_rate_result, | |
| manual_ota_df, | |
| manual_comp_df, | |
| owner_direct_rate, | |
| occupancy, | |
| pickup_points, | |
| manual_event_level, | |
| manual_traffic_level, | |
| floor, | |
| ceiling, | |
| direct_discount, | |
| weather_map, | |
| events_by_date, | |
| event_source, | |
| traffic_tuple, | |
| time_context=None, | |
| ): | |
| own_df, comp_df, own_rates, comp_rates, rate_source = _build_rate_inputs( | |
| live_rate_result, manual_ota_df, manual_comp_df, owner_direct_rate | |
| ) | |
| entered_rate = _safe_float(owner_direct_rate) | |
| owner_direct_rate = entered_rate if entered_rate is not None and entered_rate > 0 else None | |
| reference_prices = dict(zip( | |
| [(date(2026,9,20)+timedelta(days=i)).isoformat() for i in range(14)], | |
| [85,85,85,85,85,199,269,85,85,85,85,85,199,229])) | |
| reference_base = reference_prices.get(target_date.isoformat(),85) | |
| reference_label = ("ASI screenshot reference · " + target_date.isoformat() + " · not live") if target_date.isoformat() in reference_prices else "ASI $85 weekday baseline · estimate, not a quote for this date" | |
| anchor, own_median, primary_comp_rate, comp_benchmark, anchor_source, own_rates, comp_rates = _market_anchor( | |
| own_df, comp_df, owner_direct_rate | |
| ) | |
| reference_fallback = anchor is None | |
| if reference_fallback: | |
| anchor = reference_base | |
| anchor_source = reference_label | |
| rate_source = "Reference-based estimate; no current market quotes" | |
| own_low = min(own_rates) if own_rates else None | |
| weather = weather_map.get(target_date.isoformat()) | |
| weather_adj, weather_text = weather_adjustment(weather) | |
| events = events_by_date.get(target_date.isoformat(), []) | |
| if time_context: | |
| active_events = [] | |
| for event in events: | |
| end = event.get("end_time") | |
| try: | |
| ended = end and datetime.fromisoformat(end.replace("Z", "+00:00")).astimezone(TZ) <= time_context["selected"] | |
| except (ValueError, TypeError): | |
| ended = False | |
| if not ended: | |
| active_events.append(event) | |
| events = active_events | |
| event_adj, highlights = event_adjustment(events, manual_event_level) | |
| auto_congestion, traffic_details, traffic_source = traffic_tuple | |
| traffic_adj, traffic_text = traffic_adjustment(auto_congestion, manual_traffic_level, target_date) | |
| if time_context and not time_context["live"]: | |
| traffic_adj = {"Normal": 0, "Busier than normal": .015, "Heavy inbound traffic": .03, "Severe inbound congestion": .045}.get(manual_traffic_level, 0) | |
| traffic_text = f"Manual scenario traffic estimate: {manual_traffic_level}; current road speeds excluded" | |
| late_high_traffic = bool(time_context and time_context["selected"].hour >= 18 and traffic_adj >= .03) | |
| if late_high_traffic: | |
| traffic_adj = 0.0 | |
| traffic_text += "; late-day traffic uplift disabled; rooms with ≤25% available receive a 3% reduction" | |
| time_adj = time_context["adjustment"] if time_context else 0.0 | |
| occ_adj = occupancy_adjustment(occupancy) | |
| pick_adj = pickup_adjustment(pickup_points) | |
| dow_adj = day_adjustment(target_date) | |
| lead_adj, lead_days = lead_time_adjustment(target_date, occupancy) | |
| total_adj = clamp( | |
| occ_adj + pick_adj + dow_adj + lead_adj + event_adj + weather_adj + traffic_adj, | |
| -0.20, | |
| 0.30, | |
| ) | |
| raw_bar = anchor * (1 + total_adj) | |
| # Because Surf City is positioned as a 2-star motel equal to Hotel Solares next door, | |
| # keep the base room recommendation reasonably close to Solares unless occupancy/events are exceptional. | |
| if primary_comp_rate is not None: | |
| low_mult = 0.78 if (float(occupancy) if occupancy is not None else 65.0) < 55 else 0.84 | |
| high_mult = 1.28 if (float(occupancy) if occupancy is not None else 65.0) >= 90 or event_adj >= 0.12 else 1.16 | |
| raw_bar = clamp(raw_bar, primary_comp_rate * low_mult, primary_comp_rate * high_mult) | |
| # Market guardrails prevent a noisy signal from sending the estimate far outside the observed comp set. | |
| if comp_rates: | |
| q25 = float(pd.Series(comp_rates).quantile(0.25)) | |
| q75 = float(pd.Series(comp_rates).quantile(0.75)) | |
| occ = (float(occupancy) if occupancy is not None else 65.0) | |
| market_low = max(min(comp_rates) * 0.85, q25 * 0.88) | |
| market_high_multiplier = 1.18 if occ >= 90 else 1.10 | |
| market_high = max(max(comp_rates), q75) * market_high_multiplier | |
| raw_bar = clamp(raw_bar, market_low, market_high) | |
| # Final sanity guardrail around the observable market. At ordinary occupancy, | |
| # a recommendation cannot leap far above both the current rate and comparable | |
| # public rates. Exceptional occupancy/event conditions get more headroom. | |
| observed_refs = [v for v in [own_median, primary_comp_rate, comp_benchmark, _safe_float(owner_direct_rate)] if v is not None] | |
| if observed_refs: | |
| market_center = median(observed_refs) | |
| if (float(occupancy) if occupancy is not None else 65.0) >= 90 or event_adj >= 0.12: | |
| max_mult = 1.45 | |
| elif (float(occupancy) if occupancy is not None else 65.0) >= 80 or event_adj >= 0.06: | |
| max_mult = 1.30 | |
| else: | |
| max_mult = 1.20 | |
| max_allowed = max(market_center * max_mult, market_center + 20.0) | |
| min_allowed = max(floor, market_center * 0.78) | |
| raw_bar = clamp(raw_bar, min_allowed, max_allowed) | |
| raw_bar *= (1.0 + time_adj) # Apply the time reduction once, after market guardrails. | |
| recommended_bar = round(clamp(raw_bar, floor, ceiling)) | |
| direct_rate = round(clamp(recommended_bar * (1 - float(direct_discount) / 100.0), floor, ceiling)) | |
| safe_low = round(clamp(recommended_bar * 0.94, floor, ceiling)) | |
| safe_high = round(clamp(recommended_bar * 1.07, floor, ceiling)) | |
| parity = ((recommended_bar - own_low) / own_low) * 100 if own_low else None | |
| live_rate_count = len(own_rates) + len(comp_rates) | |
| live_source_points = 0 | |
| if live_rate_result: | |
| live_source_points += 3 | |
| if weather: | |
| live_source_points += 1 | |
| if event_source.startswith("Live"): | |
| live_source_points += 1 | |
| if traffic_source.startswith("Live") and target_date == datetime.now(TZ).date(): | |
| live_source_points += 1 | |
| if live_rate_count >= 6 and live_source_points >= 4: | |
| confidence = "High" | |
| conf_score = 88 | |
| elif live_rate_count >= 4: | |
| confidence = "Medium" | |
| conf_score = 72 | |
| else: | |
| confidence = "Low" | |
| conf_score = 52 | |
| confidence = "Hotel/date/tax checked; room and cancellation unverified" if (live_rate_result or {}).get("own_sources") else "No matched Surf City price; inspect source status" | |
| return { | |
| "pricing_time": time_context["selected"].isoformat() if time_context else "", | |
| "generated_at": datetime.now(TZ).isoformat(), | |
| "time_note": time_context["note"] if time_context else "", | |
| "closing_adjustment_pct": round(time_adj * 100, 1), | |
| "hotel": DEFAULT_HOTEL, | |
| "checkin_date": target_date.isoformat(), | |
| "recommended_bar": recommended_bar, | |
| "current_direct_rate": _safe_float(owner_direct_rate), | |
| "reference_base": reference_base, | |
| "reference_label": reference_label, | |
| "reference_fallback": reference_fallback, | |
| "source_audit_json": json.dumps((live_rate_result or {}).get("audit", [])), | |
| "direct_rate": direct_rate, | |
| "working_low": safe_low, | |
| "working_high": safe_high, | |
| "market_anchor": round(anchor, 2), | |
| "hotel_channel_average": round(own_median, 2) if own_median is not None else None, | |
| "competitor_average": round(comp_benchmark, 2) if comp_benchmark is not None else None, | |
| "occupancy_pct": occupancy, | |
| "pickup_points": pickup_points, | |
| "event_adjustment_pct": round(event_adj * 100, 1), | |
| "weather_adjustment_pct": round(weather_adj * 100, 1), | |
| "traffic_adjustment_pct": round(traffic_adj * 100, 1), | |
| "total_adjustment_pct": round(total_adj * 100, 1), | |
| "confidence": confidence, | |
| "evidence_note": "Heuristic, not a calibrated probability", | |
| "rate_source": f"{rate_source}; {anchor_source}", | |
| "primary_competitor": PRIMARY_COMPETITOR, | |
| "primary_comp_rate": round(primary_comp_rate, 2) if primary_comp_rate is not None else None, | |
| "weather_text": weather_text, | |
| "event_source": event_source, | |
| "event_highlights": highlights, | |
| "traffic_text": traffic_text, | |
| "traffic_source": traffic_source, | |
| "traffic_details": traffic_details, | |
| "own_df": own_df, | |
| "comp_df": comp_df, | |
| "parity": parity, | |
| "lead_days": lead_days, | |
| "occ_adj": occ_adj, | |
| "pick_adj": pick_adj, | |
| "dow_adj": dow_adj, | |
| "lead_adj": lead_adj, | |
| "event_adj": event_adj, | |
| "weather_adj": weather_adj, | |
| "traffic_adj": traffic_adj, | |
| "late_high_traffic": late_high_traffic, | |
| } | |
| def quick_tomorrow(): | |
| return (datetime.now(TZ).date() + timedelta(days=1)).isoformat(), "Selected date" | |
| def quick_next_30_days(): | |
| return (datetime.now(TZ).date() + timedelta(days=1)).isoformat(), "30-day forecast" | |
| def quick_next_weekend(): | |
| today = datetime.now(TZ).date() | |
| days_ahead = (4 - today.weekday()) % 7 | |
| if days_ahead == 0: | |
| days_ahead = 7 | |
| return (today + timedelta(days=days_ahead)).isoformat(), "Selected date" | |
| def _status_markdown(rate_status, event_status, traffic_status, weather_map): | |
| stamp = datetime.now(TZ).strftime("%b %d, %Y • %I:%M %p PT") | |
| serp_ok = "✅" if os.getenv("SERPAPI_API_KEY", "").strip() else "⚠️" | |
| tm_ok = "✅" if os.getenv("TICKETMASTER_API_KEY", "").strip() else "⚠️" | |
| tt_ok = "✅" if os.getenv("TOMTOM_API_KEY", "").strip() else "⚠️" | |
| weather_ok = "✅" if weather_map else "⚠️" | |
| return f""" | |
| ### Live data status | |
| **Last refresh:** {stamp} | |
| - {serp_ok} **Competitor rates:** {rate_status} | |
| - {tt_ok} **Traffic:** {traffic_status} | |
| - {tm_ok} **Events:** {event_status} | |
| - {weather_ok} **Weather:** {'Open-Meteo live forecast loaded' if weather_map else 'Open-Meteo unavailable'} | |
| """ | |
| def analyze_selection( | |
| view_mode, | |
| checkin_value, | |
| hotel_name, | |
| market, | |
| auto_rates, | |
| max_comps, | |
| owner_direct_rate, | |
| manual_ota_df, | |
| manual_comp_df, | |
| room_profile_df, | |
| occupancy, | |
| pickup_points, | |
| manual_event_level, | |
| manual_traffic_level, | |
| price_floor, | |
| price_ceiling, | |
| direct_discount, | |
| time_mode="Selected time", | |
| pricing_hour=15, | |
| restaurant_close="", | |
| event_end="", | |
| closure_discount=0, | |
| fresh_prices=False, | |
| allow_bulk=False, | |
| late_reduction=2.0, | |
| ): | |
| if view_mode == "30-day forecast" and auto_rates and not allow_bulk: | |
| return "<p>Enable 30-day API queries in Sources & refresh first (up to 300 searches).</p>", pd.DataFrame(), pd.DataFrame(), "", pd.DataFrame(), "", None, "No pricing calls made." | |
| try: | |
| start_date = datetime.now(TZ).date() if time_mode == "Live now" else parse_target_date(checkin_value) | |
| hourly_context(start_date, time_mode, pricing_hour, restaurant_close, event_end, closure_discount, late_reduction=late_reduction) | |
| except Exception: | |
| return "### ⚠️ Select a valid date/time and use HH:MM for closing times.", pd.DataFrame(), pd.DataFrame(), "", pd.DataFrame(), "", None, "" | |
| hotel_name = (hotel_name or DEFAULT_HOTEL).strip() | |
| market = (market or DEFAULT_MARKET).strip() | |
| floor = _safe_float(price_floor, 59.0) | |
| ceiling = _safe_float(price_ceiling, 399.0) | |
| if floor >= ceiling: | |
| return "### Minimum rate must be lower than maximum rate.", pd.DataFrame(), pd.DataFrame(), "", pd.DataFrame(), "", None, "" | |
| if auto_rates and start_date < datetime.now(TZ).date(): | |
| return empty_dashboard("Choose today or a future stay date"), pd.DataFrame(), pd.DataFrame(), "", pd.DataFrame(), "", None, "Public rate searches cannot verify past stays." | |
| if floor <= 0 or ceiling <= 0: | |
| return empty_dashboard("Minimum and maximum rates must be positive"), pd.DataFrame(), pd.DataFrame(), "", pd.DataFrame(), "", None, "No pricing calls made." | |
| weather_map = get_weather_map() | |
| end_date = start_date if view_mode == "Selected date" else start_date + timedelta(days=29) | |
| events_by_date, event_status = get_ticketmaster_events_range(start_date, end_date) | |
| traffic_tuple = get_tomtom_traffic() | |
| traffic_status = traffic_tuple[2] | |
| live_map = {} | |
| rate_status = "Automatic rate shopping disabled; manual fallback used" | |
| if auto_rates: | |
| if view_mode == "Selected date": | |
| live, rate_status = get_live_google_hotel_rates(start_date, hotel_name, market, max_comps=int(max_comps), fresh=fresh_prices) | |
| if live: | |
| live_map[start_date.isoformat()] = live | |
| else: | |
| live_map, rate_status = get_live_google_hotel_rates_30(start_date, hotel_name, market, max_comps=int(max_comps), fresh=fresh_prices) | |
| if view_mode == "Selected date": | |
| night = compute_night( | |
| start_date, | |
| live_map.get(start_date.isoformat()), | |
| manual_ota_df, | |
| manual_comp_df, | |
| owner_direct_rate, | |
| occupancy, | |
| pickup_points, | |
| manual_event_level, | |
| manual_traffic_level, | |
| floor, | |
| ceiling, | |
| direct_discount, | |
| weather_map, | |
| events_by_date, | |
| event_status, | |
| traffic_tuple, | |
| hourly_context(start_date, time_mode, pricing_hour, restaurant_close, event_end, closure_discount, late_reduction=late_reduction), | |
| ) | |
| if not night: | |
| return ( | |
| empty_dashboard("No current quote for this stay") + source_cards(live_map.get(start_date.isoformat())), | |
| pd.DataFrame(), pd.DataFrame(), "", pd.DataFrame(), "", None, | |
| _status_markdown(rate_status, event_status, traffic_status, weather_map), | |
| ) | |
| own_df = night["own_df"].copy() | |
| comp_df = night["comp_df"].copy() | |
| for col in ["Role", "Distance (mi)", "Rating", "Hotel class"]: | |
| if col not in comp_df.columns: | |
| comp_df[col] = None | |
| room_prices = room_type_recommendations( | |
| night["recommended_bar"], room_profile_df, occupancy, floor, ceiling, direct_discount, owner_direct_rate, late_high_traffic=night["late_high_traffic"] | |
| ) | |
| parity_text = "" | |
| if night["parity"] is not None: | |
| if night["parity"] > 3: | |
| parity_text = f"The recommendation is **{night['parity']:.0f}% above** the lowest observed public rate for the hotel." | |
| elif night["parity"] < -3: | |
| parity_text = f"The recommendation is **{abs(night['parity']):.0f}% below** the lowest observed public rate for the hotel." | |
| else: | |
| parity_text = "The recommendation is close to the lowest observed public hotel rate." | |
| movement = rate_movement_html( | |
| night["current_direct_rate"], | |
| night["competitor_average"], | |
| night["recommended_bar"], | |
| night["working_low"], | |
| night["working_high"], | |
| ) | |
| result_html = f""" | |
| <div class="result-card"> | |
| <div class="eyebrow">{start_date.strftime('%A, %B %d, %Y')}</div> | |
| <div class="metric-row"><div><span>{"Current direct rate (entered)" if night["current_direct_rate"] is not None else "ASI reference · not live"}</span><strong>{money_or_na(night["current_direct_rate"] if night["current_direct_rate"] is not None else night["reference_base"])}</strong></div><div><span>{"Competitor average" if night["competitor_average"] is not None else "Pricing baseline · estimate"}</span><strong>{money_or_na(night["competitor_average"] if night["competitor_average"] is not None else night["market_anchor"])}</strong></div><div><span>Suggested base rate · model</span><strong>${night['recommended_bar']}</strong>{daadu_note(night['recommended_bar'])}</div></div> | |
| {movement} | |
| <div class="range">Working range: <b>${night['working_low']}–${night['working_high']}</b> • Suggested direct rate: <b>${night['direct_rate']}</b></div> | |
| <div class="confidence">Observed source average: <b>{money_or_na(night['hotel_channel_average'])}</b> • Source checks: <b>{night['confidence']}</b></div> | |
| </div> | |
| """ | |
| if night["reference_fallback"]: | |
| result_html += '<div class="result-card"><b>Reference estimate</b><p>' + escape(night["reference_label"]) + '. Live prices could not be verified. Time and demand rules adjust this reference; competitor prices have not been invented.</p></div>' | |
| result_html += source_cards(live_map.get(start_date.isoformat())) | |
| rows = [ | |
| ["Market anchor", f"${night['market_anchor']:.0f}", "+0.0%", night["rate_source"]], | |
| ["Occupancy", f"${night['market_anchor']:.0f}", f"{night['occ_adj']:+.1%}", (f"Entered occupancy {float(occupancy):.0f}%" if occupancy is not None else "Not entered; neutral adjustment")], | |
| ["Booking pickup", f"${night['market_anchor']:.0f}", f"{night['pick_adj']:+.1%}", (f"Entered pickup {float(pickup_points):+.1f} pts" if pickup_points is not None else "Not entered; neutral adjustment")], | |
| ["Day of week", f"${night['market_anchor']:.0f}", f"{night['dow_adj']:+.1%}", start_date.strftime("%A")], | |
| ["Lead time", f"${night['market_anchor']:.0f}", f"{night['lead_adj']:+.1%}", f"{night['lead_days']} days before check-in"], | |
| ["Events", f"${night['market_anchor']:.0f}", f"{night['event_adj']:+.1%}", night["event_source"]], | |
| ["Weather", f"${night['market_anchor']:.0f}", f"{night['weather_adj']:+.1%}", night["weather_text"]], | |
| ["Closing-time adjustment", f"${night['market_anchor']:.0f}", f"{night['closing_adjustment_pct']:+.1f}%", night["time_note"]], | |
| ["Traffic", f"${night['market_anchor']:.0f}", f"{night['traffic_adj']:+.1%}", f"{night['traffic_text']}; {night['traffic_source']}"], | |
| ] | |
| breakdown = pd.DataFrame(rows, columns=["Signal", "Anchor", "Rate impact", "Evidence"]) | |
| event_md = "" | |
| if night["event_highlights"]: | |
| event_md = "\n\n**Nearby events detected:**\n" + "\n".join(f"- {x}" for x in night["event_highlights"]) | |
| traffic_md = "" | |
| if night["traffic_details"]: | |
| traffic_md = "\n\n**Current traffic samples:** " + "; ".join(night["traffic_details"]) | |
| explanation = f""" | |
| **Time note:** {night["time_note"]} | |
| ### Why this rate ${night['recommended_bar']} | |
| The pricing anchor is **${night['market_anchor']:.0f}**. The competitor figure is an arithmetic mean of available hotel starting-rate offers, before tax. Missing hotels are excluded. Your current direct rate is **{money_or_na(night['current_direct_rate'])}**. Surf City's observed-source average is **{money_or_na(night['hotel_channel_average'])}**; the competitor average is **{money_or_na(night['competitor_average'])}**. Missing prices are excluded. Demand adjustment: **{night['total_adjustment_pct']:+.1f}%**. Advertised offers may have unverified room/cancellation details; review before changing prices. | |
| The room-type table uses the property's fixed **70-room inventory** (4 1 Queen, 4 King Kitchen, 11 Two Queen Plus, 5 Two Queen Kitchen, 33 KQS, 13 Three Queen). Enter actual rooms-left counts when you know them; if a rooms-left cell is blank, the app estimates remaining inventory from the overall occupancy percentage. | |
| {parity_text} | |
| **Important:** this is a **best estimate, not an exact or guaranteed selling price**. Public OTA prices can differ by room type, cancellation rules, taxes, loyalty status and device/user targeting. Occupancy and booking pickup remain owner-entered because public sites do not know the hotel's true rooms-on-the-books. | |
| {event_md}{traffic_md} | |
| """ | |
| export = pd.DataFrame([{k: v for k, v in night.items() if k not in {"own_df", "comp_df", "event_highlights", "traffic_details"}}]) | |
| tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".csv", prefix="hotel-rate-live-") | |
| export.to_csv(tmp.name, index=False) | |
| tmp.close() | |
| status = _status_markdown(rate_status, event_status, traffic_status, weather_map) | |
| return result_html, own_df, comp_df, compact_room_table(room_prices), breakdown, explanation, tmp.name, status | |
| # 30-day forecast | |
| rows = [] | |
| for offset in range(30): | |
| target_date = start_date + timedelta(days=offset) | |
| night = compute_night( | |
| target_date, | |
| live_map.get(target_date.isoformat()), | |
| manual_ota_df, | |
| manual_comp_df, | |
| owner_direct_rate, | |
| occupancy, | |
| pickup_points, | |
| manual_event_level, | |
| manual_traffic_level, | |
| floor, | |
| ceiling, | |
| direct_discount, | |
| weather_map, | |
| events_by_date, | |
| event_status, | |
| traffic_tuple, | |
| hourly_context(target_date, time_mode, pricing_hour, restaurant_close, event_end, closure_discount, late_reduction=late_reduction), | |
| ) | |
| if night: | |
| rows.append(night) | |
| if not rows: | |
| return ( | |
| "### No current quotes · Check Source status or enter manual rates.", | |
| pd.DataFrame(), pd.DataFrame(), "", pd.DataFrame(), "", None, | |
| _status_markdown(rate_status, event_status, traffic_status, weather_map), | |
| ) | |
| export_rows = [] | |
| for night in rows: | |
| export_rows.append({k: v for k, v in night.items() if k not in {"own_df", "comp_df", "event_highlights", "traffic_details"}}) | |
| export = pd.DataFrame(export_rows) | |
| display = pd.DataFrame({ | |
| "Date": pd.to_datetime(export["checkin_date"]).dt.strftime("%a %b %d"), | |
| "Recommended BAR": export["recommended_bar"].map(lambda x: f"${x:.0f}"), | |
| "Direct rate": export["direct_rate"].map(lambda x: f"${x:.0f}"), | |
| "Working range": [f"${lo:.0f}–${hi:.0f}" for lo, hi in zip(export["working_low"], export["working_high"])], | |
| "Market anchor": export["market_anchor"].map(lambda x: f"${x:.0f}"), | |
| "Demand adj.": export["total_adjustment_pct"].map(lambda x: f"{x:+.1f}%"), | |
| "Confidence": export["confidence"], | |
| "Pricing time": export["pricing_time"], | |
| "Time note": export["time_note"], | |
| }) | |
| avg_bar = round(export["recommended_bar"].mean()) | |
| low_bar = int(export["recommended_bar"].min()) | |
| high_bar = int(export["recommended_bar"].max()) | |
| peak_idx = export["recommended_bar"].idxmax() | |
| peak_date = pd.to_datetime(export.loc[peak_idx, "checkin_date"]).strftime("%A, %b %d") | |
| peak_rate = int(export.loc[peak_idx, "recommended_bar"]) | |
| avg_current = _safe_float(owner_direct_rate) | |
| avg_comp = float(export["competitor_average"].dropna().mean()) if export["competitor_average"].notna().any() else None | |
| movement = rate_movement_html(avg_current, avg_comp, avg_bar, low_bar, high_bar, title="30-day average rate movement") | |
| result_html = f""" | |
| <div class="result-card"> | |
| <div class="eyebrow">30-night live-market forecast • {start_date.strftime('%b %d')} – {(start_date + timedelta(days=29)).strftime('%b %d, %Y')}</div> | |
| <div class="price-label">AVERAGE RECOMMENDED BAR</div> | |
| <div class="big-price">${avg_bar}</div> | |
| {daadu_note(avg_bar)} | |
| {movement} | |
| <div class="range">Nightly recommendations: <b>${low_bar}–${high_bar}</b> • Peak: <b>{peak_date} ${peak_rate}</b></div> | |
| <div class="confidence">Each night is priced separately using the rate shop available for that date.</div> | |
| </div> | |
| """ | |
| explanation = f""" | |
| ### 30-day pricing outlook | |
| Selected time: **{hour_to_label(parse_hour_value(pricing_hour))} America/Los_Angeles** (the first night uses current time in Live now mode). | |
| Occupancy, pickup, manual rates and closing hours are shared scenario assumptions across these dates. Blank occupancy/pickup have no adjustment; manual prices are not live quotes. The room-type table previews the first night only. | |
| The app attempted live Google Hotels rate shopping for each of the 30 nights, then combined the available public hotel/competitor rates with occupancy, pickup, events, weekday/weekend and available weather. | |
| **API-quota note:** one uncached 30-day refresh can use up to **300 SerpApi searches**. Use the refresh only when you need a new market snapshot. The app caches each date for 60 minutes, and SerpApi also documents one-hour caching for identical searches. | |
| **Traffic:** TomTom is truly live, so current road conditions are displayed but are **not used as if they were a forecast** for future nights. This prevents today's Hwy 17 congestion from incorrectly changing a rate three weeks from now. | |
| **Weather:** Open-Meteo's standard live forecast reaches up to 16 days, so later nights receive no weather adjustment rather than an invented forecast. | |
| """ | |
| tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".csv", prefix="hotel-rate-30day-live-") | |
| export.to_csv(tmp.name, index=False) | |
| tmp.close() | |
| # Show the start night's auto rate shop in the two live tables as a helpful preview. | |
| first = rows[0] | |
| own_df = first["own_df"].copy() | |
| comp_df = first["comp_df"].copy() | |
| room_prices = room_type_recommendations( | |
| first["recommended_bar"], room_profile_df, occupancy, floor, ceiling, direct_discount, owner_direct_rate, late_high_traffic=first["late_high_traffic"] | |
| ) | |
| status = _status_markdown(rate_status, event_status, traffic_status, weather_map) | |
| return result_html, own_df, comp_df, compact_room_table(room_prices), display, explanation, tmp.name, status | |
| def compact_room_table(frame): | |
| if frame.empty: | |
| return "<p>No room recommendations available.</p>" | |
| cols = ["Room type", "Inventory", "Rooms left", "Current rate", "Recommended BAR"] | |
| headers = ["Room type", "Total rooms", "Rooms left", "Current (entered)", "Suggested (model)"] | |
| head = "".join(f"<th>{x}</th>" for x in headers) | |
| rows = "".join( | |
| "<tr>" + "".join(f"<td>{escape(str(row[c]))}</td>" for c in cols) + "</tr>" | |
| for _, row in frame.iterrows() | |
| ) | |
| return f'<div class="room-table"><table><thead><tr>{head}</tr></thead><tbody>{rows}</tbody></table></div>' | |
| def empty_dashboard(message="Choose your stay. See the market clearly."): | |
| return ('<div class="result-card welcome"><div class="eyebrow">YOUR PRICING WORKSPACE</div>' | |
| f'<h2>{escape(message)}</h2><p>Choose a date and refresh to compare public offers. ' | |
| 'Your current rate and hotel occupancy stay under your control.</p>' | |
| '<div class="metric-row"><div><span>Current entered rate</span><strong>—</strong></div>' | |
| '<div><span>Observed market</span><strong>—</strong></div>' | |
| '<div><span>Suggested rate · model</span><strong>—</strong></div></div>' | |
| '<div class="confidence">No sample prices. Every observation has a source and timestamp.</div></div>') | |
| def source_cards(result): | |
| audit=(result or {}).get('audit',[]) | |
| if not audit: return '' | |
| cards=[] | |
| for item in audit: | |
| prior=item.get('Last known') | |
| price=item.get('Average') | |
| if price is not None: | |
| badge='Google quote · '+item.get('Tax basis','Before tax'); tone='observed' | |
| stamp=', '.join(sorted(set(str(o.get('Observed at','')) for o in item.get('Offers',[])))) | |
| elif prior: | |
| price=prior['price'];badge='Last known · not current';tone='historical' | |
| stamp=', '.join(prior.get('timestamps',[])) | |
| else: | |
| badge='No saved quote';tone='missing';stamp='Refresh details in Source status' | |
| cards.append(f'<div class="source-card"><span class="source-name">{escape(item["Hotel"])}</span>' | |
| f'<strong>{money_or_na(price)}</strong><span class="quote-badge {tone}">{badge}</span>' | |
| f'<small>{escape(stamp)}</small></div>') | |
| return '<div class="source-heading">Public market snapshot <span>Hotel starting rates · nightly · see tax basis</span></div><div class="source-grid">'+''.join(cards)+'</div>' | |
| CUSTOM_CSS = """ | |
| .gradio-container {max-width:1180px!important;background:linear-gradient(180deg,#eef6ff 0%,#eaf3ff 100%)!important} | |
| #hero{background:linear-gradient(135deg,#102a52 0%,#173c75 58%,#2460c7 100%);color:white;border-radius:14px;padding:22px 24px;margin-bottom:12px;box-shadow:0 8px 22px #254f8420;position:relative;overflow:hidden;min-height:120px;display:flex;flex-direction:column;justify-content:center} | |
| #hero::after{content:"";position:absolute;inset:0;background:linear-gradient(120deg,rgba(255,255,255,.08),rgba(255,255,255,0) 52%);pointer-events:none} | |
| #hero p{margin:6px 0 0;font-size:15px;line-height:1.45;color:#dce8ff!important;position:relative;z-index:1} | |
| #hero .copyright{font-size:13px;color:#dce8ff!important;position:relative;z-index:1} | |
| .result-card{background:rgba(247,251,255,.96);backdrop-filter:blur(2px);border:1px solid #cfdef1;border-radius:12px;padding:16px;margin:6px 0 16px;box-shadow:0 3px 12px #254f8410}.eyebrow{font-size:12px;color:#4e6582}.metric-row{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin:12px 0}.metric-row>div{padding:14px 16px;background:#eaf3ff;border-radius:9px}.metric-row>div:last-child{background:linear-gradient(125deg,#1d55c6,#2879e8);box-shadow:0 6px 16px #2261c626}.metric-row span{display:block;font-size:12px;color:#4e6582}.metric-row strong{font-size:29px;color:#122b50;display:block;margin-top:5px}.metric-row>div:last-child span,.metric-row>div:last-child strong{color:white}.range,.confidence{font-size:12px;color:#4e6582;margin-top:6px}.big-price{font-size:30px}.price-label{font-size:12px} | |
| button.primary{background:linear-gradient(110deg,#2059c5,#2879e8)!important;border-color:#2463db!important;color:white!important;border-radius:7px!important}footer{display:none!important} | |
| #pricing-inputs{background:rgba(240,247,255,.95);backdrop-filter:blur(2px);border:1px solid #cfdef1;border-radius:10px;padding:14px;gap:10px!important}#pricing-inputs .block{box-shadow:none!important;border-radius:7px!important;min-width:0!important}#pricing-inputs input{min-height:38px!important;padding:7px 10px!important}#pricing-inputs label{font-size:12px!important}#pricing-inputs .form{min-width:0!important}.room-table{background:#f7fbff;border:1px solid #cfdef1;border-radius:10px;overflow:hidden}.room-table table{border-collapse:collapse;width:100%;table-layout:fixed;font-family:Inter,system-ui,sans-serif;font-size:13px;color:#122b50}.room-table th{background:#e2eeff;color:#4e6582;font-weight:600;text-align:right;padding:12px 10px}.room-table td{padding:13px 10px;border-bottom:1px solid #dae5f3;text-align:right;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.room-table tr:last-child td{border-bottom:0}.room-table td:first-child,.room-table th:first-child{text-align:left;width:38%}.room-table td:last-child{font-weight:650;color:#2059c5}.room-table tbody tr:nth-child(even){background:#eef5ff} | |
| #hero h1{color:#fff!important;position:relative;z-index:1;font-size:40px;margin:0 0 4px} | |
| .rate-movement{background:#edf5ff;border:1px solid #d4e3f6;border-radius:9px;padding:10px 12px 12px;margin:10px 0 8px} | |
| .rr-title{font-size:12px;font-weight:700;color:#35577f;margin-bottom:4px} | |
| .rr-range{display:flex;justify-content:space-between;font-size:10px;color:#6b82a0;margin:0 2px 5px} | |
| .rr-track{position:relative;height:62px;background:linear-gradient(90deg,#dceaff,#f3f8ff,#dceaff);border-radius:8px;overflow:visible} | |
| .rr-line{position:absolute;left:3%;right:3%;top:35px;height:7px;border-radius:999px;background:linear-gradient(90deg,#8fb7ef,#4f86dc);opacity:.5} | |
| .rr-marker{position:absolute;top:0;transform:translateX(-50%);z-index:2} | |
| .rr-dot,.rr-pin{position:absolute;top:31px;left:50%;transform:translateX(-50%);width:11px;height:11px;border-radius:50%;background:#123c79;border:2px solid #fff;box-shadow:0 2px 6px #244e8030} | |
| .rr-label{position:absolute;top:44px;left:50%;transform:translateX(-50%);white-space:nowrap;font-size:9px;color:#4e6582} | |
| .rr-label b{color:#122b50} | |
| .rr-rec{z-index:4} | |
| .rr-rec .rr-pin{background:#fff;border-color:#2059c5;width:13px;height:13px;top:30px} | |
| .rr-callout{position:absolute;top:2px;left:50%;transform:translateX(-50%);white-space:nowrap;border-radius:7px;padding:4px 8px;font-size:11px;font-weight:750;box-shadow:0 3px 8px #254f8420} | |
| .rr-callout span{font-size:14px;margin-right:3px} | |
| .rr-callout small{display:block;font-size:8px;font-weight:650;line-height:1.1;text-align:center} | |
| .move-up{background:#e4f8eb;color:#167245;border:1px solid #b8e6c8} | |
| .move-down{background:#ffe9e9;color:#a62a2a;border:1px solid #f4c2c2} | |
| .move-flat{background:#fff;color:#24508b;border:1px solid #cbdcf2} | |
| .daadu-note{font-size:12px;font-weight:700;margin-top:5px;line-height:1.2} | |
| .daadu-happy{color:#e9fff0} | |
| .daadu-sad{color:#fff2c7} | |
| .result-card>.daadu-note{display:inline-block;padding:4px 8px;border-radius:999px;background:#edf5ff;color:#24508b;border:1px solid #cbdcf2;margin:4px 0 8px} | |
| .gradio-container input,.gradio-container textarea,.gradio-container select{background:#ffffff!important;color:#122b50!important;border:1px solid #cddcf1!important} | |
| .gradio-container label,.gradio-container .wrap label,.gradio-container .block label{color:#24508b!important;font-weight:600!important} | |
| .gradio-container [role="listbox"],.gradio-container [role="option"],.gradio-container .options,.gradio-container .option,.gradio-container ul[role="listbox"]{background:#ffffff!important;color:#122b50!important} | |
| .gradio-container [role="option"]:hover,.gradio-container .option:hover,.gradio-container [aria-selected="true"]{background:#eaf3ff!important;color:#122b50!important} | |
| .gradio-container button,.gradio-container .secondary{color:inherit} | |
| @media(max-width:650px){.metric-row{grid-template-columns:1fr}.metric-row>div{display:flex;justify-content:space-between;align-items:center}.metric-row strong{font-size:24px;margin:0}.gradio-container{padding:12px!important}.room-table td,.room-table th{padding:10px 6px;font-size:12px}} | |
| """ | |
| CUSTOM_CSS += '\n.gradio-container{max-width:1280px!important;padding:28px!important;font-family:Inter,ui-sans-serif,system-ui,sans-serif!important;background:#edf4fc!important}\n#hero{border-radius:20px;padding:26px 30px;min-height:146px;background:radial-gradient(ellipse at 95% 15%,#327ad0 0%,transparent 48%),linear-gradient(120deg,#102744,#163e70);box-shadow:0 12px 32px #16355716;margin-bottom:16px}\n#hero h1{font-size:38px!important;letter-spacing:-1.7px;font-weight:750!important;margin:9px 0 2px}#hero h1 span{color:#72c9ff}\n.brand-kicker{font-size:10px;letter-spacing:2px;font-weight:650;color:#b3d0ef}.hero-bottom{display:flex;justify-content:space-between;align-items:center;gap:12px}.hero-bottom p{font-size:14px!important}.property-pill{background:#ffffff12;border:1px solid #ffffff29;border-radius:30px;padding:7px 12px;font-size:11px;color:#dfedff;white-space:nowrap}\n#hero .copyright{font-size:11px;color:#b9cfe8!important}\n#search-bar{gap:12px!important}#search-bar .block{border-radius:10px!important;box-shadow:none!important}#quick-actions{gap:8px!important}#quick-actions button{min-height:40px!important;font-size:12px!important;border-radius:9px!important}\n.search-context{display:flex;flex-wrap:wrap;gap:7px;align-items:center;margin:0 0 10px;color:#46607d}.search-context span{font-size:10px;font-weight:650;padding:4px 8px;border-radius:6px;background:#dfeaf7}.search-context small{font-size:11px;margin-left:4px}\n.result-card{border-radius:15px;padding:20px;background:#fafdff;border-color:#d6e2f1;box-shadow:0 4px 18px #18385805;margin:0 0 14px}.welcome h2{font-size:22px;letter-spacing:-.5px;color:#142d4b;margin:9px 0}.welcome p{font-size:13px;line-height:1.65;color:#5b6e85;max-width:650px}.eyebrow{font-size:10px;font-weight:700;letter-spacing:1.3px;color:#6c7e94}.metric-row{gap:12px}.metric-row>div{border:1px solid #dae6f4;background:#edf4fd;border-radius:11px;padding:16px}.metric-row strong{font-size:32px;font-weight:720;letter-spacing:-1px;font-variant-numeric:tabular-nums}.metric-row>div:last-child{border-color:#225fb0;background:linear-gradient(125deg,#164581,#2566bb)}\n.source-heading{display:flex;justify-content:space-between;font-size:12px;font-weight:650;color:#233e60;margin:8px 0}.source-heading span{font-weight:400;font-size:11px;color:#677b94}.source-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px;margin-bottom:20px}.source-card{background:#f9fcff;border:1px solid #d7e3f1;border-radius:11px;padding:12px;display:flex;flex-direction:column;gap:8px}.source-name{font-size:11px;font-weight:600;line-height:1.4;min-height:31px;color:#45607e}.source-card strong{font-size:23px;color:#173957;font-variant-numeric:tabular-nums;letter-spacing:-.5px}.quote-badge{font-size:9px;font-weight:650;border-radius:5px;padding:4px 6px;align-self:flex-start}.observed{color:#14604c;background:#e1f3ec}.historical{color:#805b18;background:#fff1d7}.missing{color:#607189;background:#e9eef5}.source-card small{font-size:9px;line-height:1.5;color:#6c7e92;overflow-wrap:anywhere}\n#pricing-inputs{padding:16px;background:#e5effa;border-radius:13px;gap:12px!important}#pricing-inputs h3{font-size:16px!important;margin:0!important}#pricing-inputs p{font-size:11px!important;color:#62768c}#pricing-inputs .block{background:#f5f9fe!important}#pricing-inputs label{font-size:11px!important}\n.empty-panel{padding:28px 24px;min-height:180px;border:1px dashed #c4d5e9;background:#f4f9ff;border-radius:12px;margin:12px 0}.empty-panel h3{font-size:17px;color:#244567;margin:0 0 9px}.empty-panel p{font-size:13px;color:#5d718b}.empty-panel span{font-size:11px;color:#76869c}.room-table{margin-top:12px}.room-table th{font-size:10px;letter-spacing:.3px;padding:13px 10px}.room-table td{padding:15px 10px}.room-table tbody tr:hover{background:#e1effe}.room-table td:first-child,.room-table th:first-child{width:30%}\n.gradio-container button:focus-visible,.gradio-container input:focus-visible{outline:3px solid #68a9ed!important;outline-offset:3px}.gradio-container .tab-nav button{font-size:12px!important}.app-footer{border-top:1px solid #d6e2f0;margin-top:12px;padding:18px 0 0;display:flex;justify-content:space-between;color:#6b7f98;font-size:11px}\n@media(max-width:900px){.source-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.gradio-container{padding:18px!important}}\n@media(max-width:650px){#hero{padding:22px;min-height:140px}#hero h1{font-size:32px!important}.hero-bottom{align-items:flex-start;flex-direction:column}.source-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.metric-row{grid-template-columns:1fr!important}.source-heading,.app-footer{gap:8px;flex-direction:column}.source-name{min-height:0}.room-table{overflow-x:auto}.room-table table{min-width:490px}.gradio-container{padding:12px!important}.result-card{padding:16px}.brand-kicker{font-size:9px;letter-spacing:1.1px}}\n@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}}\n' | |
| with gr.Blocks(title=APP_NAME) as demo: | |
| gr.HTML('<div id="hero"><div class="brand-kicker">SURF CITY INN & SUITES · SANTA CRUZ</div><h1>Rate Radar<span>.</span></h1><div class="copyright">© 2026 John DM. All rights reserved.</div><div class="hero-bottom"><p>A clearer view of your next rate.</p><span class="property-pill">70 rooms · 6 room types</span></div></div>') | |
| # Fixed property identity avoids mixing a renamed hotel with Santa Cruz data. | |
| hotel_name = gr.State(DEFAULT_HOTEL) | |
| market = gr.State(DEFAULT_MARKET) | |
| max_comps = gr.State(4) | |
| with gr.Row(elem_id="search-bar"): | |
| checkin = gr.DateTime(label="Stay date",value=datetime.now(TZ).date().isoformat(),include_time=False,type="string",timezone="America/Los_Angeles") | |
| time_mode = gr.Dropdown(["Selected time","Live now"],value="Live now",label="Time mode") | |
| pricing_hour = gr.Dropdown(HOUR_CHOICES, value=hour_to_label(15), label="Time (Santa Cruz)") | |
| view_mode = gr.Dropdown(["Selected date","30-day forecast"],value="Selected date",label="View") | |
| with gr.Row(elem_id="quick-actions"): | |
| tomorrow_btn=gr.Button("Tomorrow",size="sm") | |
| month_btn=gr.Button("Next 30 days",size="sm") | |
| weekend_btn=gr.Button("Next weekend",size="sm") | |
| analyze_btn=gr.Button("Analyze rates →",variant="primary") | |
| gr.HTML('<div class="search-context"><span>1 night</span><span>2 adults</span><span>Hotel starting rates</span><span>USD · before tax</span><small>Public offers are observations. Recommendations are estimates.</small></div>') | |
| result_html=gr.HTML(value=empty_dashboard()) | |
| with gr.Row(): | |
| with gr.Column(scale=3,min_width=320): | |
| with gr.Tabs(): | |
| with gr.Tab("Room prices"): | |
| room_prices=gr.HTML(value='<div class="empty-panel"><h3>Your rooms, in one view</h3><p>Run an analysis to see recommendations for all six room types.</p><span>Enter known room rates and rooms left under Room inventory.</span></div>',label="Room-type recommendations") | |
| with gr.Tab("Public price sources"): | |
| live_own=gr.Dataframe(interactive=False,label="Matched Surf City offers · source and timestamp") | |
| with gr.Tab("Competitor prices"): | |
| live_comps=gr.Dataframe(interactive=False,label="Matched competitors · equal-weight averages") | |
| with gr.Tab("Signals / 30 days"): | |
| breakdown=gr.Dataframe(interactive=False,label="Pricing detail") | |
| with gr.Tab("Why this rate?"): | |
| explanation=gr.Markdown() | |
| download=gr.File(label="Download CSV") | |
| with gr.Column(scale=1,min_width=270,elem_id="pricing-inputs"): | |
| gr.Markdown("### Your pricing inputs\nLeave unknown values blank.") | |
| with gr.Row(): | |
| occupancy=gr.Number(value=None,minimum=0,maximum=100,label="Occupancy (%)") | |
| pickup=gr.Number(value=None,minimum=-15,maximum=30,label="Pickup (pts)") | |
| with gr.Row(): | |
| owner_direct_rate=gr.Number(value=None,label="Current direct rate (manual $)") | |
| floor=gr.Number(value=59,label="Minimum nightly rate ($)") | |
| with gr.Accordion("Room inventory & premiums",open=False): | |
| gr.Markdown("Room prices follow the long-running Surf City room ladder. Enter Rooms left only when known; blank inventory does not create a scarcity adjustment. Exact current room rates are optional.") | |
| room_profile=gr.Dataframe(value=ROOM_PROFILE,headers=list(ROOM_PROFILE.columns),datatype=["str","number","number","number","number"],type="pandas",row_count=(6,"fixed"),column_count=(5,"fixed")) | |
| with gr.Accordion("Closing times & guardrails",open=False): | |
| restaurant_close=gr.Textbox(label="Restaurants close (HH:MM)") | |
| event_end=gr.Textbox(label="Local event ends (HH:MM)") | |
| late_reduction=gr.Slider(0,5,value=2,step=.5,label="Reduction per hour after 6 PM (%)",info="Up to 10% late-day reduction; 0 disables. High traffic + ≤25% rooms left adds a 3% room reduction after 6 PM.") | |
| closure_discount=gr.Slider(0,10,value=0,step=.5,label="After-closing reduction (%)") | |
| ceiling=gr.Number(value=599,label="Maximum nightly rate ($)") | |
| direct_discount=gr.Slider(0,10,value=3,step=.5,label="Suggested direct discount (%)") | |
| manual_event=gr.Dropdown(["None / normal day","Small local activity","Moderate event demand","Large event / graduation / festival","Exceptional sell-out demand"],value="None / normal day",label="Event estimate") | |
| manual_traffic=gr.Dropdown(["Normal","Busier than normal","Heavy inbound traffic","Severe inbound congestion"],value="Normal",label="Traffic estimate") | |
| with gr.Accordion("Sources & refresh",open=False): | |
| auto_rates=gr.Checkbox(True,label="Use public / Google pricing") | |
| fresh_prices=gr.Checkbox(True,label="Request fresh provider prices (uses quota)") | |
| allow_bulk=gr.Checkbox(False,label="Allow 30-day pricing queries (up to 300 searches)") | |
| auto_refresh=gr.Checkbox(True,label="Refresh prices every hour while open") | |
| refresh_timer=gr.Timer(3600,active=True) | |
| gr.Markdown("Hourly refresh requires this page to remain open and the Space awake. Live now advances the Santa Cruz clock; Selected time stays a scenario. No ASI rates are changed. Selected-date refresh may use an extra Google Hotels property-detail lookup to find more booking sources (for example Priceline, KAYAK, Booking.com, Expedia, Hotels.com or direct offers when Google returns them). Both views accept Google hotel starting rates; one date costs up to 10 requests and 30 days up to 300. Missing offers do not mean sold out.") | |
| with gr.Accordion("Manual price fallbacks",open=False): | |
| manual_ota=gr.Dataframe(value=DEFAULT_OTA,headers=list(DEFAULT_OTA.columns),datatype=["str","number"],type="pandas",column_count=(2,"fixed")) | |
| manual_comp=gr.Dataframe(value=DEFAULT_COMPS,headers=list(DEFAULT_COMPS.columns),datatype=["str","number"],type="pandas",column_count=(2,"fixed")) | |
| with gr.Accordion("Source status & timestamps",open=True): | |
| live_status=gr.Markdown("Ready when you are. Choose a stay date, then select **Analyze rates**.") | |
| tomorrow_btn.click(fn=lambda: (*quick_tomorrow(),"Selected time"),outputs=[checkin,view_mode,time_mode]) | |
| month_btn.click(fn=lambda: (*quick_next_30_days(),"Selected time"),outputs=[checkin,view_mode,time_mode]) | |
| weekend_btn.click(fn=lambda: (*quick_next_weekend(),"Selected time"),outputs=[checkin,view_mode,time_mode]) | |
| calculation_inputs=[view_mode,checkin,hotel_name,market,auto_rates,max_comps,owner_direct_rate,manual_ota,manual_comp,room_profile,occupancy,pickup,manual_event,manual_traffic,floor,ceiling,direct_discount,time_mode,pricing_hour,restaurant_close,event_end,closure_discount,fresh_prices,allow_bulk,late_reduction] | |
| calculation_outputs=[result_html,live_own,live_comps,room_prices,breakdown,explanation,download,live_status] | |
| def clear_old_results(): | |
| return (empty_dashboard("Inputs changed · ready for a new analysis"), pd.DataFrame(), pd.DataFrame(), | |
| '<div class="empty-panel"><h3>Refresh your recommendations</h3><p>Select Analyze rates to apply your latest inputs.</p></div>', | |
| pd.DataFrame(), "", None, "Inputs changed. Previous results were cleared to avoid showing prices for the wrong search.") | |
| for control in calculation_inputs: | |
| if not isinstance(control, gr.State): | |
| control.change(fn=clear_old_results, outputs=calculation_outputs, queue=False, show_progress="hidden") | |
| analysis_event=analyze_btn.click(fn=analyze_selection,inputs=calculation_inputs,outputs=calculation_outputs,concurrency_limit=1,concurrency_id="rate-shopping",show_progress="minimal") | |
| auto_refresh.change(lambda enabled: gr.Timer(3600,active=enabled),inputs=auto_refresh,outputs=refresh_timer) | |
| refresh_timer.tick(fn=analyze_selection,inputs=calculation_inputs,outputs=calculation_outputs,concurrency_limit=1,concurrency_id="rate-shopping",trigger_mode="once",show_progress="minimal") | |
| gr.HTML('<div class="app-footer">Rate Radar · Built for thoughtful pricing.<span>Public quotes are not guaranteed booking rates.</span></div>') | |
| if __name__ == "__main__": | |
| demo.launch(css=CUSTOM_CSS,theme=gr.themes.Soft(primary_hue="blue",neutral_hue="slate").set(body_background_fill="#eaf3ff",body_background_fill_dark="#eaf3ff",body_text_color="#122b50",body_text_color_dark="#122b50",block_background_fill="#f7fbff",block_background_fill_dark="#f7fbff",input_background_fill="#f7fbff",input_background_fill_dark="#f7fbff"),ssr_mode=False) | |