""" pages/data_entry.py ──────────────────────────────────────────────────────────────────── SPJIMR Waste Analytics — Daily Data Entry Form Replaces the Excel upload workflow entirely. Staff enter wet & dry waste kg per block directly in this page. Data is persisted in SQLite (data/waste_log.db). After saving, the shared session state key "waste_all_df" is refreshed so the Analytics and Gamification pages immediately reflect the new data — no re-upload needed. Add to app.py: from pages.data_entry import render_data_entry # in your navigation block: render_data_entry() """ from __future__ import annotations import io import logging from datetime import date, timedelta import pandas as pd import plotly.graph_objects as go import streamlit as st from core.waste_db import WasteDB from core.waste_parser import LOCATIONS, LOCATION_LABELS, LOCATION_GROUPS logger = logging.getLogger(__name__) # ── Session state key shared across all pages ────────────────────────────────── _ALL_DF_KEY = "waste_all_df" _DB_KEY = "waste_db_instance" # ── Block display order — group them visually ────────────────────────────────── DISPLAY_ORDER = [ # Academic "A&B Block", "C&D Block", # Hostels "L H Hostel", "Hostel no -25", "Hostel no -26", "Hostel no -27", "Hostel no -28", "Hostel no -29", "Hostel no -30", # Dining "cantean", "MESS", ] GROUP_ICONS = {"Academic": "🏫", "Hostels": "🏠", "Dining": "🍽️"} def _loc_group(loc: str) -> str: return next((g for g, locs in LOCATION_GROUPS.items() if loc in locs), "Other") # ── CSS ──────────────────────────────────────────────────────────────────────── _CSS = """ """ 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=36, b=10), legend=dict(bgcolor="rgba(17,30,46,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.08)"), yaxis=dict(gridcolor="rgba(255,255,255,0.05)", linecolor="rgba(255,255,255,0.08)"), ) # ── DB singleton via session state ───────────────────────────────────────────── def _get_db() -> WasteDB: if _DB_KEY not in st.session_state: st.session_state[_DB_KEY] = WasteDB() return st.session_state[_DB_KEY] def _refresh_session_df(db: WasteDB) -> None: """Rebuild the shared DataFrame so analytics/gamification pages update instantly.""" df = db.to_dataframe() st.session_state[_ALL_DF_KEY] = df if not df.empty else None # ── Helpers ──────────────────────────────────────────────────────────────────── def _group_header(group: str) -> str: icon = GROUP_ICONS.get(group, "📦") return f'
{icon} {group}
' def _completion_chip(pct: float) -> str: if pct == 100: return '✓ Complete' elif pct > 0: return f'⚡ {pct:.0f}%' return '○ No data' # ══════════════════════════════════════════════════════════════════════════════ # Tab 1 — Daily Entry Form # ══════════════════════════════════════════════════════════════════════════════ def _render_entry_form(db: WasteDB) -> None: st.markdown('
📅 Select Date
', unsafe_allow_html=True) col_date, col_nav = st.columns([2, 3]) with col_date: selected_date = st.date_input( "Entry Date", value=date.today(), max_value=date.today(), key="entry_date_picker", label_visibility="collapsed", ) with col_nav: recorded = db.all_dates() if recorded: b1, b2, b3 = st.columns(3) with b1: if st.button("⬅ Previous day", use_container_width=True, key="nav_prev"): idx = recorded.index(selected_date) if selected_date in recorded else -1 if idx > 0: st.session_state["entry_date_picker"] = recorded[idx - 1] st.rerun() with b2: if st.button("Today", use_container_width=True, key="nav_today"): st.session_state["entry_date_picker"] = date.today() st.rerun() with b3: if st.button("Next day ➡", use_container_width=True, key="nav_next"): idx = recorded.index(selected_date) if selected_date in recorded else -1 if idx >= 0 and idx < len(recorded) - 1: st.session_state["entry_date_picker"] = recorded[idx + 1] st.rerun() # Load existing values for this date existing = db.get_day(selected_date) comp = db.date_completion(selected_date) # Status summary bar status_chip = _completion_chip(comp["pct"]) st.markdown( f"""
Selected Date
{selected_date.strftime("%d %b %Y")}
Blocks filled
{comp["filled"]} / {comp["total"]}
{status_chip}
""", unsafe_allow_html=True, ) st.markdown('
⚖️ Enter Waste Data (kg)
', unsafe_allow_html=True) st.caption("Enter 0 for blocks with no data collected that day.") # ── Build form inputs grouped by category ───────────────────────────────── form_values: dict[str, dict[str, float]] = {} prev_group = None # Use a single st.form so all 11 blocks save atomically with st.form(key=f"waste_entry_{selected_date}", clear_on_submit=False): for loc in DISPLAY_ORDER: group = _loc_group(loc) label = LOCATION_LABELS.get(loc, loc) ex = existing.get(loc, {"wet": 0.0, "dry": 0.0}) # Group separator header if group != prev_group: st.markdown(_group_header(group), unsafe_allow_html=True) prev_group = group # Card header with location name is_filled = ex["wet"] > 0 or ex["dry"] > 0 card_class = "block-card filled" if is_filled else "block-card" last_edit = f"Last saved: {ex['updated_at'][:16].replace('T',' ')}" if ex.get("updated_at") else "" st.markdown( f"""
{label}
{group}  ·  {last_edit}
""", unsafe_allow_html=True, ) c1, c2 = st.columns(2) with c1: wet = st.number_input( f"🟢 Wet waste (kg) — {label}", min_value=0.0, max_value=5000.0, value=float(ex["wet"]), step=0.5, format="%.1f", key=f"wet_{loc}", label_visibility="visible", ) with c2: dry = st.number_input( f"🟡 Dry waste (kg) — {label}", min_value=0.0, max_value=5000.0, value=float(ex["dry"]), step=0.5, format="%.1f", key=f"dry_{loc}", label_visibility="visible", ) total_now = wet + dry if total_now > 0: st.markdown( f'
Total: {total_now:.1f} kg
', unsafe_allow_html=True, ) form_values[loc] = {"wet": wet, "dry": dry} st.markdown("---") # Live preview inside form grand_wet = sum(v["wet"] for v in form_values.values()) grand_dry = sum(v["dry"] for v in form_values.values()) grand_total = grand_wet + grand_dry pcol1, pcol2, pcol3, pcol4 = st.columns(4) pcol1.metric("Total Wet", f"{grand_wet:.1f} kg") pcol2.metric("Total Dry", f"{grand_dry:.1f} kg") pcol3.metric("Grand Total", f"{grand_total:.1f} kg") pcol4.metric("Blocks with data", sum(1 for v in form_values.values() if v["wet"]+v["dry"] > 0)) submitted = st.form_submit_button( "💾 Save Entry", use_container_width=True, type="primary", ) if submitted: with st.spinner("Saving…"): db.upsert_day(selected_date, form_values) _refresh_session_df(db) filled = sum(1 for v in form_values.values() if v["wet"] + v["dry"] > 0) st.success(f"✅ Saved {filled} block(s) for **{selected_date.strftime('%d %b %Y')}**. " f"Analytics and Gamification pages are now updated.") st.rerun() # ══════════════════════════════════════════════════════════════════════════════ # Tab 2 — History & Edit # ══════════════════════════════════════════════════════════════════════════════ def _render_history(db: WasteDB) -> None: recorded = db.all_dates() if not recorded: st.info("No entries yet. Use the **Enter Data** tab to add your first record.") return st.markdown('
📋 Recorded Days
', unsafe_allow_html=True) # Completion calendar df_all = db.to_dataframe() if not df_all.empty: months = sorted(df_all["month"].unique(), key=lambda m: pd.to_datetime("01 " + m, format="%d %b %Y")) sel_cal_month = st.selectbox("Month", months, index=len(months)-1, key="hist_cal_month") mdf = df_all[df_all["month"] == sel_cal_month] st.markdown("**Completeness calendar** (🟢 full · 🟡 partial · ⬛ missing):") cal_dates = sorted(mdf["date"].dt.date.unique()) dt_comp = {d: db.date_completion(d) for d in cal_dates} cells_html = "" for d in cal_dates: c = dt_comp[d] if c["pct"] == 100: css = "cal-full" elif c["pct"] > 0: css = "cal-partial" else: css = "cal-empty" cells_html += f'{d.day}' st.markdown(cells_html, unsafe_allow_html=True) st.markdown("---") st.markdown('
🗂️ All Entries
', unsafe_allow_html=True) # Search / filter fc1, fc2 = st.columns([2, 3]) with fc1: filter_loc = st.selectbox( "Filter by block", ["All blocks"] + [LOCATION_LABELS[l] for l in DISPLAY_ORDER], key="hist_filter_loc", ) with fc2: sort_by = st.radio( "Sort by", ["Date (newest first)", "Date (oldest first)", "Total waste (highest)", "Total waste (lowest)"], horizontal=True, key="hist_sort", ) if df_all.empty: st.info("No data recorded yet.") return disp = df_all.copy() if filter_loc != "All blocks": disp = disp[disp["label"] == filter_loc] sort_map = { "Date (newest first)": ("date", False), "Date (oldest first)": ("date", True), "Total waste (highest)": ("total_kg", False), "Total waste (lowest)": ("total_kg", True), } col, asc = sort_map[sort_by] disp = disp.sort_values(col, ascending=asc) # Render as styled rows grouped by date grouped = disp.groupby("date") for dt, grp in grouped: day_str = pd.Timestamp(dt).strftime("%d %b %Y (%A)") day_total = grp["total_kg"].sum() filled = (grp["total_kg"] > 0).sum() chip = _completion_chip(filled / len(LOCATIONS) * 100) with st.expander(f"📅 {day_str} — {day_total:,.1f} kg total {chip}", expanded=False): # Mini table t_df = grp[["label","group","wet_kg","dry_kg","total_kg"]].rename(columns={ "label":"Block","group":"Category", "wet_kg":"Wet (kg)","dry_kg":"Dry (kg)","total_kg":"Total (kg)" }) st.dataframe(t_df, use_container_width=True, hide_index=True) # Quick bar chart for this day fig = go.Figure() fig.add_bar(name="Wet", x=grp["label"], y=grp["wet_kg"], marker_color="#2E9E6B", text=grp["wet_kg"].round(1), textposition="inside") fig.add_bar(name="Dry", x=grp["label"], y=grp["dry_kg"], marker_color="#F5A623", text=grp["dry_kg"].round(1), textposition="inside") fig.update_layout(barmode="stack", height=260, title=f"Waste breakdown — {day_str}", **CHART_LAYOUT) fig.update_xaxes(tickangle=-30) st.plotly_chart(fig, use_container_width=True) # Delete this day if st.button(f"🗑️ Delete all entries for {pd.Timestamp(dt).strftime('%d %b %Y')}", key=f"del_day_{dt}", type="secondary"): deleted = db.delete_day(pd.Timestamp(dt).date()) _refresh_session_df(db) st.warning(f"Deleted {deleted} record(s) for {pd.Timestamp(dt).strftime('%d %b %Y')}.") st.rerun() # ══════════════════════════════════════════════════════════════════════════════ # Tab 3 — Database Health & Export # ══════════════════════════════════════════════════════════════════════════════ def _render_db_health(db: WasteDB) -> None: st.markdown('
🗄️ Database Status
', unsafe_allow_html=True) row_count = db.row_count() all_dates = db.all_dates() months_df = db.monthly_completion() mc1, mc2, mc3 = st.columns(3) mc1.metric("Total Records", f"{row_count:,}") mc2.metric("Days Recorded", f"{len(all_dates)}") mc3.metric("Months Covered", f"{len(months_df)}" if not months_df.empty else "0") if not months_df.empty: st.markdown('
📊 Monthly Completeness
', unsafe_allow_html=True) fig_comp = go.Figure(go.Bar( x=months_df["month"], y=months_df["completeness"], marker=dict( color=months_df["completeness"], colorscale=[[0,"#FF6B6B"],[0.5,"#F5A623"],[1.0,"#00C9A7"]], showscale=False, ), text=months_df["completeness"].apply(lambda x: f"{x:.0f}%"), textposition="outside", )) fig_comp.add_hline(y=100, line_dash="dot", line_color="rgba(0,201,167,0.4)", annotation_text="100% target") fig_comp.update_layout( title="Data Completeness by Month (%)", **CHART_LAYOUT, ) # yaxis=dict(range=[0, 115], title="%"), fig_comp.update_yaxes(range=[0, 115], title="%") st.plotly_chart(fig_comp, use_container_width=True) st.dataframe( months_df.rename(columns={ "month":"Month","days":"Days with data", "location_days":"Block-days recorded","completeness":"Completeness %" }), use_container_width=True, hide_index=True, ) st.markdown('
📥 Export Data
', unsafe_allow_html=True) ec1, ec2 = st.columns(2) with ec1: if st.button("📊 Refresh Analytics & Gamification", use_container_width=True, type="primary"): _refresh_session_df(db) st.success("✅ Analytics and Gamification pages updated with latest data.") with ec2: df_export = db.to_dataframe() if not df_export.empty: csv_bytes = df_export.to_csv(index=False).encode() st.download_button( label="⬇️ Download Full Dataset (CSV)", data=csv_bytes, file_name="spjimr_waste_data.csv", mime="text/csv", use_container_width=True, ) else: st.info("No data to export yet.") # Optional: import from Excel on this tab as a one-time migration st.markdown('
📤 One-time Excel Import
', unsafe_allow_html=True) st.caption("Already have historical Excel files? Import them once — data is saved to the database and you won't need Excel files again.") uploaded_files = st.file_uploader( "Upload historical Excel files (one per month)", type=["xlsx"], accept_multiple_files=True, key="db_import_xlsx", ) if uploaded_files and st.button("⬆️ Import into Database", type="primary", key="do_import"): from core.waste_parser import parse_waste_excel total_imported = 0 for f in uploaded_files: try: df_xl = parse_waste_excel(io.BytesIO(f.read())) if df_xl.empty: st.warning(f"⚠️ No data found in {f.name}") continue for _, row in df_xl.iterrows(): db.upsert( row["date"].date(), row["location"], row["wet_kg"], row["dry_kg"], ) total_imported += len(df_xl) st.success(f"✅ Imported {len(df_xl)} records from **{f.name}**") except Exception as exc: st.error(f"❌ Failed to import {f.name}: {exc}") if total_imported: _refresh_session_df(db) st.success(f"🎉 Total {total_imported} records imported. Analytics pages are now live.") st.rerun() # ══════════════════════════════════════════════════════════════════════════════ # Main entry point # ══════════════════════════════════════════════════════════════════════════════ def render_data_entry() -> None: st.markdown(_CSS, unsafe_allow_html=True) st.markdown("## 📝 Daily Waste Data Entry") st.markdown( "

" "Enter daily block-wise waste directly — no Excel file needed

", unsafe_allow_html=True, ) db = _get_db() # Auto-refresh shared DataFrame on page load so analytics stay in sync if st.session_state.get(_ALL_DF_KEY) is None and db.row_count() > 0: _refresh_session_df(db) # Quick status banner at the top row_count = db.row_count() all_dates = db.all_dates() if row_count > 0: last_date = max(all_dates).strftime("%d %b %Y") st.markdown( f'
' f'🗄️ Database contains {row_count:,} records across ' f'{len(all_dates)} days. ' f'Last entry: {last_date}
', unsafe_allow_html=True, ) tab1, tab2, tab3 = st.tabs(["✏️ Enter Data", "📋 History & Edit", "🗄️ Database & Export"]) with tab1: _render_entry_form(db) with tab2: _render_history(db) with tab3: _render_db_health(db)