feat: analysis view with spending trends, breakdowns, and forecasting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
564
src/ui/analysis_view.py
Normal file
564
src/ui/analysis_view.py
Normal 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")
|
||||
Reference in New Issue
Block a user