|
6 | 6 | sys.path.insert(0,os.path.abspath(os.path.join(os.path.dirname(__file__),'..', '..'))) |
7 | 7 |
|
8 | 8 | # Initialize the session states |
9 | | -from CLI.app.streamlit_setup import init_st, sync_data |
| 9 | +from CLI.app.streamlit_setup import init_st, sync_data, BACKEND_URL, USE_LOCAL_BACKEND |
| 10 | +import requests as _requests |
10 | 11 |
|
11 | 12 | init_st() |
12 | 13 |
|
| 14 | + |
| 15 | +def _backend_post(endpoint: str, payload: dict): |
| 16 | + return _requests.post(f'{st.session_state.get("backend_url", BACKEND_URL)}{endpoint}', json=payload, timeout=30) |
| 17 | + |
13 | 18 | st.title('Web-based Expense and Income Tracking') |
14 | 19 |
|
15 | 20 | tab_dashboard, tab_add, tab_delete, tab_edit, tab_view_expenses, tab_view_income, tab_view_subscriptions = st.tabs([ |
|
32 | 37 | else: |
33 | 38 | st.write("No income found.") |
34 | 39 |
|
35 | | - budget_totals = {} |
36 | | - for expense in st.session_state.expenses: |
37 | | - if expense['date'][:7] == st.session_state.current_month: |
38 | | - tag = expense['tags'] |
39 | | - budget_totals[tag] = budget_totals.get(tag, 0) + expense['price'] |
40 | | - for budget in st.session_state.budget: |
41 | | - total_spent = budget_totals.get(budget['category'], 0) |
42 | | - limit = float(budget['amount']) |
43 | | - if limit < total_spent: |
44 | | - st.warning(f"{budget['category']} budget has been surpassed by {total_spent - limit:.2f}.") |
45 | | - elif limit == total_spent: |
46 | | - st.warning(f"{budget['category']} budget has reached its limit.") |
| 40 | + if st.session_state.budget: |
| 41 | + st.subheader('Budget Status') |
| 42 | + budget_totals = {} |
| 43 | + for expense in st.session_state.expenses: |
| 44 | + if expense['date'][:7] == st.session_state.current_month: |
| 45 | + tag = expense['tags'] |
| 46 | + budget_totals[tag] = budget_totals.get(tag, 0) + expense['price'] |
| 47 | + for budget in st.session_state.budget: |
| 48 | + total_spent = budget_totals.get(budget['category'], 0) |
| 49 | + limit = float(budget['amount']) |
| 50 | + pct = min(total_spent / limit, 1.0) if limit > 0 else 0.0 |
| 51 | + over = total_spent > limit |
| 52 | + col_label, col_bar = st.columns([1, 3]) |
| 53 | + with col_label: |
| 54 | + st.write(f"**{budget['category']}**") |
| 55 | + st.caption(f"{total_spent:.2f} / {limit:.2f} {budget.get('currency','USD').upper()}") |
| 56 | + with col_bar: |
| 57 | + st.progress(pct) |
| 58 | + if over: |
| 59 | + st.error(f"Over by {total_spent - limit:.2f} — reduce {budget['category']} spending", icon='🚨') |
| 60 | + elif pct >= 0.9: |
| 61 | + st.warning(f"{int(pct*100)}% used — approaching limit", icon='⚠️') |
47 | 62 |
|
48 | 63 | st.divider() |
49 | 64 |
|
50 | 65 | # ── Net-worth snapshot ─────────────────────────────────────────────────── |
51 | 66 | st.subheader('Net-Worth Snapshot') |
52 | 67 | try: |
53 | | - from backend.analytics import net_worth_snapshot |
54 | | - _nw_data = { |
55 | | - 'expenses': st.session_state.expenses, |
56 | | - 'income': st.session_state.income, |
57 | | - 'subscriptions': st.session_state.subscriptions, |
58 | | - 'goals': st.session_state.goals, |
59 | | - } |
60 | | - _nw = net_worth_snapshot(_nw_data, convert_fn=st.session_state.tracker.convert_currency) |
61 | | - if _nw['success']: |
| 68 | + if USE_LOCAL_BACKEND: |
| 69 | + from backend.analytics import net_worth_snapshot |
| 70 | + _nw = net_worth_snapshot( |
| 71 | + {'expenses': st.session_state.expenses, 'income': st.session_state.income, |
| 72 | + 'subscriptions': st.session_state.subscriptions, 'goals': st.session_state.goals}, |
| 73 | + convert_fn=st.session_state.tracker.convert_currency, |
| 74 | + ) |
| 75 | + else: |
| 76 | + _resp = _backend_post('/net-worth', { |
| 77 | + 'expenses': st.session_state.expenses, 'income': st.session_state.income, |
| 78 | + 'subscriptions': st.session_state.subscriptions, 'goals': st.session_state.goals, |
| 79 | + }) |
| 80 | + _nw = _resp.json() if _resp.ok else {'success': False} |
| 81 | + if _nw.get('success'): |
62 | 82 | _nw_cols = st.columns(4) |
63 | 83 | _nw_cols[0].metric('Total Income', f"{_nw['total_income']:,.2f} {_nw['base_currency']}") |
64 | 84 | _nw_cols[1].metric('Total Expenses', f"{_nw['total_expenses']:,.2f} {_nw['base_currency']}") |
|
75 | 95 | # ── Spending forecast ──────────────────────────────────────────────────── |
76 | 96 | st.subheader('Spending Forecast') |
77 | 97 | try: |
78 | | - from backend.analytics import forecast_spending |
79 | | - _fc = forecast_spending(st.session_state.expenses) |
80 | | - if _fc['success'] and _fc['forecasts']: |
| 98 | + if USE_LOCAL_BACKEND: |
| 99 | + from backend.analytics import forecast_spending |
| 100 | + _fc = forecast_spending(st.session_state.expenses) |
| 101 | + else: |
| 102 | + _resp = _backend_post('/forecast', {'expenses': st.session_state.expenses, 'base_currency': 'USD'}) |
| 103 | + _fc = _resp.json() if _resp.ok else {'success': False, 'forecasts': {}} |
| 104 | + if _fc.get('success') and _fc.get('forecasts'): |
81 | 105 | st.caption(f"Based on {_fc['based_on_months']} month(s) of history ({_fc['base_currency']} only)") |
82 | 106 | _fc_cols = st.columns(3) |
83 | 107 | for _fi, (_cat, _info) in enumerate(_fc['forecasts'].items()): |
84 | | - _col = _fc_cols[_fi % 3] |
85 | 108 | _arrow = '↑' if _info['trend'] == 'increasing' else '↓' if _info['trend'] == 'decreasing' else '→' |
86 | | - _col.metric( |
87 | | - f'{_cat} {_arrow}', |
88 | | - f"{_info['next_month_forecast']:,.2f}", |
89 | | - delta=f"avg {_info['current_avg']:,.2f}", |
90 | | - ) |
91 | | - elif not _fc['success']: |
| 109 | + _fc_cols[_fi % 3].metric(f'{_cat} {_arrow}', f"{_info['next_month_forecast']:,.2f}", delta=f"avg {_info['current_avg']:,.2f}") |
| 110 | + else: |
92 | 111 | st.info('Not enough expense history to generate a forecast yet.') |
93 | 112 | except Exception as _e: |
94 | 113 | st.info(f'Forecast unavailable: {_e}') |
|
98 | 117 | # ── Anomaly detection ──────────────────────────────────────────────────── |
99 | 118 | st.subheader('Unusual Expenses') |
100 | 119 | try: |
101 | | - from backend.analytics import detect_anomalies |
102 | | - _ad = detect_anomalies(st.session_state.expenses) |
103 | | - if _ad['anomalies']: |
| 120 | + if USE_LOCAL_BACKEND: |
| 121 | + from backend.analytics import detect_anomalies |
| 122 | + _ad = detect_anomalies(st.session_state.expenses) |
| 123 | + else: |
| 124 | + _resp = _backend_post('/detect-anomalies', {'expenses': st.session_state.expenses, 'z_threshold': 2.5}) |
| 125 | + _ad = _resp.json() if _resp.ok else {'anomalies': []} |
| 126 | + if _ad.get('anomalies'): |
104 | 127 | st.caption(f"{_ad['count']} statistically unusual expense(s) detected:") |
105 | 128 | for _anom in _ad['anomalies']: |
106 | 129 | _dev_sign = '+' if _anom['deviation'] > 0 else '' |
|
119 | 142 |
|
120 | 143 | # ── Natural language query ─────────────────────────────────────────────── |
121 | 144 | st.subheader('Ask About Your Finances') |
122 | | - try: |
123 | | - from backend.ai import is_configured as _ai_ok, answer_query as _answer_query |
124 | | - if not _ai_ok(): |
125 | | - st.info('Set AI_API_KEY (and optionally AI_PROVIDER, AI_MODEL) to enable natural-language queries.') |
126 | | - else: |
127 | | - _nl_question = st.text_input('Ask anything about your finances …', key='nl_query_input') |
128 | | - if st.button('Ask', key='nl_query_btn') and _nl_question.strip(): |
129 | | - with st.spinner('Thinking …'): |
130 | | - _nl_data = { |
131 | | - 'expenses': st.session_state.expenses, |
132 | | - 'income': st.session_state.income, |
133 | | - 'budget': st.session_state.budget, |
134 | | - 'subscriptions': st.session_state.subscriptions, |
135 | | - 'goals': st.session_state.goals, |
136 | | - } |
137 | | - try: |
138 | | - _nl_answer = _answer_query(_nl_question, _nl_data) |
139 | | - st.session_state['nl_last_answer'] = _nl_answer |
140 | | - except Exception as _exc: |
141 | | - st.error(f'Query failed: {_exc}') |
142 | | - if st.session_state.get('nl_last_answer'): |
143 | | - st.markdown(st.session_state['nl_last_answer']) |
144 | | - except Exception as _e: |
145 | | - st.info(f'AI query unavailable: {_e}') |
| 145 | + _nl_question = st.text_input('Ask anything about your finances …', key='nl_query_input') |
| 146 | + if st.button('Ask', key='nl_query_btn') and _nl_question.strip(): |
| 147 | + with st.spinner('Thinking …'): |
| 148 | + _nl_data = { |
| 149 | + 'expenses': st.session_state.expenses, 'income': st.session_state.income, |
| 150 | + 'budget': st.session_state.budget, 'subscriptions': st.session_state.subscriptions, |
| 151 | + 'goals': st.session_state.goals, |
| 152 | + } |
| 153 | + try: |
| 154 | + if USE_LOCAL_BACKEND: |
| 155 | + from backend.ai import answer_query as _answer_query |
| 156 | + _nl_answer = _answer_query(_nl_question, _nl_data) |
| 157 | + else: |
| 158 | + _resp = _backend_post('/query', {'question': _nl_question, 'data': _nl_data}) |
| 159 | + _nl_answer = _resp.json().get('answer', _resp.text) if _resp.ok else f'Error {_resp.status_code}' |
| 160 | + st.session_state['nl_last_answer'] = _nl_answer |
| 161 | + except Exception as _exc: |
| 162 | + st.error(f'Query failed: {_exc}') |
| 163 | + if st.session_state.get('nl_last_answer'): |
| 164 | + st.markdown(st.session_state['nl_last_answer']) |
146 | 165 |
|
147 | 166 | # ── Add ────────────────────────────────────────────────────────────────────── |
148 | 167 | with tab_add: |
|
511 | 530 | else: |
512 | 531 | st.write("No expenses found. Add expenses to get started.") |
513 | 532 |
|
514 | | - st.file_uploader('Import expenses from .csv', type=['csv'], key='expenses_file_uploader') |
515 | | - if st.session_state.get('expenses_file_uploader'): |
516 | | - st.session_state.tracker.import_from_csv('expenses', st.session_state.expenses_file_uploader) |
517 | | - sync_data() |
518 | | - st.success('Data imported successfully!') |
519 | | - st.rerun() |
| 533 | + with st.form('import_expenses_form', clear_on_submit=True): |
| 534 | + uploaded_csv = st.file_uploader('Import expenses from .csv', type=['csv'], key='expenses_csv_upload') |
| 535 | + if st.form_submit_button('Import') and uploaded_csv: |
| 536 | + import pandas as _pd |
| 537 | + try: |
| 538 | + preview = _pd.read_csv(uploaded_csv) |
| 539 | + st.dataframe(preview.head(3)) |
| 540 | + uploaded_csv.seek(0) |
| 541 | + st.session_state.tracker.import_from_csv('expenses', uploaded_csv) |
| 542 | + sync_data() |
| 543 | + st.success(f'Imported {len(preview)} rows successfully.') |
| 544 | + st.rerun() |
| 545 | + except Exception as _e: |
| 546 | + st.error(f'Import failed: {_e}') |
520 | 547 | result = st.session_state.tracker.export_to_csv('expenses', 'expenses.csv') |
521 | 548 | if result['success']: |
522 | 549 | st.download_button(label='Export expenses to .csv', data=result['data'].to_csv(index=False).encode('utf-8'), file_name='expenses.csv', mime='text/csv', key='exp_download') |
|
550 | 577 | else: |
551 | 578 | st.write("No income found. Add income to get started.") |
552 | 579 |
|
553 | | - st.file_uploader('Import income from .csv', type=['csv'], key='income_file_uploader') |
554 | | - if st.session_state.get('income_file_uploader'): |
555 | | - st.session_state.tracker.import_from_csv('income', st.session_state.income_file_uploader) |
556 | | - sync_data() |
557 | | - st.success('Data imported successfully!') |
558 | | - st.rerun() |
| 580 | + with st.form('import_income_form', clear_on_submit=True): |
| 581 | + uploaded_csv = st.file_uploader('Import income from .csv', type=['csv'], key='income_csv_upload') |
| 582 | + if st.form_submit_button('Import') and uploaded_csv: |
| 583 | + import pandas as _pd |
| 584 | + try: |
| 585 | + preview = _pd.read_csv(uploaded_csv) |
| 586 | + st.dataframe(preview.head(3)) |
| 587 | + uploaded_csv.seek(0) |
| 588 | + st.session_state.tracker.import_from_csv('income', uploaded_csv) |
| 589 | + sync_data() |
| 590 | + st.success(f'Imported {len(preview)} rows successfully.') |
| 591 | + st.rerun() |
| 592 | + except Exception as _e: |
| 593 | + st.error(f'Import failed: {_e}') |
559 | 594 | result = st.session_state.tracker.export_to_csv('income', 'income.csv') |
560 | 595 | if result['success']: |
561 | 596 | st.download_button(label='Export income to .csv', data=result['data'].to_csv(index=False).encode('utf-8'), file_name='income.csv', mime='text/csv', key='inc_download') |
|
584 | 619 | else: |
585 | 620 | st.write("No subscriptions found. Add subscriptions to get started.") |
586 | 621 |
|
587 | | - st.file_uploader('Import subscriptions from .csv', type=['csv'], key='subscriptions_file_uploader') |
588 | | - if st.session_state.get('subscriptions_file_uploader'): |
589 | | - st.session_state.tracker.import_from_csv('subscriptions', st.session_state.subscriptions_file_uploader) |
590 | | - sync_data() |
591 | | - st.success('Data imported successfully!') |
592 | | - st.rerun() |
| 622 | + with st.form('import_subscriptions_form', clear_on_submit=True): |
| 623 | + uploaded_csv = st.file_uploader('Import subscriptions from .csv', type=['csv'], key='subscriptions_csv_upload') |
| 624 | + if st.form_submit_button('Import') and uploaded_csv: |
| 625 | + import pandas as _pd |
| 626 | + try: |
| 627 | + preview = _pd.read_csv(uploaded_csv) |
| 628 | + st.dataframe(preview.head(3)) |
| 629 | + uploaded_csv.seek(0) |
| 630 | + st.session_state.tracker.import_from_csv('subscriptions', uploaded_csv) |
| 631 | + sync_data() |
| 632 | + st.success(f'Imported {len(preview)} rows successfully.') |
| 633 | + st.rerun() |
| 634 | + except Exception as _e: |
| 635 | + st.error(f'Import failed: {_e}') |
593 | 636 | result = st.session_state.tracker.export_to_csv('subscriptions', 'subscriptions.csv') |
594 | 637 | if result['success']: |
595 | 638 | st.download_button(label='Export subscriptions to .csv', data=result['data'].to_csv(index=False).encode('utf-8'), file_name='subscriptions.csv', mime='text/csv', key='sub_download') |
0 commit comments