WISE_Energy / src /pages /gamification.py
ahanbose's picture
Update src/pages/gamification.py
463d226 verified
"""
pages/gamification.py
────────────────────────────────────────────────────────────────────
SPJIMR Waste Reduction Gamification System β€” Multi-Month Edition
Reads the shared multi-month DataFrame from session state (populated
by waste_analytics.py). If the user hasn't visited analytics first,
a lightweight uploader is shown here too.
New with multi-month support:
β€’ ⚑ Most Improved badge now LIVE β€” compares vs actual previous month
β€’ Cross-month leaderboard β€” cumulative points over all loaded months
β€’ Season standings β€” who's winning the full campaign
β€’ Month-over-month score delta chart per block
"""
from __future__ import annotations
import io
import logging
import numpy as np
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import streamlit as st
from core.waste_parser import parse_waste_excel, LOCATION_LABELS, LOCATION_GROUPS
logger = logging.getLogger(__name__)
# ── Shared session-state keys (same as waste_analytics.py) ────────────────────
_ALL_DF_KEY = "waste_all_df"
_MONTHS_KEY = "waste_loaded_months"
# ── Scoring constants ──────────────────────────────────────────────────────────
BASE_SCORE = 1000
WASTE_PENALTY = 1.0 # pts per kg
CONSISTENCY_BONUS = 5 # pts per active reporting day
IMPROVEMENT_BONUS = 50 # pts if total < previous month ← NOW LIVE
BEST_DAY_BONUS = 20 # pts to the block with the best single day in month
# ── Badge definitions ──────────────────────────────────────────────────────────
BADGES = {
"πŸ† Champion": lambda r: r["rank"] == 1,
"πŸ₯ˆ Runner-Up": lambda r: r["rank"] == 2,
"πŸ₯‰ Third Place": lambda r: r["rank"] == 3,
"⚑ Most Improved": lambda r: r.get("improved", False),
"πŸ“Š Data Star": lambda r: r.get("reporting_days", 0) >= 28,
"🌿 Green Day": lambda r: r.get("best_day_winner", False),
"♻️ Dry King": lambda r: r.get("min_dry_ratio", False),
"πŸ’§ Wet Warrior": lambda r: r.get("min_wet_ratio", False),
"πŸ“‰ Streak Cutter": lambda r: r.get("two_month_improve", False),
}
GOLD, SILVER, BRONZE = "#FFD700", "#C0C0C0", "#CD7F32"
RANK_COLORS = {1: GOLD, 2: SILVER, 3: BRONZE}
BLOCK_COLORS = [
"#2E9E6B","#F5A623","#4A90D9","#FF6B6B","#7B68EE",
"#00C9A7","#FFD93D","#6BCB77","#FF8C42","#845EC2","#C4F135",
]
MONTH_COLORS = [
"#00C9A7","#F5A623","#4A90D9","#FF6B6B","#7B68EE",
"#FFD93D","#6BCB77","#FF8C42","#845EC2","#C4F135","#2E9E6B","#E040FB",
]
_CSS = """
<style>
@import url('https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=DM+Sans:wght@300;400;500&display=swap');
[data-testid="stAppViewContainer"] { background:#0A1525; }
h1,h2,h3,h4 { font-family:'Syne',sans-serif !important; }
p,div,span,label { font-family:'DM Sans',sans-serif !important; }
.podium-block {
display:flex; flex-direction:column; align-items:center;
border-radius:14px 14px 4px 4px; padding:20px 28px 16px;
min-width:160px; border:1px solid rgba(255,255,255,0.1);
box-shadow:0 8px 32px rgba(0,0,0,0.4);
}
.podium-1 { background:linear-gradient(160deg,#3D2B00,#7A5500); height:220px; border-color:#FFD700 !important; }
.podium-2 { background:linear-gradient(160deg,#1A1A2E,#2D2D4E); height:180px; border-color:#C0C0C0 !important; }
.podium-3 { background:linear-gradient(160deg,#1A0D00,#3D1F00); height:150px; border-color:#CD7F32 !important; }
.podium-rank { font-family:'Syne',sans-serif; font-size:2.4rem; font-weight:800; line-height:1; }
.podium-name { font-family:'Syne',sans-serif; font-size:1rem; font-weight:700; color:#E8F4F8; text-align:center; margin:8px 0 4px; }
.podium-score { font-size:0.85rem; color:#7A9BB5; }
.podium-pts { font-family:'Syne',sans-serif; font-size:1.3rem; font-weight:700; color:#00C9A7; margin-top:4px; }
.leaderboard-row {
display:flex; align-items:center; justify-content:space-between;
background:rgba(22,36,54,0.8); border:1px solid rgba(0,201,167,0.12);
border-radius:10px; padding:14px 20px; margin-bottom:10px;
}
.leaderboard-row:hover { border-color:rgba(0,201,167,0.4); }
.lr-rank { font-family:'Syne',sans-serif; font-size:1.4rem; font-weight:800; width:40px; text-align:center; }
.lr-name { font-family:'Syne',sans-serif; font-size:1rem; font-weight:600; color:#E8F4F8; flex:1; padding:0 16px; }
.lr-waste { font-size:0.85rem; color:#7A9BB5; width:120px; text-align:right; }
.lr-pts { font-family:'Syne',sans-serif; font-size:1.1rem; font-weight:700; color:#00C9A7; width:100px; text-align:right; }
.lr-badges { display:flex; gap:4px; flex-wrap:wrap; width:200px; justify-content:flex-end; }
.badge-pill {
font-size:0.7rem; padding:3px 8px; border-radius:20px;
background:rgba(0,201,167,0.15); color:#00C9A7;
border:1px solid rgba(0,201,167,0.3); white-space:nowrap;
}
.winner-banner {
background:linear-gradient(135deg,#0D3B2E,#1A5C47);
border:1px solid #00C9A7; border-radius:16px; padding:28px 32px;
text-align:center; margin-bottom:28px; box-shadow:0 0 40px rgba(0,201,167,0.2);
}
.winner-title { font-family:'Syne',sans-serif; font-size:0.9rem; color:#7DDFBF; letter-spacing:0.15em; text-transform:uppercase; }
.winner-name { font-family:'Syne',sans-serif; font-size:2.8rem; font-weight:800; color:#FFD700; line-height:1.1; }
.winner-sub { font-size:1rem; color:#A8E6CF; margin-top:8px; }
.season-banner {
background:linear-gradient(135deg,#1A0A3E,#2D1060);
border:1px solid rgba(123,104,238,0.5); border-radius:16px; padding:24px 32px;
text-align:center; margin-bottom:28px; box-shadow:0 0 40px rgba(123,104,238,0.2);
}
.season-title { font-family:'Syne',sans-serif; font-size:0.9rem; color:#B09EFF; letter-spacing:0.15em; text-transform:uppercase; }
.season-name { font-family:'Syne',sans-serif; font-size:2.4rem; font-weight:800; color:#E8D5FF; line-height:1.1; }
.season-sub { font-size:1rem; color:#A89EC0; margin-top:8px; }
.section-hdr {
font-family:'Syne',sans-serif; font-size:1.1rem; font-weight:700;
color:#E8F4F8; border-left:3px solid #00C9A7; padding-left:12px; margin:28px 0 14px;
}
.improvement-chip {
display:inline-block; padding:2px 8px; border-radius:12px;
font-size:0.75rem; font-weight:600;
}
.chip-up { background:rgba(255,107,107,0.15); color:#FF6B6B; }
.chip-down { background:rgba(0,201,167,0.15); color:#00C9A7; }
</style>
"""
CHART_LAYOUT = dict(
paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
font=dict(family="DM Sans", color="#E8F4F8", size=12),
margin=dict(l=10, r=10, t=40, b=10),
legend=dict(bgcolor="rgba(22,36,54,0.8)", bordercolor="rgba(0,201,167,0.2)", borderwidth=1),
xaxis=dict(gridcolor="rgba(255,255,255,0.05)", linecolor="rgba(255,255,255,0.1)"),
yaxis=dict(gridcolor="rgba(255,255,255,0.05)", linecolor="rgba(255,255,255,0.1)"),
)
# ── Month sort helper ──────────────────────────────────────────────────────────
def _month_sort_key(month_str: str) -> tuple:
try:
dt = pd.to_datetime("01 " + month_str, format="%d %b %Y")
return (dt.year, dt.month)
except Exception:
return (9999, 99)
def _sorted_months(df: pd.DataFrame) -> list[str]:
return sorted(df["month"].unique(), key=_month_sort_key)
# ══════════════════════════════════════════════════════════════════════════════
# Scoring engine
# ══════════════════════════════════════════════════════════════════════════════
def compute_scores(df: pd.DataFrame, month: str, prev_month: str | None = None) -> pd.DataFrame:
"""
Score all blocks for a given month.
prev_month: if supplied, enables the ⚑ Most Improved badge and bonus.
"""
mdf = df[df["month"] == month].copy()
pdf = df[df["month"] == prev_month].copy() if prev_month else pd.DataFrame()
locations = mdf["location"].unique()
# Previous month totals per location (for improvement calc)
prev_totals: dict[str, float] = {}
if not pdf.empty:
prev_totals = pdf.groupby("location")["total_kg"].sum().to_dict()
rows = []
for loc in locations:
ldf = mdf[mdf["location"] == loc]
label = LOCATION_LABELS.get(loc, loc)
group = next((g for g, locs in LOCATION_GROUPS.items() if loc in locs), "Other")
total_wet = ldf["wet_kg"].sum()
total_dry = ldf["dry_kg"].sum()
total_kg = ldf["total_kg"].sum()
reporting_days = int((ldf["total_kg"] > 0).sum())
score = BASE_SCORE - (total_kg * WASTE_PENALTY)
score += reporting_days * CONSISTENCY_BONUS
# ── Improvement bonus β€” LIVE when prev_month is available ─────────────
prev_kg = prev_totals.get(loc, None)
improved = bool(prev_kg is not None and total_kg < prev_kg)
kg_change = round(total_kg - prev_kg, 1) if prev_kg is not None else None
pct_change= round((total_kg - prev_kg) / prev_kg * 100, 1) if prev_kg else None
if improved:
score += IMPROVEMENT_BONUS
# Daily stats
daily_loc = ldf.groupby("date")["total_kg"].sum()
best_day_kg = float(daily_loc.min()) if len(daily_loc) else 0.0
best_day_dt = str(daily_loc.idxmin())[:10] if len(daily_loc) else "β€”"
wet_pct = (total_wet / total_kg * 100) if total_kg > 0 else 100.0
rows.append({
"location": loc,
"label": label,
"group": group,
"total_kg": round(total_kg, 1),
"wet_kg": round(total_wet, 1),
"dry_kg": round(total_dry, 1),
"wet_pct": round(wet_pct, 1),
"reporting_days": reporting_days,
"best_day_kg": round(best_day_kg, 1),
"best_day_date": best_day_dt,
"prev_kg": round(prev_kg, 1) if prev_kg is not None else None,
"kg_change": kg_change,
"pct_change": pct_change,
"improved": improved,
"raw_score": round(score, 1),
})
result = pd.DataFrame(rows).sort_values("raw_score", ascending=False).reset_index(drop=True)
result["rank"] = result.index + 1
# ── Special badge flags ────────────────────────────────────────────────────
result["best_day_winner"] = result.index == result["best_day_kg"].idxmin()
result["min_wet_ratio"] = result.index == result["wet_pct"].idxmin()
result["min_dry_ratio"] = (
result.index == (result["dry_kg"] / result["total_kg"].replace(0, np.nan)).idxmin()
)
# Two-consecutive-month improvement (if we add more months later)
result["two_month_improve"] = False
# Normalise score β†’ 200–1000 for display
rng = result["raw_score"].max() - result["raw_score"].min() or 1
result["score"] = (
(result["raw_score"] - result["raw_score"].min()) / rng * 800 + 200
).round(0).astype(int)
def _badges(row) -> list[str]:
return [name for name, fn in BADGES.items() if fn(row)]
result["badges"] = result.apply(_badges, axis=1)
return result
def compute_season_standings(df: pd.DataFrame) -> pd.DataFrame:
"""
Cumulative scores across all loaded months β€” the 'season leaderboard'.
"""
months = _sorted_months(df)
all_scores = []
for i, m in enumerate(months):
prev = months[i - 1] if i > 0 else None
s = compute_scores(df, m, prev)[["label","group","score","total_kg","improved"]]
s["month"] = m
all_scores.append(s)
if not all_scores:
return pd.DataFrame()
combined = pd.concat(all_scores, ignore_index=True)
season = (
combined.groupby(["label","group"])
.agg(
total_pts=("score", "sum"),
total_kg=("total_kg", "sum"),
months_improved=("improved", "sum"),
months_played=("month", "count"),
)
.reset_index()
.sort_values("total_pts", ascending=False)
.reset_index(drop=True)
)
season["season_rank"] = season.index + 1
return season
# ══════════════════════════════════════════════════════════════════════════════
# Render helpers
# ══════════════════════════════════════════════════════════════════════════════
def _render_winner_banner(winner: pd.Series, month: str) -> None:
badges_html = " ".join(f'<span style="font-size:1.3rem">{b.split()[0]}</span>' for b in winner["badges"])
change_html = ""
if winner["kg_change"] is not None:
sign = "β–Ό" if winner["kg_change"] < 0 else "β–²"
color = "#00C9A7" if winner["kg_change"] < 0 else "#FF6B6B"
change_html = (
f'&nbsp;Β·&nbsp;<span style="color:{color}">{sign} {abs(winner["kg_change"]):,.0f} kg '
f'({winner["pct_change"]:+.1f}%) vs last month</span>'
)
st.markdown(
f"""<div class="winner-banner">
<div class="winner-title">πŸ† &nbsp; {month} Champion &nbsp; πŸ†</div>
<div class="winner-name">{winner['label']}</div>
<div class="winner-sub">
{winner['score']} pts &nbsp;Β·&nbsp; {winner['total_kg']:,.0f} kg waste
&nbsp;Β·&nbsp; {winner['group']}{change_html}
</div>
<div style="margin-top:12px">{badges_html}</div>
</div>""",
unsafe_allow_html=True,
)
def _render_season_banner(leader: pd.Series, months_count: int) -> None:
st.markdown(
f"""<div class="season-banner">
<div class="season-title">πŸ… Season Leader β€” {months_count} Months</div>
<div class="season-name">{leader['label']}</div>
<div class="season-sub">
{leader['total_pts']:,} cumulative pts &nbsp;Β·&nbsp;
{leader['total_kg']:,.0f} kg total waste &nbsp;Β·&nbsp;
{leader['months_improved']} month(s) improved
</div>
</div>""",
unsafe_allow_html=True,
)
def _render_podium(scores: pd.DataFrame) -> None:
top3 = scores.head(3)
order = [1, 0, 2] # 2nd left Β· 1st centre Β· 3rd right
labels = {0:"πŸ₯‡", 1:"πŸ₯ˆ", 2:"πŸ₯‰"}
classes = {0:"podium-1", 1:"podium-2", 2:"podium-3"}
colors = {0: GOLD, 1: SILVER, 2: BRONZE}
cols = st.columns(3)
for pos, idx in enumerate(order):
if idx >= len(top3):
continue
row = top3.iloc[idx]
chg = ""
if row["kg_change"] is not None:
sign = "β–Ό" if row["kg_change"] < 0 else "β–²"
color = "#00C9A7" if row["kg_change"] < 0 else "#FF6B6B"
chg = f'<div style="font-size:0.78rem;color:{color}">{sign} {abs(row["kg_change"]):.0f} kg</div>'
with cols[pos]:
st.markdown(
f"""<div class="podium-block {classes[idx]}" style="border-color:{colors[idx]}">
<div class="podium-rank">{labels[idx]}</div>
<div class="podium-name">{row['label']}</div>
<div class="podium-score">{row['group']}</div>
<div class="podium-pts">{row['score']} pts</div>
<div class="podium-score">{row['total_kg']:,.0f} kg waste</div>
{chg}
</div>""",
unsafe_allow_html=True,
)
def _render_leaderboard(scores: pd.DataFrame) -> None:
for _, row in scores.iterrows():
rank_color = RANK_COLORS.get(row["rank"], "#7A9BB5")
badges_html = "".join(f'<span class="badge-pill">{b}</span>' for b in row["badges"])
trend = "πŸ“‰" if row["total_kg"] < scores["total_kg"].mean() else "πŸ“ˆ"
# Month-over-month chip
chg_chip = ""
if row["kg_change"] is not None:
sign = "β–Ό" if row["kg_change"] < 0 else "β–²"
cls = "chip-down" if row["kg_change"] < 0 else "chip-up"
chg_chip = (
f'<span class="improvement-chip {cls}">'
f'{sign} {abs(row["pct_change"]):.1f}%</span>'
)
st.markdown(
f"""<div class="leaderboard-row">
<div class="lr-rank" style="color:{rank_color}">#{row['rank']}</div>
<div class="lr-name">{row['label']} &nbsp;
<span style="font-size:0.75rem;color:#7A9BB5">{row['group']}</span>
&nbsp;{chg_chip}
</div>
<div class="lr-waste">{trend} {row['total_kg']:,.0f} kg</div>
<div class="lr-pts">{row['score']} pts</div>
<div class="lr-badges">{badges_html}</div>
</div>""",
unsafe_allow_html=True,
)
def _render_season_leaderboard(season: pd.DataFrame) -> None:
for _, row in season.iterrows():
rank_color = RANK_COLORS.get(row["season_rank"], "#7A9BB5")
impr_html = (
f'<span class="improvement-chip chip-down">⚑ {row["months_improved"]}x improved</span>'
if row["months_improved"] > 0 else ""
)
st.markdown(
f"""<div class="leaderboard-row">
<div class="lr-rank" style="color:{rank_color}">#{row['season_rank']}</div>
<div class="lr-name">{row['label']} &nbsp;
<span style="font-size:0.75rem;color:#7A9BB5">{row['group']}</span>
&nbsp;{impr_html}
</div>
<div class="lr-waste">{row['total_kg']:,.0f} kg total</div>
<div class="lr-pts">{row['total_pts']:,} pts</div>
<div class="lr-badges">
<span class="badge-pill">πŸ“… {row['months_played']} month(s)</span>
</div>
</div>""",
unsafe_allow_html=True,
)
def _render_score_charts(scores: pd.DataFrame, df: pd.DataFrame, month: str) -> None:
mdf = df[df["month"] == month]
c1, c2 = st.columns(2)
with c1:
fig = go.Figure(go.Bar(
x=scores["score"], y=scores["label"], orientation="h",
marker=dict(color=scores["score"],
colorscale=[[0,"#FF4444"],[0.5,"#F5A623"],[1.0,"#00C9A7"]],
showscale=False),
text=scores["score"], textposition="inside",
))
fig.update_layout(title="Gamification Score by Block", xaxis_title="Score",
height=380, **CHART_LAYOUT)
fig.update_yaxes(autorange="reversed")
st.plotly_chart(fig, use_container_width=True)
# yaxis=dict(autorange="reversed"),
with c2:
fig2 = go.Figure(go.Bar(
x=scores["total_kg"], y=scores["label"], orientation="h",
marker=dict(color=scores["total_kg"],
colorscale=[[0,"#00C9A7"],[0.5,"#F5A623"],[1.0,"#FF4444"]],
showscale=False),
text=scores["total_kg"].apply(lambda x: f"{x:,.0f} kg"), textposition="inside",
))
fig2.update_layout(title="Total Waste (lower = better)", xaxis_title="kg",height=380, **CHART_LAYOUT)
fig2.update_yaxes(autorange="reversed")
st.plotly_chart(fig2, use_container_width=True)
# Efficiency scatter
st.markdown('<div class="section-hdr">Score vs Waste β€” Efficiency Quadrant</div>', unsafe_allow_html=True)
fig_s = px.scatter(scores, x="total_kg", y="score", size="reporting_days",
color="group", text="label",
color_discrete_map={"Academic":"#4A90D9","Hostels":"#7B68EE","Dining":"#FF6B6B"},
title="Waste vs Score (bubble = reporting days)", size_max=40,
labels={"total_kg":"Total Waste (kg)","score":"Score (pts)"})
fig_s.update_traces(textposition="top center", textfont=dict(size=10, color="#E8F4F8"))
fig_s.add_vline(x=scores["total_kg"].mean(), line_dash="dot", line_color="rgba(255,255,255,0.2)")
fig_s.add_hline(y=scores["score"].mean(), line_dash="dot", line_color="rgba(255,255,255,0.2)")
fig_s.update_layout(height=420, **CHART_LAYOUT)
st.plotly_chart(fig_s, use_container_width=True)
# Daily rank race
st.markdown('<div class="section-hdr">Daily Rank Race</div>', unsafe_allow_html=True)
daily_loc = mdf.groupby(["date","label"])["total_kg"].sum().reset_index()
daily_loc["daily_rank"] = daily_loc.groupby("date")["total_kg"].rank(method="min")
fig_r = px.line(daily_loc, x="date", y="daily_rank", color="label", markers=True,
color_discrete_sequence=BLOCK_COLORS,
title="Daily Rank per Block (1 = least waste = BEST)",
labels={"daily_rank":"Rank (1=best)","date":"Date"})
fig_r.update_yaxes(autorange="reversed")
fig_r.update_layout(height=380, **CHART_LAYOUT)
st.plotly_chart(fig_r, use_container_width=True)
def _render_cross_month_charts(df: pd.DataFrame, all_months: list[str]) -> None:
"""Charts that need all months β€” shown in the Season tab."""
# Score trajectory per block across months
score_rows = []
for i, m in enumerate(all_months):
prev = all_months[i - 1] if i > 0 else None
s = compute_scores(df, m, prev)[["label","score","total_kg","pct_change"]]
s["month"] = m
score_rows.append(s)
score_df = pd.concat(score_rows, ignore_index=True)
st.markdown('<div class="section-hdr">Score Trajectory Across Months</div>', unsafe_allow_html=True)
fig_traj = px.line(score_df, x="month", y="score", color="label", markers=True,
color_discrete_sequence=BLOCK_COLORS,
title="Monthly Gamification Score per Block",
category_orders={"month": all_months},
labels={"score":"Score (pts)","month":"Month","label":"Block"})
fig_traj.update_layout(**CHART_LAYOUT)
st.plotly_chart(fig_traj, use_container_width=True)
# Month-over-month % change heatmap
st.markdown('<div class="section-hdr">Improvement Heatmap (Block Γ— Month)</div>', unsafe_allow_html=True)
pivot = score_df.pivot(index="label", columns="month", values="pct_change").reindex(columns=all_months)
fig_heat = go.Figure(go.Heatmap(
z=pivot.values, x=pivot.columns.tolist(), y=pivot.index.tolist(),
colorscale=[[0.0,"#00C9A7"],[0.5,"#162436"],[1.0,"#FF4444"]],
zmid=0,
hovertemplate="%{y}<br>%{x}<br><b>%{z:+.1f}% vs prev month</b><extra></extra>",
colorbar=dict(title="%Ξ”", tickfont=dict(color="#E8F4F8")),
))
fig_heat.update_layout(title="% Change vs Previous Month (green = less waste)",
height=400, **CHART_LAYOUT)
st.plotly_chart(fig_heat, use_container_width=True)
# Cumulative score bar
season = compute_season_standings(df)
st.markdown('<div class="section-hdr">Cumulative Season Score</div>', unsafe_allow_html=True)
fig_cum = go.Figure(go.Bar(
x=season["total_pts"], y=season["label"], orientation="h",
marker=dict(color=season["total_pts"],
colorscale=[[0,"#FF4444"],[0.5,"#F5A623"],[1.0,"#00C9A7"]],
showscale=False),
text=season["total_pts"].apply(lambda x: f"{x:,} pts"), textposition="outside",
))
fig_cum.update_layout(title="Season Cumulative Points",
xaxis_title="Total Points", height=380, **CHART_LAYOUT)
fig_cum.update_yaxes(autorange="reversed")
st.plotly_chart(fig_cum, use_container_width=True)
def _render_score_breakdown(scores: pd.DataFrame) -> None:
st.markdown('<div class="section-hdr">Score Breakdown & Badges</div>', unsafe_allow_html=True)
for _, row in scores.iterrows():
rank_icon = {1:"πŸ₯‡",2:"πŸ₯ˆ",3:"πŸ₯‰"}.get(row["rank"], f"#{row['rank']}")
badges_str = " ".join(row["badges"]) or "No badges yet"
delta_str = ""
if row["kg_change"] is not None:
sign = "β–Ό" if row["kg_change"] < 0 else "β–²"
delta_str = f" | {sign} {abs(row['kg_change']):.0f} kg ({row['pct_change']:+.1f}%)"
with st.expander(f"{rank_icon} **{row['label']}** β€” {row['score']} pts{delta_str}"):
c1, c2, c3, c4 = st.columns(4)
c1.metric("Total Waste", f"{row['total_kg']:,.1f} kg",
delta=f"{row['kg_change']:+.0f} kg" if row["kg_change"] is not None else None,
delta_color="inverse")
c2.metric("Reporting Days", f"{row['reporting_days']} days")
c3.metric("Best Day", f"{row['best_day_kg']:,.1f} kg")
c4.metric("Wet %", f"{row['wet_pct']:.1f}%")
st.markdown(f"**Badges earned:** {badges_str}")
st.progress(min(int(row["score"]) / 1000, 1.0), text=f"Score: {row['score']} / 1000")
# ══════════════════════════════════════════════════════════════════════════════
# Main entry point
# ══════════════════════════════════════════════════════════════════════════════
def render_gamification() -> None:
st.markdown(_CSS, unsafe_allow_html=True)
st.markdown("## πŸ† Waste Reduction Challenge")
st.markdown("<p style='color:#7A9BB5;margin-top:-10px;'>Monthly gamification β€” blocks compete to generate the least waste</p>",
unsafe_allow_html=True)
# ── Try to reuse data from analytics page first ───────────────────────────
df: pd.DataFrame | None = st.session_state.get(_ALL_DF_KEY)
if df is None or df.empty:
# Lazy uploader for users who land here directly
from pages.waste_analytics import render_upload_panel
df = render_upload_panel(page_key="gamif")
if df is None or df.empty:
return
all_months = _sorted_months(df)
# ── Tab layout ────────────────────────────────────────────────────────────
if len(all_months) > 1:
tab_labels = ["πŸ“… Monthly", "πŸ… Season Standings"]
tab1, tab2 = st.tabs(tab_labels)
else:
tab1 = st.container()
tab2 = None
# ══ Tab 1: Monthly ════════════════════════════════════════════════════════
with tab1:
sel_month = st.selectbox(
"Competition Month", all_months,
index=len(all_months) - 1, key="gamif_month_select",
)
month_idx = all_months.index(sel_month)
prev_month = all_months[month_idx - 1] if month_idx > 0 else None
if prev_month:
st.caption(f"πŸ“Š Comparing vs previous month: **{prev_month}** Β· ⚑ Most Improved badge is LIVE")
else:
st.caption("⚠️ No previous month loaded β€” upload more files to unlock improvement tracking.")
with st.spinner("Computing scores…"):
scores = compute_scores(df, sel_month, prev_month)
_render_winner_banner(scores.iloc[0], sel_month)
st.markdown('<div class="section-hdr">πŸŽ–οΈ Podium</div>', unsafe_allow_html=True)
_render_podium(scores)
st.markdown("---")
st.markdown('<div class="section-hdr">πŸ“Š Full Leaderboard</div>', unsafe_allow_html=True)
_render_leaderboard(scores)
st.markdown("---")
st.markdown('<div class="section-hdr">πŸ“ˆ Score Analytics</div>', unsafe_allow_html=True)
_render_score_charts(scores, df, sel_month)
_render_score_breakdown(scores)
# ══ Tab 2: Season ═════════════════════════════════════════════════════════
if tab2 is not None:
with tab2:
with st.spinner("Computing season standings…"):
season = compute_season_standings(df)
if not season.empty:
_render_season_banner(season.iloc[0], len(all_months))
st.markdown('<div class="section-hdr">πŸ… Season Leaderboard</div>', unsafe_allow_html=True)
_render_season_leaderboard(season)
st.markdown("---")
_render_cross_month_charts(df, all_months)
# ── Scoring rules ─────────────────────────────────────────────────────────
with st.expander("πŸ“œ How scoring works"):
st.markdown(f"""
| Component | Points |
|---|---|
| Base score | **{BASE_SCORE} pts** β€” every block starts equal each month |
| Waste penalty | **βˆ’{WASTE_PENALTY:.0f} pt per kg** generated |
| Consistency bonus | **+{CONSISTENCY_BONUS} pts per active reporting day** |
| Improvement bonus | **+{IMPROVEMENT_BONUS} pts** if total waste < previous month *(requires 2+ months)* |
| Best day bonus | **+{BEST_DAY_BONUS} pts** to block with the single lowest-waste day |
**Badges:**
- πŸ† Champion Β· πŸ₯ˆ Runner-Up Β· πŸ₯‰ Third Place β€” top 3 monthly scores
- ⚑ Most Improved β€” waste reduced vs previous month *(live with 2+ months)*
- πŸ“Š Data Star β€” β‰₯ 28 reporting days
- 🌿 Green Day β€” best single day across all blocks
- ♻️ Dry King β€” highest dry waste proportion
- πŸ’§ Wet Warrior β€” lowest wet waste proportion
- πŸ“‰ Streak Cutter β€” improved two months in a row *(requires 3+ months)*
""")