expenses / app.py
ibra1999's picture
Update app.py
2d611d5 verified
# 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(
"""
<style>
/* Add comfortable top padding and account for iOS safe-area */
.block-container {
padding-top: calc(4rem + env(safe-area-inset-top));
padding-bottom: 2rem;
}
/* Hide Streamlit built-in UI chrome */
[data-testid="stToolbar"] { display: none !important; } /* top-right menu */
header { visibility: hidden; } /* top header */
footer { visibility: hidden; } /* "Made with Streamlit" */
.stButton>button { padding: 0.6rem 1rem; font-weight: 600; }
@media (max-width: 640px) {
.stMetric { text-align: center; }
.block-container { padding-top: calc(5rem + env(safe-area-inset-top)); }
}
.thin-sep { margin-top: 0.4rem; margin-bottom: 0.8rem; height: 1px; background: #eee; }
</style>
""",
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("<div style='height:2.5rem'></div>", 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('<div class="thin-sep"></div>', 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('<div class="thin-sep"></div>', 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()