feat: add Sankey "Money Flow" chart to analysis tab

Three-column Sankey diagram (Income → Categories → Tags) rendered with
custom matplotlib bezier paths. Features gradient-fill bands that
interpolate color from source to destination nodes, neon glow effects
on nodes and bands, and a surplus node when income exceeds expenses.

Also fixes QFont::setPointSize warning by switching dark.qss font-size
units from px to pt, preventing Qt delegate code from receiving -1
point sizes during inline cell editing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 22:44:43 -05:00
parent 41e1d452e2
commit b04d6d90fa
4 changed files with 497 additions and 11 deletions

View File

@@ -0,0 +1,114 @@
# Sankey "Money Flow" Chart — Design
## Summary
Add a new "Money Flow" tab to the Analysis view that renders a three-column Sankey diagram showing how income flows through spending categories into needs/wants/savings tags. Rendered entirely with matplotlib using custom bezier path drawing — no new dependencies.
## Data Structure
Three columns of nodes connected by curved bands:
```
Income ──► Categories ──► Tags
```
- **Left**: Single "Income" node — sum of all positive-amount transactions in the filtered period.
- **Middle**: One node per spending category — sized by total absolute spend. Plus a "Surplus" node if income exceeds total expenses.
- **Right**: Needs, Wants, Savings, Untagged — sized by total absolute spend per tag.
Bands represent dollar amounts flowing between nodes. Thickness is proportional to amount. All values come from `AnalysisService` queries filtered by the standard `_FilterBar` (date range, account, person).
## Visual Design
### Theme Integration (Cyberpunk "Matrix 2026")
- Background: `VOID` (#0a0a0f), matching all other charts
- Axes styled via existing `_style_ax()` / `_style_figure()` helpers
- Color palette: reuses `CHART_COLORS`, `TAG_COLORS`, `NEON_GREEN`, `NEON_AMBER`
### Nodes
- Thin vertical rectangles (width ~0.02 in axes coords)
- Fill: node color at 60% opacity
- Border: node color at full opacity, 1.5px
- Glow: 2-3 progressively wider/more-transparent copies behind for neon bloom
Colors:
- Income node: `NEON_GREEN` (#00ff41)
- Category nodes: `CHART_COLORS` palette (same as existing charts)
- Tag nodes: `TAG_COLORS` (needs=green, wants=magenta, savings=cyan)
- Surplus node: `NEON_AMBER` (#ffb627)
- Untagged node: `TEXT_DIM` (#4a5568)
### Bands (Flows)
- Smooth cubic bezier curves (matplotlib `Path` with `CURVE4` codes)
- **Gradient fill**: Each band sliced into ~50 vertical strips, color interpolated from source to destination node color
- **Glow effect**: A wider, more-transparent copy of each band rendered behind it for neon bloom
- Alpha: ~0.35 for main band, ~0.08-0.15 for glow passes
- Sorting: Largest bands drawn first (back), smallest on top
### Labels
- Node name + dollar amount (e.g., "Groceries $1,234")
- Left column labels: right-aligned, to the left of the node
- Middle column labels: centered below/above nodes
- Right column labels: left-aligned, to the right of the node
- Font: `TEXT` color (#b8c4d4), fontsize 8
- Dollar amounts: bold
### Layout
- Three columns at x = 0.1, 0.5, 0.9 (in axes normalized coords)
- Nodes stacked vertically, sorted largest-on-top
- Small gaps between nodes (~2% of total height)
- Axes limits: x=[0, 1], y=[0, 1], all axes/spines hidden
## Implementation
### New Class: `_MoneyFlowTab(QWidget)`
Follows the same pattern as `_SpendingOverTimeTab`, `_CategoryBreakdownTab`, and `_ForecastingTab`:
```python
class _MoneyFlowTab(QWidget):
def __init__(self, session, parent=None):
# _FilterBar, Figure, FigureCanvasQTAgg, NavigationToolbar2QT
# Connect filter signals to refresh()
def refresh(self):
# 1. Query income total, category totals, tag totals
# 2. Build node positions and sizes
# 3. Draw nodes, bands, labels via _draw_sankey()
```
### Helper: `_draw_sankey(ax, income, categories, tags)`
Separated rendering logic for testability and clarity:
1. Calculate node heights proportional to amounts
2. Position nodes in three columns with vertical gaps
3. For each flow (income→category, category→tag): draw gradient bezier bands with glow
4. Draw node rectangles with glow
5. Draw labels
### Data Queries
Reuses existing `AnalysisService` methods:
- `spending_by_category()` — for category totals
- `spending_by_tag()` — for tag totals
- Direct query for income total (sum of positive amounts in filtered period)
- Cross-query: category-to-tag amounts (new query needed — group by category_id + tag)
### Tab Placement
4th tab in `AnalysisView._tabs`: "Money Flow" — inserted after "Category Breakdown", before "Forecasting".
## Files Changed
- `src/ui/analysis_view.py` — add `_MoneyFlowTab` class and register in `AnalysisView.__init__`
- `src/services/analysis.py` — add `spending_by_category_and_tag()` method for the category→tag cross-query
## No New Dependencies
Everything uses matplotlib's existing `PathPatch`, `Path`, `Rectangle`, and color utilities — all already available via the `matplotlib>=3.8` dependency.

View File

@@ -70,6 +70,20 @@ class AnalysisService:
)
return [{"category": r.category, "total": float(r.total), "count": r.count} for r in q.all()]
def spending_by_category_and_tag(self, start=None, end=None, **filters) -> list[dict]:
q = self._base_query(start=start, end=end, **filters)
q = (
q.join(Category, Transaction.category_id == Category.id, isouter=True)
.with_entities(
func.coalesce(Category.name, "Uncategorized").label("category"),
func.coalesce(Transaction.tag, "untagged").label("tag"),
func.sum(Transaction.amount).label("total"),
)
.group_by("category", "tag")
.order_by(func.sum(Transaction.amount))
)
return [{"category": r.category, "tag": r.tag, "total": float(r.total)} for r in q.all()]
def spending_by_tag(self, start=None, end=None, **filters) -> list[dict]:
q = self._base_query(start=start, end=end, **filters)
q = (

View File

@@ -1,5 +1,5 @@
# src/ui/analysis_view.py
"""Analysis view with three tabs: Spending Over Time, Category Breakdown, Forecasting."""
"""Analysis view with four tabs: Spending Over Time, Category Breakdown, Money Flow, Forecasting."""
from __future__ import annotations
@@ -27,8 +27,10 @@ from sqlalchemy import func
from sqlalchemy.orm import Session
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg, NavigationToolbar2QT
from matplotlib.colors import to_rgba
from matplotlib.figure import Figure
from matplotlib.patches import Circle
from matplotlib.patches import Circle, PathPatch, Rectangle
from matplotlib.path import Path as MplPath
from src.models.account import Account
from src.models.category import Category
@@ -38,7 +40,7 @@ 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,
VOID, SURFACE, TEXT, TEXT_BRIGHT, TEXT_DIM, BORDER, NEON_GREEN, NEON_AMBER,
CHART_COLORS, TAG_COLORS, chart_color,
)
@@ -510,11 +512,364 @@ class _ForecastingTab(QWidget):
self._canvas.draw()
# ======================================================================
# Tab 4 -- Money Flow (Sankey)
# ======================================================================
# -- Sankey drawing helpers ------------------------------------------------
def _bezier_band(ax, x0, y0_top, y0_bot, x1, y1_top, y1_bot,
color_src, color_dst, n_slices=50, alpha=0.35, glow=True):
"""Draw a gradient-filled bezier band between two vertical positions.
The band curves from (x0, y0) to (x1, y1) using cubic bezier control
points at the horizontal midpoint. Gradient is achieved by slicing
the band into thin vertical strips with interpolated colors.
"""
cx = (x0 + x1) / 2 # bezier control-point x
rgba_src = to_rgba(color_src, alpha)
rgba_dst = to_rgba(color_dst, alpha)
# Glow pass — wider, more transparent, drawn first
if glow:
glow_expand = (y0_top - y0_bot) * 0.15 # expand by 15% of band height
for g_alpha_mult, g_expand_mult in [(0.08, 3.0), (0.15, 1.5)]:
ge = glow_expand * g_expand_mult
_bezier_band(ax,
x0, y0_top + ge, y0_bot - ge,
x1, y1_top + ge, y1_bot - ge,
color_src, color_dst,
n_slices=max(20, n_slices // 2),
alpha=g_alpha_mult, glow=False)
# Slice the band into vertical strips for gradient effect
for i in range(n_slices):
t0 = i / n_slices
t1 = (i + 1) / n_slices
# Interpolate color
t_mid = (t0 + t1) / 2
r = rgba_src[0] + (rgba_dst[0] - rgba_src[0]) * t_mid
g = rgba_src[1] + (rgba_dst[1] - rgba_src[1]) * t_mid
b = rgba_src[2] + (rgba_dst[2] - rgba_src[2]) * t_mid
a = rgba_src[3] + (rgba_dst[3] - rgba_src[3]) * t_mid
color = (r, g, b, a)
# Evaluate bezier curve at t0 and t1 for top and bottom edges
def _bezier_y(t, ya, yb):
"""Cubic bezier y-value with control points at midpoint."""
# P0=ya, P1=ya, P2=yb, P3=yb (smooth S-curve)
return ya * (1 - t) ** 3 + ya * 3 * (1 - t) ** 2 * t + yb * 3 * (1 - t) * t ** 2 + yb * t ** 3
def _bezier_x(t):
return x0 * (1 - t) ** 3 + cx * 3 * (1 - t) ** 2 * t + cx * 3 * (1 - t) * t ** 2 + x1 * t ** 3
xl = _bezier_x(t0)
xr = _bezier_x(t1)
yt0_l = _bezier_y(t0, y0_top, y1_top)
yb0_l = _bezier_y(t0, y0_bot, y1_bot)
yt0_r = _bezier_y(t1, y0_top, y1_top)
yb0_r = _bezier_y(t1, y0_bot, y1_bot)
# Draw filled quad
verts = [(xl, yb0_l), (xl, yt0_l), (xr, yt0_r), (xr, yb0_r), (xl, yb0_l)]
codes = [MplPath.MOVETO, MplPath.LINETO, MplPath.LINETO, MplPath.LINETO, MplPath.CLOSEPOLY]
patch = PathPatch(MplPath(verts, codes), facecolor=color, edgecolor="none", linewidth=0)
ax.add_patch(patch)
def _draw_node(ax, x, y_bot, height, color, width=0.03, glow=True):
"""Draw a glowing node rectangle."""
rgba_fill = to_rgba(color, 0.6)
rgba_edge = to_rgba(color, 1.0)
if glow:
for g_alpha, g_pad in [(0.06, 0.012), (0.12, 0.006)]:
glow_rect = Rectangle(
(x - width / 2 - g_pad, y_bot - g_pad),
width + 2 * g_pad, height + 2 * g_pad,
facecolor=to_rgba(color, g_alpha), edgecolor="none", linewidth=0,
)
ax.add_patch(glow_rect)
rect = Rectangle(
(x - width / 2, y_bot), width, height,
facecolor=rgba_fill, edgecolor=rgba_edge, linewidth=1.5,
)
ax.add_patch(rect)
def _draw_sankey(ax, income_total, cat_totals, tag_totals, cat_tag_totals):
"""Render the full Sankey diagram on the given axes.
Parameters
----------
income_total : float
Total income (positive amount sum).
cat_totals : list[dict]
Each dict has 'category' and 'total' (positive).
tag_totals : list[dict]
Each dict has 'tag' and 'total' (positive).
cat_tag_totals : list[dict]
Each dict has 'category', 'tag', and 'total' (positive).
"""
ax.set_xlim(-0.15, 1.15)
ax.set_ylim(-0.05, 1.05)
ax.axis("off")
_style_ax(ax)
expense_total = sum(c["total"] for c in cat_totals)
surplus = max(0, income_total - expense_total)
if income_total <= 0:
ax.text(0.5, 0.5, "No income data for selected filters",
ha="center", va="center", color=TEXT_COLOR, fontsize=10,
transform=ax.transAxes)
return
# Column x-positions
x_left, x_mid, x_right = 0.08, 0.50, 0.92
node_gap = 0.012 # vertical gap between nodes
node_w = 0.03
# -- Build nodes -------------------------------------------------------
# Left: single Income node
usable_height = 0.9 # leave margins top/bottom
# Middle: categories + surplus, sorted by total descending
mid_items = sorted(cat_totals, key=lambda d: d["total"], reverse=True)
if surplus > 0:
mid_items.append({"category": "Surplus", "total": surplus})
mid_total = sum(d["total"] for d in mid_items)
# Right: tags, sorted by total descending
right_items = sorted(tag_totals, key=lambda d: d["total"], reverse=True)
right_total = sum(d["total"] for d in right_items)
# Assign colors
cat_colors = {}
for i, item in enumerate(mid_items):
if item["category"] == "Surplus":
cat_colors["Surplus"] = NEON_AMBER
else:
cat_colors[item["category"]] = chart_color(i)
tag_color_map = {
"needs": TAG_COLORS.get("need", NEON_GREEN),
"wants": TAG_COLORS.get("want", NEON_GREEN),
"savings": TAG_COLORS.get("savings", NEON_GREEN),
"untagged": TEXT_DIM,
}
# -- Compute vertical positions ----------------------------------------
def _layout_column(items, total_val, key="total"):
"""Return list of (y_bot, y_top) for each item, stacked top-to-bottom."""
n = len(items)
total_gap = node_gap * max(0, n - 1)
available = usable_height - total_gap
positions = []
y = 0.95 # start from top
for item in items:
frac = item[key] / total_val if total_val > 0 else 1.0 / max(n, 1)
h = available * frac
y_top = y
y_bot = y - h
positions.append((y_bot, y_top))
y = y_bot - node_gap
return positions
# Income — single node spanning full usable height
inc_positions = [(0.95 - usable_height, 0.95)]
# Middle nodes
mid_positions = _layout_column(mid_items, mid_total)
# Right nodes
right_positions = _layout_column(right_items, right_total)
# -- Draw bands (Income → Category) ------------------------------------
# Track current y-offset within income node for stacking bands
inc_y_cursor = inc_positions[0][1] # start from top of income node
inc_height = inc_positions[0][1] - inc_positions[0][0]
for i, item in enumerate(mid_items):
cat_name = item["category"]
band_frac = item["total"] / income_total if income_total > 0 else 0
band_h = inc_height * band_frac
src_top = inc_y_cursor
src_bot = inc_y_cursor - band_h
inc_y_cursor = src_bot
dst_bot, dst_top = mid_positions[i]
color = cat_colors.get(cat_name, chart_color(i))
_bezier_band(ax, x_left + node_w / 2, src_top, src_bot,
x_mid - node_w / 2, dst_top, dst_bot,
NEON_GREEN, color)
# -- Draw bands (Category → Tag) ---------------------------------------
# Build a lookup: {category: {tag: amount}}
cat_tag_map = defaultdict(lambda: defaultdict(float))
for item in cat_tag_totals:
cat_tag_map[item["category"]][item["tag"]] = item["total"]
# For each category, split its outgoing flow into tag bands
tag_y_cursors = {} # track where each tag's incoming bands stack
for j, t_item in enumerate(right_items):
tag_y_cursors[t_item["tag"]] = right_positions[j][1] # start from top
for i, item in enumerate(mid_items):
cat_name = item["category"]
if cat_name == "Surplus":
continue # surplus doesn't flow to tags
cat_bot, cat_top = mid_positions[i]
cat_height = cat_top - cat_bot
cat_y_cursor = cat_top
tag_flows = cat_tag_map.get(cat_name, {})
cat_flow_total = sum(tag_flows.values())
# Draw band for each tag
for t_item in right_items:
tag_name = t_item["tag"]
flow_amt = tag_flows.get(tag_name, 0)
if flow_amt <= 0:
continue
band_frac = flow_amt / cat_flow_total if cat_flow_total > 0 else 0
band_h_src = cat_height * band_frac
tag_total = t_item["total"]
tag_idx = next(j for j, r in enumerate(right_items) if r["tag"] == tag_name)
tag_bot, tag_top = right_positions[tag_idx]
tag_height = tag_top - tag_bot
band_h_dst = tag_height * (flow_amt / tag_total) if tag_total > 0 else 0
src_top = cat_y_cursor
src_bot = cat_y_cursor - band_h_src
cat_y_cursor = src_bot
dst_top = tag_y_cursors[tag_name]
dst_bot = dst_top - band_h_dst
tag_y_cursors[tag_name] = dst_bot
src_color = cat_colors.get(cat_name, chart_color(i))
dst_color = tag_color_map.get(tag_name, TEXT_DIM)
_bezier_band(ax, x_mid + node_w / 2, src_top, src_bot,
x_right - node_w / 2, dst_top, dst_bot,
src_color, dst_color)
# -- Draw nodes --------------------------------------------------------
# Income
_draw_node(ax, x_left, inc_positions[0][0],
inc_positions[0][1] - inc_positions[0][0], NEON_GREEN, node_w)
ax.text(x_left - 0.04, (inc_positions[0][0] + inc_positions[0][1]) / 2,
f"Income\n${income_total:,.0f}", ha="right", va="center",
color=NEON_GREEN, fontsize=8, fontweight="bold")
# Categories
for i, item in enumerate(mid_items):
cat_bot, cat_top = mid_positions[i]
color = cat_colors.get(item["category"], chart_color(i))
_draw_node(ax, x_mid, cat_bot, cat_top - cat_bot, color, node_w)
label = item["category"]
if len(label) > 14:
label = label[:12] + ".."
ax.text(x_mid, cat_bot - 0.008, f"{label}\n${item['total']:,.0f}",
ha="center", va="top", color=TEXT_COLOR, fontsize=6.5)
# Tags
for j, t_item in enumerate(right_items):
tag_bot, tag_top = right_positions[j]
tag_name = t_item["tag"]
color = tag_color_map.get(tag_name, TEXT_DIM)
_draw_node(ax, x_right, tag_bot, tag_top - tag_bot, color, node_w)
display_name = tag_name.capitalize()
ax.text(x_right + 0.04, (tag_bot + tag_top) / 2,
f"{display_name}\n${t_item['total']:,.0f}", ha="left", va="center",
color=color, fontsize=8, fontweight="bold")
class _MoneyFlowTab(QWidget):
"""Sankey diagram showing Income → Categories → Tags."""
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()
# Query income (positive amounts, excluding transfers)
start = filters.get("start")
end = filters.get("end")
query_kwargs = {}
if "account_id" in filters:
query_kwargs["account_id"] = filters["account_id"]
if "person_id" in filters:
query_kwargs["person_id"] = filters["person_id"]
income_q = self._analysis._base_query(start=start, end=end, **query_kwargs)
income_q = income_q.filter(Transaction.amount > 0)
income_total = sum(float(t.amount) for t in income_q.all())
# Category spending (expenses only)
cat_data = self._analysis.spending_by_category(start=start, end=end, **query_kwargs)
cat_totals = [{"category": d["category"], "total": abs(d["total"])}
for d in cat_data if d["total"] < 0]
# Tag spending
tag_data = self._analysis.spending_by_tag(start=start, end=end, **query_kwargs)
tag_totals = [{"tag": d["tag"], "total": abs(d["total"])}
for d in tag_data if d["total"] < 0]
# Category × Tag cross data
cat_tag_data = self._analysis.spending_by_category_and_tag(
start=start, end=end, **query_kwargs
)
cat_tag_totals = [{"category": d["category"], "tag": d["tag"], "total": abs(d["total"])}
for d in cat_tag_data if d["total"] < 0]
# Draw
self._fig.clear()
ax = self._fig.add_subplot(111)
_style_ax(ax)
_style_figure(self._fig)
_draw_sankey(ax, income_total, cat_totals, tag_totals, cat_tag_totals)
self._canvas.draw()
# ======================================================================
# Main AnalysisView
# ======================================================================
class AnalysisView(QWidget):
"""Three-tab analysis dashboard."""
"""Four-tab analysis dashboard."""
def __init__(self, session: Session, parent=None):
super().__init__(parent)
@@ -530,5 +885,8 @@ class AnalysisView(QWidget):
self._category_tab = _CategoryBreakdownTab(session)
self._tabs.addTab(self._category_tab, "Category Breakdown")
self._flow_tab = _MoneyFlowTab(session)
self._tabs.addTab(self._flow_tab, "Money Flow")
self._forecast_tab = _ForecastingTab(session)
self._tabs.addTab(self._forecast_tab, "Forecasting")

View File

@@ -10,7 +10,7 @@ QMainWindow, QWidget {
background-color: #0a0a0f;
color: #b8c4d4;
font-family: "JetBrains Mono", "Cascadia Code", "Consolas", monospace;
font-size: 13px;
font-size: 10pt;
}
QLabel {
@@ -32,7 +32,7 @@ QLabel {
border-left: 3px solid transparent;
padding: 12px 16px;
text-align: left;
font-size: 13px;
font-size: 10pt;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 1px;
@@ -60,7 +60,7 @@ QPushButton {
border-radius: 4px;
padding: 8px 16px;
font-weight: bold;
font-size: 13px;
font-size: 10pt;
}
QPushButton:hover {
@@ -85,7 +85,7 @@ QTableView QPushButton, QTableWidget QPushButton {
border: 1px solid #00ff41;
background-color: transparent;
padding: 3px 10px;
font-size: 12px;
font-size: 9pt;
border-radius: 3px;
}
@@ -126,7 +126,7 @@ QHeaderView::section {
border: none;
border-bottom: 2px solid #00ff41;
border-right: 1px solid #1a2332;
font-size: 13px;
font-size: 10pt;
font-weight: bold;
}
@@ -165,7 +165,7 @@ QComboBox QAbstractItemView {
background-color: #0d1117;
color: #b8c4d4;
border: 1px solid #00ff41;
font-size: 13px;
font-size: 10pt;
selection-background-color: rgba(0,255,65,0.15);
selection-color: #e8edf5;
}
@@ -233,7 +233,7 @@ QCalendarWidget QToolButton:hover {
QCalendarWidget QAbstractItemView {
background-color: #0d1117;
color: #b8c4d4;
font-size: 13px;
font-size: 10pt;
selection-background-color: rgba(0,255,65,0.15);
selection-color: #e8edf5;
}