feat: analysis view with spending trends, breakdowns, and forecasting

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 14:53:04 -05:00
parent cfde077055
commit a3fae3fa95

564
src/ui/analysis_view.py Normal file
View File

@@ -0,0 +1,564 @@
# src/ui/analysis_view.py
"""Analysis view with three tabs: Spending Over Time, Category Breakdown, Forecasting."""
from __future__ import annotations
import datetime
from collections import defaultdict
import numpy as np
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
QHBoxLayout,
QTabWidget,
QPushButton,
QButtonGroup,
QLabel,
QComboBox,
QDateEdit,
QCheckBox,
QScrollArea,
QFrame,
)
from PySide6.QtCore import QDate
from sqlalchemy import func
from sqlalchemy.orm import Session
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg, NavigationToolbar2QT
from matplotlib.figure import Figure
from matplotlib.patches import Circle
from src.models.account import Account
from src.models.category import Category
from src.models.household import HouseholdMember
from src.models.transaction import Transaction
from src.services.analysis import AnalysisService
from src.services.forecasting import ForecastingService
from src.services.recurring import RecurringDetector
# ---------------------------------------------------------------------------
# Dark-theme constants
# ---------------------------------------------------------------------------
BG_COLOR = "#1e1e2e"
TEXT_COLOR = "#cdd6f4"
GRID_COLOR = "#45475a"
# Category color palette — consistent across all charts
CATEGORY_COLORS = [
"#f38ba8", # red
"#fab387", # peach
"#f9e2af", # yellow
"#a6e3a1", # green
"#94e2d5", # teal
"#89b4fa", # blue
"#b4befe", # lavender
"#cba6f7", # mauve
"#f5c2e7", # pink
"#eba0ac", # maroon
"#89dceb", # sky
"#74c7ec", # sapphire
"#f2cdcd", # flamingo
"#f5e0dc", # rosewater
]
TAG_COLORS = {
"need": "#a6e3a1",
"want": "#f38ba8",
"savings": "#89b4fa",
}
def _color_for(index: int) -> str:
return CATEGORY_COLORS[index % len(CATEGORY_COLORS)]
def _style_ax(ax):
"""Apply dark theme to a matplotlib Axes."""
ax.set_facecolor(BG_COLOR)
ax.tick_params(colors=TEXT_COLOR, which="both")
for spine in ax.spines.values():
spine.set_color(GRID_COLOR)
ax.xaxis.label.set_color(TEXT_COLOR)
ax.yaxis.label.set_color(TEXT_COLOR)
ax.title.set_color(TEXT_COLOR)
def _style_figure(fig: Figure):
fig.set_facecolor(BG_COLOR)
# ---------------------------------------------------------------------------
# Filter bar used across tabs
# ---------------------------------------------------------------------------
class _FilterBar(QWidget):
def __init__(self, session: Session, parent=None):
super().__init__(parent)
self._session = session
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
# Date range
layout.addWidget(QLabel("From:"))
self.date_from = QDateEdit()
self.date_from.setCalendarPopup(True)
self.date_from.setDate(QDate.currentDate().addMonths(-6))
self.date_from.setDisplayFormat("yyyy-MM-dd")
layout.addWidget(self.date_from)
layout.addWidget(QLabel("To:"))
self.date_to = QDateEdit()
self.date_to.setCalendarPopup(True)
self.date_to.setDate(QDate.currentDate())
self.date_to.setDisplayFormat("yyyy-MM-dd")
layout.addWidget(self.date_to)
# Account filter
layout.addWidget(QLabel("Account:"))
self.account_combo = QComboBox()
self.account_combo.addItem("All", None)
for acct in session.query(Account).order_by(Account.name).all():
self.account_combo.addItem(acct.name, acct.id)
layout.addWidget(self.account_combo)
# Person filter
layout.addWidget(QLabel("Person:"))
self.person_combo = QComboBox()
self.person_combo.addItem("All", None)
for member in session.query(HouseholdMember).order_by(HouseholdMember.name).all():
self.person_combo.addItem(member.name, member.id)
layout.addWidget(self.person_combo)
layout.addStretch()
# Convenience ---------------------------------------------------------
def filters(self) -> dict:
"""Return a dict ready for AnalysisService filter kwargs."""
f: dict = {}
qd_from = self.date_from.date()
f["start"] = datetime.date(qd_from.year(), qd_from.month(), qd_from.day())
qd_to = self.date_to.date()
f["end"] = datetime.date(qd_to.year(), qd_to.month(), qd_to.day())
acct = self.account_combo.currentData()
if acct is not None:
f["account_id"] = acct
person = self.person_combo.currentData()
if person is not None:
f["person_id"] = person
return f
# ======================================================================
# Tab 1 — Spending Over Time
# ======================================================================
class _SpendingOverTimeTab(QWidget):
def __init__(self, session: Session, parent=None):
super().__init__(parent)
self._session = session
self._analysis = AnalysisService(session)
root = QVBoxLayout(self)
# Filters
self._filters = _FilterBar(session)
root.addWidget(self._filters)
# Period toggle
period_row = QHBoxLayout()
self._period_group = QButtonGroup(self)
self._period_group.setExclusive(True)
for label, value in [("Day", "day"), ("Week", "week"), ("Month", "month")]:
btn = QPushButton(label)
btn.setCheckable(True)
btn.setProperty("period_value", value)
self._period_group.addButton(btn)
period_row.addWidget(btn)
if value == "month":
btn.setChecked(True)
period_row.addStretch()
root.addLayout(period_row)
# Matplotlib canvas
self._fig = Figure(tight_layout=True)
_style_figure(self._fig)
self._canvas = FigureCanvasQTAgg(self._fig)
self._toolbar = NavigationToolbar2QT(self._canvas, self)
root.addWidget(self._toolbar)
root.addWidget(self._canvas, stretch=1)
# Signals
self._period_group.buttonClicked.connect(lambda _btn: self.refresh())
self._filters.date_from.dateChanged.connect(lambda: self.refresh())
self._filters.date_to.dateChanged.connect(lambda: self.refresh())
self._filters.account_combo.currentIndexChanged.connect(lambda: self.refresh())
self._filters.person_combo.currentIndexChanged.connect(lambda: self.refresh())
self.refresh()
# ------------------------------------------------------------------
def _current_period(self) -> str:
btn = self._period_group.checkedButton()
return btn.property("period_value") if btn else "month"
def refresh(self):
filters = self._filters.filters()
period = self._current_period()
# We need per-category-per-period data for stacked bars.
# Build it via raw query because AnalysisService.spending_by_period
# returns only flat totals.
start = filters.pop("start", None)
end = filters.pop("end", None)
fmt_map = {"month": "%Y-%m", "week": "%Y-W%W", "day": "%Y-%m-%d"}
fmt = fmt_map[period]
q = (
self._session.query(
func.strftime(fmt, Transaction.date).label("period"),
func.coalesce(Category.name, "Uncategorized").label("category"),
func.sum(Transaction.amount).label("total"),
)
.join(Category, Transaction.category_id == Category.id, isouter=True)
.filter(Transaction.is_transfer == False) # noqa: E712
.filter(Transaction.amount < 0)
)
if start:
q = q.filter(Transaction.date >= start)
if end:
q = q.filter(Transaction.date <= end)
if "account_id" in filters:
q = q.filter(Transaction.account_id == filters["account_id"])
if "person_id" in filters:
q = q.filter(Transaction.attributed_to_id == filters["person_id"])
rows = q.group_by("period", "category").order_by("period").all()
# Organize data
periods_set: list[str] = []
cat_data: dict[str, dict[str, float]] = defaultdict(dict)
seen_periods: set[str] = set()
for row in rows:
if row.period not in seen_periods:
periods_set.append(row.period)
seen_periods.add(row.period)
# Use absolute value for chart readability
cat_data[row.category][row.period] = abs(float(row.total))
categories = sorted(cat_data.keys())
cat_color_map = {cat: _color_for(i) for i, cat in enumerate(categories)}
# Draw
self._fig.clear()
ax = self._fig.add_subplot(111)
_style_ax(ax)
if periods_set and categories:
x = np.arange(len(periods_set))
width = 0.7
bottoms = np.zeros(len(periods_set))
for cat in categories:
values = [cat_data[cat].get(p, 0.0) for p in periods_set]
ax.bar(x, values, width, bottom=bottoms, label=cat, color=cat_color_map[cat])
bottoms += np.array(values)
ax.set_xticks(x)
ax.set_xticklabels(periods_set, rotation=45, ha="right", fontsize=7)
ax.set_ylabel("Spending ($)")
ax.set_title(f"Spending Over Time ({period})")
ax.legend(fontsize=7, loc="upper left", framealpha=0.6,
facecolor=BG_COLOR, edgecolor=GRID_COLOR, labelcolor=TEXT_COLOR)
else:
ax.text(0.5, 0.5, "No data for selected filters",
transform=ax.transAxes, ha="center", va="center", color=TEXT_COLOR)
ax.set_title("Spending Over Time")
self._canvas.draw()
# ======================================================================
# Tab 2 — Category Breakdown
# ======================================================================
class _CategoryBreakdownTab(QWidget):
def __init__(self, session: Session, parent=None):
super().__init__(parent)
self._session = session
self._analysis = AnalysisService(session)
root = QVBoxLayout(self)
# Filters
self._filters = _FilterBar(session)
root.addWidget(self._filters)
# Canvas — two subplots side-by-side, plus a third row for tag bar
self._fig = Figure(tight_layout=True)
_style_figure(self._fig)
self._canvas = FigureCanvasQTAgg(self._fig)
self._toolbar = NavigationToolbar2QT(self._canvas, self)
root.addWidget(self._toolbar)
root.addWidget(self._canvas, stretch=1)
# Signals
self._filters.date_from.dateChanged.connect(lambda: self.refresh())
self._filters.date_to.dateChanged.connect(lambda: self.refresh())
self._filters.account_combo.currentIndexChanged.connect(lambda: self.refresh())
self._filters.person_combo.currentIndexChanged.connect(lambda: self.refresh())
self.refresh()
def refresh(self):
filters = self._filters.filters()
cat_data = self._analysis.spending_by_category(**filters)
tag_data = self._analysis.spending_by_tag(**filters)
# Use absolute values
for item in cat_data:
item["total"] = abs(item["total"])
for item in tag_data:
item["total"] = abs(item["total"])
self._fig.clear()
# ---- Donut chart (top-left) ------------------------------------
ax_donut = self._fig.add_subplot(2, 2, 1)
_style_ax(ax_donut)
if cat_data:
labels = [d["category"] for d in cat_data]
sizes = [d["total"] for d in cat_data]
colors = [_color_for(i) for i in range(len(labels))]
wedges, texts, autotexts = ax_donut.pie(
sizes,
labels=None,
autopct="%1.1f%%",
colors=colors,
pctdistance=0.80,
startangle=90,
)
for t in autotexts:
t.set_color(TEXT_COLOR)
t.set_fontsize(7)
# White center -> donut
centre_circle = ax_donut.add_artist(
Circle((0, 0), 0.55, fc=BG_COLOR)
)
ax_donut.set_title("Category Breakdown", fontsize=10)
else:
ax_donut.text(0.5, 0.5, "No data", transform=ax_donut.transAxes,
ha="center", va="center", color=TEXT_COLOR)
# ---- Horizontal bar ranking (top-right) -------------------------
ax_bar = self._fig.add_subplot(2, 2, 2)
_style_ax(ax_bar)
if cat_data:
sorted_cats = sorted(cat_data, key=lambda d: d["total"])
labels_bar = [d["category"] for d in sorted_cats]
values_bar = [d["total"] for d in sorted_cats]
colors_bar = [_color_for(i) for i, _ in enumerate(sorted_cats)]
ax_bar.barh(labels_bar, values_bar, color=colors_bar)
ax_bar.set_xlabel("Spending ($)")
ax_bar.set_title("Categories Ranked", fontsize=10)
ax_bar.tick_params(axis="y", labelsize=7)
else:
ax_bar.text(0.5, 0.5, "No data", transform=ax_bar.transAxes,
ha="center", va="center", color=TEXT_COLOR)
# ---- Needs / Wants / Savings summary bar (bottom, spanning) -----
ax_tag = self._fig.add_subplot(2, 1, 2)
_style_ax(ax_tag)
tag_map = {d["tag"]: d["total"] for d in tag_data}
need_val = tag_map.get("need", 0)
want_val = tag_map.get("want", 0)
savings_val = tag_map.get("savings", 0)
total_tagged = need_val + want_val + savings_val
if total_tagged > 0:
left = 0.0
for tag_label, val, color in [
("Needs", need_val, TAG_COLORS["need"]),
("Wants", want_val, TAG_COLORS["want"]),
("Savings", savings_val, TAG_COLORS["savings"]),
]:
pct = val / total_tagged * 100
ax_tag.barh([""], val, left=left, color=color, label=f"{tag_label} ({pct:.0f}%)")
if val > 0:
ax_tag.text(left + val / 2, 0, f"${val:,.0f}",
ha="center", va="center", color=BG_COLOR, fontsize=8, fontweight="bold")
left += val
ax_tag.legend(fontsize=8, loc="upper right", framealpha=0.6,
facecolor=BG_COLOR, edgecolor=GRID_COLOR, labelcolor=TEXT_COLOR)
ax_tag.set_title("Needs / Wants / Savings", fontsize=10)
ax_tag.set_xlabel("Spending ($)")
ax_tag.set_yticks([])
else:
ax_tag.text(0.5, 0.5, "No tagged data", transform=ax_tag.transAxes,
ha="center", va="center", color=TEXT_COLOR)
ax_tag.set_title("Needs / Wants / Savings", fontsize=10)
self._canvas.draw()
# ======================================================================
# Tab 3 — Forecasting
# ======================================================================
class _ForecastingTab(QWidget):
def __init__(self, session: Session, parent=None):
super().__init__(parent)
self._session = session
self._forecasting = ForecastingService(session)
self._recurring = RecurringDetector(session)
self._analysis = AnalysisService(session)
root = QVBoxLayout(self)
# What-if controls — scrollable list of recurring-charge checkboxes
what_if_label = QLabel("What-if: toggle recurring charges")
what_if_label.setStyleSheet(f"color: {TEXT_COLOR}; font-weight: bold;")
root.addWidget(what_if_label)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setMaximumHeight(140)
scroll.setFrameShape(QFrame.Shape.NoFrame)
self._checkbox_container = QWidget()
self._checkbox_layout = QVBoxLayout(self._checkbox_container)
self._checkbox_layout.setContentsMargins(4, 4, 4, 4)
scroll.setWidget(self._checkbox_container)
root.addWidget(scroll)
self._checkboxes: list[tuple[QCheckBox, dict]] = []
recurring = self._recurring.detect()
for item in recurring:
cb = QCheckBox(f"{item['description']} (${item['typical_amount']:.2f} / {item['frequency']})")
cb.setChecked(True)
cb.stateChanged.connect(lambda _state: self.refresh())
self._checkbox_layout.addWidget(cb)
self._checkboxes.append((cb, item))
if not recurring:
no_data = QLabel("No recurring charges detected.")
no_data.setStyleSheet(f"color: {TEXT_COLOR};")
self._checkbox_layout.addWidget(no_data)
# Canvas — month-ahead (left) and year-ahead (right)
self._fig = Figure(tight_layout=True)
_style_figure(self._fig)
self._canvas = FigureCanvasQTAgg(self._fig)
self._toolbar = NavigationToolbar2QT(self._canvas, self)
root.addWidget(self._toolbar)
root.addWidget(self._canvas, stretch=1)
self.refresh()
# ------------------------------------------------------------------
def _excluded_descriptions(self) -> list[str]:
"""Return descriptions of unchecked recurring charges."""
return [
item["description"]
for cb, item in self._checkboxes
if not cb.isChecked()
]
def refresh(self):
exclude = self._excluded_descriptions()
month_forecast = self._forecasting.forecast_month(exclude_descriptions=exclude or None)
year_forecast = self._forecasting.forecast_year(exclude_descriptions=exclude or None)
# Fetch last 3 actual months for comparison
today = datetime.date.today()
actual_months: list[dict] = []
for offset in [3, 2, 1]:
m_start = (today.replace(day=1) - datetime.timedelta(days=offset * 30)).replace(day=1)
# End of that month
if m_start.month == 12:
m_end = m_start.replace(year=m_start.year + 1, month=1, day=1) - datetime.timedelta(days=1)
else:
m_end = m_start.replace(month=m_start.month + 1, day=1) - datetime.timedelta(days=1)
cats = self._analysis.spending_by_category(start=m_start, end=m_end)
label = m_start.strftime("%Y-%m")
actual_months.append({"label": label, "categories": {c["category"]: abs(c["total"]) for c in cats}})
# Build unified category list
all_cats: set[str] = set()
for am in actual_months:
all_cats.update(am["categories"].keys())
for f in month_forecast:
all_cats.add(f["category"])
categories = sorted(all_cats)
cat_color_map = {cat: _color_for(i) for i, cat in enumerate(categories)}
self._fig.clear()
# ---- Month-ahead (left) — stacked bars: last 3 months + forecast
ax_month = self._fig.add_subplot(1, 2, 1)
_style_ax(ax_month)
bar_labels = [am["label"] for am in actual_months] + ["Forecast"]
x = np.arange(len(bar_labels))
width = 0.6
forecast_map = {f["category"]: abs(f["projected"]) for f in month_forecast}
bottoms = np.zeros(len(bar_labels))
for cat in categories:
vals = []
for am in actual_months:
vals.append(am["categories"].get(cat, 0.0))
vals.append(forecast_map.get(cat, 0.0))
ax_month.bar(x, vals, width, bottom=bottoms, label=cat, color=cat_color_map[cat])
bottoms += np.array(vals)
ax_month.set_xticks(x)
ax_month.set_xticklabels(bar_labels, rotation=30, ha="right", fontsize=8)
ax_month.set_ylabel("Spending ($)")
ax_month.set_title("Month-Ahead Forecast vs Actuals", fontsize=10)
ax_month.legend(fontsize=6, loc="upper left", framealpha=0.6,
facecolor=BG_COLOR, edgecolor=GRID_COLOR, labelcolor=TEXT_COLOR)
# ---- Year-ahead (right) — simple bar chart per category
ax_year = self._fig.add_subplot(1, 2, 2)
_style_ax(ax_year)
if year_forecast:
sorted_yr = sorted(year_forecast, key=lambda f: abs(f["projected"]))
yr_cats = [f["category"] for f in sorted_yr]
yr_vals = [abs(f["projected"]) for f in sorted_yr]
yr_colors = [cat_color_map.get(c, _color_for(i)) for i, c in enumerate(yr_cats)]
ax_year.barh(yr_cats, yr_vals, color=yr_colors)
ax_year.set_xlabel("Projected Annual ($)")
ax_year.set_title("Year-Ahead Projection", fontsize=10)
ax_year.tick_params(axis="y", labelsize=7)
else:
ax_year.text(0.5, 0.5, "No forecast data",
transform=ax_year.transAxes, ha="center", va="center", color=TEXT_COLOR)
ax_year.set_title("Year-Ahead Projection", fontsize=10)
self._canvas.draw()
# ======================================================================
# Main AnalysisView
# ======================================================================
class AnalysisView(QWidget):
"""Three-tab analysis dashboard."""
def __init__(self, session: Session, parent=None):
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(8, 8, 8, 8)
self._tabs = QTabWidget()
layout.addWidget(self._tabs)
self._spending_tab = _SpendingOverTimeTab(session)
self._tabs.addTab(self._spending_tab, "Spending Over Time")
self._category_tab = _CategoryBreakdownTab(session)
self._tabs.addTab(self._category_tab, "Category Breakdown")
self._forecast_tab = _ForecastingTab(session)
self._tabs.addTab(self._forecast_tab, "Forecasting")