Spaces:
Sleeping
Sleeping
| """ | |
| 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' Β· <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">π {month} Champion π</div> | |
| <div class="winner-name">{winner['label']}</div> | |
| <div class="winner-sub"> | |
| {winner['score']} pts Β· {winner['total_kg']:,.0f} kg waste | |
| Β· {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 Β· | |
| {leader['total_kg']:,.0f} kg total waste Β· | |
| {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']} | |
| <span style="font-size:0.75rem;color:#7A9BB5">{row['group']}</span> | |
| {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']} | |
| <span style="font-size:0.75rem;color:#7A9BB5">{row['group']}</span> | |
| {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)* | |
| """) |