06/05 Optimize app: report upgrades

This commit is contained in:
2026-06-05 14:22:19 -04:00
parent 2f62417c9f
commit 04acefd9f8
6 changed files with 486 additions and 25 deletions
+194 -9
View File
@@ -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():