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
rtapseudo-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 } >forSelkie::Widget::BarChart.cash-flow(:@transactions!, :%accounts!, :%peer-accounts --Hash)> —< { inflow, outflow, net } >in pence.assigned-total($view, Str $period --Int)>report-summary(...)— every figure plussegmentsandtext.summary-segments(%figures --List)> /summary-line(%figures --Str)> /summary-spans(%figures, :$theme! --List)>summary-rows-needed(Str $line, Int $inner-width --Int)> —SUMMARY-ROWS-MINorSUMMARY-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::Screen::Reports — the widget layer.
App::Moneymoor::View::BudgetRow —
UNGROUPED-NAMEand the §2 severity palette these share.App::Moneymoor::Service::Budget —
BudgetPeriod.assigned-totaland theactivityfigure spending is derived from.