Rebuild the overview around where the money actually went

The page reported three totals, a net-per-month bar chart and six ranked lists.
That answers how much moved, never where it went, and the one chart carrying a
shape printed its full formatted amount above every 43px column: eleven values
collided into a single line of text, the dates read as 2025-11, and negative
months were grey while positive ones were green, so the sign of a month was the
one thing the colour did not say. The dashboard now answers four questions in
the order a person asks them - am I ahead, where did it go, what changed, and
what is committed - and every panel is a click into the transactions behind it.

Monthly cash flow becomes a measured SVG chart: income drawn above the zero
line in the brand green, spending below it in the danger red, and net as a line
whose dot takes the colour of its sign. Exact figures move into a hover tooltip
that names the month, both directions, the net and the transaction count, so the
plot area carries a y-axis of about three gridlines a side instead of eleven
overlapping labels, and the month axis prints Nov with the year only where the
year changes. The chart measures its own content box through a ResizeObserver
and draws at real pixel size rather than scaling a viewBox, because scaled axis
text is the wrong weight at every width except one. A month with no activity is
filled in as an explicit zero: it is a real answer, not a gap to close.

A six-month window is the default view, long enough to show a trend and a
seasonal bill and short enough that the current month still matters. The window
starts on the first of a month so the buckets are whole, leaves its upper bound
open so it always reaches today, and lives in the shared filter bar next to
1M/3M/12M/YTD/All, so the transactions page inherits the same framing. Reset
returns to six months rather than to all of history.

Where the money went is a Sankey, because the question is literally a flow: the
income categories a user named, through one trunk, into the categories that
consumed it. Both columns balance by construction - a surplus is a node called
Left over on the right, a deficit is one called Drawn from reserves feeding the
trunk from the left - so an overspend is visible as money entering from outside
the period rather than as a total that silently fails to add up. Ancestor
rollups already include their descendants, so a root's unexplained remainder
becomes its own slice and the columns stay honest. Beside it, a donut ranks the
same spending by share, and the category tree keeps the drill-down it had.

What changed compares spending per leaf category against the preceding interval,
which required the analytics index to return that interval's categories as well:
categoryGroups is now a constant run over both filters, so the two sides of a
delta cannot disagree about how a parent rolls up. Monthly stops being a list of
Group rows carrying only a net and becomes MonthlyPoint, with income and
spending as separate positive magnitudes and net as the only signed figure,
which is what a two-sided chart needs and what a single SUM could not give.

Biggest payments keeps one row per payee. Ranking outflows by amount returned
the same rent six times, which explains nothing; the window now picks each
merchant's single largest payment before the per-currency ranking, and a fact
with no merchant competes as itself. Both windows order by the DECIMAL column
rather than by its VARCHAR rendering, which would sort -0.0009 ahead of
-900719925474.0991. The per-currency partition stays: one busy currency must not
crowd another out of its own list.

Two figures are withheld rather than printed wrong. A savings rate is net over
income, and a part-month carrying only an interest credit read -10268% kept;
below -100% the outflow was more than twice the income and that sentence is the
answer, so the ratio is replaced by it. Period-over-period change truncates
instead of rounding, because a 99.6% fall rendered as -100% claims the figure
went to zero.

Charts are per currency by their nature, and four copies of every panel is not a
dashboard, so the busiest currency leads and a chip row switches between them.
That is a view choice and not a filter: it never narrows the data the totals or
the ranked lists were computed from.

