File size: 9,105 Bytes
f10032f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
import streamlit as st
import pandas as pd
import plotly.express as px
from datetime import datetime
import os
import tempfile
import traceback

# ------------------------
# Config
# ------------------------
st.set_page_config(page_title="Expense Tracker", page_icon="💰", layout="centered")
DATA_FILE = os.path.join(os.path.dirname(__file__), "expenses.csv")

# ------------------------
# Helpers
# ------------------------
def get_empty_df():
    return pd.DataFrame(columns=["Date", "Description", "Amount", "Category"])

def load_data():
    """Load CSV safely and normalize types. Returns DataFrame."""
    if not os.path.exists(DATA_FILE):
        return get_empty_df()

    try:
        df = pd.read_csv(DATA_FILE)
        # Ensure required columns exist
        for col in ["Date", "Description", "Amount", "Category"]:
            if col not in df.columns:
                df[col] = pd.NA

        # Parse Date to datetime (coerce errors -> NaT)
        df["Date"] = pd.to_datetime(df["Date"], errors="coerce")

        # Coerce Amount to numeric and fill NaNs with 0.0 (won't crash plots)
        df["Amount"] = pd.to_numeric(df["Amount"], errors="coerce").fillna(0.0)

        # Ensure Description and Category are strings
        df["Description"] = df["Description"].astype(str).fillna("")
        df["Category"] = df["Category"].astype(str).fillna("Other")

        # Re-order columns
        df = df[["Date", "Description", "Amount", "Category"]]
        return df
    except Exception as e:
        st.error("Error loading data file. Starting with empty dataset.")
        st.text(traceback.format_exc())
        return get_empty_df()

def save_data(df: pd.DataFrame):
    """Save CSV atomically to avoid partial writes."""
    try:
        df_to_save = df.copy()
        # Save Date as ISO date (YYYY-MM-DD) for readability
        df_to_save["Date"] = pd.to_datetime(df_to_save["Date"], errors="coerce").dt.date
        dirpath = os.path.dirname(DATA_FILE) or "."
        with tempfile.NamedTemporaryFile("w", delete=False, dir=dirpath, newline='') as tf:
            df_to_save.to_csv(tf.name, index=False)
            tf.flush()
            try:
                os.fsync(tf.fileno())
            except Exception:
                pass
        os.replace(tf.name, DATA_FILE)
    except Exception as e:
        st.error("Failed to save data.")
        st.text(traceback.format_exc())

# ------------------------
# Session state for persistent DataFrame between interactions
# ------------------------
if "df" not in st.session_state:
    st.session_state.df = load_data()

# Keep a local reference for convenience
df = st.session_state.df

# ------------------------
# UI - Title
# ------------------------
st.title("💰 Personal Expense Tracker")
st.markdown("Track your expenses and visualize your spending patterns.")

# ------------------------
# Input form
# ------------------------
with st.form("expense_form", clear_on_submit=False):
    st.subheader("Add New Expense")
    c1, c2 = st.columns(2)
    with c1:
        date_input = st.date_input("Date", value=datetime.today().date(), key="date_input")
        category = st.selectbox(
            "Category",
            options=["Food", "Transport", "Entertainment", "Shopping", "Bills", "Healthcare", "Other"],
            index=0,
            key="category_input"
        )
    with c2:
        description = st.text_input("Description", key="description_input")
        amount = st.number_input("Amount ($)", min_value=0.0, format="%.2f", step=0.5, key="amount_input")

    submitted = st.form_submit_button("Add Expense")
    if submitted:
        # validation
        if amount <= 0:
            st.error("Amount must be greater than 0.")
        elif not description or not description.strip():
            st.error("Please enter a description.")
        else:
            try:
                new_row = {
                    "Date": pd.to_datetime(date_input),
                    "Description": description.strip(),
                    "Amount": float(amount),
                    "Category": category or "Other",
                }
                # Append to session-state DataFrame
                st.session_state.df = pd.concat(
                    [st.session_state.df, pd.DataFrame([new_row])],
                    ignore_index=True
                )
                # Persist to disk
                save_data(st.session_state.df)
                st.success("Expense added successfully!")
                # Refresh local reference
                df = st.session_state.df
                # Clear form inputs (workaround)
                st.experimental_rerun()
            except Exception as e:
                st.error("Failed to add expense.")
                st.text(traceback.format_exc())

