dfe62f442a
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>
535 lines
20 KiB
Python
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")
|