Files
SpendingAnalysis/src/ui/analysis_view.py
T
andy dfe62f442a feat: cyberpunk "Matrix 2026" UI redesign
Replace Catppuccin theme with single dark cyberpunk palette featuring
neon green accents, monospace fonts, and void-dark backgrounds. Remove
light theme toggle entirely. Fix hardcoded inline colors across all
views to use centralized palette constants. Fix table column widths
in transactions, recurring, settings, and CSV mappings views. Add
design system documentation for future development.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 16:10:29 -05:00

535 lines
20 KiB
Python

# 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
from src.ui.themes import (
VOID, SURFACE, TEXT, TEXT_BRIGHT, TEXT_DIM, BORDER, NEON_GREEN,
CHART_COLORS, TAG_COLORS, chart_color,
)
# ---------------------------------------------------------------------------
# Matplotlib theme helpers
# ---------------------------------------------------------------------------
BG_COLOR = VOID
TEXT_COLOR = TEXT
GRID_COLOR = BORDER
def _style_ax(ax):
"""Apply cyberpunk 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_BRIGHT)
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()
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)
cat_data[row.category][row.period] = abs(float(row.total))
categories = sorted(cat_data.keys())
cat_color_map = {cat: chart_color(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
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 = [chart_color(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)
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 = [chart_color(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
what_if_label = QLabel("What-if: toggle recurring charges")
what_if_label.setStyleSheet(f"color: {TEXT_BRIGHT}; 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_DIM};")
self._checkbox_layout.addWidget(no_data)
# 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)
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)
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: chart_color(i) for i, cat in enumerate(categories)}
self._fig.clear()
# ---- Month-ahead (left)
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)
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, chart_color(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")