# 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")