# frontend/dashboard.py
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
"""
AtliQ Hospitality Executive Dashboard
Powered by the shared metrics engine (same SQL as the chat agent).
All KPIs calculated via deterministic SQL builder — numbers always match.
"""
import streamlit as st
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
from utils.metrics_engine import (
get_core_metrics,
get_all_wow_deltas,
get_latest_full_week,
get_db_context,
get_trend_data,
get_category_comparison,
get_city_comparison,
get_day_type_comparison,
get_platform_performance,
get_property_table,
get_metric_by_dimension,
build_dashboard_filters,
)
# ════════════════════════════════════════════════
# PAGE CONFIG
# ════════════════════════════════════════════════
# Custom CSS for better KPI cards
try:
st.set_page_config(
page_title="AtliQ Hospitality Dashboard",
page_icon="🏨",
layout="wide",
initial_sidebar_state="expanded",
)
except Exception:
# set_page_config can only be called once per session.
# If a sub-page already triggered Streamlit, swallow the error
# so the rest of the dashboard still renders.
pass
st.markdown("""
""", unsafe_allow_html=True)
# ════════════════════════════════════════════════
# LOAD DATABASE CONTEXT
# ════════════════════════════════════════════════
@st.cache_data(ttl=300)
def load_db_context():
"""Load available filter options from database."""
return get_db_context()
ctx = load_db_context()
if "error" in ctx:
st.error(f"Database connection failed: {ctx['error']}")
st.stop()
# Extract filter options
available_weeks = sorted(ctx.get("weeks", []), key=lambda x: int(x))
available_cities = ctx.get("cities", [])
latest_full_week = ctx.get("latest_full_week", available_weeks[-1] if available_weeks else None)
# ════════════════════════════════════════════════
# SIDEBAR FILTERS
# ════════════════════════════════════════════════
with st.sidebar:
st.title("🎛️ Filters")
st.markdown("---")
# City filter
selected_city = st.selectbox(
"City",
options=["All"] + available_cities,
index=0,
)
# Category filter (Luxury / Business)
selected_category = st.selectbox(
"Hotel Category",
options=["All", "Luxury", "Business"],
index=0,
)
# Room class filter
selected_room_class = st.selectbox(
"Room Class",
options=["All", "Standard", "Elite", "Premium", "Presidential"],
index=0,
)
st.markdown("---")
# Month filter
selected_month = st.selectbox(
"Month",
options=["All", "May 22", "Jun 22", "Jul 22"],
index=0,
)
# Week filter
selected_week = st.selectbox(
"Week",
options=["All"] + available_weeks,
index=0,
help=f"Latest full week: {latest_full_week}",
)
st.markdown("---")
st.caption(f"Data: {ctx.get('date_range', 'N/A')}")
st.caption(f"Latest full week: {latest_full_week}")
# ════════════════════════════════════════════════
# BUILD FILTERS DICT
# ════════════════════════════════════════════════
filters = build_dashboard_filters(
city=selected_city,
category=selected_category,
room_class=selected_room_class,
month=selected_month if selected_month != "All" else None,
week=selected_week if selected_week != "All" else None,
)
# Determine which week to use for WoW calculations
wow_week = selected_week if selected_week != "All" else latest_full_week
# ════════════════════════════════════════════════
# LOAD METRICS
# ════════════════════════════════════════════════
@st.cache_data(ttl=60)
def load_metrics(_filters_tuple):
"""Load all core metrics with caching. _filters_tuple for hashability."""
filters_dict = dict(_filters_tuple)
return get_core_metrics(filters_dict)
@st.cache_data(ttl=60)
def load_wow_deltas(_wow_week, _filters_tuple):
"""Load WoW deltas with caching."""
filters_dict = dict(_filters_tuple)
return get_all_wow_deltas(_wow_week, filters_dict)
# Convert filters dict to tuple for caching
filters_tuple = tuple(sorted(filters.items()))
metrics = load_metrics(filters_tuple)
wow = load_wow_deltas(wow_week, filters_tuple)
# ════════════════════════════════════════════════
# HEADER
# ════════════════════════════════════════════════
st.title("🏨 AtliQ Hospitality Dashboard")
# Active filters display
active_filters = [f"{k}: {v}" for k, v in filters.items()]
if active_filters:
st.caption(f"Filters: {' | '.join(active_filters)}")
else:
st.caption("Showing: All Data")
st.markdown("---")
# ════════════════════════════════════════════════
# KPI CARDS (Row 1)
# ════════════════════════════════════════════════
def format_revenue(val):
"""Format revenue in readable form."""
if val >= 1e9:
return f"₹{val/1e9:.2f}B"
elif val >= 1e6:
return f"₹{val/1e6:.1f}M"
elif val >= 1e3:
return f"₹{val/1e3:.1f}K"
return f"₹{val:.0f}"
def format_rate(val):
"""Format ADR/RevPAR."""
return f"₹{val:,.0f}"
def format_pct(val):
"""Format percentage."""
return f"{val:.1f}%"
col1, col2, col3, col4, col5, col6 = st.columns(6)
with col1:
st.metric(
label="Revenue",
value=format_revenue(metrics.get("revenue", 0)),
delta=wow.get("revenue", "N/A"),
)
with col2:
st.metric(
label="RevPAR",
value=format_rate(metrics.get("revpar", 0)),
delta=wow.get("revpar", "N/A"),
)
with col3:
st.metric(
label="Occupancy %",
value=format_pct(metrics.get("occupancy_pct", 0)),
delta=wow.get("occupancy", "N/A"),
)
with col4:
st.metric(
label="ADR",
value=format_rate(metrics.get("adr", 0)),
delta=wow.get("adr", "N/A"),
)
with col5:
st.metric(
label="Realisation %",
value=format_pct(metrics.get("realisation_pct", 0)),
delta=wow.get("realisation", "N/A"),
)
with col6:
st.metric(
label="DSRN",
value=f"{metrics.get('dsrn', 0):,.0f}",
delta=wow.get("dsrn", "N/A"),
)
st.markdown("---")
# ════════════════════════════════════════════════
# ROW 2: Category Breakdown + Trend
# ════════════════════════════════════════════════
col_pie, col_trend = st.columns([1, 2])
with col_pie:
st.subheader("Revenue by Category")
@st.cache_data(ttl=60)
def load_category_revenue(_filters_tuple):
f = dict(_filters_tuple)
return get_metric_by_dimension("revenue", "category", f)
cat_df = load_category_revenue(filters_tuple)
if not cat_df.empty:
fig_pie = px.pie(
cat_df,
values="revenue",
names="category",
hole=0.55,
color="category",
color_discrete_map={"Luxury": "#5D5FEF", "Business": "#7EC8E3"},
)
fig_pie.update_traces(
textposition="inside",
textinfo="percent+label",
textfont_size=12,
)
fig_pie.update_layout(
showlegend=False,
margin=dict(t=20, b=20, l=20, r=20),
height=300,
)
st.plotly_chart(fig_pie, use_container_width=True)
else:
st.info("No data available for selected filters.")
with col_trend:
st.subheader("Weekly Revenue Trend")
@st.cache_data(ttl=60)
def load_revenue_trend(_filters_tuple):
f = dict(_filters_tuple)
# Remove week filter for trend view
f_trend = {k: v for k, v in f.items() if k != "week_no"}
return get_trend_data("revenue", f_trend)
trend_df = load_revenue_trend(filters_tuple)
if not trend_df.empty:
# Sort by week number
trend_df["week_sort"] = trend_df["week_no"].astype(int)
trend_df = trend_df.sort_values("week_sort")
trend_df["week_label"] = "W" + trend_df["week_no"].astype(str)
fig_trend = px.line(
trend_df,
x="week_label",
y="revenue",
markers=True,
color_discrete_sequence=["#5D5FEF"],
)
fig_trend.update_traces(
line_width=3,
marker_size=8,
hovertemplate="Week %{x}
Revenue: ₹%{y:,.0f}",
)
fig_trend.update_layout(
xaxis_title="Week",
yaxis_title="Revenue (₹)",
margin=dict(t=20, b=40, l=60, r=20),
height=300,
hovermode="x unified",
)
fig_trend.update_yaxes(tickformat=",")
st.plotly_chart(fig_trend, use_container_width=True)
else:
st.info("No trend data available.")
st.markdown("---")
# ════════════════════════════════════════════════
# ROW 3: Day Type Analysis + Platform Performance
# ════════════════════════════════════════════════
col_day, col_plat = st.columns([1, 2])
with col_day:
st.subheader("Weekend vs Weekday")
@st.cache_data(ttl=60)
def load_day_comparison(_filters_tuple):
f = dict(_filters_tuple)
return get_day_type_comparison(
["revenue", "total_bookings", "adr", "occupancy_pct"], f
)
day_df = load_day_comparison(filters_tuple)
if not day_df.empty:
# Format for display
display_df = day_df.copy()
display_df["revenue"] = display_df["revenue"].apply(
lambda x: f"₹{x/1e6:.1f}M" if x else "₹0"
)
display_df["adr"] = display_df["adr"].apply(
lambda x: f"₹{x:,.0f}" if x else "₹0"
)
display_df["occupancy_pct"] = display_df["occupancy_pct"].apply(
lambda x: f"{x:.1f}%" if x else "0%"
)
display_df["total_bookings"] = display_df["total_bookings"].apply(
lambda x: f"{int(x):,}" if x else "0"
)
display_df = display_df.rename(columns={
"day_type": "Day Type",
"revenue": "Revenue",
"total_bookings": "Bookings",
"adr": "ADR",
"occupancy_pct": "Occupancy",
})
st.dataframe(
display_df,
hide_index=True,
use_container_width=True,
)
else:
st.info("No data available.")
with col_plat:
st.subheader("Realisation % & ADR by Platform")
@st.cache_data(ttl=60)
def load_platform_perf(_filters_tuple):
f = dict(_filters_tuple)
return get_platform_performance(f)
plat_df = load_platform_perf(filters_tuple)
if not plat_df.empty:
plat_df = plat_df.sort_values("realisation_pct", ascending=False)
fig_combo = go.Figure()
# Bar: Realisation %
fig_combo.add_trace(go.Bar(
x=plat_df["booking_platform"],
y=plat_df["realisation_pct"],
name="Realisation %",
marker_color="#5D5FEF",
text=plat_df["realisation_pct"].apply(lambda x: f"{x:.1f}%"),
textposition="inside",
textfont_color="white",
yaxis="y",
))
# Line: ADR
fig_combo.add_trace(go.Scatter(
x=plat_df["booking_platform"],
y=plat_df["adr"],
name="ADR",
mode="lines+markers",
line=dict(color="#FF6B6B", width=3),
marker=dict(size=8),
yaxis="y2",
))
fig_combo.update_layout(
yaxis=dict(
title="Realisation %",
range=[0, 100],
title_font=dict(color="#5D5FEF"),
tickfont=dict(color="#5D5FEF"),
),
yaxis2=dict(
title="ADR (₹)",
overlaying="y",
side="right",
showgrid=False,
title_font=dict(color="#FF6B6B"),
tickfont=dict(color="#FF6B6B"),
tickformat=",",
),
legend=dict(
orientation="h",
yanchor="bottom",
y=1.02,
xanchor="right",
x=1,
),
margin=dict(t=40, b=40, l=60, r=60),
height=300,
hovermode="x unified",
)
st.plotly_chart(fig_combo, use_container_width=True)
else:
st.info("No platform data available.")
st.markdown("---")
# ════════════════════════════════════════════════
# ROW 4: City Comparison + Occupancy Trend
# ════════════════════════════════════════════════
col_city, col_occ = st.columns(2)
with col_city:
st.subheader("City Performance")
@st.cache_data(ttl=60)
def load_city_comparison(_filters_tuple):
f = dict(_filters_tuple)
# Remove city filter for comparison
f_city = {k: v for k, v in f.items() if k != "city"}
return get_city_comparison(
["revenue", "occupancy_pct", "adr", "revpar"], f_city
)
city_df = load_city_comparison(filters_tuple)
if not city_df.empty:
city_df = city_df.sort_values("revenue", ascending=True)
fig_city = go.Figure()
fig_city.add_trace(go.Bar(
y=city_df["city"],
x=city_df["revenue"],
orientation="h",
name="Revenue",
marker_color="#5D5FEF",
text=city_df["revenue"].apply(lambda x: f"₹{x/1e6:.0f}M"),
textposition="outside",
))
fig_city.update_layout(
xaxis_title="Revenue (₹)",
yaxis_title="",
margin=dict(t=20, b=40, l=100, r=80),
height=280,
showlegend=False,
)
fig_city.update_xaxes(tickformat=",")
st.plotly_chart(fig_city, use_container_width=True)
else:
st.info("No city data available.")
with col_occ:
st.subheader("Weekly Occupancy Trend")
@st.cache_data(ttl=60)
def load_occupancy_trend(_filters_tuple):
f = dict(_filters_tuple)
f_trend = {k: v for k, v in f.items() if k != "week_no"}
return get_trend_data("occupancy_pct", f_trend)
occ_df = load_occupancy_trend(filters_tuple)
if not occ_df.empty:
occ_df["week_sort"] = occ_df["week_no"].astype(int)
occ_df = occ_df.sort_values("week_sort")
occ_df["week_label"] = "W" + occ_df["week_no"].astype(str)
fig_occ = px.area(
occ_df,
x="week_label",
y="occupancy_pct",
color_discrete_sequence=["#7EC8E3"],
)
fig_occ.update_traces(
line_width=2,
hovertemplate="Week %{x}
Occupancy: %{y:.1f}%",
)
fig_occ.update_layout(
xaxis_title="Week",
yaxis_title="Occupancy %",
yaxis_range=[0, 100],
margin=dict(t=20, b=40, l=60, r=20),
height=280,
)
st.plotly_chart(fig_occ, use_container_width=True)
else:
st.info("No occupancy data available.")
st.markdown("---")
# ════════════════════════════════════════════════
# ROW 5: Property Performance Table
# ════════════════════════════════════════════════
st.subheader("🏢 Property Performance")
@st.cache_data(ttl=60)
def load_property_table(_filters_tuple):
f = dict(_filters_tuple)
return get_property_table(f)
prop_df = load_property_table(filters_tuple)
if not prop_df.empty:
# Format columns for display
display_prop = prop_df.copy()
# Format revenue
if "revenue" in display_prop.columns:
display_prop["revenue"] = display_prop["revenue"].apply(
lambda x: f"₹{x/1e6:.1f}M" if pd.notna(x) and x else "₹0"
)
# Format RevPAR & ADR
for col in ["revpar", "adr"]:
if col in display_prop.columns:
display_prop[col] = display_prop[col].apply(
lambda x: f"₹{x:,.0f}" if pd.notna(x) and x else "₹0"
)
# Format percentages
for col in ["occupancy_pct", "realisation_pct", "cancellation_pct"]:
if col in display_prop.columns:
display_prop[col] = display_prop[col].apply(
lambda x: f"{x:.1f}%" if pd.notna(x) else "0%"
)
# Format daily metrics
for col in ["dsrn", "dbrn", "durn"]:
if col in display_prop.columns:
display_prop[col] = display_prop[col].apply(
lambda x: f"{x:,.1f}" if pd.notna(x) else "0"
)
# Format rating
if "average_rating" in display_prop.columns:
display_prop["average_rating"] = display_prop["average_rating"].apply(
lambda x: f"{x:.2f}" if pd.notna(x) and x else "N/A"
)
# Rename columns for display
column_names = {
"property_name": "Property",
"city": "City",
"category": "Category",
"revenue": "Revenue",
"revpar": "RevPAR",
"occupancy_pct": "Occupancy",
"adr": "ADR",
"dsrn": "DSRN",
"dbrn": "DBRN",
"durn": "DURN",
"realisation_pct": "Realisation",
"cancellation_pct": "Cancellation",
"average_rating": "Rating",
}
display_prop = display_prop.rename(columns=column_names)
st.dataframe(
display_prop,
hide_index=True,
use_container_width=True,
height=400,
)
# Summary stats
col_stat1, col_stat2, col_stat3, col_stat4 = st.columns(4)
with col_stat1:
st.caption(f"Total Properties: {len(prop_df)}")
with col_stat2:
if "category" in prop_df.columns:
lux = len(prop_df[prop_df["category"] == "Luxury"])
st.caption(f"Luxury: {lux}")
with col_stat3:
if "category" in prop_df.columns:
biz = len(prop_df[prop_df["category"] == "Business"])
st.caption(f"Business: {biz}")
with col_stat4:
st.caption(f"Cities: {prop_df['city'].nunique() if 'city' in prop_df.columns else 'N/A'}")
else:
st.info("No property data available for selected filters.")
# ════════════════════════════════════════════════
# FOOTER
# ════════════════════════════════════════════════
st.markdown("---")
st.caption(
"📊 Data powered by AtliQ Hospitality Metrics Engine | "
f"Showing data for weeks {available_weeks[0]}–{available_weeks[-1]} (2022)"
)