File size: 12,931 Bytes
828f6f3
4fc20a3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54ad358
4fc20a3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54ad358
4fc20a3
 
 
 
 
 
 
 
 
 
 
 
 
54ad358
4fc20a3
 
 
 
 
 
 
54ad358
 
 
 
 
 
 
 
 
 
4fc20a3
 
 
 
 
 
 
54ad358
 
 
 
4fc20a3
 
 
54ad358
 
4fc20a3
54ad358
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4fc20a3
54ad358
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4fc20a3
54ad358
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
import streamlit as st
import pandas as pd
import altair as alt
from datetime import datetime
from decimal import Decimal
import io

# PDF export via ReportLab
from reportlab.lib.pagesizes import letter
from reportlab.pdfgen import canvas

# ─── Helpers ───────────────────────────────────────────────────────────────────
def normalize_percentages(raw_dict):
    cats, vals = list(raw_dict.keys()), list(raw_dict.values())
    total = sum(vals)
    normalized = {}
    if total <= 0:
        each = round(100 / len(cats), 2)
        for c in cats:
            normalized[c] = each
        diff = 100 - sum(normalized.values())
        normalized[cats[-1]] += diff
    else:
        cum = 0.0
        for i, c in enumerate(cats):
            if i < len(cats) - 1:
                p = round((raw_dict[c] / total) * 100, 2)
                normalized[c] = p
                cum += p
            else:
                normalized[c] = round(100 - cum, 2)
    return normalized

def max_to_str(x):
    if isinstance(x, (int, float, Decimal)):
        return f"${float(x):,.2f}"
    return x  # e.g. "No Max"

def df_to_pdf(df: pd.DataFrame) -> io.BytesIO:
    buffer = io.BytesIO()
    c = canvas.Canvas(buffer, pagesize=letter)
    width, height = letter
    x_offset, y_offset = 40, height - 40
    line_height = 14

    # Header
    for i, col in enumerate(df.columns):
        c.drawString(x_offset + i*100, y_offset, str(col))
    y_offset -= line_height

    # Rows
    for _, row in df.iterrows():
        for i, cell in enumerate(row):
            c.drawString(x_offset + i*100, y_offset, str(cell))
        y_offset -= line_height
        if y_offset < 40:
            c.showPage()
            y_offset = height - 40

    c.save()
    buffer.seek(0)
    return buffer

# ─── Page setup ────────────────────────────────────────────────────────────────
st.set_page_config(
    page_title="Priority Budget Allocator",
    page_icon="πŸ’Έ",
    layout="wide"
)
st.title("πŸ’Έ Priority Budget Allocator")
st.subheader("We budget for you!")
st.markdown(
    "Enter your balances and categories below, then **Generate Budget** to see your allocation. "
    "When you’re happy, download your results as **CSV**, **Excel** or **PDF**."
)

# ─── STEP 1: Balances ──────────────────────────────────────────────────────────
with st.expander("Step 1: Account Balances", expanded=True):
    num_accounts = st.number_input("How many accounts?", min_value=1, max_value=10, step=1, value=1)
    account_balances = [
        st.number_input(
            f"Account {i+1} balance ($)",
            min_value=0.0, format="%.2f", key=f"acct_{i}"
        )
        for i in range(num_accounts)
    ]
    total_balance = sum(account_balances)
    st.success(f"Total Available Balance: **${total_balance:,.2f}**")

# ─── STEP 2: Categories ─────────────────────────────────────────────────────────
invalid_max_min = False
with st.expander("Step 2: Define Spending Categories", expanded=True):
    num_categories = st.number_input("How many categories?", min_value=1, max_value=15, step=1, value=1)
    categories = []
    for i in range(num_categories):
        st.subheader(f"Category {i+1}")
        name = st.text_input("Name", key=f"name_{i}").strip() or f"Category {i+1}"
        minimum = st.number_input("Minimum ($)", min_value=0.0, format="%.2f", key=f"min_{i}")
        has_max = st.checkbox("Has a maximum?", key=f"has_max_{i}")
        max_amt = None
        if has_max:
            max_amt = st.number_input("Maximum ($)", min_value=0.0, format="%.2f", key=f"max_{i}")
            if max_amt < minimum:
                st.warning("⚠️ Maximum < Minimumβ€”please adjust.")
                invalid_max_min = True
        categories.append({
            "Category": name,
            "Min": minimum,
            "Has_Max": has_max,
            "Max": max_amt
        })

