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 use UNGROUPED-ID (0) for the bucket of categories with no group; no real row can have that id, because SQLite's INTEGER PRIMARY KEY starts at 1.

  • group-id — the owning group of a category row (again UNGROUPED-ID when it has none).

  • hidden — True for a retired envelope being shown because show-hidden is on.

  • severity — the §2 state key; severity-style turns it into a Selkie::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 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.