# app.py import os from datetime import date, timedelta import pandas as pd import streamlit as st from supabase import create_client, Client from dotenv import load_dotenv load_dotenv() st.set_page_config(page_title="Daily Expense Tracker", page_icon="💰", layout="centered") # Read secrets / env APP_PASSWORD = os.getenv("APP_PASSWORD") SUPABASE_URL = os.getenv("SUPABASE_URL") SUPABASE_ANON_KEY = os.getenv("SUPABASE_ANON_KEY") TABLE_EXPENSES = "expenses" TABLE_BUDGETS = "budgets" st.markdown( """ """, unsafe_allow_html=True, ) # ========== LOGIN GATE (no widget-key mutation) ========== def check_login() -> bool: """ Login with a username + password (APP_PASSWORD). Uses separate widget keys, stores auth state in session_state (auth_ok, auth_user), and reruns on success. """ if "auth_ok" not in st.session_state: st.session_state.auth_ok = False st.session_state.auth_user = None if not st.session_state.auth_ok: # Spacer so the first widget never touches the top edge (prevents clipping) st.markdown("
", unsafe_allow_html=True) st.markdown("Log in") # Use a form; do NOT mutate widget keys after creation with st.form("login_form", clear_on_submit=False): u = st.text_input("Username", key="login_user_input") p = st.text_input("Password", type="password", key="login_pw_input") submitted = st.form_submit_button("Enter") if submitted: if p == APP_PASSWORD: st.session_state.auth_ok = True st.session_state.auth_user = u.strip() or "Mohammed" st.rerun() # refresh so the login form disappears else: st.error("Incorrect password.") st.stop() # Stop rendering the rest of the app until authenticated st.stop() return True # ========== SUPABASE CLIENT ========== @st.cache_resource(show_spinner=False) def get_supabase() -> Client: if not SUPABASE_URL or not SUPABASE_ANON_KEY: raise RuntimeError("Set SUPABASE_URL and SUPABASE_ANON_KEY in environment variables.") return create_client(SUPABASE_URL, SUPABASE_ANON_KEY) # ========== HELPERS ========== def to_float(x): try: return float(x) except Exception: return 0.0 def format_sr(amount: float) -> str: return f"SR {amount:,.2f}" def month_key_from_date(d: date) -> str: return f"{d.year:04d}-{d.month:02d}" def month_range(year: int, month: int) -> tuple[date, date]: first_day = date(year, month, 1) next_month = (first_day.replace(day=28) + timedelta(days=4)).replace(day=1) last_day = next_month - timedelta(days=1) return first_day, last_day def fetch_expenses(sb: Client, start_date: date, end_date: date, min_amount: float) -> pd.DataFrame: """Always fetch fresh data (no cache) for the current filters.""" try: q = ( sb.table(TABLE_EXPENSES) .select("*") .gte("date", start_date.isoformat()) .lte("date", end_date.isoformat()) .gte("amount", float(min_amount)) .order("date", desc=False) .limit(5000) ) data = q.execute() rows = data.data if hasattr(data, "data") else [] return pd.DataFrame(rows) except Exception as e: st.error(f"Read error: {e}") return pd.DataFrame() def get_budget(sb: Client, month_key: str) -> float | None: try: res = sb.table(TABLE_BUDGETS).select("*").eq("month_key", month_key).limit(1).execute() rows = res.data if hasattr(res, "data") else [] if rows: return to_float(rows[0].get("amount", 0)) return None except Exception as e: st.warning(f"Budget read error for {month_key}: {e}") return None def upsert_budget(sb: Client, month_key: str, amount: float) -> bool: try: # Upsert requires PRIMARY KEY on month_key res = sb.table(TABLE_BUDGETS).upsert({"month_key": month_key, "amount": float(amount)}).execute() if getattr(res, "error", None): st.error(f"Budget upsert failed: {res.error}") return False return True except Exception as e: st.error(f"Budget upsert error: {e}") return False if check_login(): # Header st.title("Daily Expense Tracker") st.subheader(f"Hello {st.session_state.auth_user}") st.caption("Data is saved to Supabase. Protected by a login gate.") # Connect try: sb = get_supabase() except Exception as e: st.error(f"Supabase configuration error: {e}") st.stop() # Tabs: Entries vs Insights vs Budget tab_entries, tab_insights, tab_budget = st.tabs(["Entries", "Insights", "Budget"]) # -------------------- ENTRIES TAB -------------------- with tab_entries: # Input form with st.form("add_expense", clear_on_submit=True): c1, c2 = st.columns(2) expense_date = c1.date_input("Date", value=date.today()) category = c2.selectbox( "Category", [ "Food", "Transport", "Bills", "Shopping", "Entertainment", "Health", "Cars", "Schools", "Salaries", "Other" ], index=0, ) amount = st.number_input("Amount (SR)", min_value=0.0, step=1.0, format="%.2f") note = st.text_input("Note (optional)") submitted = st.form_submit_button("âž• Add Expense") if submitted: payload = { "date": expense_date.isoformat(), "category": category, "amount": float(amount), "note": note or None, } try: res = sb.table(TABLE_EXPENSES).insert(payload).execute() if getattr(res, "error", None): st.error(f"Insert failed: {res.error}") else: st.success("Expense added.") st.rerun() # ensure fresh fetch + UI update except Exception as e: st.error(f"Insert error: {e}") st.divider() st.subheader("Browse") f1, f2, f3, f4 = st.columns([1, 1, 1, 1]) start_date = f1.date_input("From", value=date(date.today().year, date.today().month, 1)) end_date = f2.date_input("To", value=date.today()) min_amount = f3.number_input("Min Amount (SR)", value=0.0, step=1.0, format="%.2f") if f4.button("Refresh data"): st.rerun() # Query fresh data for table/metrics df = fetch_expenses(sb, start_date, end_date, min_amount) if df.empty: st.info("No expenses found for the selected range.") else: # Clean types if "amount" in df.columns: df["amount"] = df["amount"].apply(to_float) if "date" in df.columns: try: df["date"] = pd.to_datetime(df["date"]).dt.date except Exception: pass # Display with SR-formatted amount (keep numeric df for Insights) display_df = df.copy() if "amount" in display_df.columns: display_df["amount (SR)"] = display_df["amount"].apply(format_sr) st.write("Expenses") st.dataframe( display_df[["id", "date", "category", "amount (SR)", "note"]].sort_values("date"), width="stretch", # replaces deprecated use_container_width=True hide_index=True, ) total = df["amount"].sum() if "amount" in df.columns else 0.0 st.metric("Total (selected period)", format_sr(total)) # -------- Manage Records (Edit/Delete) -------- st.write('
', unsafe_allow_html=True) st.subheader("Manage Records") if "id" not in df.columns: st.warning("Cannot edit/delete because the 'id' column is missing in your expenses table.") else: # Build options for selection df_sorted = df.sort_values("date", ascending=False).copy() df_sorted["label"] = df_sorted.apply( lambda r: f"{r['date']} | {r['category']} | SR {to_float(r['amount']):,.2f} | {r['id']}", axis=1 ) selected_label = st.selectbox( "Pick a record to edit/delete", options=["-- Select a record --"] + df_sorted["label"].tolist(), index=0, ) if selected_label != "-- Select a record --": # Extract selected row sel_row = df_sorted[df_sorted["label"] == selected_label].iloc[0] sel_id = sel_row["id"] # Prepare values sel_date = pd.to_datetime(sel_row["date"]).date() if pd.notna(sel_row["date"]) else date.today() sel_cat = sel_row.get("category", "Other") sel_amt = to_float(sel_row.get("amount", 0.0)) sel_note = sel_row.get("note") or "" with st.form("edit_delete_form", clear_on_submit=False): ec1, ec2 = st.columns(2) new_date = ec1.date_input("Date", value=sel_date, key=f"edit_date_{sel_id}") cat_list = [ "Food", "Transport", "Bills", "Shopping", "Entertainment", "Health", "Cars", "Schools", "Salaries", "Other" ] new_cat = ec2.selectbox( "Category", cat_list, index=(cat_list.index(sel_cat) if sel_cat in cat_list else 0), key=f"edit_cat_{sel_id}" ) new_amt = st.number_input( "Amount (SR)", min_value=0.0, step=1.0, format="%.2f", value=float(sel_amt), key=f"edit_amt_{sel_id}" ) new_note = st.text_input("Note (optional)", value=sel_note, key=f"edit_note_{sel_id}") ec3, ec4 = st.columns(2) save_btn = ec3.form_submit_button("Save changes") del_btn = ec4.form_submit_button("Delete") if save_btn: try: res_upd = ( sb.table(TABLE_EXPENSES) .update({ "date": new_date.isoformat(), "category": new_cat, "amount": float(new_amt), "note": new_note or None, }) .eq("id", sel_id) .execute() ) if getattr(res_upd, "error", None): st.error(f"Update failed: {res_upd.error}") else: st.success("Record updated.") st.rerun() except Exception as e: st.error(f"Update error: {e}") if del_btn: try: res_del = sb.table(TABLE_EXPENSES).delete().eq("id", sel_id).execute() if getattr(res_del, "error", None): st.error(f"Delete failed: {res_del.error}") else: st.success("Record deleted.") st.rerun() except Exception as e: st.error(f"Delete error: {e}") # Download CSV (raw numeric amount) csv = df.sort_values("date").to_csv(index=False).encode("utf-8") st.download_button("Download CSV", csv, file_name="expenses.csv", mime="text/csv") # -------------------- INSIGHTS TAB -------------------- with tab_insights: st.subheader("Insights") # Reuse same filters as Entries tab if they exist, otherwise fall back to full range. if "start_date" in locals() and "end_date" in locals() and "min_amount" in locals(): df_insights = fetch_expenses(sb, start_date, end_date, min_amount) else: df_insights = fetch_expenses(sb, date(1970, 1, 1), date.today(), 0.0) if df_insights.empty: st.info("No data to chart yet.") else: # Ensure types if "amount" in df_insights.columns: df_insights["amount"] = df_insights["amount"].apply(to_float) if "date" in df_insights.columns: try: df_insights["date"] = pd.to_datetime(df_insights["date"]).dt.date except Exception: pass # Category summary if "category" in df_insights.columns and "amount" in df_insights.columns: st.write("### By Category (Sum)") cat = ( df_insights.groupby("category", as_index=False)["amount"] .sum() .sort_values("amount", ascending=False) ) # Show totals per category in SR (table) cat_display = cat.copy() cat_display["amount (SR)"] = cat_display["amount"].apply(format_sr) st.dataframe( cat_display[["category", "amount (SR)"]], width="stretch", # replaces deprecated use_container_width=True hide_index=True, ) # Chart (Altair-based via st.bar_chart); width must be "container" or a number st.write("### Category Chart") st.bar_chart(cat.set_index("category")["amount"], width="container") # -------------------- BUDGET TAB -------------------- with tab_budget: st.subheader("Monthly Budget") # Pick a month month_picker = st.date_input("Select month", value=date.today().replace(day=1)) m_key = month_key_from_date(month_picker) m_first, m_last = month_range(month_picker.year, month_picker.month) # Load current budget and spent for month current_budget = get_budget(sb, m_key) month_df = fetch_expenses(sb, m_first, m_last, 0.0) spent = to_float(month_df["amount"].sum()) if not month_df.empty else 0.0 # Show metrics col1, col2, col3 = st.columns(3) col1.metric("Budget", format_sr(current_budget or 0.0)) col2.metric("Spent", format_sr(spent)) remaining = (current_budget or 0.0) - spent col3.metric("Remaining", format_sr(remaining)) # Progress if current_budget and current_budget > 0: ratio = max(0.0, min(spent / current_budget, 1.0)) st.progress(ratio, text=f"{ratio*100:.1f}% of budget used") else: st.info("No budget set for this month yet.") st.write('
', unsafe_allow_html=True) # Set / Update budget new_budget_val = st.number_input( "Set monthly budget (SR)", min_value=0.0, step=100.0, format="%.2f", value=float(current_budget or 0.0), key=f"budget_input_{m_key}", ) if st.button("Save Budget"): ok = upsert_budget(sb, m_key, new_budget_val) if ok: st.success("Budget saved.") st.rerun()