# ─── Optional: Mandatory Savings ────────────────────────────────────────────────
with st.expander("Optional: Mandatory Savings", expanded=False):
    include_savings = st.checkbox("Include a mandatory 'Savings' category?")
    if include_savings:
        savings_pct = st.number_input(
            "Savings (% of total balance)", min_value=0.0, max_value=100.0,
            value=10.0, format="%.2f", key="savings_pct"
        )
        st.info("This will reserve that % before other allocations.")

# ─── STEP 2b: Surplus % for β€œNo Max” ────────────────────────────────────────────
no_max = [c["Category"] for c in categories if not c["Has_Max"]]
raw = {}
if no_max:
    with st.expander("Step 2b: % Distribution for β€˜No Max’ Categories"):
        st.markdown("They’ll be normalized to sum to 100%.")
        for c in no_max:
            raw[c] = st.number_input(
                f"% for {c}", min_value=0.0, max_value=100.0,
                value=round(100/len(no_max), 2), key=f"raw_{c}"
            )

# ─── STEP 3: Generate & Display ─────────────────────────────────────────────────
if st.button("πŸ“Š Generate Budget"):
    if invalid_max_min:
        st.error("❌ Please fix category errors (Max β‰₯ Min) before generating budget.")
    else:
        # Insert mandatory savings category if requested
        if include_savings:
            savings_amt = round(total_balance * savings_pct / 100, 2)
            categories.insert(0, {
                "Category": "Savings",
                "Min": savings_amt,
                "Has_Max": False,
                "Max": None
            })

        df = pd.DataFrame(categories)
        df["Allocation"] = df["Min"].copy()
        sum_min = df["Min"].sum()

        if total_balance < sum_min:
            st.warning("⚠️ Balance < sum of minimumsβ€”allocating proportionally to Min.")
            df["Allocation"] = (df["Min"] / sum_min * total_balance).round(2)
        else:
            remaining = total_balance - sum_min
            # fill to Max
            for idx, row in df.iterrows():
                if row["Has_Max"]:
                    cap = row["Max"] - row["Min"]
                    add = min(cap, remaining)
                    df.at[idx, "Allocation"] += round(add, 2)
                    remaining -= add
            # distribute leftover
            if remaining > 0 and no_max:
                norm = normalize_percentages(raw)
                st.subheader("πŸ”’ Normalized % Distribution")
                dist_df = (
                    pd.DataFrame.from_dict(norm, orient="index", columns=["Pct"])
                    .rename_axis("Category").reset_index()
                )
                st.dataframe(dist_df, use_container_width=True)
                for idx, row in df.iterrows():
                    if not row["Has_Max"]:
                        df.at[idx, "Allocation"] += round(remaining * norm[row["Category"]] / 100, 2)
                remaining = 0

        df["Surplus/Deficit"] = (df["Allocation"] - df["Min"]).round(2)

        # ── Metrics ──────────────────────────────────────────────────────────────
        alloc_sum = df["Allocation"].sum()
        unalloc = total_balance - alloc_sum
        st.header("Key Metrics")
        c1, c2, c3 = st.columns(3)
        c1.metric("Total Balance", f"${total_balance:,.2f}")
        c2.metric("Total Allocated", f"${alloc_sum:,.2f}")
        c3.metric(
            "Unallocated",
            f"${unalloc:,.2f}" if unalloc >= 0 else f"-${abs(unalloc):,.2f}"
        )

        # ── Explain Terms ─────────────────────────────────────────────────────────
        with st.expander("❓ What do Allocation & Surplus/Deficit mean?", expanded=False):
            st.markdown(
                "- **Allocation**: The dollar amount assigned to each category based on your inputs.\n"
                "- **Surplus/Deficit**: Allocation minus your Minimum. A positive number means you have extra above your minimum; negative means you fell short."
            )

        # ── Display Table ────────────────────────────────────────────────────────
        disp = df.copy()
        disp["Max"] = disp["Max"].fillna("No Max").apply(max_to_str)
        disp["Min"] = disp["Min"].apply(lambda x: f"${x:,.2f}")
        disp["Allocation"] = disp["Allocation"].apply(lambda x: f"${x:,.2f}")
        disp["Surplus/Deficit"] = disp["Surplus/Deficit"].apply(lambda x: f"${x:,.2f}")
        st.header("πŸ“‹ Allocation Breakdown")
        st.dataframe(disp, use_container_width=True)

        # ── Charts ───────────────────────────────────────────────────────────────
        st.header("πŸ“Š Allocation by Category")
        chart1 = (
            alt.Chart(df)
            .mark_bar()
            .encode(
                x=alt.X("Category:N", sort=None),
                y=alt.Y("Allocation:Q", title="Allocated ($)"),
                tooltip=[
                    alt.Tooltip("Category:N"),
                    alt.Tooltip("Allocation:Q", format="$,.2f"),
                    alt.Tooltip("Min:Q", format="$,.2f"),
                    alt.Tooltip("Surplus/Deficit:Q", format="$,.2f"),
                ]
            )
            .properties(height=300)
        )
        st.altair_chart(chart1, use_container_width=True)

        st.header("πŸ“ˆ Surplus / Deficit by Category")
        sd = df[["Category", "Surplus/Deficit"]]
        chart2 = (
            alt.Chart(sd)
            .mark_bar()
            .encode(
                x="Category:N",
                y=alt.Y("Surplus/Deficit:Q", title="Surplus / Deficit ($)"),
                color=alt.condition(
                    alt.datum["Surplus/Deficit"] >= 0,
                    alt.value("#4caf50"),
                    alt.value("#e15759"),
                ),
            )
            .properties(height=300)
        )
        st.altair_chart(chart2, use_container_width=True)

        st.header("🍰 Allocation Distribution")
        pie_df = df[df["Allocation"] > 0]
        chart3 = (
            alt.Chart(pie_df)
            .mark_arc()
            .encode(
                theta="Allocation:Q",
                color=alt.Color("Category:N", legend=alt.Legend(title="Category")),
                tooltip=["Category", "Allocation"],
            )
            .properties(height=300)
        )
        st.altair_chart(chart3, use_container_width=True)

        # ── Download buttons ─────────────────────────────────────────────────────
        csv_data = df.to_csv(index=False).encode("utf-8")
        st.download_button(
            label="πŸ“₯ Download as CSV",
            data=csv_data,
            file_name=f"budget_{datetime.now():%Y%m%d_%H%M%S}.csv",
            mime="text/csv"
        )

        to_excel = io.BytesIO()
        with pd.ExcelWriter(to_excel, engine="xlsxwriter") as writer:
            df.to_excel(writer, index=False, sheet_name="Budget")
        to_excel.seek(0)
        st.download_button(
            label="πŸ“₯ Download as Excel",
            data=to_excel.getvalue(),
            file_name=f"budget_{datetime.now():%Y%m%d_%H%M%S}.xlsx",
            mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
        )

        pdf_buffer = df_to_pdf(disp)
        st.download_button(
            label="πŸ“₯ Download as PDF",
            data=pdf_buffer,
            file_name=f"budget_{datetime.now():%Y%m%d_%H%M%S}.pdf",
            mime="application/pdf"
        )

        # ── Future Feature Placeholder ──────────────────────────────────────────
        st.info("πŸ“ˆ Investment recommendations coming soon!")