This commit is contained in:
Lars Nolden
2026-09-10 12:30:42 +02:00
commit 9843fe0c50
79 changed files with 16318 additions and 0 deletions
+171
View File
@@ -0,0 +1,171 @@
package analytics
import (
"context"
"database/sql"
"fmt"
"time"
)
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{},
}
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 := filter.where()
prefix := "WITH filtered AS (SELECT t.* FROM transactions t WHERE " + where + ") "
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.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`},
}
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
}
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
}