WISE_Energy / src /pages /data_entry.py
ahanbose's picture
Update src/pages/data_entry.py
5370dbe verified
"""
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 = """
<style>
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;700&family=Space+Grotesk:wght@400;600;700;800&display=swap');
[data-testid="stAppViewContainer"] { background: #0B1622; }
[data-testid="stSidebar"] { background: #07111D; }
h1,h2,h3,h4 { font-family:'Space Grotesk',sans-serif !important; }
p,div,span,label,input { font-family:'DM Sans',sans-serif !important; }
/* ── Block input card ── */
.block-card {
background: linear-gradient(145deg,#111E2E,#172840);
border: 1px solid rgba(0,201,167,0.15);
border-radius: 14px;
padding: 16px 18px 14px;
margin-bottom: 12px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.block-card:hover {
border-color: rgba(0,201,167,0.4);
box-shadow: 0 4px 20px rgba(0,201,167,0.08);
}
.block-card.filled {
border-color: rgba(0,201,167,0.35);
background: linear-gradient(145deg,#0E2220,#14352E);
}
.block-card.warning {
border-color: rgba(245,166,35,0.4);
background: linear-gradient(145deg,#1E1800,#2A2000);
}
.block-title {
font-family:'Space Grotesk',sans-serif;
font-size: 0.95rem; font-weight: 700;
color: #E8F4F8; margin-bottom: 2px;
}
.block-group {
font-size: 0.72rem; color: #7A9BB5;
text-transform: uppercase; letter-spacing: 0.08em;
margin-bottom: 10px;
}
.block-total {
font-family:'Space Grotesk',sans-serif;
font-size: 1.05rem; font-weight: 700; color: #00C9A7;
text-align: right; margin-top: 6px;
}
/* ── Section headers ── */
.section-hdr {
font-family:'Space Grotesk',sans-serif;
font-size: 1.1rem; font-weight: 700; color: #E8F4F8;
border-left: 3px solid #00C9A7; padding-left: 12px;
margin: 28px 0 14px;
}
.group-hdr {
font-family:'Space Grotesk',sans-serif;
font-size: 0.82rem; font-weight: 600; color: #7A9BB5;
text-transform: uppercase; letter-spacing: 0.12em;
margin: 20px 0 8px; padding-bottom: 4px;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
/* ── Summary bar ── */
.summary-bar {
background: linear-gradient(135deg,#0D2B3E,#112840);
border: 1px solid rgba(0,201,167,0.25);
border-radius: 12px; padding: 16px 24px;
display: flex; justify-content: space-between;
align-items: center; margin-bottom: 20px;
}
.summary-val {
font-family:'Space Grotesk',sans-serif;
font-size: 1.6rem; font-weight: 800; color: #00C9A7;
}
.summary-lbl { font-size: 0.76rem; color: #7A9BB5; text-transform: uppercase; letter-spacing: 0.1em; }
/* ── Status chips ── */
.chip {
display: inline-block; padding: 3px 10px; border-radius: 20px;
font-size: 0.74rem; font-weight: 600; margin: 2px;
}
.chip-ok { background: rgba(0,201,167,0.15); color: #00C9A7; border: 1px solid rgba(0,201,167,0.3); }
.chip-warn { background: rgba(245,166,35,0.15); color: #F5A623; border: 1px solid rgba(245,166,35,0.3); }
.chip-err { background: rgba(255,107,107,0.15); color: #FF6B6B; border: 1px solid rgba(255,107,107,0.3); }
/* ── Calendar completion dots ── */
.cal-cell {
display: inline-block; width: 28px; height: 28px;
border-radius: 6px; line-height: 28px; text-align: center;
font-size: 0.72rem; font-weight: 600; margin: 2px;
}
.cal-full { background: #00C9A7; color: #0B1622; }
.cal-partial { background: #F5A623; color: #0B1622; }
.cal-empty { background: rgba(255,255,255,0.06); color: #7A9BB5; }
/* ── History table rows ── */
.hist-row {
display: flex; align-items: center; justify-content: space-between;
background: rgba(17,30,46,0.9); border: 1px solid rgba(0,201,167,0.1);
border-radius: 10px; padding: 12px 18px; margin-bottom: 8px;
}
.hist-date { font-family:'Space Grotesk',sans-serif; font-size:0.9rem; font-weight:700; color:#E8F4F8; width:110px; }
.hist-stat { font-size:0.82rem; color:#7A9BB5; }
.hist-total { font-family:'Space Grotesk',sans-serif; font-size:1rem; font-weight:700; color:#00C9A7; width:100px; text-align:right; }
</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=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'<div class="group-hdr">{icon} {group}</div>'
def _completion_chip(pct: float) -> str:
if pct == 100:
return '<span class="chip chip-ok">βœ“ Complete</span>'
elif pct > 0:
return f'<span class="chip chip-warn">⚑ {pct:.0f}%</span>'
return '<span class="chip chip-err">β—‹ No data</span>'
# ══════════════════════════════════════════════════════════════════════════════
# Tab 1 β€” Daily Entry Form
# ══════════════════════════════════════════════════════════════════════════════
def _render_entry_form(db: WasteDB) -> None:
st.markdown('<div class="section-hdr">πŸ“… Select Date</div>', 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"""<div class="summary-bar">
<div>
<div class="summary-lbl">Selected Date</div>
<div class="summary-val">{selected_date.strftime("%d %b %Y")}</div>
</div>
<div style="text-align:center">
<div class="summary-lbl">Blocks filled</div>
<div class="summary-val">{comp["filled"]} / {comp["total"]}</div>
</div>
<div style="text-align:right">
{status_chip}
</div>
</div>""",
unsafe_allow_html=True,
)
st.markdown('<div class="section-hdr">βš–οΈ Enter Waste Data (kg)</div>', 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"""<div class="{card_class}">
<div class="block-title">{label}</div>
<div class="block-group">{group} &nbsp;Β·&nbsp; {last_edit}</div>
</div>""",
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'<div class="block-total">Total: {total_now:.1f} kg</div>',
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('<div class="section-hdr">πŸ“‹ Recorded Days</div>', 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'<span class="cal-cell {css}" title="{d}: {c["filled"]}/{c["total"]}">{d.day}</span>'
st.markdown(cells_html, unsafe_allow_html=True)
st.markdown("---")
st.markdown('<div class="section-hdr">πŸ—‚οΈ All Entries</div>', 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('<div class="section-hdr">πŸ—„οΈ Database Status</div>', 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('<div class="section-hdr">πŸ“Š Monthly Completeness</div>', 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('<div class="section-hdr">πŸ“₯ Export Data</div>', 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('<div class="section-hdr">πŸ“€ One-time Excel Import</div>', 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(
"<p style='color:#7A9BB5;margin-top:-10px;'>"
"Enter daily block-wise waste directly β€” no Excel file needed</p>",
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'<div style="background:rgba(0,201,167,0.08);border:1px solid rgba(0,201,167,0.2);'
f'border-radius:10px;padding:10px 18px;margin-bottom:16px;font-size:0.88rem;color:#7A9BB5;">'
f'πŸ—„οΈ Database contains <strong style="color:#00C9A7">{row_count:,} records</strong> across '
f'<strong style="color:#00C9A7">{len(all_dates)} days</strong>. '
f'Last entry: <strong style="color:#E8F4F8">{last_date}</strong></div>',
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)