diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index e3e843a..4f9ae12 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -10,6 +10,9 @@ from app.services.ai_service import get_latest_daily_insight from app.services.account_service import get_total_assets, get_total_liabilities from datetime import date, datetime, timedelta import calendar +import logging + +log = logging.getLogger('app.dashboard') dashboard_bp = Blueprint('dashboard', __name__) @@ -250,6 +253,18 @@ def reconcile_api(): }) +@dashboard_bp.route('/api/anomalies') +@login_required +def anomalies_api(): + from app.services.report_service import spending_anomalies + try: + items = spending_anomalies() + except Exception as e: + log.warning('[dashboard] anomalies_api failed: %s', e) + items = [] + return jsonify({'anomalies': items}) + + @dashboard_bp.route('/api/fx-history') @login_required def fx_history_api(): diff --git a/app/routes/reports.py b/app/routes/reports.py index ee9caa5..ca61617 100644 --- a/app/routes/reports.py +++ b/app/routes/reports.py @@ -5,7 +5,7 @@ from app.models.transaction import Transaction from app.services.report_service import ( monthly_report, quarterly_report, yearly_report, net_worth_history, category_trends, tax_year_summary, - take_net_worth_snapshot, + take_net_worth_snapshot, category_mom_comparison, ) from app.services.export_service import ( transactions_to_csv, transactions_to_excel, @@ -24,16 +24,16 @@ _CUR_MONTH = date.today().month @login_required def index(): today = date.today() - # Default: current month summary - report = monthly_report(today.year, today.month) + report = monthly_report(today.year, today.month) nw_history = net_worth_history() - cat_trend = category_trends(6) - + cat_trend = category_trends(6) + mom_data = category_mom_comparison() years = list(range(_CUR_YEAR, _CUR_YEAR - 5, -1)) return render_template('reports/index.html', report=report, nw_history=nw_history, cat_trend=cat_trend, + mom_data=mom_data, years=years, current_year=_CUR_YEAR, current_month=_CUR_MONTH, @@ -46,16 +46,18 @@ def index(): @reports_bp.route('/monthly') @login_required def monthly(): - year = request.args.get('year', _CUR_YEAR, type=int) + year = request.args.get('year', _CUR_YEAR, type=int) month = request.args.get('month', _CUR_MONTH, type=int) - report = monthly_report(year, month) + report = monthly_report(year, month) nw_history = net_worth_history() - cat_trend = category_trends(6) + cat_trend = category_trends(6) + mom_data = category_mom_comparison() years = list(range(_CUR_YEAR, _CUR_YEAR - 5, -1)) return render_template('reports/index.html', report=report, nw_history=nw_history, cat_trend=cat_trend, + mom_data=mom_data, years=years, current_year=_CUR_YEAR, current_month=_CUR_MONTH, @@ -78,6 +80,7 @@ def quarterly(): report=report, nw_history=nw_history, cat_trend=cat_trend, + mom_data=[], years=years, current_year=_CUR_YEAR, current_month=_CUR_MONTH, @@ -99,6 +102,7 @@ def yearly(): report=report, nw_history=nw_history, cat_trend=cat_trend, + mom_data=[], years=years, current_year=_CUR_YEAR, current_month=_CUR_MONTH, diff --git a/app/services/report_service.py b/app/services/report_service.py index 9467aae..b25cdca 100644 --- a/app/services/report_service.py +++ b/app/services/report_service.py @@ -4,8 +4,9 @@ net worth history, category trends, and tax year reports. """ import calendar -from datetime import date, datetime -from sqlalchemy import func +from collections import defaultdict +from datetime import date, datetime, timedelta +from sqlalchemy import func, extract from app.extensions import db from app.models.transaction import Transaction from app.models.category import Category @@ -172,18 +173,48 @@ def yearly_report(year): # ── Net worth history ───────────────────────────────────────────────────────── def net_worth_history(): + from dateutil.relativedelta import relativedelta + snapshots = NetWorthSnapshot.query\ .order_by(NetWorthSnapshot.snapshot_date.asc())\ .all() - return { - 'snapshots': snapshots, - 'labels': [s.snapshot_date.strftime('%b %Y') for s in snapshots], - 'values': [float(s.net_worth) for s in snapshots], - 'assets': [float(s.total_assets) for s in snapshots], - 'liabilities': [float(s.total_liabilities) for s in snapshots], - 'count': len(snapshots), + + result = { + 'snapshots': snapshots, + 'labels': [s.snapshot_date.strftime('%b %Y') for s in snapshots], + 'values': [float(s.net_worth) for s in snapshots], + 'assets': [float(s.total_assets) for s in snapshots], + 'liabilities': [float(s.total_liabilities) for s in snapshots], + 'count': len(snapshots), + 'proj_labels': [], + 'proj_values': [], + 'projected_1yr': None, + 'monthly_delta': None, } + if len(snapshots) >= 3: + recent = snapshots[-6:] # up to last 6 data points + deltas = [ + float(recent[i].net_worth) - float(recent[i - 1].net_worth) + for i in range(1, len(recent)) + ] + avg_delta = sum(deltas) / len(deltas) + + last_nw = float(snapshots[-1].net_worth) + last_date = snapshots[-1].snapshot_date + + proj_labels, proj_values = [], [] + for i in range(1, 13): + proj_labels.append((last_date + relativedelta(months=i)).strftime('%b %Y')) + proj_values.append(round(last_nw + avg_delta * i, 2)) + + result['proj_labels'] = proj_labels + result['proj_values'] = proj_values + result['projected_1yr'] = proj_values[-1] + result['monthly_delta'] = round(avg_delta, 2) + + return result + # ── Category spending trends (last 6 months) ────────────────────────────────── @@ -268,6 +299,160 @@ def tax_year_summary(year): } +# ── Month-over-month category comparison ───────────────────────────────────── + +def category_mom_comparison(): + """ + Returns per-category expense totals for: this month, last month, and the + 3-month rolling average (last 3 complete months). Sorted by this-month + spend descending. + """ + today = date.today() + + this_start = today.replace(day=1) + this_end = today + + last_end = this_start - timedelta(days=1) + last_start = last_end.replace(day=1) + + # Build month ranges for the 3-month rolling average (the 3 complete months + # ending with last month) + avg_ranges = [] + cursor = last_start + for _ in range(3): + me = cursor - timedelta(days=1) + ms = me.replace(day=1) + avg_ranges.append((ms, me)) + cursor = ms + + def _totals_by_cat(start, end): + rows = db.session.query( + Category.id, + Category.name, + Category.color, + Category.icon, + func.sum(Transaction.amount).label('total'), + ).join(Transaction, Transaction.category_id == Category.id)\ + .filter( + Transaction.transaction_type == 'expense', + Transaction.date >= start, + Transaction.date <= end, + ).group_by(Category.id).all() + return {r.id: {'name': r.name, 'color': r.color, 'icon': r.icon, + 'total': float(r.total)} for r in rows} + + this_data = _totals_by_cat(this_start, this_end) + last_data = _totals_by_cat(last_start, last_end) + avg_data = [_totals_by_cat(s, e) for s, e in avg_ranges] + + # Collect all known category IDs + their metadata + cat_meta = {} + for src in [this_data, last_data] + avg_data: + for cid, info in src.items(): + if cid not in cat_meta: + cat_meta[cid] = {k: info[k] for k in ('name', 'color', 'icon')} + + rows = [] + for cid, meta in cat_meta.items(): + this_amt = this_data.get(cid, {}).get('total', 0.0) + last_amt = last_data.get(cid, {}).get('total', 0.0) + avg_monthly = ( + sum(md.get(cid, {}).get('total', 0.0) for md in avg_data) / len(avg_data) + if avg_data else 0.0 + ) + change_pct = ( + round((this_amt - last_amt) / last_amt * 100, 1) + if last_amt > 0 else None + ) + rows.append({ + **meta, + 'id': cid, + 'this_month': round(this_amt, 2), + 'last_month': round(last_amt, 2), + 'avg_3mo': round(avg_monthly, 2), + 'change_pct': change_pct, + }) + + rows.sort(key=lambda r: r['this_month'], reverse=True) + return rows + + +# ── Spending anomaly detection ──────────────────────────────────────────────── + +def spending_anomalies(days_back=30, multiplier=2.0, min_avg=10.0, min_amount=10.0): + """ + Find transactions in the last `days_back` days whose amount is more than + `multiplier` × the category's average monthly spend over the prior 3 months. + Returns a list of dicts with transaction details + context, capped at 5. + """ + today = date.today() + window_start = today - timedelta(days=days_back) + + # Baseline: the 3 complete months before today's month + base_end = today.replace(day=1) - timedelta(days=1) + base_start = (base_end.replace(day=1) - timedelta(days=60)).replace(day=1) + + # Per-category, per-month totals over baseline + rows = db.session.query( + Transaction.category_id, + extract('year', Transaction.date).label('yr'), + extract('month', Transaction.date).label('mo'), + func.sum(Transaction.amount).label('total'), + ).filter( + Transaction.transaction_type == 'expense', + Transaction.date >= base_start, + Transaction.date <= base_end, + Transaction.category_id.isnot(None), + ).group_by( + Transaction.category_id, + extract('year', Transaction.date), + extract('month', Transaction.date), + ).all() + + cat_month_totals = defaultdict(list) + for r in rows: + cat_month_totals[r.category_id].append(float(r.total)) + + cat_avg = { + cid: sum(totals) / len(totals) + for cid, totals in cat_month_totals.items() + } + + # Recent transactions in the window + recent = ( + Transaction.query + .filter( + Transaction.transaction_type == 'expense', + Transaction.date >= window_start, + Transaction.date <= today, + Transaction.category_id.isnot(None), + ) + .order_by(Transaction.date.desc()) + .all() + ) + + anomalies = [] + for txn in recent: + avg = cat_avg.get(txn.category_id) + amt = float(txn.amount) + if avg and avg >= min_avg and amt >= min_amount and amt > avg * multiplier: + anomalies.append({ + 'id': txn.id, + 'date': txn.date.strftime('%b %d'), + 'description': txn.description, + 'amount': amt, + 'category': txn.category.name if txn.category else 'Other', + 'category_color': txn.category.color if txn.category else '#94a3b8', + 'category_icon': txn.category.icon if txn.category else 'bi-tag', + 'avg': round(avg, 2), + 'multiple': round(amt / avg, 1), + }) + + # Sort by multiple desc (biggest outliers first), cap at 5 + anomalies.sort(key=lambda a: a['multiple'], reverse=True) + return anomalies[:5] + + # ── Snapshot helpers ────────────────────────────────────────────────────────── def take_net_worth_snapshot(): diff --git a/app/templates/base.html b/app/templates/base.html index 8bb4cbf..d13b96e 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -169,7 +169,10 @@ .table-wrap, .pcard.p-0 { overflow-x: auto; -webkit-overflow-scrolling: touch; } /* Remove h-100 height constraint on table wrappers so overflow-x works */ .pcard.p-0.h-100 { height: auto !important; } - .pfm-table { min-width: 560px; } + /* Default min-width — hides .d-mob-none columns first, then scroll */ + .pfm-table { min-width: 420px; } + /* Wider tables (investments, etc.) can opt in to more space */ + .pfm-table.wide { min-width: 700px; } /* Hide low-priority columns */ .d-mob-none { display: none !important; } /* Topbar title: truncate so action buttons always fit */ @@ -187,6 +190,10 @@ .tb-right .btn { padding-left: 8px; padding-right: 8px; } .stat-card .stat-value { font-size: 16px; } } + /* Keyboard shortcut cheatsheet modal */ + #kbd-modal .kbd-row { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 13px; } + #kbd-modal .kbd-row:last-child { border: none; } + #kbd-modal kbd { background: #f1f5f9; border: 1px solid #cbd5e1; border-radius: 4px; padding: 2px 7px; font-size: 12px; font-family: 'DM Mono', monospace; } .sb-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 1039; } .sb-overlay.on { display: block; } @@ -311,6 +318,12 @@ {% block page_title %}{% endblock %}