|
|
|
|
|
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") |
|
|
|
|
|
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, |
|
) |
|
|
|
|
|
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: |
|
|
|
st.markdown("<div style='height:2.5rem'></div>", unsafe_allow_html=True) |
|
st.markdown("Log in") |
|
|
|
|
|
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() |
|
else: |
|
st.error("Incorrect password.") |
|
st.stop() |
|
|
|
|
|
st.stop() |
|
|
|
return True |
|
|
|
|
|
@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) |
|
|
|
|
|
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: |
|
|
|
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(): |
|
|
|
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.") |
|
|
|
|
|
try: |
|
sb = get_supabase() |
|
except Exception as e: |
|
st.error(f"Supabase configuration error: {e}") |
|
st.stop() |
|
|
|
|
|
tab_entries, tab_insights, tab_budget = st.tabs(["Entries", "Insights", "Budget"]) |
|
|
|
|
|
with tab_entries: |
|
|
|
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() |
|
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() |
|
|
|
|
|
df = fetch_expenses(sb, start_date, end_date, min_amount) |
|
|
|
if df.empty: |
|
st.info("No expenses found for the selected range.") |
|
else: |
|
|
|
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_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", |
|
hide_index=True, |
|
) |
|
|
|
total = df["amount"].sum() if "amount" in df.columns else 0.0 |
|
st.metric("Total (selected period)", format_sr(total)) |
|
|
|
|
|
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: |
|
|
|
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 --": |
|
|
|
sel_row = df_sorted[df_sorted["label"] == selected_label].iloc[0] |
|
sel_id = sel_row["id"] |
|
|
|
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}") |
|
|
|
|
|
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") |
|
|
|
|
|
with tab_insights: |
|
st.subheader("Insights") |
|
|
|
|
|
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: |
|
|
|
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 |
|
|
|
|
|
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) |
|
) |
|
|
|
|
|
cat_display = cat.copy() |
|
cat_display["amount (SR)"] = cat_display["amount"].apply(format_sr) |
|
st.dataframe( |
|
cat_display[["category", "amount (SR)"]], |
|
width="stretch", |
|
hide_index=True, |
|
) |
|
|
|
|
|
st.write("### Category Chart") |
|
st.bar_chart(cat.set_index("category")["amount"], width="container") |
|
|
|
|
|
with tab_budget: |
|
st.subheader("Monthly Budget") |
|
|
|
|
|
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) |
|
|
|
|
|
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 |
|
|
|
|
|
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)) |
|
|
|
|
|
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) |
|
|
|
|
|
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() |
|
|