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:
114
docs/plans/2026-02-10-sankey-money-flow-design.md
Normal file
114
docs/plans/2026-02-10-sankey-money-flow-design.md
Normal 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.
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user