|
|
import streamlit as st |
|
|
import pandas as pd |
|
|
import plotly.express as px |
|
|
from io import StringIO |
|
|
from datetime import date |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def format_rs(amount): |
|
|
"""Format numeric amount into Rupee string with thousands separators.""" |
|
|
try: |
|
|
|
|
|
return f"Rs {int(round(float(amount))):,}" |
|
|
except Exception: |
|
|
return str(amount) |
|
|
|
|
|
def ensure_numeric_amount(col): |
|
|
"""Convert amount column to numeric (int) safely.""" |
|
|
return pd.to_numeric(col, errors="coerce").fillna(0).astype(int) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
st.set_page_config(page_title="πΈ Expensive Tracker", page_icon="π³", layout="centered") |
|
|
|
|
|
st.markdown( |
|
|
""" |
|
|
<style> |
|
|
/* Page background gradient */ |
|
|
.reportview-container { |
|
|
background: linear-gradient(135deg,#0f172a,#3b4252); |
|
|
color: #e6eef8; |
|
|
} |
|
|
.stButton>button { |
|
|
background: linear-gradient(90deg,#ff7a18,#af002d); |
|
|
color: white; |
|
|
border-radius: 10px; |
|
|
padding: 0.55em 1em; |
|
|
font-weight: 600; |
|
|
} |
|
|
.stButton>button:hover { |
|
|
transform: scale(1.02); |
|
|
filter: brightness(1.05); |
|
|
} |
|
|
.card { |
|
|
background: rgba(255,255,255,0.06); |
|
|
padding: 12px; |
|
|
border-radius: 12px; |
|
|
border: 1px solid rgba(255,255,255,0.06); |
|
|
box-shadow: 0 6px 18px rgba(0,0,0,0.3); |
|
|
} |
|
|
.dataframe td, .dataframe th { |
|
|
color: #e6eef8 !important; |
|
|
} |
|
|
</style> |
|
|
""", |
|
|
unsafe_allow_html=True, |
|
|
) |
|
|
|
|
|
st.title("πΈ Expensive Tracker") |
|
|
st.caption("Track expenses in Rupees (Rs). Enter whole numbers like 100, 500, 10000.") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if "expenses" not in st.session_state: |
|
|
|
|
|
st.session_state.expenses = pd.DataFrame( |
|
|
columns=["Date", "Category", "Amount", "Notes"] |
|
|
) |
|
|
|
|
|
|
|
|
if not st.session_state.expenses.empty: |
|
|
st.session_state.expenses["Amount"] = ensure_numeric_amount(st.session_state.expenses["Amount"]) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
st.sidebar.header("βοΈ Menu") |
|
|
page = st.sidebar.radio("Choose view", ["Add Expense", "View Expenses", "Summary", "Import / Export"]) |
|
|
|
|
|
|
|
|
CATEGORIES = ["Food", "Travel", "Shopping", "Bills", "Entertainment", "Health", "Other"] |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if page == "Add Expense": |
|
|
st.header("Add a new expense (Amount in Rs)") |
|
|
with st.form("add_expense_form", clear_on_submit=True): |
|
|
c1, c2, c3 = st.columns([1, 1, 1]) |
|
|
with c1: |
|
|
exp_date = st.date_input("Date", value=date.today()) |
|
|
with c2: |
|
|
category = st.selectbox("Category", options=CATEGORIES) |
|
|
with c3: |
|
|
|
|
|
amount = st.number_input("Amount (Rs)", min_value=0, step=1, format="%d", value=0) |
|
|
notes = st.text_area("Notes (optional)", max_chars=200, placeholder="Where/what for?") |
|
|
submitted = st.form_submit_button("β Add Expense") |
|
|
|
|
|
if submitted: |
|
|
new_row = { |
|
|
"Date": pd.to_datetime(exp_date).date(), |
|
|
"Category": category, |
|
|
"Amount": int(amount), |
|
|
"Notes": notes |
|
|
} |
|
|
st.session_state.expenses = pd.concat([st.session_state.expenses, pd.DataFrame([new_row])], ignore_index=True) |
|
|
st.success(f"Expense added β
{format_rs(amount)}") |
|
|
st.balloons() |
|
|
|
|
|
if not st.session_state.expenses.empty: |
|
|
st.markdown("**Quick preview of latest expenses**") |
|
|
preview = st.session_state.expenses.tail(6).reset_index(drop=True).copy() |
|
|
|
|
|
preview["Amount (Rs)"] = preview["Amount"].apply(format_rs) |
|
|
st.dataframe(preview[["Date", "Category", "Amount (Rs)", "Notes"]]) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
elif page == "View Expenses": |
|
|
st.header("All Expenses (Amounts in Rs)") |
|
|
if st.session_state.expenses.empty: |
|
|
st.info("No expenses yet β add some from the 'Add Expense' tab.") |
|
|
else: |
|
|
df = st.session_state.expenses.copy() |
|
|
df["Amount"] = ensure_numeric_amount(df["Amount"]) |
|
|
|
|
|
st.markdown("Filter") |
|
|
cols = st.columns([1, 1, 1]) |
|
|
with cols[0]: |
|
|
min_date = st.date_input("From", value=pd.to_datetime(df["Date"]).min().date()) |
|
|
with cols[1]: |
|
|
max_date = st.date_input("To", value=pd.to_datetime(df["Date"]).max().date()) |
|
|
with cols[2]: |
|
|
sel_cat = st.multiselect("Category", options=["All"] + CATEGORIES, default=["All"]) |
|
|
filtered = df[ |
|
|
(pd.to_datetime(df["Date"]) >= pd.to_datetime(min_date)) & |
|
|
(pd.to_datetime(df["Date"]) <= pd.to_datetime(max_date)) |
|
|
] |
|
|
if sel_cat and "All" not in sel_cat: |
|
|
filtered = filtered[filtered["Category"].isin(sel_cat)] |
|
|
|
|
|
display_df = filtered.sort_values(by="Date", ascending=False).reset_index(drop=True).copy() |
|
|
display_df["Amount (Rs)"] = display_df["Amount"].apply(format_rs) |
|
|
st.dataframe(display_df[["Date", "Category", "Amount (Rs)", "Notes"]]) |
|
|
|
|
|
|
|
|
st.markdown("---") |
|
|
cdel, cclear = st.columns(2) |
|
|
with cdel: |
|
|
if st.button("ποΈ Delete last entry"): |
|
|
st.session_state.expenses = st.session_state.expenses[:-1].reset_index(drop=True) |
|
|
st.success("Last entry removed.") |
|
|
with cclear: |
|
|
if st.button("β οΈ Clear all expenses"): |
|
|
st.session_state.expenses = pd.DataFrame(columns=["Date", "Category", "Amount", "Notes"]) |
|
|
st.success("All expenses cleared.") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
elif page == "Summary": |
|
|
st.header("Summary Dashboard (Rs)") |
|
|
if st.session_state.expenses.empty: |
|
|
st.info("No data yet β add expenses to see the summary.") |
|
|
else: |
|
|
df = st.session_state.expenses.copy() |
|
|
df["Date"] = pd.to_datetime(df["Date"]) |
|
|
df["Amount"] = ensure_numeric_amount(df["Amount"]) |
|
|
|
|
|
total = df["Amount"].sum() |
|
|
avg = df["Amount"].mean() |
|
|
max_exp = df["Amount"].max() |
|
|
st.markdown("<div class='card'>", unsafe_allow_html=True) |
|
|
c1, c2, c3 = st.columns(3) |
|
|
c1.metric("Total Spent", format_rs(total)) |
|
|
c2.metric("Average Expense", format_rs(avg)) |
|
|
c3.metric("Largest Expense", format_rs(max_exp)) |
|
|
st.markdown("</div>", unsafe_allow_html=True) |
|
|
|
|
|
st.markdown("### π Expenses by Category") |
|
|
cat_summary = df.groupby("Category", as_index=False)["Amount"].sum().sort_values("Amount", ascending=False) |
|
|
|
|
|
fig_pie = px.pie(cat_summary, names="Category", values="Amount", title="Spending by Category", hole=0.4) |
|
|
fig_pie.update_traces(textinfo="percent+label", hovertemplate="%{label}: Rs %{value:,}<extra></extra>") |
|
|
st.plotly_chart(fig_pie, use_container_width=True) |
|
|
|
|
|
st.markdown("### π Expenses Over Time") |
|
|
timeseries = df.groupby(pd.Grouper(key="Date", freq="D"))["Amount"].sum().reset_index() |
|
|
timeseries = timeseries.set_index("Date").resample("D").sum().fillna(0).reset_index() |
|
|
fig_line = px.bar(timeseries, x="Date", y="Amount", title="Daily Spending (bar)") |
|
|
fig_line.update_traces(hovertemplate="Date: %{x}<br>Amount: Rs %{y:,}<extra></extra>") |
|
|
st.plotly_chart(fig_line, use_container_width=True) |
|
|
|
|
|
st.markdown("### π Top 5 Expenses") |
|
|
top5 = df.nlargest(5, "Amount")[["Date", "Category", "Amount", "Notes"]].reset_index(drop=True).copy() |
|
|
top5["Amount (Rs)"] = top5["Amount"].apply(format_rs) |
|
|
st.dataframe(top5[["Date", "Category", "Amount (Rs)", "Notes"]]) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
elif page == "Import / Export": |
|
|
st.header("Import or Export your data (CSV)") |
|
|
st.markdown("You can download your current expenses as a CSV or upload a CSV to load expenses. Amounts are stored as integers (Rs).") |
|
|
|
|
|
|
|
|
if st.session_state.expenses.empty: |
|
|
st.info("No expenses to export.") |
|
|
else: |
|
|
|
|
|
export_df = st.session_state.expenses.copy() |
|
|
export_df["Amount"] = ensure_numeric_amount(export_df["Amount"]) |
|
|
csv = export_df.to_csv(index=False) |
|
|
st.download_button("β¬οΈ Download CSV", data=csv, file_name="expenses.csv", mime="text/csv") |
|
|
|
|
|
st.markdown("---") |
|
|
st.markdown("Upload a CSV file (columns: Date, Category, Amount, Notes). Amounts should be numeric (Rs).") |
|
|
uploaded = st.file_uploader("Upload CSV", type=["csv"]) |
|
|
if uploaded is not None: |
|
|
try: |
|
|
uploaded_df = pd.read_csv(uploaded, parse_dates=["Date"]) |
|
|
|
|
|
required = {"Date", "Category", "Amount"} |
|
|
if not required.issubset(set(uploaded_df.columns)): |
|
|
st.error("CSV must include at least Date, Category, and Amount columns.") |
|
|
else: |
|
|
|
|
|
if "Notes" not in uploaded_df.columns: |
|
|
uploaded_df["Notes"] = "" |
|
|
uploaded_df = uploaded_df[["Date", "Category", "Amount", "Notes"]] |
|
|
uploaded_df["Amount"] = ensure_numeric_amount(uploaded_df["Amount"]) |
|
|
uploaded_df["Date"] = pd.to_datetime(uploaded_df["Date"]).dt.date |
|
|
st.session_state.expenses = pd.concat([st.session_state.expenses, uploaded_df], ignore_index=True) |
|
|
st.success("Uploaded expenses added to your tracker.") |
|
|
except Exception as e: |
|
|
st.error(f"Failed to parse CSV: {e}") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
st.markdown("---") |
|
|
st.markdown( |
|
|
"<div style='text-align:center;opacity:0.8'>Made with β€οΈ β Deploy to Hugging Face Spaces (SDK: Streamlit). " |
|
|
"Tip: Use the Import/Export tab to keep your data between sessions.</div>", |
|
|
unsafe_allow_html=True, |
|
|
) |
|
|
|