# ------------------------
# Display data & visualizations
# ------------------------
df = st.session_state.df  # refresh reference after any changes

if df is None or df.empty:
    st.info("No expenses recorded yet. Add your first expense above!")
else:
    st.subheader("Expense History")
    # Defensive: ensure Amount is numeric
    df["Amount"] = pd.to_numeric(df["Amount"], errors="coerce").fillna(0.0)

    # Summary stats (handle possible empty cases)
    total_expenses = float(df["Amount"].sum())
    avg_expense = float(df["Amount"].mean()) if len(df) > 0 else 0.0

    # Largest expense (defensive)
    largest_amount_display = "$0.00"
    largest_caption = ""
    try:
        if df["Amount"].notna().any() and len(df) > 0:
            idx = df["Amount"].idxmax()
            row = df.loc[idx]
            largest_amount_display = f"${float(row['Amount']):,.2f}"
            largest_caption = str(row.get("Description", ""))
    except Exception:
        pass

    col1, col2, col3 = st.columns(3)
    col1.metric("Total Expenses", f"${total_expenses:,.2f}")
    col2.metric("Average Expense", f"${avg_expense:,.2f}")
    col3.metric("Largest Expense", largest_amount_display, largest_caption)

    # Table (most recent first)
    try:
        display_df = df.sort_values("Date", ascending=False, na_position="last").reset_index(drop=True)
        st.dataframe(display_df, hide_index=True, use_container_width=True)
    except Exception:
        st.dataframe(df, hide_index=True, use_container_width=True)

    # Visualizations
    st.subheader("Spending Analysis")
    tab1, tab2, tab3 = st.tabs(["By Category", "Over Time", "Detailed Analysis"])

    with tab1:
        try:
            category_totals = df.groupby("Category", sort=False)["Amount"].sum().reset_index()
            if category_totals.empty:
                st.info("No category data to plot yet.")
            else:
                fig = px.pie(category_totals, values="Amount", names="Category", title="Expenses by Category")
                st.plotly_chart(fig, use_container_width=True)
        except Exception:
            st.error("Couldn't generate category chart.")
            st.text(traceback.format_exc())

    with tab2:
        try:
            # Group by date (daily). Remove rows without a valid date first.
            df_time = df.dropna(subset=["Date"]).copy()
            if df_time.empty:
                st.info("No dated expenses to show over time.")
            else:
                df_time = df_time.groupby(pd.Grouper(key="Date", freq="D"))["Amount"].sum().reset_index()
                fig = px.line(df_time, x="Date", y="Amount", title="Spending Over Time")
                st.plotly_chart(fig, use_container_width=True)
        except Exception:
            st.error("Couldn't generate time series.")
            st.text(traceback.format_exc())

    with tab3:
        try:
            category_totals = df.groupby("Category", sort=False)["Amount"].sum().reset_index()
            if category_totals.empty:
                st.info("No data for detailed analysis.")
            else:
                fig = px.bar(category_totals, x="Category", y="Amount", title="Total Spending by Category")
                st.plotly_chart(fig, use_container_width=True)
        except Exception:
            st.error("Couldn't generate detailed analysis chart.")
            st.text(traceback.format_exc())

    # Download CSV
    try:
        csv = df.copy()
        csv["Date"] = pd.to_datetime(csv["Date"], errors="coerce").dt.date
        st.download_button(
            label="Download Expenses as CSV",
            data=csv.to_csv(index=False),
            file_name="expenses.csv",
            mime="text/csv",
        )
    except Exception:
        st.error("Failed to prepare CSV for download.")
        st.text(traceback.format_exc())

# ------------------------
# Footer and optional debug
# ------------------------
st.markdown("---")
st.markdown("Built with Streamlit • Deploy on Hugging Face Spaces")

with st.expander("Debug / Data snapshot (expand if you need)"):
    try:
        st.write("Data file path:", DATA_FILE)
        st.write("Rows in memory:", len(st.session_state.df))
        st.dataframe(st.session_state.df.head(10))
    except Exception:
        st.text("No debug info available.")