Coverage for src/sankey_cashflow/diagram.py: 95%
64 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-05 04:36 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-05 04:36 +0000
1import pandas as pd
2import plotly.graph_objects as go
5def _hover_breakdown(transactions, node_name, hover_field):
6 """
7 Build hover text for a node: average spend/day plus a breakdown by hover_field, for all
8 transactions flowing into that node. Returns "" if there's nothing to break down (eg. a
9 node that's never a Target).
10 """
11 df = transactions.processed_data
12 node_rows = df[df['Target'] == node_name]
13 breakdown = node_rows.groupby([hover_field]).agg({'Amount': 'sum'})
14 if len(breakdown) == 0:
15 return ""
16 days = (df['Date'].max() - df['Date'].min()).days
17 avg_per_day = node_rows['Amount'].sum() / days if days else 0
18 text = f"Avg/day: ${avg_per_day:.2f}<br>-------------------<br>Categories:<br>"
19 for item, amount in breakdown['Amount'].items():
20 text += f"{item}: ${amount:.2f}<br>"
21 return text
24def build_sankey_figure(transactions, labels, app_settings) -> go.Figure:
25 """
26 Build a plotly Sankey figure from processed transaction data. `transactions` must have
27 already been through Transactions.process().
28 """
29 grouped = transactions.grouped_data.copy() # Work on a copy - this function must be safe to call more than once.
30 unique_nodes = list(pd.unique(grouped[['Source', 'Target']].values.ravel('K')))
31 node_index = {name: idx for idx, name in enumerate(unique_nodes)}
33 node_colors = [labels.get_attribute(name, "node_color") for name in unique_nodes]
35 # Link color: prefer the target node's color, falling back to the source node's (or the
36 # sheet's default color if neither is set).
37 link_colors = []
38 for source, target in zip(grouped['Source'], grouped['Target']):
39 color = labels.get_attribute(target, "link_color", use_default=False)
40 if not color:
41 color = labels.get_attribute(source, "link_color")
42 link_colors.append(color)
44 node_settings = {
45 'pad': 15,
46 'thickness': 20,
47 'line': dict(color='black', width=0.5),
48 'label': unique_nodes,
49 'color': node_colors,
50 }
52 if app_settings.hover:
53 node_settings['customdata'] = [
54 _hover_breakdown(transactions, name, app_settings.hover) for name in unique_nodes
55 ]
56 node_settings['hovertemplate'] = 'Total: %{value}<br>%{customdata}<extra></extra>'
58 fig = go.Figure(data=[go.Sankey(
59 valueformat="$.2f",
60 node=node_settings,
61 link=dict(
62 source=grouped['Source'].map(node_index),
63 target=grouped['Target'].map(node_index),
64 value=grouped['Amount'],
65 color=link_colors,
66 )
67 )])
69 title = go.layout.Title({'font': {'family': 'Courier New', 'size': 12}, 'text': transactions.title})
70 fig.update_layout(title=title)
71 return fig
74def _sum_row_amount(row):
75 """
76 Sum Amount + Sales Tax + Tips for a row, treating unparseable/missing values as zero.
77 """
78 total = 0
79 for col in ("Amount", "Sales Tax", "Tips"):
80 try:
81 total += float(row[col])
82 except (ValueError, TypeError):
83 pass
84 return total
87def build_line_figure(transactions, app_settings) -> go.Figure:
88 """
89 Build a plotly line chart of spend over time, grouped by Classification.
91 NOTE: less mature than build_sankey_figure() - can get noisy for large datasets, doesn't
92 fill in zero-value gaps for categories with sparse activity, and doesn't support a log
93 scale. 'Income', 'Uncategorized', and any classification prefixed with 'x' (a convention
94 used in sample_data/labels.csv, eg. 'xEntertainment') are hidden by default.
95 """
96 df = transactions.processed_data.assign(**{"Total Amount": None})
97 df["Total Amount"] = df.apply(_sum_row_amount, axis=1)
99 classifications = sorted(df["Classification"].unique())
100 date_idx = pd.date_range(start=df["Date"].min(), end=df["Date"].max())
102 fig = go.Figure()
103 for classification in classifications:
104 if classification in ("Income", "Uncategorized"):
105 continue
106 if classification.startswith('x'):
107 continue
108 series = df[df["Classification"] == classification].groupby('Date')["Total Amount"].sum()
109 series.index = pd.DatetimeIndex(series.index)
110 series = series.reindex(date_idx, fill_value=float("nan")) # connectgaps needs NaN, not 0
111 if app_settings.chart_resolution == 'day':
112 series = series.resample('D', label='left').sum()
113 elif app_settings.chart_resolution == 'week':
114 series = series.resample('W', label='left').sum()
115 elif app_settings.chart_resolution == 'month':
116 series = series.resample('ME', label='left').sum()
117 series = series.replace(0, float("nan"))
118 fig.add_trace(go.Scatter(
119 y=series.to_list(), x=series.index.to_list(), mode='lines', name=classification, connectgaps=True
120 ))
121 return fig