diff --git a/CLAUDE.md b/CLAUDE.md index 42fb057..116f4f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -142,6 +142,35 @@ Anything new that reports a rule must report its file position too. settles ties: a saved rule cannot displace an equally specific one written by hand, while a narrower one is meant to take precedence and does. +**The report's period narrows the report and nothing else.** The screen opens +on last month, and `←`/`→` step along `report.Periods` — the named windows, +then the months the index holds. `reloadReport` therefore runs its *own* query +rather than reusing the rows the transaction list is showing: the two share +`m.filter` (account, search, untagged) and differ only in the date bounds, so +opening on last month must not hide the rest of the index from the list beside +it. Nothing may put the period into `m.filter`, which is exactly what would +make it leak. Covered by `TestReportPeriodLeavesTheTransactionListAlone`. + +The axis is built from `store.Months` over the *whole* index, not from the rows +in view, or it would grow and shrink as the account or search filter changed and +move under the cursor. It is rebuilt on every `reload` (an import can reach +further back) but keeps the window the user was on. The windows are relative to +today, never to the newest statement: "last month" with nothing in it reports +nothing and says so, because silently answering for a month nobody asked for is +worse than an empty screen. That is also why an empty period and an empty index +give different messages — one asks for another period, the other for an import. + +**The report's sort rearranges rows; it never changes which rows there are.** +`report.Order` is passed to `ByTag` and is deliberately *not* part of +`store.Filter` or `m.filter` — the period decides what is counted, the order +only how it is listed, and merging the two would make a sort able to hide a +tag. Currency stays the outer sort key under every order, because there are no +exchange rates to compare two currencies by, and every order falls back to the +tag so ties keep a fixed position instead of shuffling between reloads. +`Order.Column` is what lets the TUI mark the sorted heading without keeping its +own copy of that mapping; a new order needs a column of its own, which +`TestOrderColumnsAreDistinct` checks. + **The builders are forms, so the global keymap must not apply there.** `Update` routes to `updateRules` / `updateTransfers` before `updateNormal` whenever the view is `viewRules` or `viewTransfers`, or typing `q` would quit diff --git a/README.md b/README.md index bd90029..0ee1253 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ money ls --untagged --uniq # the same, one row per distinct description money ls --wide # also show type and reported balance money ls --account checking --month 2026-01 money report --month 2026-01 # spending by tag +money report --sort count # ordered by number of transactions, not amount money accounts # balances money parsers # available statement parsers money config # which data root is in use, and why @@ -113,12 +114,65 @@ combined with `--wide`, whose columns all belong to a single transaction. | `/` | filter by description | | `u` | show only untagged transactions (matched transfer legs are not among them) | | `a` | clear the account filter | +| `←` `→` | move the report's period (report view) | +| `s` | change how the report is sorted (report view) | | `i` | import · `r` re-apply rules · `q` quit | There is no key that tags a transaction. Tags come from `rules.toml` and nowhere else, so tagging what you are looking at means writing a rule for it on `4` — which is why that screen shows you what a glob catches before you save. +### Report (`3`) + +The report opens on **last month**, not on everything you have ever imported: +an all-time total is the one number a spending report is least often asked for, +and the month that has just ended is the last one your statements can be +complete for. If you have not downloaded that month yet, the screen says the +period is empty rather than quietly showing you a different one. + +`←` and `→` move along the time axis on the left of the totals, which runs from +the widest window down to the oldest month in the index: + +``` + Period Tag Cur Out In Net N + all time groceries EUR 10.00 0.00 -10.00 1 + this year TOTAL EUR 10.00 0.00 -10.00 + last 12 months + last 3 months + this month +▸ last month + 2026-05 + 2026-04 +``` + +The three rolling windows run to the end of *this* month rather than to the +last complete one — you ask for "last 3 months" to see what is happening now, +and leaving out the days since the 1st would answer a different question. Each +month below `last month` is a month the index actually holds; months that a +named window above already covers are not repeated. The axis is hidden on a +terminal too narrow for it and the totals both, where the title still names the +period. + +`s` cycles how the rows are arranged, and the marked column heading says which +one they are arranged by: `Out ▾` largest spend first (the default question a +spending report answers), then `In ▾`, `Net ▾` lowest first so the biggest +losses lead, `N ▾` most transactions first, and `Tag ▴` A→Z. `money report` +takes the same choice as `--sort out|in|net|count|tag`. + +Currency is always the outer grouping and no sort changes that — there are no +exchange rates here, so two currencies interleaved by amount would invite a +comparison between numbers that cannot be compared. Rows that tie fall back to +the tag, so they keep a fixed position rather than shuffling between reloads. +The sort rearranges rows; it never changes which rows there are. + +The period narrows the report and only the report. The account filter (`a`, +`enter`), the search (`/`) and the untagged toggle (`u`) are shared with the +transaction list as before, so opening on last month does not hide the rest of +the index from `2`. + +`money report` takes `--month` instead; there is no command-line equivalent of +the wider windows. + ### Rule builder (`4`) Writing rules by hand means guessing what a glob will catch. This screen shows @@ -512,7 +566,7 @@ transfers excluded: 2 legs in EUR, 500.00 out, 495.00 in, 5.00 in fees ``` The report screen (`3`) shows the same thing under its `TOTAL`, the fee on its -own row: +own row (for whichever period it is on): ``` TAG CUR OUT IN NET N diff --git a/cmd/money/main.go b/cmd/money/main.go index 0febac6..2214490 100644 --- a/cmd/money/main.go +++ b/cmd/money/main.go @@ -385,9 +385,14 @@ func cmdReport(root string, args []string) error { fs := flag.NewFlagSet("report", flag.ContinueOnError) month := fs.String("month", "", "only this month (YYYY-MM)") account := fs.String("account", "", "only this account slug") + sortBy := fs.String("sort", report.OrderOut.String(), "order rows by: out, in, net, count, tag") if err := fs.Parse(args); err != nil { return err } + order, err := report.ParseOrder(*sortBy) + if err != nil { + return err + } o, err := open(root) if err != nil { @@ -399,7 +404,7 @@ func cmdReport(root string, args []string) error { if err != nil { return err } - rows := report.ByTag(txns) + rows := report.ByTag(txns, order) w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintln(w, "TAG\tCUR\tOUT\tIN\tNET\tN") diff --git a/internal/report/period.go b/internal/report/period.go new file mode 100644 index 0000000..607bcda --- /dev/null +++ b/internal/report/period.go @@ -0,0 +1,114 @@ +package report + +import ( + "fmt" + "time" +) + +// Period is the window a report covers: an inclusive range of ISO dates, with +// either side empty for unbounded. Dates are stored as YYYY-MM-DD, so comparing +// them as strings compares them as dates and nothing has to be parsed to filter +// on one. +type Period struct { + // Label is what to call the window: "last month", "all time", "2026-06". + Label string + // Span names the dates Label stands for, because "last month" says nothing + // on its own about which month it was. Empty when the label already is the + // span, as it is for a single month and for all time. + Span string + From string + To string +} + +// String names the window, and what it covers when the label does not. +func (p Period) String() string { + if p.Span == "" { + return p.Label + } + return p.Label + " · " + p.Span +} + +// LastMonth is the label of the window a report opens on. The month that has +// just ended is the last one a statement can be complete for: this month is +// still filling up, so its total is not comparable with any other. +const LastMonth = "last month" + +// Periods is the axis a report steps along, widest window first: the named +// ranges, then every month the index holds that is older than last month, most +// recent of those first. months is expected newest first, as store.Months +// returns it; anything in it that a named range already covers is left out of +// the tail rather than offered twice. +// +// The named ranges are relative to today, not to the newest statement. "Last +// month" means the month that has just ended, so if no statement covers it yet +// the report for it is empty — which is the honest answer, and one the user can +// act on, where silently reporting an older month they did not ask for is not. +func Periods(today time.Time, months []string) []Period { + thisMonth := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, time.UTC) + lastMonth := thisMonth.AddDate(0, -1, 0) + + // A range ends with this month rather than with the last complete one: + // "last 3 months" is asked in order to see what is happening now, and + // leaving out the days since the 1st would answer a different question. + rolling := func(back int, label string) Period { + from := thisMonth.AddDate(0, -back, 0) + return Period{ + Label: label, + Span: from.Format("2006-01") + " … " + thisMonth.Format("2006-01"), + From: isoDate(from), + To: isoDate(endOfMonth(thisMonth)), + } + } + month := func(start time.Time, label string) Period { + p := Period{Label: label, From: isoDate(start), To: isoDate(endOfMonth(start))} + if label != start.Format("2006-01") { + p.Span = start.Format("2006-01") + } + return p + } + year := time.Date(today.Year(), time.January, 1, 0, 0, 0, 0, time.UTC) + + out := []Period{ + {Label: "all time"}, + { + Label: "this year", + Span: fmt.Sprintf("%d", today.Year()), + From: isoDate(year), + To: isoDate(year.AddDate(1, 0, -1)), + }, + rolling(11, "last 12 months"), + rolling(2, "last 3 months"), + month(thisMonth, "this month"), + month(lastMonth, LastMonth), + } + + cutoff := lastMonth.Format("2006-01") + for _, ym := range months { + // Months at or after last month are already on the axis above, and one + // dated in the future is a parsing bug rather than a window to offer. + if ym >= cutoff { + continue + } + start, err := time.Parse("2006-01", ym) + if err != nil { + continue + } + out = append(out, month(start, ym)) + } + return out +} + +// DefaultIndex is where on the axis a report opens. +func DefaultIndex(ps []Period) int { + for i, p := range ps { + if p.Label == LastMonth { + return i + } + } + return 0 +} + +func isoDate(t time.Time) string { return t.Format("2006-01-02") } + +// endOfMonth takes the first of a month to its last day, whatever its length. +func endOfMonth(first time.Time) time.Time { return first.AddDate(0, 1, -1) } diff --git a/internal/report/period_test.go b/internal/report/period_test.go new file mode 100644 index 0000000..ae5577e --- /dev/null +++ b/internal/report/period_test.go @@ -0,0 +1,102 @@ +package report + +import ( + "testing" + "time" +) + +func on(s string) time.Time { + t, err := time.Parse("2006-01-02", s) + if err != nil { + panic(err) + } + return t +} + +// The axis opens on the month that has just ended, whatever the index holds. +func TestPeriodsDefaultsToLastMonth(t *testing.T) { + ps := Periods(on("2026-08-23"), []string{"2026-08", "2026-07", "2026-06"}) + p := ps[DefaultIndex(ps)] + if p.Label != LastMonth { + t.Fatalf("default = %q, want %q", p.Label, LastMonth) + } + if p.From != "2026-07-01" || p.To != "2026-07-31" { + t.Errorf("last month = %s…%s, want the whole of July", p.From, p.To) + } + if p.Span != "2026-07" { + t.Errorf("span = %q, want the month it stands for", p.Span) + } +} + +// An index that stops short of last month still opens there: an empty report +// for the window asked for beats a full one for a window nobody chose. +func TestPeriodsDefaultsToLastMonthWithNothingInIt(t *testing.T) { + ps := Periods(on("2026-08-23"), []string{"2026-03", "2026-02"}) + if p := ps[DefaultIndex(ps)]; p.Label != LastMonth { + t.Fatalf("default = %q, want %q", p.Label, LastMonth) + } +} + +// The named windows come first, widest to narrowest, and the months follow in +// reverse order, skipping the ones the named windows already name. +func TestPeriodsOrder(t *testing.T) { + ps := Periods(on("2026-08-23"), []string{"2026-09", "2026-08", "2026-07", "2026-06", "2026-01"}) + var got []string + for _, p := range ps { + got = append(got, p.Label) + } + want := []string{ + "all time", "this year", "last 12 months", "last 3 months", + "this month", LastMonth, "2026-06", "2026-01", + } + if len(got) != len(want) { + t.Fatalf("periods = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("periods = %v, want %v", got, want) + } + } +} + +// A month is bounded by its own length, not by a fixed 30 or 31 days. +func TestPeriodsMonthEnds(t *testing.T) { + ps := Periods(on("2026-03-15"), []string{"2026-02"}) + p := ps[DefaultIndex(ps)] + if p.From != "2026-02-01" || p.To != "2026-02-28" { + t.Errorf("February 2026 = %s…%s, want the 1st to the 28th", p.From, p.To) + } +} + +// A rolling window runs to the end of this month rather than to the last +// complete one: it is asked in order to see what is happening now. +func TestPeriodsRollingWindows(t *testing.T) { + ps := Periods(on("2026-08-23"), nil) + for _, tc := range []struct{ label, from, to string }{ + {"last 3 months", "2026-06-01", "2026-08-31"}, + {"last 12 months", "2025-09-01", "2026-08-31"}, + {"this year", "2026-01-01", "2026-12-31"}, + } { + var found bool + for _, p := range ps { + if p.Label != tc.label { + continue + } + found = true + if p.From != tc.from || p.To != tc.to { + t.Errorf("%s = %s…%s, want %s…%s", tc.label, p.From, p.To, tc.from, tc.to) + } + } + if !found { + t.Errorf("no %q window", tc.label) + } + } +} + +// All time is unbounded on both sides, so nothing has to special-case it. +func TestPeriodsAllTimeIsUnbounded(t *testing.T) { + ps := Periods(on("2026-08-23"), []string{"2026-01"}) + if ps[0].Label != "all time" || ps[0].From != "" || ps[0].To != "" { + t.Errorf("first window = %+v, want an unbounded all time", ps[0]) + } +} diff --git a/internal/report/report.go b/internal/report/report.go index 093bf74..488315c 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -2,7 +2,9 @@ package report import ( + "fmt" "sort" + "strings" "git.petrovv.com/nikola/money/internal/model" ) @@ -24,7 +26,105 @@ func (t TagTotal) Net() int64 { return t.In - t.Out } // collected under "(untagged)" so they stay visible instead of vanishing. const Untagged = "(untagged)" -// ByTag returns totals sorted by currency, then by largest outflow first. +// Order is how the rows of a breakdown are arranged within a currency. +// +// Currency is always the outer key and no order can change that: the tool holds +// no exchange rates, so two currencies interleaved by amount would invite a +// comparison between numbers that cannot be compared. +type Order int + +const ( + // OrderOut is the default, and the question a spending report is usually + // opened to answer: where did the money go. + OrderOut Order = iota // largest outflow first + OrderIn // largest inflow first + OrderNet // lowest net first, so the biggest losses lead + OrderCount // most transactions first + OrderTag // tag name, A→Z + + numOrders // how many there are, for cycling; not an order itself +) + +// Next is the order after o, wrapping round. There are too many for a toggle, +// so a view offering the choice cycles through them. +func (o Order) Next() Order { return (o + 1) % numOrders } + +// String is the short name the `--sort` flag takes, and ParseOrder accepts. +func (o Order) String() string { + switch o { + case OrderIn: + return "in" + case OrderNet: + return "net" + case OrderCount: + return "count" + case OrderTag: + return "tag" + } + return "out" +} + +// Label says what the order actually does, for a view with room to say it. +func (o Order) Label() string { + switch o { + case OrderIn: + return "largest in first" + case OrderNet: + return "lowest net first" + case OrderCount: + return "most transactions first" + case OrderTag: + return "tag A→Z" + } + return "largest out first" +} + +// Column is the heading the order sorts on, so a view can mark the column the +// rows are arranged by without keeping its own copy of this mapping. +func (o Order) Column() string { + switch o { + case OrderIn: + return "In" + case OrderNet: + return "Net" + case OrderCount: + return "N" + case OrderTag: + return "Tag" + } + return "Out" +} + +// Ascending reports which way the order runs. Only the tag reads naturally +// upwards; every amount and count is asked for biggest-first. +func (o Order) Ascending() bool { return o == OrderTag } + +// Orders is every order, in the cycle Next follows. +func Orders() []Order { + out := make([]Order, 0, numOrders) + for o := Order(0); o < numOrders; o++ { + out = append(out, o) + } + return out +} + +// ParseOrder turns a short name back into an order, naming the alternatives +// when it cannot, since a misspelt sort would otherwise silently report in an +// order the caller did not ask for. +func ParseOrder(s string) (Order, error) { + for _, o := range Orders() { + if o.String() == s { + return o, nil + } + } + var names []string + for _, o := range Orders() { + names = append(names, o.String()) + } + return OrderOut, fmt.Errorf("unknown sort %q: want one of %s", s, strings.Join(names, ", ")) +} + +// ByTag returns totals grouped by currency and arranged within each by order. // // Matched transfer legs are left out entirely. Both legs of a transfer are // dropped together, so a report over everything is unchanged in total by money @@ -36,7 +136,7 @@ const Untagged = "(untagged)" // all — so with such a definition in play the report *is* short by the fee, and // callers are expected to show Excluded alongside it rather than leave that // difference unexplained. -func ByTag(txns []model.Transaction) []TagTotal { +func ByTag(txns []model.Transaction, order Order) []TagTotal { type key struct{ currency, tag string } acc := map[key]*TagTotal{} @@ -67,13 +167,33 @@ func ByTag(txns []model.Transaction) []TagTotal { out = append(out, *row) } sort.Slice(out, func(i, j int) bool { - if out[i].Currency != out[j].Currency { - return out[i].Currency < out[j].Currency + a, b := out[i], out[j] + if a.Currency != b.Currency { + return a.Currency < b.Currency } - if out[i].Out != out[j].Out { - return out[i].Out > out[j].Out + switch order { + case OrderIn: + if a.In != b.In { + return a.In > b.In + } + case OrderNet: + if a.Net() != b.Net() { + return a.Net() < b.Net() + } + case OrderCount: + if a.Count != b.Count { + return a.Count > b.Count + } + case OrderTag: + // The tag is the whole order here, and also the tie-break below. + default: + if a.Out != b.Out { + return a.Out > b.Out + } } - return out[i].Tag < out[j].Tag + // Every order falls back to the tag, so rows that tie on the chosen + // column keep a fixed position instead of shuffling between reloads. + return a.Tag < b.Tag }) return out } @@ -190,19 +310,3 @@ func Excluded(txns []model.Transaction) []TransferTotal { sort.Slice(out, func(i, j int) bool { return out[i].Currency < out[j].Currency }) return out } - -// Months lists the distinct YYYY-MM present in txns, most recent first. -func Months(txns []model.Transaction) []string { - seen := map[string]bool{} - for _, t := range txns { - if len(t.Date) >= 7 { - seen[t.Date[:7]] = true - } - } - out := make([]string, 0, len(seen)) - for m := range seen { - out = append(out, m) - } - sort.Sort(sort.Reverse(sort.StringSlice(out))) - return out -} diff --git a/internal/report/report_test.go b/internal/report/report_test.go index df88f8e..ef99474 100644 --- a/internal/report/report_test.go +++ b/internal/report/report_test.go @@ -1,6 +1,7 @@ package report import ( + "strings" "testing" "git.petrovv.com/nikola/money/internal/model" @@ -27,7 +28,7 @@ func TestByTagLeavesTransferLegsOut(t *testing.T) { leg(1, -50000, &id), leg(2, 50000, &id), {Currency: "EUR", MinorDigits: 2, AmountMinor: -2000, RuleTag: "groceries"}, - }) + }, OrderOut) if len(rows) != 1 || rows[0].Tag != "groceries" { t.Fatalf("rows = %+v, want only the groceries row", rows) @@ -41,7 +42,7 @@ func TestByTagLeavesTransferLegsOut(t *testing.T) { // account and cannot be shown to have arrived must not quietly vanish from the // report. func TestByTagKeepsUnpairedLegs(t *testing.T) { - rows := ByTag([]model.Transaction{leg(1, -50000, nil)}) + rows := ByTag([]model.Transaction{leg(1, -50000, nil)}, OrderOut) if len(rows) != 1 || rows[0].Out != 50000 { t.Fatalf("rows = %+v, want the unpaired leg counted", rows) } @@ -133,3 +134,133 @@ func TestExcludedClaimsNoFeeAcrossCurrencies(t *testing.T) { } } } + +// row is a transaction under one tag, for the ordering tests. +func row(tag string, amount int64) model.Transaction { + return model.Transaction{Currency: "EUR", MinorDigits: 2, AmountMinor: amount, RuleTag: tag} +} + +// tags is the order the breakdown came out in. +func tags(rows []TagTotal) []string { + var out []string + for _, r := range rows { + out = append(out, r.Tag) + } + return out +} + +func TestByTagOrders(t *testing.T) { + txns := []model.Transaction{ + row("rent", -100000), + row("groceries", -2000), + row("groceries", -3000), + row("groceries", -1000), + row("salary", 250000), + row("refund", 500), + row("refund", -400), + } + + for _, tc := range []struct { + order Order + want []string + }{ + {OrderOut, []string{"rent", "groceries", "refund", "salary"}}, + {OrderIn, []string{"salary", "refund", "groceries", "rent"}}, + {OrderNet, []string{"rent", "groceries", "refund", "salary"}}, + {OrderCount, []string{"groceries", "refund", "rent", "salary"}}, + {OrderTag, []string{"groceries", "refund", "rent", "salary"}}, + } { + got := tags(ByTag(txns, tc.order)) + if len(got) != len(tc.want) { + t.Errorf("%s: rows = %v, want %v", tc.order, got, tc.want) + continue + } + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("%s: rows = %v, want %v", tc.order, got, tc.want) + break + } + } + } +} + +// Every order falls back to the tag, so rows that tie do not shuffle about +// between one reload and the next. +func TestByTagTiesBreakOnTheTag(t *testing.T) { + txns := []model.Transaction{row("zebra", -1000), row("apple", -1000)} + for _, o := range Orders() { + if got := tags(ByTag(txns, o)); got[0] != "apple" { + t.Errorf("%s: rows = %v, want the tie broken on the tag", o, got) + } + } +} + +// No order may interleave currencies: with no exchange rates here, two amounts +// in different columns are not comparable and must not be sorted against each +// other. +func TestByTagNeverInterleavesCurrencies(t *testing.T) { + txns := []model.Transaction{ + {Currency: "EUR", MinorDigits: 2, AmountMinor: -100, RuleTag: "small"}, + {Currency: "BGN", MinorDigits: 2, AmountMinor: -900000, RuleTag: "huge"}, + {Currency: "EUR", MinorDigits: 2, AmountMinor: -200, RuleTag: "medium"}, + } + for _, o := range Orders() { + rows := ByTag(txns, o) + if rows[0].Currency != "BGN" || rows[1].Currency != "EUR" || rows[2].Currency != "EUR" { + t.Errorf("%s: currencies = %+v, want them grouped", o, rows) + } + } +} + +// The cycle visits every order and comes back to where it started. +func TestOrderNextCycles(t *testing.T) { + seen := map[Order]bool{} + o := OrderOut + for range len(Orders()) { + if seen[o] { + t.Fatalf("order %s came round twice in one cycle", o) + } + seen[o] = true + o = o.Next() + } + if o != OrderOut { + t.Errorf("the cycle ended on %s, want it back at %s", o, OrderOut) + } +} + +// A misspelt --sort is refused rather than silently reporting in the default +// order, and the error names what it would have accepted. +func TestParseOrder(t *testing.T) { + for _, o := range Orders() { + got, err := ParseOrder(o.String()) + if err != nil || got != o { + t.Errorf("ParseOrder(%q) = %s, %v, want %s", o.String(), got, err, o) + } + } + _, err := ParseOrder("descending") + if err == nil { + t.Fatal("ParseOrder accepted a name that is not an order") + } + for _, want := range []string{"descending", "out", "tag"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not mention %q", err, want) + } + } +} + +// Each order marks a different column, and only headings the table actually +// has, or the marker would land nowhere. +func TestOrderColumnsAreDistinct(t *testing.T) { + headings := map[string]bool{"Tag": true, "Cur": true, "Out": true, "In": true, "Net": true, "N": true} + seen := map[string]bool{} + for _, o := range Orders() { + c := o.Column() + if !headings[c] { + t.Errorf("%s sorts on %q, which is not a report column", o, c) + } + if seen[c] { + t.Errorf("%s marks %q, which another order already marks", o, c) + } + seen[c] = true + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 6d342be..e34f12c 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -203,8 +203,13 @@ type Filter struct { // leaves out anyway. Untagged bool Month string // YYYY-MM - Search string // case-insensitive substring of the description - Limit int + // From and To bound the dates inclusively, as YYYY-MM-DD, either side empty + // for unbounded. Dates are stored ISO-8601, so a string comparison is a date + // comparison; nothing has to be parsed to filter on a range. + From string + To string + Search string // case-insensitive substring of the description + Limit int } // Transactions returns rows matching f, newest first. @@ -231,6 +236,14 @@ func (d *DB) Transactions(f Filter) ([]model.Transaction, error) { q += ` AND substr(t.date, 1, 7) = ?` args = append(args, f.Month) } + if f.From != "" { + q += ` AND t.date >= ?` + args = append(args, f.From) + } + if f.To != "" { + q += ` AND t.date <= ?` + args = append(args, f.To) + } q += ` ORDER BY t.date DESC, t.id DESC` // Search and Limit are applied in Go: SQLite's upper()/LIKE fold ASCII // only, which would silently fail on Cyrillic statement descriptions. @@ -358,6 +371,28 @@ func (d *DB) Count(accountID int64) (int, error) { return n, err } +// Months lists the distinct YYYY-MM the index holds, most recent first. It is +// asked of the whole index rather than of a filtered view on purpose: it is the +// time axis the report screen steps along, and an axis that grew and shrank as +// the account or search filter changed would move under the cursor. +func (d *DB) Months() ([]string, error) { + rows, err := d.sql.Query( + `SELECT DISTINCT substr(date, 1, 7) FROM transactions ORDER BY 1 DESC`) + if err != nil { + return nil, fmt.Errorf("list months: %w", err) + } + defer rows.Close() + var out []string + for rows.Next() { + var s string + if err := rows.Scan(&s); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} + // Tags lists every tag in use, for completion in the TUI. func (d *DB) Tags() ([]string, error) { rows, err := d.sql.Query(` diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 32bfbab..e8b168d 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -8,6 +8,7 @@ import ( "sort" "strconv" "strings" + "time" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/table" @@ -163,6 +164,19 @@ type Model struct { filter store.Filter onlyUntagged bool + // The report's time axis: the windows ←/→ steps along, and which one it is + // on. It is built from the whole index rather than from the rows on screen, + // so changing account or search does not move the axis under the cursor. + reportPeriods []report.Period + reportPeriod int + // reportOrder is how the breakdown is arranged. It is not part of the + // filter: it changes the order of the rows, never which rows there are. + reportOrder report.Order + // indexEmpty is whether the index holds no transactions at all, which the + // report's empty state has to tell apart from a period that happens to be + // empty: one asks for an import, the other for another period. + indexEmpty bool + status string err error width int @@ -192,6 +206,12 @@ var ( hintStyle = lipgloss.NewStyle().Faint(true).PaddingLeft(2) matchCountStyle = lipgloss.NewStyle().Bold(true).PaddingLeft(2) ruleFormStyle = lipgloss.NewStyle().Width(ruleFormWidth) + + // The report's time axis. The header is bold and indented like the table's + // own header beside it, so the two panels read as one grid. + periodHeaderStyle = lipgloss.NewStyle().Bold(true).PaddingLeft(2).Width(periodListWidth) + periodStyle = lipgloss.NewStyle().Faint(true).Width(periodListWidth) + periodSelectedStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("62")).Width(periodListWidth) ) // Run starts the interface. @@ -244,7 +264,7 @@ func New(root string, db *store.DB, accounts []*config.Account, engine *rules.En table.WithHeight(15), table.WithStyles(styles)) } - return &Model{ + m := &Model{ root: root, db: db, accounts: accounts, @@ -308,15 +328,49 @@ func New(root string, db *store.DB, accounts []*config.Account, engine *rules.En {Title: "Tol", Width: 6}, {Title: "Note", Width: 20}, }), - reportTable: newTable([]table.Column{ - {Title: "Tag", Width: 20}, - {Title: "Cur", Width: 4}, - {Title: "Out", Width: 14}, - {Title: "In", Width: 14}, - {Title: "Net", Width: 14}, - {Title: "N", Width: 5}, - }), + reportTable: newTable(slices.Clone(reportColumns)), } + // The default order needs its marker as much as a chosen one does. + m.markSortedColumn() + return m +} + +// reportColumns are the report's headings before the sort marker is added. +// They live here rather than being written into the table once, because +// markSortedColumn rebuilds them every time the order changes. +var reportColumns = []table.Column{ + {Title: "Tag", Width: 20}, + {Title: "Cur", Width: 4}, + {Title: "Out", Width: 14}, + {Title: "In", Width: 14}, + {Title: "Net", Width: 14}, + {Title: "N", Width: 5}, +} + +// markSortedColumn moves the marker onto the heading the rows are ordered by, +// so the sort is visible where it is acting rather than only in the key list. +func (m *Model) markSortedColumn() { + marker := " ▾" + if m.reportOrder.Ascending() { + marker = " ▴" + } + cols := slices.Clone(reportColumns) + for i := range cols { + if cols[i].Title == m.reportOrder.Column() { + cols[i].Title += marker + break + } + } + m.reportTable.SetColumns(cols) +} + +// setOrder cycles the report to the next arrangement of the same rows. +func (m *Model) setOrder(o report.Order) { + m.reportOrder = o + m.markSortedColumn() + m.status = "sort: " + o.Label() + m.reportTable.SetCursor(0) + m.err = m.reloadReport() } // Init implements tea.Model. @@ -327,9 +381,59 @@ func (m *Model) reload() error { if err := m.reloadAccounts(); err != nil { return err } + if err := m.reloadPeriods(); err != nil { + return err + } return m.reloadTxns() } +// reloadPeriods rebuilds the report's time axis from the months the index now +// holds, staying on the window the user was looking at if it is still there. +// An import that reaches further back grows the axis, so it cannot simply be +// built once at startup; but it must not throw the user back to the default +// either, since the import they just ran is usually the reason they are looking. +func (m *Model) reloadPeriods() error { + months, err := m.db.Months() + if err != nil { + return err + } + var was string + if p, ok := m.currentPeriod(); ok { + was = p.Label + } + m.indexEmpty = len(months) == 0 + m.reportPeriods = report.Periods(time.Now(), months) + m.reportPeriod = report.DefaultIndex(m.reportPeriods) + for i, p := range m.reportPeriods { + if p.Label == was { + m.reportPeriod = i + break + } + } + return nil +} + +// currentPeriod is the window the report is on, if the axis has been built. +func (m *Model) currentPeriod() (report.Period, bool) { + if m.reportPeriod < 0 || m.reportPeriod >= len(m.reportPeriods) { + return report.Period{}, false + } + return m.reportPeriods[m.reportPeriod], true +} + +// setPeriod moves the report along its axis, clamping at both ends: the axis +// runs from all time down to the oldest month, and wrapping round from one to +// the other would land somewhere nobody aimed for. +func (m *Model) setPeriod(i int) { + if i < 0 || i >= len(m.reportPeriods) || i == m.reportPeriod { + return + } + m.reportPeriod = i + m.status = "period: " + m.reportPeriods[i].String() + m.reportTable.SetCursor(0) + m.err = m.reloadReport() +} + func (m *Model) reloadAccounts() error { accounts, err := m.db.Accounts() if err != nil { @@ -380,8 +484,7 @@ func (m *Model) reloadTxns() error { } m.txnTable.SetCursor(cursor) - m.reloadReport(txns) - return nil + return m.reloadReport() } // The two rows the report grows below TOTAL. They are bracketed like @@ -393,8 +496,25 @@ const ( feesRow = " ⤷ fees" ) -func (m *Model) reloadReport(txns []model.Transaction) { - rows := report.ByTag(txns) +// reloadReport re-queries under the report's own time window. +// +// It cannot reuse the rows the transaction list is showing: the period narrows +// the report and only the report, so that opening on last month does not also +// hide the rest of the index from the list beside it. Everything else about the +// scope — the account, the search, the untagged toggle — is shared, so the two +// screens still answer for the same selection of transactions. +func (m *Model) reloadReport() error { + f := m.filter + f.Untagged = m.onlyUntagged + if p, ok := m.currentPeriod(); ok { + f.From, f.To = p.From, p.To + } + txns, err := m.db.Transactions(f) + if err != nil { + return err + } + + rows := report.ByTag(txns, m.reportOrder) out := make([]table.Row, 0, len(rows)+2) for _, r := range rows { out = append(out, table.Row{ @@ -435,6 +555,7 @@ func (m *Model) reloadReport(txns []model.Transaction) { } } m.reportTable.SetRows(out) + return nil } // ruleFormWidth is the width of the rule builder's left-hand form, and @@ -1862,6 +1983,27 @@ func (m *Model) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "7": m.openTransferList() return m, nil + case "s": + // Re-sorting only means anything where there is a breakdown to sort. + if m.view != viewReport { + break + } + m.setOrder(m.reportOrder.Next()) + return m, nil + + case "left", "right": + // The report's time axis. Every other view leaves these to its table, + // which is what breaking out of the switch here does. + if m.view != viewReport { + break + } + step := 1 + if msg.String() == "left" { + step = -1 + } + m.setPeriod(m.reportPeriod + step) + return m, nil + case "tab": m.view = (m.view + 1) % viewCount switch m.view { @@ -1984,7 +2126,7 @@ func (m *Model) View() string { case viewTxns: b.WriteString(m.txnTable.View()) case viewReport: - b.WriteString(m.reportTable.View()) + b.WriteString(m.reportView()) case viewRules: b.WriteString(m.rulesView()) case viewRuleList: @@ -2019,6 +2161,72 @@ func (m *Model) View() string { return b.String() } +// periodListWidth is the width of the report's time axis, wide enough for the +// longest window name plus its cursor. +const periodListWidth = 18 + +// reportView puts the time axis to the left of the totals, so the window the +// numbers are for is visible without reading the title and ←/→ has something to +// point at. On a window too narrow for both, the totals win and the axis is +// dropped: the title still names the period, and truncated money columns would +// be worse than a hidden list of periods. +func (m *Model) reportView() string { + axis := m.periodList() + if axis == "" { + return m.reportTable.View() + } + return lipgloss.JoinHorizontal(lipgloss.Top, axis, m.reportTable.View()) +} + +// periodList renders the axis, scrolled to keep the selected window on screen. +// Its header stands in for the table's own, which is a single unruled line, so +// the two panels line up row for row without a separator of their own. +func (m *Model) periodList() string { + if len(m.reportPeriods) == 0 { + return "" + } + if m.width > 0 && m.width < tableWidth(m.reportTable)+periodListWidth { + return "" + } + + h := m.reportTable.Height() + if h <= 0 || h > len(m.reportPeriods) { + h = len(m.reportPeriods) + } + // Centre the cursor where there is room, so the windows either side of the + // one being read are visible and stepping has somewhere obvious to go. + start := m.reportPeriod - h/2 + if start > len(m.reportPeriods)-h { + start = len(m.reportPeriods) - h + } + if start < 0 { + start = 0 + } + + var b strings.Builder + b.WriteString(periodHeaderStyle.Render("Period")) + for i := start; i < start+h; i++ { + line := " " + m.reportPeriods[i].Label + style := periodStyle + if i == m.reportPeriod { + line = "▸ " + m.reportPeriods[i].Label + style = periodSelectedStyle + } + b.WriteString("\n" + style.Render(line)) + } + return b.String() +} + +// tableWidth is how many cells a table renders across, the cell style's padding +// on either side of every column included. +func tableWidth(t table.Model) int { + w := 0 + for _, c := range t.Columns() { + w += c.Width + 2 + } + return w +} + // rulesView puts the form on the left and the live preview on the right. func (m *Model) rulesView() string { return lipgloss.JoinHorizontal(lipgloss.Top, m.ruleFormView(), m.ruleTable.View()) @@ -2150,6 +2358,12 @@ func (m *Model) emptyMessage() string { if len(m.reportTable.Rows()) > 0 { return "" } + // A period with nothing in it is not the same as an empty index, and + // saying so matters most in the default window: statements for the month + // that has just ended often have not been downloaded yet. + if p, ok := m.currentPeriod(); ok && p.From != "" && !m.indexEmpty { + return fmt.Sprintf("Nothing in %s.\n\nPress ←/→ for another period, or i to import.", p.String()) + } return "Nothing to report yet.\n\nPress i to import some statements first." case viewRuleList: @@ -2205,6 +2419,9 @@ func (m *Model) title() string { } return fmt.Sprintf("money · transfers · %d definitions · every leg paired", n) case viewReport: + if p, ok := m.currentPeriod(); ok { + return "money · report · " + p.String() + " · " + scope + } return "money · report · " + scope default: return fmt.Sprintf("money · transactions · %s · %d rows", scope, len(m.txns)) @@ -2256,7 +2473,10 @@ func (m *Model) help() string { } return "d delete transfer · p prune all unmatched · r refresh pairing · 6 new transfer · 5 rules · 1 accounts · esc back · q quit" case viewReport: - return "1 accounts · 2 transactions · 4 new rule · 5 rules · 7 transfers · u untagged · a all accounts · q quit" + // The key is named by what pressing it does now, not by the order the + // rows are already in. + return "←/→ period · s sort by " + m.reportOrder.Next().String() + + " · 1 accounts · 2 transactions · 4 new rule · 5 rules · 7 transfers · u untagged · a all accounts · q quit" default: return "4 new rule · / search · u untagged · a all · i import · r retag · 5 rules · 7 transfers · q quit" } diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index b133bdf..a3cf605 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -69,6 +69,20 @@ func newTestModel(t *testing.T) (*Model, *store.DB) { return m, db } +// allTime moves the report off its default window. That window is last month, +// while the fixtures are dated whenever they were written, so a test that wants +// to see every transaction in the report has to say so. +func allTime(t *testing.T, m *Model) { + t.Helper() + m.setPeriod(0) + if p, _ := m.currentPeriod(); p.Label != "all time" { + t.Fatalf("period = %q, want all time", p.Label) + } + if m.err != nil { + t.Fatal(m.err) + } +} + // key sends a single keypress to the model. func key(t *testing.T, m *Model, s string) { t.Helper() @@ -183,6 +197,7 @@ func TestReportViewCoversEveryTransaction(t *testing.T) { m, _ := newTestModel(t) key(t, m, "3") // report view + allTime(t, m) view := m.View() for _, want := range []string{"groceries", report.Untagged, "TOTAL"} { if !strings.Contains(view, want) { @@ -1131,6 +1146,7 @@ func TestEmptyReportMessage(t *testing.T) { // With data present, the tables render instead of the empty-state text. func TestNoEmptyMessageWhenPopulated(t *testing.T) { m, _ := newTestModel(t) + allTime(t, m) for _, k := range []string{"1", "2", "3"} { key(t, m, k) if got := m.emptyMessage(); got != "" { @@ -1316,7 +1332,7 @@ func TestTransferBuilderSavesAndPairs(t *testing.T) { // The paired movement is out of the report; the unpaired leg is not. var out int64 - for _, r := range report.ByTag(txns) { + for _, r := range report.ByTag(txns, report.OrderOut) { out += r.Out } if want := int64(52000); out != want { @@ -1799,6 +1815,7 @@ func TestTransferBuilderTolerance(t *testing.T) { } // The pair is gone from the report, so the fee it took has to be named. + allTime(t, m) var rows []string for _, r := range m.reportTable.Rows() { rows = append(rows, strings.Join(r, " ")) @@ -2185,3 +2202,383 @@ func TestRuleBuilderSortsByCount(t *testing.T) { t.Errorf("preview = %v, want %v: the glob still filters, in count order", all, want) } } + +// monthsAgo is the 15th of the month n before this one. Mid-month on purpose: +// a fixture on the 31st would land in a different month than intended as soon +// as the month before it is shorter. +func monthsAgo(n int) time.Time { + now := time.Now() + return time.Date(now.Year(), now.Month(), 15, 0, 0, 0, 0, time.UTC).AddDate(0, -n, 0) +} + +// newDatedModel builds a model whose transactions are dated relative to today: +// 10.00 of groceries last month and 25.00 three months back. +func newDatedModel(t *testing.T) *Model { + t.Helper() + + db, err := store.Open(filepath.Join(t.TempDir(), "index.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + + accountID, err := db.UpsertAccount(model.Account{ + Slug: "checking", Name: "Checking", Currency: "EUR", MinorDigits: 2, + }) + if err != nil { + t.Fatal(err) + } + sourceID, err := db.SourceFile(accountID, "checking/st.csv", "sha", "2026-01-01T00:00:00Z") + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + when time.Time + desc string + amount int64 + }{ + {monthsAgo(1), "LIDL SOFIA", -1000}, + {monthsAgo(3), "LIDL PLOVDIV", -2500}, + } { + if _, err := db.InsertTransaction(model.Transaction{ + AccountID: accountID, + SourceFileID: sourceID, + Fingerprint: tc.desc, + Date: tc.when.Format("2006-01-02"), + Description: tc.desc, + AmountMinor: tc.amount, + }); err != nil { + t.Fatal(err) + } + } + + engine := rules.New(&config.Rules{Rule: []config.Rule{{Match: "*LIDL*", Tag: "groceries"}}}) + if _, err := engine.Retag(db); err != nil { + t.Fatal(err) + } + + m := New(t.TempDir(), db, nil, engine, transfers.New(&config.Rules{})) + if err := m.reload(); err != nil { + t.Fatal(err) + } + m.Update(tea.WindowSizeMsg{Width: 120, Height: 30}) + return m +} + +// reportOut is what the report says one tag spent, or "" if it has no row. +func reportOut(m *Model, tag string) string { + for _, r := range m.reportTable.Rows() { + if r[0] == tag { + return r[2] + } + } + return "" +} + +// The report opens on last month, not on everything: an all-time total is the +// one number a spending report is least often asked for. +func TestReportOpensOnLastMonth(t *testing.T) { + m := newDatedModel(t) + + p, ok := m.currentPeriod() + if !ok || p.Label != report.LastMonth { + t.Fatalf("period = %+v, want %q", p, report.LastMonth) + } + if got := reportOut(m, "groceries"); got != "10.00" { + t.Errorf("groceries = %q, want only last month's 10.00", got) + } + key(t, m, "3") + if view := m.View(); !strings.Contains(view, "last month") { + t.Errorf("title does not name the period:\n%s", view) + } +} + +// The period narrows the report and only the report. Opening on last month +// must not quietly hide the rest of the index from the transaction list. +func TestReportPeriodLeavesTheTransactionListAlone(t *testing.T) { + m := newDatedModel(t) + + if len(m.txns) != 2 { + t.Fatalf("transaction list has %d rows, want both", len(m.txns)) + } + key(t, m, "3") + key(t, m, "right") + if len(m.txns) != 2 { + t.Errorf("stepping the period changed the transaction list to %d rows", len(m.txns)) + } + if m.filter.From != "" || m.filter.To != "" { + t.Errorf("the period leaked into the shared filter: %+v", m.filter) + } +} + +// →Steps to the next window along, which past last month is the months the +// index actually holds. +func TestReportPeriodSteps(t *testing.T) { + m := newDatedModel(t) + key(t, m, "3") + + key(t, m, "right") + want := monthsAgo(3).Format("2006-01") + if p, _ := m.currentPeriod(); p.Label != want { + t.Fatalf("period = %q, want %q", p.Label, want) + } + if got := reportOut(m, "groceries"); got != "25.00" { + t.Errorf("groceries = %q, want that month's 25.00", got) + } + + key(t, m, "left") + if p, _ := m.currentPeriod(); p.Label != report.LastMonth { + t.Errorf("stepping back landed on %q, want %q", p.Label, report.LastMonth) + } +} + +// The axis clamps at both ends rather than wrapping: all time and the oldest +// month are as far as the two directions go. +func TestReportPeriodClampsAtBothEnds(t *testing.T) { + m := newDatedModel(t) + key(t, m, "3") + + for range 10 { + key(t, m, "left") + } + if p, _ := m.currentPeriod(); p.Label != "all time" { + t.Errorf("period = %q, want all time", p.Label) + } + if got := reportOut(m, "groceries"); got != "35.00" { + t.Errorf("groceries over all time = %q, want 35.00", got) + } + + last := len(m.reportPeriods) - 1 + for range 20 { + key(t, m, "right") + } + if m.reportPeriod != last { + t.Errorf("period index = %d, want it to stop at %d", m.reportPeriod, last) + } +} + +// The axis is on screen beside the totals, so the window the numbers are for +// does not have to be read out of the title. +func TestReportShowsThePeriodAxis(t *testing.T) { + m := newDatedModel(t) + key(t, m, "3") + + view := m.View() + for _, want := range []string{"Period", "▸ " + report.LastMonth, "all time", "←/→ period"} { + if !strings.Contains(view, want) { + t.Errorf("report view is missing %q:\n%s", want, view) + } + } +} + +// A window with nothing in it is not an empty index, and the two want different +// answers: one is fixed by stepping the period, the other by importing. +func TestEmptyPeriodIsNotAnEmptyIndex(t *testing.T) { + m := newDatedModel(t) + key(t, m, "3") + + // Two months back holds neither fixture. + for _, p := range m.reportPeriods { + if p.Label == monthsAgo(2).Format("2006-01") { + t.Fatalf("did not expect a window for an empty month: %+v", p) + } + } + m.filter.Search = "NOTHING MATCHES THIS" + if err := m.reloadReport(); err != nil { + t.Fatal(err) + } + if got := m.emptyMessage(); !strings.Contains(got, "period") { + t.Errorf("empty period message = %q, want it to point at the period", got) + } + if strings.Contains(m.emptyMessage(), "import some statements") { + t.Error("an empty period asked for an import, but the index has statements") + } +} + +// An import that reaches further back grows the axis without moving the window +// the user is reading. +func TestPeriodAxisSurvivesAReload(t *testing.T) { + m := newDatedModel(t) + key(t, m, "3") + key(t, m, "right") + was, _ := m.currentPeriod() + + if err := m.reload(); err != nil { + t.Fatal(err) + } + if p, _ := m.currentPeriod(); p.Label != was.Label { + t.Errorf("reload moved the period from %q to %q", was.Label, p.Label) + } +} + +// newSortModel builds a report worth sorting: three tags that come out in a +// different order under each arrangement. +func newSortModel(t *testing.T) *Model { + t.Helper() + + db, err := store.Open(filepath.Join(t.TempDir(), "index.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + + accountID, err := db.UpsertAccount(model.Account{ + Slug: "checking", Name: "Checking", Currency: "EUR", MinorDigits: 2, + }) + if err != nil { + t.Fatal(err) + } + sourceID, err := db.SourceFile(accountID, "checking/st.csv", "sha", "2026-01-01T00:00:00Z") + if err != nil { + t.Fatal(err) + } + // One big outflow, three small ones, and an inflow bigger than any of them. + rows := []struct { + desc string + amount int64 + }{ + {"RENT", -100000}, + {"COFFEE 1", -500}, + {"COFFEE 2", -500}, + {"COFFEE 3", -500}, + {"SALARY", 200000}, + } + for _, r := range rows { + if _, err := db.InsertTransaction(model.Transaction{ + AccountID: accountID, + SourceFileID: sourceID, + Fingerprint: r.desc, + Date: monthsAgo(1).Format("2006-01-02"), + Description: r.desc, + AmountMinor: r.amount, + }); err != nil { + t.Fatal(err) + } + } + + engine := rules.New(&config.Rules{Rule: []config.Rule{ + {Match: "RENT", Tag: "rent"}, + {Match: "COFFEE*", Tag: "coffee"}, + {Match: "SALARY", Tag: "salary"}, + }}) + if _, err := engine.Retag(db); err != nil { + t.Fatal(err) + } + + m := New(t.TempDir(), db, nil, engine, transfers.New(&config.Rules{})) + if err := m.reload(); err != nil { + t.Fatal(err) + } + m.Update(tea.WindowSizeMsg{Width: 120, Height: 30}) + key(t, m, "3") + return m +} + +// reportTags is the order the report's tag rows came out in, without the +// TOTAL and transfer rows the table grows below them. +func reportTags(m *Model) []string { + var out []string + for _, r := range m.reportTable.Rows() { + if r[0] == "TOTAL" || r[0] == transfersRow || r[0] == feesRow { + continue + } + out = append(out, r[0]) + } + return out +} + +// s cycles the arrangement, and every one of them is reachable that way. +func TestReportSortCyclesThroughEveryOrder(t *testing.T) { + m := newSortModel(t) + + want := map[report.Order][]string{ + report.OrderOut: {"rent", "coffee", "salary"}, + report.OrderIn: {"salary", "coffee", "rent"}, + report.OrderNet: {"rent", "coffee", "salary"}, + report.OrderCount: {"coffee", "rent", "salary"}, + report.OrderTag: {"coffee", "rent", "salary"}, + } + if m.reportOrder != report.OrderOut { + t.Fatalf("report opened on %s, want %s", m.reportOrder, report.OrderOut) + } + seen := map[report.Order]bool{} + for range len(want) { + seen[m.reportOrder] = true + if got := reportTags(m); !slices.Equal(got, want[m.reportOrder]) { + t.Errorf("%s: rows = %v, want %v", m.reportOrder, got, want[m.reportOrder]) + } + key(t, m, "s") + } + if len(seen) != len(want) { + t.Errorf("cycling reached %d of %d orders", len(seen), len(want)) + } + if m.reportOrder != report.OrderOut { + t.Errorf("the cycle ended on %s, want it back where it started", m.reportOrder) + } +} + +// The sort rearranges the rows and nothing else: the same tags are there, for +// the same period, and the transaction list is untouched. +func TestReportSortChangesOnlyTheOrder(t *testing.T) { + m := newSortModel(t) + + before := slices.Clone(reportTags(m)) + txns := len(m.txns) + period, _ := m.currentPeriod() + + key(t, m, "s") + after := slices.Clone(reportTags(m)) + slices.Sort(before) + slices.Sort(after) + if !slices.Equal(before, after) { + t.Errorf("sorting changed which rows there are: %v then %v", before, after) + } + if len(m.txns) != txns { + t.Errorf("sorting the report changed the transaction list to %d rows", len(m.txns)) + } + if p, _ := m.currentPeriod(); p.Label != period.Label { + t.Errorf("sorting moved the period to %q", p.Label) + } +} + +// The marker sits on the column being sorted, so the order is visible in the +// table rather than only in the key list. +func TestReportMarksTheSortedColumn(t *testing.T) { + m := newSortModel(t) + + for _, want := range []string{"Out ▾", "In ▾", "Net ▾", "N ▾", "Tag ▴"} { + view := m.View() + if !strings.Contains(view, want) { + t.Errorf("%s: header does not mark %q:\n%s", m.reportOrder, want, view) + } + // Exactly one column carries it, or the header would lie about which + // column the rows are arranged by. + if n := strings.Count(view, "▾") + strings.Count(view, "▴"); n != 1 { + t.Errorf("%s: %d columns marked, want 1", m.reportOrder, n) + } + key(t, m, "s") + } +} + +// The key list names what pressing s does next, not the order already in force. +func TestReportSortHelpNamesTheNextOrder(t *testing.T) { + m := newSortModel(t) + if got := m.help(); !strings.Contains(got, "s sort by in") { + t.Errorf("help = %q, want it to offer the next order", got) + } + key(t, m, "s") + if got := m.help(); !strings.Contains(got, "s sort by net") { + t.Errorf("help = %q, want it to offer the next order", got) + } +} + +// s belongs to the report. Elsewhere it goes to the table like any other key, +// so a view that later wants it is not fighting a global binding. +func TestSortKeyIsReportOnly(t *testing.T) { + m := newSortModel(t) + key(t, m, "2") + key(t, m, "s") + if m.reportOrder != report.OrderOut { + t.Errorf("s re-sorted the report from the transaction list: %s", m.reportOrder) + } +}