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.
226 lines
9.0 KiB
Go
226 lines
9.0 KiB
Go
package analytics
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"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: []MonthlyPoint{},
|
|
Categories: []Group{}, PreviousCategories: []Group{}, Tags: []Group{},
|
|
Merchants: []Group{}, Accounts: []Group{}, Recurring: []Group{}, Largest: []Group{},
|
|
}
|
|
if err := filter.validate(); err != nil {
|
|
return empty, err
|
|
}
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return empty, err
|
|
}
|
|
defer tx.Rollback()
|
|
result := empty
|
|
if result.Totals, err = queryTotals(ctx, tx, filter); err != nil {
|
|
return empty, err
|
|
}
|
|
previous, ok, err := previousFilter(ctx, tx, filter)
|
|
if err != nil {
|
|
return empty, err
|
|
}
|
|
if ok {
|
|
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 := filteredPrefix + where + ") "
|
|
if result.Monthly, err = queryMonthly(ctx, tx, prefix, args); err != nil {
|
|
return empty, err
|
|
}
|
|
queries := []struct {
|
|
output *[]Group
|
|
query string
|
|
}{
|
|
{&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`},
|
|
{&result.Merchants, `SELECT t.merchant_id, COALESCE(m.name, 'No merchant'), t.currency, '', CAST(SUM(t.amount) AS VARCHAR), COUNT(*)
|
|
FROM filtered t LEFT JOIN merchants m ON m.id = t.merchant_id
|
|
GROUP BY t.merchant_id, m.name, t.currency ORDER BY t.merchant_id, t.currency`},
|
|
{&result.Accounts, `SELECT a.id, a.display_name, t.currency, '', CAST(SUM(t.amount) AS VARCHAR), COUNT(*)
|
|
FROM filtered t JOIN accounts a ON a.id = t.account_id
|
|
GROUP BY a.id, a.display_name, t.currency ORDER BY a.id, t.currency`},
|
|
{&result.Recurring, `, spaced AS (
|
|
SELECT *, date_diff('day', LAG(booking_date) OVER (
|
|
PARTITION BY merchant_id, account_id, currency, amount ORDER BY booking_date, id), booking_date) AS gap
|
|
FROM filtered WHERE amount < 0 AND merchant_id <> ''
|
|
), candidates AS (
|
|
SELECT merchant_id, account_id, currency, amount, SUM(amount) AS total, COUNT(*) AS occurrences,
|
|
CASE WHEN MIN(gap) >= 5 AND MAX(gap) <= 9 THEN 'weekly'
|
|
WHEN MIN(gap) >= 26 AND MAX(gap) <= 35 THEN 'monthly'
|
|
WHEN MIN(gap) >= 350 AND MAX(gap) <= 380 THEN 'yearly' ELSE '' END AS cadence
|
|
FROM spaced GROUP BY merchant_id, account_id, currency, amount
|
|
HAVING COUNT(*) >= 3
|
|
)
|
|
SELECT c.merchant_id || ':' || c.account_id || ':' || CAST(c.amount AS VARCHAR),
|
|
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)
|
|
if err != nil {
|
|
return empty, fmt.Errorf("query analytics groups: %w", err)
|
|
}
|
|
*item.output = groups
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return empty, err
|
|
}
|
|
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,
|
|
CAST(SUM(CASE WHEN t.amount < 0 THEN -t.amount ELSE CAST(0 AS DECIMAL(24,4)) END) AS VARCHAR),
|
|
CAST(SUM(CASE WHEN t.amount > 0 THEN t.amount ELSE CAST(0 AS DECIMAL(24,4)) END) AS VARCHAR),
|
|
CAST(SUM(t.amount) AS VARCHAR)
|
|
FROM transactions t WHERE `+where+` GROUP BY t.currency ORDER BY t.currency`, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query analytics totals: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
result := []Total{}
|
|
for rows.Next() {
|
|
var total Total
|
|
if err := rows.Scan(&total.Currency, &total.Expenses, &total.Income, &total.Net); err != nil {
|
|
return nil, err
|
|
}
|
|
result = append(result, total)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
func queryGroups(ctx context.Context, tx *sql.Tx, query string, args []any) ([]Group, error) {
|
|
rows, err := tx.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
result := []Group{}
|
|
for rows.Next() {
|
|
var group Group
|
|
if err := rows.Scan(&group.ID, &group.Name, &group.Currency, &group.Period, &group.Amount, &group.Count); err != nil {
|
|
return nil, err
|
|
}
|
|
result = append(result, group)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
// Previous is the immediately preceding inclusive interval of equal length.
|
|
// For one-sided filters, the missing boundary comes from the matching dataset,
|
|
// not the wall clock. All-time queries intentionally have no previous period.
|
|
func previousFilter(ctx context.Context, tx *sql.Tx, filter Filter) (Filter, bool, error) {
|
|
if filter.From == "" && filter.To == "" {
|
|
return filter, false, nil
|
|
}
|
|
if filter.From == "" || filter.To == "" {
|
|
bounds := filter
|
|
bounds.From, bounds.To = "", ""
|
|
where, args := bounds.where()
|
|
var first, last sql.NullString
|
|
if err := tx.QueryRowContext(ctx, "SELECT CAST(MIN(t.booking_date) AS VARCHAR), CAST(MAX(t.booking_date) AS VARCHAR) FROM transactions t WHERE "+where, args...).Scan(&first, &last); err != nil {
|
|
return filter, false, err
|
|
}
|
|
if !first.Valid || !last.Valid {
|
|
return filter, false, nil
|
|
}
|
|
if filter.From == "" {
|
|
filter.From = first.String
|
|
}
|
|
if filter.To == "" {
|
|
filter.To = last.String
|
|
}
|
|
if filter.From > filter.To {
|
|
return filter, false, nil
|
|
}
|
|
}
|
|
from, err := time.Parse(time.DateOnly, filter.From)
|
|
if err != nil {
|
|
return filter, false, err
|
|
}
|
|
to, err := time.Parse(time.DateOnly, filter.To)
|
|
if err != nil {
|
|
return filter, false, err
|
|
}
|
|
days := int((to.Unix()-from.Unix())/86400) + 1
|
|
previousFrom, previousTo := from.AddDate(0, 0, -days), from.AddDate(0, 0, -1)
|
|
// ISO date filters cannot describe dates before year zero.
|
|
if previousFrom.Year() < 0 {
|
|
return filter, false, fmt.Errorf("previous period falls outside supported date range")
|
|
}
|
|
filter.From, filter.To = previousFrom.Format(time.DateOnly), previousTo.Format(time.DateOnly)
|
|
return filter, true, nil
|
|
}
|