ReportRow

NAME

App::Moneymoor::View::ReportRow - the reports tab's numbers: what was spent on what, and the period's cash flow.

SYNOPSIS


use App::Moneymoor::View::ReportRow;

# What the period was spent on, biggest first.
my @rows = spending-rows(
    categories => $main.categories,
    groups     => $main.groups,
    view       => $main.view,
    period     => '2026-03-01',
);
@rows[0];        # { kind => 'category', id => 4, name => 'Groceries',
                 #   pence => 41250 }

# The same, aggregated up to the groups.
my @by-group = spending-rows(:@categories, :@groups, :$view,
                             period => '2026-03-01', :by-group);

# Only as many bars as the chart has rows for, and a caption for the
# ones that did not fit.
my %cut = top-spending(@rows, 5);
%cut<rows>.elems;                  # 5
more-label(%cut<hidden>);          # '+3 more…'

# Bars, ready for Selkie::Widget::BarChart in horizontal mode.
report-bars(%cut<rows>).head;      # { label => '£412.50  Groceries',
                                   #   value => 412.5 }

# The strip above the chart.
my %sum = report-summary(
    transactions  => $ws.transactions.find-by-period('2026-03-01'),
    accounts      => %accounts-by-id,
    peer-accounts => %account-id-by-txn-id,
    view          => $main.view,
    period        => '2026-03-01',
    spending      => @rows,
);
%sum<text>;
# 'Inflow £2,000.00 · Outflow £412.50 · Net £1,587.50   Assigned …'

# How deep a box that strip needs, given the content width it is
# going into: frame plus one line, or frame plus two if it wraps.
summary-rows-needed(summary-line(%sum), 96);   # 3
summary-rows-needed(summary-line(%sum), 40);   # 4

DESCRIPTION

Pure functions: models, lookup hashes and a BudgetView in, row hashes and formatted strings out. No widgets, no store, no database — App::Moneymoor::Screen::Reports is the wiring, and every definition worth arguing about lives here where it can be argued about in a test.

Spending is not "negative activity"

An envelope's activity in a period is the sum of the splits filed against it. Spending is -activity when activity is negative, and nothing at all otherwise: a period where the only movement on "Groceries" was a £20 refund did not spend minus twenty pounds on groceries, and a chart with a bar pointing the wrong way says something the reader has to stop and decode.

Three kinds of row never appear:

  • the rta pseudo-category — it is not an envelope; money passing through it is money arriving, which is the summary strip's inflow figure and not a category anybody spent.

  • payment envelopes — a credit-card payment envelope's activity is the card's own spending seen a second time, from the other side. Counting it would double every card purchase in the chart.

  • zero rows — an envelope with no spending is not a bar of length zero, it is an absence, and the derivation is full of them (every envelope exists in every period).

:by-group sums the same per-category figures into their groups; a category with no group lands in the UNGROUPED-NAME bucket, which is the same bucket the envelope grid puts it in. Aggregating the spending rather than the activity matters: a group holding one envelope that spent £40 and another that was refunded £10 spent £40, not £30. The refund is a fact about that envelope's balance, not about the group's outgoings.

Transfers are not cash flow, except when they are

The summary strip's inflow and outflow come from the period's transactions on on-budget accounts, and the interesting case is the transfer. Moving £500 from the current account to savings is two legs, -500 and +500, and both accounts are on-budget: counting them would add £500 to both inflow and outflow while nothing entered or left the budget. So a transfer leg whose peer is also on-budget is dropped from both sums.

A transfer to (or from) a tracking account is the opposite case: the money really did leave the budget, and that leg counts like any other outflow. So does an ordinary transaction, transfer or not.

A leg whose peer cannot be resolved — the peer index is built from the same transaction set, so this does not happen in the app — counts. The alternative is silently dropping real money from the report because a lookup missed.

The bars carry their own figures

Selkie::Widget::BarChart renders a label and a bar; it has no per-bar value annotation, and its value axis is a scale along the top, not a figure per row. A spending report whose figures are only readable off a scale is a spending report nobody can quote, so report-bars puts the money in the label, right-aligned into a column as wide as the widest figure in the set:

£412.50  Groceries
      £88.00  Transport
       £4.20  Coffee

Money first because BarChart clips a label that does not fit its label column from the right — the same call View::RegisterRow's sidebar-label makes, for the same reason. A name cut short is still recognisable; a figure cut short is a different figure.

Values are handed over in pounds, not pence: the axis labels are generated from the values, and a scale reading "0 20000 40000" is not a scale about money anybody has.

The cut, and the caption

Bars do not scroll and they do not compress below one row each, so a chart with more categories than rows draws some of them on top of the axis. top-spending takes the biggest $limit and reports how many it left behind; more-label turns that count into the '+3 more…' caption the pane puts in its bottom title. Biggest-first is what makes the cut defensible: the rows that fall off are the ones that matter least.

The strip is one line, until it is two

Selkie::Widget::RichText word-wraps, so the summary strip needs a second content row on a terminal too narrow for its five figures — and a second content row that nothing wraps into is a blank line inside a box, which reads as a rendering fault rather than as breathing room.

