BudgetRow
NAME
App::Moneymoor::View::BudgetRow - the row model behind the envelope table, and the one place §2's "state → colour" table is written down.
SYNOPSIS
use App::Moneymoor::View::BudgetRow;
my @rows = budget-rows(
groups => $ws.categories.find-groups(:include-hidden),
categories => $ws.categories.find-all(:include-hidden),
view => $view,
scheme => $ws.scheme,
period => '2026-08-01',
collapsed => %( '3' => True ), # group 3 is folded shut
show-hidden => False,
icons => icons('unicode'),
);
# Straight into Selkie::Widget::Table — the keys are the column names:
$table.set-rows(@rows);
$table.set-row-style(-> %row {
severity-style(%row<severity>, theme => $theme)
});
@rows[0]<category>; # '▾ Monthly Bills'
@rows[1]<category>; # ' Rent'
@rows[1]<available>; # ' £750.00 ' (right-aligned, one-cell gutter)
@rows[1]<target>; # ' £750.00 ' — or blank, with no target
@rows[1]<severity>; # 'positive'
# The header pill above the table is built from the same view:
rta-summary($view, '2026-08-01', icons => $icons);
# { text => 'Ready to Assign £412.50', severity => 'rta-positive',
# note => '£300.00 assigned in future periods', rta => 41250, … }
# And the pickers that have to list envelopes in the same order:
envelope-options(:@categories, :@groups, exclude-id => $rent-id);
# ('Monthly Bills · Council Tax' => 7, 'Credit Card Payments · Visa' => 9, …)
DESCRIPTION
Pure functions. No widgets, no store, no database — a BudgetView,
some models and a period go in, row hashes come out. That is what makes
the grid's awkward parts (grouping, collapse, zero-fill, the two kinds
of overspending) testable without a terminal.
What a row is
Every hash carries the five Selkie::Widget::Table column cells —
category, assigned, activity, available, target —
already rendered as strings, plus the metadata the screen needs to act
on the row:
kind—'group'or'category'.id— the group id or the category id. Group rows useUNGROUPED-ID(0) for the bucket of categories with no group; no real row can have that id, because SQLite'sINTEGER PRIMARY KEYstarts at 1.group-id— the owning group of a category row (againUNGROUPED-IDwhen it has none).hidden— True for a retired envelope being shown becauseshow-hiddenis on.severity— the §2 state key;severity-styleturns it into aSelkie::Style.
The money cells are rendered here rather than by a Table column
&render callback for one reason: a callback receives only the raw
cell value, and a group header row has no assigned value at all. The
row builder knows what kind of row it is; the column does not.
Right-alignment, and the gutter
Selkie::Widget::Table lays columns out edge to edge with no gap and
pads every cell to the column width, so a number right-aligned into the
full width would touch its neighbour. Each money cell is therefore
right-aligned into width - 1 and given a single trailing space,
which is the gutter. money-header pads the column labels the same
way, so the header sits over its own digits instead of over the column
to its left.
A figure too wide for the column is re-rendered without thousands
separators before giving up — Table truncates from the right, which
on money would silently drop the pence.
The Target column
Rightmost, and blank for an envelope with no target. Zero is the
"no target" sentinel (see App::Moneymoor::Model::Category), and a
column full of £0.00 would say that every envelope wants nothing —
a different and much louder claim than "these have not been given
targets". A group header shows the sum of its children's figures, on
the same rule as its available total: exactly the rows underneath it,
blank when they add to nothing.
What the figure is depends on the target's kind, and the column
asks App::Moneymoor::Service::Target rather than reading
target-pence: refill and set_aside show their own amount,
because that is their per-period figure, while by_period shows the
current milestone — where this period's plan says the envelope
should be, not the £50,000 it is heading for. That is the settled
ruling and it is what makes the column addable: a group holding one
goal envelope would otherwise show a header total dwarfed by a number
nobody is being asked for this period. The rail carries the end goal
and the schedule; the grid carries what the plan wants by now.
Because a milestone moves with the period and with the money, the
column needs the period scheme and the derived view — which is why
budget-rows takes :$scheme as well as :$view. target-figure
is the one place that decision is made, and the header sum is a sum of
exactly it.
There is no colour rule here, and specifically none for underfunded.
Table styles a row, not a cell (set-row-style takes the whole
row hash), so painting an underfunded target amber would paint the
envelope's name, its assigned and its available amber too — saying
"this row is in trouble" about a row that is merely not finished. The
detail rail says it instead, where a line can carry its own severity.
A group's own hidden flag is ignored
category_groups carries a hidden column and this builder does not
read it. That is deliberate rather than an omission: nothing in the UI
can set it (the group editor offers a name and a sort order), and
honouring it would take every envelope in that group off the screen
along with its balance — money vanishing from a budget screen because
of a flag nobody can see or clear. Hiding is a per-envelope decision,
where the user can always get the row back with u.
Zero-fill: never iterate category-periods
BudgetPeriod.category-periods omits rows that are entirely zero,
which is right for the derivation (a forty-category budget over five
years would otherwise be mostly noughts) and wrong for a grid, where a
category the user created this morning still has to appear. Every cell
here comes from view.category($period, $id), which is documented to
answer with a zero-filled row for a category it has never heard of.
§2, in one table
| State | severity | Style |
|---|---|---|
| group header row | header | fg-bright bold on bg-surface |
| cash overspend (flag) | cash-overspend | fg-red |
| credit overspend (flag) | credit-overspend | fg-amber |
| payment envelope negative (flag) | payment-negative | fg-purple |
| carried cash negative (flag) | carried-negative | fg-purple |
| hidden envelope | hidden | fg-dim |
| available > 0 | positive | fg-green |
| available == 0 | zero | fg-dim |
| Ready to Assign > 0 | rta-positive | fg-green bold |
| Ready to Assign < 0 | rta-negative | fg-red bold |
| Ready to Assign == 0 | rta-zero | fg-dim |
| anything else | normal | fg-base |
Three ways to be in the red, three colours, and none of it is a taste call — each says what the period boundary is about to do with the hole:
Red (
cash-overspend) — it resets to zero and drags every future period's Ready to Assign down with it. Money you spent that you did not have.Amber (
credit-overspend) — written off against the card's payment envelope and left where it happened. Debt on a card, not a hole in the budget.Purple (
payment-negative,carried-negative) — it carries forward intact and nothing is charged to Ready to Assign. Two severities rather than one because the states read differently — a card you are behind on, versus an envelope you told the engine it may run negative — even though the arithmetic and the hue are identical. Wording is the rail's job; having the key to word it with is this table's.
Precedence follows that table top-down: a hidden envelope that is also
cash-overspent renders red, because the money problem outranks the
filing status. Two pairs in it cannot co-occur at all — the derivation
never charges cash overspending to an envelope that carries its
negative, so cash-overspend meets neither payment-negative nor
carried-negative — and no row is ever both of the purple two, since
one is a payment envelope's flag and the other is only set on rows that
are not. So the only ordering that really bites is credit-overspend
before the purple pair, which is the order §2 lists them in.
EXPORTS
budget-rows(:@groups!, :@categories!, :$view!, :$scheme!, :$period!, :%collapsed, :$show-hidden, :$icons --List)>rta-summary($view, Str $period, :$icons --Hash)>envelope-options(:@categories!, :@groups!, :$exclude-id, :$include-hidden --List)> —< label => id> pairs for the move-money and category-editor pickers.severity-for($cm, Bool :$hidden --Str)>severity-style(Str $severity, :$theme! --Selkie::Style)>money-cell(Int $pence, Int $width --Str)> /money-header(Str $label, Int $width --Str)>target-cell(Int $pence, Int $width = TARGET-COLS --Str)> /blank-cell(Int $width --Str)>target-figure($view, $scheme, $category, Str $period --Int)> — the pence the Target column shows for one envelope: its amount, or its current milestone. 0 for no target.group-label(Str $name, Bool :$collapsed, :$icons --Str)>ASSIGNED-COLS/ACTIVITY-COLS/AVAILABLE-COLS/TARGET-COLS/UNGROUPED-ID/UNGROUPED-NAME
SEE ALSO
App::Moneymoor::Screen::Budget — the widget layer that consumes all of this.
App::Moneymoor::View::InspectorPane — the detail rail, which shares
severity-forandseverity-style.App::Moneymoor::Service::Budget —
BudgetView,CategoryPeriodand what the flags mean.App::Moneymoor::Service::Target — what a target asks for, and what its milestone is.