Verified against a running instance on a generated twelve-month, three-account,
two-currency journal. The June tooltip reports in 5,701.80, out 2,611.95, net
3,089.85 over 26 transactions, matching /api/dashboard exactly. A single-month
window with 16.99 of income against 1,761.59 of spending shows the net in red
below the axis, withholds the savings rate, and puts 1.7k of Drawn from reserves
into the trunk against 1.6k of Housing. Clicking the Housing node lands on the
transactions page filtered to Expenses / Housing with eighteen rows and the
period intact, and the whole page stacks and stays legible at 430px.
This commit is contained in:
Lars Nolden
2026-09-11 23:35:46 +02:00
parent 762ad3fae5
commit cc5912ece2
8 changed files with 2034 additions and 380 deletions
+64 -10
View File
@@ -7,11 +7,21 @@ import (
"time"
)
// filteredPrefix opens the common table expression every group query reads
// from; the closing parenthesis is supplied with the WHERE clause.
const filteredPrefix = "WITH filtered AS (SELECT t.* FROM transactions t WHERE "
// categoryGroups runs twice, once per compared interval, so a period-over-period
// delta sees exactly the same ancestor rollup on both sides.
const categoryGroups = `SELECT c.id, c.name, t.currency, '', CAST(SUM(t.amount) AS VARCHAR), COUNT(*)
FROM filtered t JOIN category_ancestors ca ON ca.category_id = t.category_id
JOIN categories c ON c.id = ca.ancestor_id GROUP BY c.id, c.name, t.currency ORDER BY c.id, t.currency`
func (s *Store) Query(ctx context.Context, filter Filter) (Dashboard, error) {
empty := Dashboard{
Totals: []Total{}, Previous: []Total{}, Monthly: []Group{},
Categories: []Group{}, Tags: []Group{}, Merchants: []Group{},
Accounts: []Group{}, Recurring: []Group{},
Totals: []Total{}, Previous: []Total{}, Monthly: []MonthlyPoint{},
Categories: []Group{}, PreviousCategories: []Group{}, Tags: []Group{},
Merchants: []Group{}, Accounts: []Group{}, Recurring: []Group{}, Largest: []Group{},
}
if err := filter.validate(); err != nil {
return empty, err
@@ -33,19 +43,21 @@ func (s *Store) Query(ctx context.Context, filter Filter) (Dashboard, error) {
if result.Previous, err = queryTotals(ctx, tx, previous); err != nil {
return empty, err
}
where, args := previous.where()
if result.PreviousCategories, err = queryGroups(ctx, tx, filteredPrefix+where+") "+categoryGroups, args); err != nil {
return empty, fmt.Errorf("query previous categories: %w", err)
}
}
where, args := filter.where()
prefix := "WITH filtered AS (SELECT t.* FROM transactions t WHERE " + where + ") "
prefix := filteredPrefix + where + ") "
if result.Monthly, err = queryMonthly(ctx, tx, prefix, args); err != nil {
return empty, err
}
queries := []struct {
output *[]Group
query string
}{
{&result.Monthly, `SELECT strftime(booking_date, '%Y-%m'), strftime(booking_date, '%Y-%m'), currency,
strftime(booking_date, '%Y-%m'), CAST(SUM(amount) AS VARCHAR), COUNT(*)
FROM filtered GROUP BY currency, strftime(booking_date, '%Y-%m') ORDER BY 4, 3`},
{&result.Categories, `SELECT c.id, c.name, t.currency, '', CAST(SUM(t.amount) AS VARCHAR), COUNT(*)
FROM filtered t JOIN category_ancestors ca ON ca.category_id = t.category_id
JOIN categories c ON c.id = ca.ancestor_id GROUP BY c.id, c.name, t.currency ORDER BY c.id, t.currency`},
{&result.Categories, categoryGroups},
{&result.Tags, `SELECT tag.id, tag.name, t.currency, '', CAST(SUM(t.amount) AS VARCHAR), COUNT(*)
FROM filtered t JOIN transaction_tags tt ON tt.transaction_id = t.id
JOIN tags tag ON tag.id = tt.tag_id GROUP BY tag.id, tag.name, t.currency ORDER BY tag.id, t.currency`},
@@ -71,6 +83,25 @@ func (s *Store) Query(ctx context.Context, filter Filter) (Dashboard, error) {
m.name, c.currency, c.cadence, CAST(c.total AS VARCHAR), c.occurrences
FROM candidates c JOIN merchants m ON m.id = c.merchant_id
WHERE c.cadence <> '' ORDER BY 1, 3`},
// One row per payee: a rent paid on time every month is six identical
// rows that explain nothing, so only a merchant's single biggest payment
// competes. Ranked per currency rather than by a plain LIMIT, so one
// busy currency cannot crowd another out of its own list. Both windows
// order by the decimal column, never by its VARCHAR rendering.
{&result.Largest, `, payments AS (
SELECT t.id, CASE WHEN COALESCE(m.name, '') <> '' THEN m.name ELSE t.raw_description END AS label,
t.currency, CAST(t.booking_date AS VARCHAR) AS day, t.amount AS value,
ROW_NUMBER() OVER (PARTITION BY t.currency,
CASE WHEN t.merchant_id <> '' THEN 'm:' || t.merchant_id ELSE 'x:' || t.id END
ORDER BY t.amount, t.id) AS repeats
FROM filtered t LEFT JOIN merchants m ON m.id = t.merchant_id WHERE t.amount < 0
), ranked AS (
SELECT id, label, currency, day, value,
ROW_NUMBER() OVER (PARTITION BY currency ORDER BY value, id) AS position
FROM payments WHERE repeats = 1
)
SELECT id, label, currency, day, CAST(value AS VARCHAR), CAST(1 AS BIGINT) FROM ranked
WHERE position <= 8 ORDER BY currency, position`},
}
for _, item := range queries {
groups, err := queryGroups(ctx, tx, prefix+item.query, args)
@@ -85,6 +116,29 @@ func (s *Store) Query(ctx context.Context, filter Filter) (Dashboard, error) {
return result, nil
}
// queryMonthly returns one row per month and currency. Months with no activity
// are absent: the caller knows the requested window and fills the gaps.
func queryMonthly(ctx context.Context, tx *sql.Tx, prefix string, args []any) ([]MonthlyPoint, error) {
rows, err := tx.QueryContext(ctx, prefix+`SELECT strftime(booking_date, '%Y-%m'), currency,
CAST(SUM(CASE WHEN amount > 0 THEN amount ELSE CAST(0 AS DECIMAL(24,4)) END) AS VARCHAR),
CAST(SUM(CASE WHEN amount < 0 THEN -amount ELSE CAST(0 AS DECIMAL(24,4)) END) AS VARCHAR),
CAST(SUM(amount) AS VARCHAR), COUNT(*)
FROM filtered GROUP BY currency, strftime(booking_date, '%Y-%m') ORDER BY currency, 1`, args...)
if err != nil {
return nil, fmt.Errorf("query analytics months: %w", err)
}
defer rows.Close()
result := []MonthlyPoint{}
for rows.Next() {
var point MonthlyPoint
if err := rows.Scan(&point.Period, &point.Currency, &point.Income, &point.Expenses, &point.Net, &point.Count); err != nil {
return nil, err
}
result = append(result, point)
}
return result, rows.Err()
}
func queryTotals(ctx context.Context, tx *sql.Tx, filter Filter) ([]Total, error) {
where, args := filter.where()
rows, err := tx.QueryContext(ctx, `SELECT t.currency,