diff --git a/src/ui/analysis_view.py b/src/ui/analysis_view.py new file mode 100644 index 0000000..18d57cf --- /dev/null +++ b/src/ui/analysis_view.py @@ -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")