summary-rows-needed is the whole decision, as a function of the strip and the width of the box it is going into:


my Str $line = summary-line(%sum);          # 74 characters
summary-rows-needed($line, 96);             # 3 — frame plus one line
summary-rows-needed($line, 60);             # 4 — frame plus two
summary-rows-needed($line, 0);              # 4 — nothing measured yet

SUMMARY-ROWS (4) is the ceiling as well as the fallback: the pane never grows a third content row, because the chart underneath is what the tab is for. A strip that would wrap twice is cut instead, and Screen::Reports is the caller that owns applying the answer.

EXPORTS

  • spending-rows(:@categories!, :@groups, :$view!, :$period!, :$by-group -- List)> — < { kind, id, name, pence } >, descending.

  • spending-total(@rows -- Int)>

  • top-spending(@rows, Int $limit -- Hash)> — < { rows, hidden } >.

  • more-label(Int $hidden -- Str)>

  • report-bars(@rows -- List)> — < { label, value } > for Selkie::Widget::BarChart.

  • cash-flow(:@transactions!, :%accounts!, :%peer-accounts -- Hash)> — < { inflow, outflow, net } > in pence.

  • assigned-total($view, Str $period -- Int)>

  • report-summary(...) — every figure plus segments and text.

  • summary-segments(%figures -- List)> / summary-line(%figures -- Str)> / summary-spans(%figures, :$theme! -- List)>

  • summary-rows-needed(Str $line, Int $inner-width -- Int)> — SUMMARY-ROWS-MIN or SUMMARY-ROWS.

  • chart-title(Bool $by-group -- Str)> / summary-title(Str $period-label -- Str)>

  • CHART-AXIS-ROWS / CHART-FALLBACK-ROWS / SUMMARY-ROWS / SUMMARY-ROWS-MIN

SEE ALSO

App::Moneymoor v0.4.2

YNAB-style envelope budgeting: a derivation engine

Authors

  • Matt Doughty

License

Artistic-2.0

Dependencies

DBIish:ver<0.6.7>:auth<zef:raku-community-modules>Notcurses::Native:ver<0.6.5+>:auth<zef:apogee>Selkie:ver<0.16.0+>:auth<zef:apogee>JSON::Fast:ver<0.19>:auth<cpan:TIMOTIMO>MacOS::NativeLib:ver<0.0.6>:auth<zef:lizmat>

Test Dependencies

Provides

  • App::Moneymoor
  • App::Moneymoor::Config
  • App::Moneymoor::DB
  • App::Moneymoor::Gateway::Account
  • App::Moneymoor::Gateway::Assignment
  • App::Moneymoor::Gateway::Category
  • App::Moneymoor::Gateway::Payee
  • App::Moneymoor::Gateway::Transaction
  • App::Moneymoor::Handlers::Boot
  • App::Moneymoor::Model::Account
  • App::Moneymoor::Model::Assignment
  • App::Moneymoor::Model::Category
  • App::Moneymoor::Model::CategoryGroup
  • App::Moneymoor::Model::Payee
  • App::Moneymoor::Model::Split
  • App::Moneymoor::Model::Transaction
  • App::Moneymoor::Screen::Accounts
  • App::Moneymoor::Screen::Budget
  • App::Moneymoor::Screen::Login
  • App::Moneymoor::Screen::Main
  • App::Moneymoor::Screen::Main::Keybinds
  • App::Moneymoor::Screen::Main::Modals
  • App::Moneymoor::Screen::Main::Subscriptions
  • App::Moneymoor::Screen::Reports
  • App::Moneymoor::Service::Budget
  • App::Moneymoor::Service::Icons
  • App::Moneymoor::Service::Target
  • App::Moneymoor::Service::Workspace
  • App::Moneymoor::StoreHandlers
  • App::Moneymoor::Theme
  • App::Moneymoor::Theme::Catppuccin
  • App::Moneymoor::Theme::Dracula
  • App::Moneymoor::Theme::Everforest
  • App::Moneymoor::Theme::Gruvbox
  • App::Moneymoor::Theme::Kanagawa
  • App::Moneymoor::Theme::Monokai
  • App::Moneymoor::Theme::Nord
  • App::Moneymoor::Theme::OneDark
  • App::Moneymoor::Theme::RosePine
  • App::Moneymoor::Theme::Solarized
  • App::Moneymoor::Theme::TokyoNight
  • App::Moneymoor::Themes
  • App::Moneymoor::UI
  • App::Moneymoor::Util::Money
  • App::Moneymoor::Util::Period
  • App::Moneymoor::View::BudgetRow
  • App::Moneymoor::View::EmptyState
  • App::Moneymoor::View::HintBar
  • App::Moneymoor::View::InspectorPane
  • App::Moneymoor::View::ModalChrome
  • App::Moneymoor::View::RegisterRow
  • App::Moneymoor::View::ReportRow
  • App::Moneymoor::Widget::BannerBar
  • App::Moneymoor::Widget::BootProgressModal

The Camelia image is copyright 2009 by Larry Wall. "Raku" is a trademark of the Yet Another Society. All rights reserved.

Built with Podlite — the markup and publishing tools behind this site.