StoreHandlers

NAME

App::Moneymoor::StoreHandlers - the store's state shape, the navigation events, and the one effect every mutation in the app funnels through.

SYNOPSIS


use App::Moneymoor::StoreHandlers;

my $handlers = App::Moneymoor::StoreHandlers.new(
    workspace => $workspace,
    toast     => -> Str $msg { $app.toast($msg) },
    on-view   => -> $view    { $main.set-view($view) },
);
$handlers.register($store);

$store.dispatch('app/init', theme => 'nord', icons => 'unicode');
$store.tick;

# Every write in the app looks like this β€” a closure the effect runs.
$store.dispatch('ws/mutate-requested', action => -> {
    $workspace.set-assigned('2026-08-01', $groceries-id, 40000);
});
$store.tick;

DESCRIPTION

The state shape

app/       tab      'budget' | 'accounts' | 'reports'
               period   'YYYY-MM-DD' β€” the budget period the user is
                        looking at, named by its start date
               theme    palette name (mirrors Config)
               icons    glyph tier (mirrors Config)
ws/        rev      monotonic recompute counter β€” the coarse change
                        token (see below)
budget/    digest            BudgetView.canonical-digest β€” THE change token
               catalogue         the envelope catalogue's shape β€” the OTHER
                                 change token (see below)
               overspent-count   categories with available < 0 in the viewed period
               selected-category-id
               collapsed-groups  { group-id => True }
               show-hidden       Bool
               inspector         Bool
accounts/  selected-account-id   0 = All Accounts
               closed-expanded       Bool β€” the sidebar's CLOSED (n) fold
               reconcile             Nil | { account-id, statement }
reports/   by-group              Bool β€” b's aggregation toggle

Note what is not here: the BudgetView itself, and the id β†’ model lookup hashes built from it. Those live on App::Moneymoor::Screen::Main and reach the store only as budget/digest. The digest is documented byte-stable and order-independent, which makes it the one thing derived from the view that is safe to hand a subscription selector β€” a selector returning the view object itself would be compared by identity and read as "changed" on every single tick.

on-view is the seam: the recompute hands the fresh view to whoever owns the cache, then writes the digest. Subscriptions watch the digest and pull from the cache in their callbacks.

The digest is not enough on its own

canonical-digest is a digest of the derivation: periods, balances, moves, account totals. It contains no category names, no group memberships and no sort orders, and it omits a category that has no money and no history entirely. So creating an envelope, renaming one, moving it to another group or reordering the groups all leave the digest byte-identical β€” and a table subscribed only to the digest would not repaint.

budget/catalogue is the second token, covering exactly what the digest does not: every group and category's id, name, group, kind, sort order, hidden flag, carry-overspend flag and target tuple, in id order. The two together are what the envelope grid watches. It costs two extra SELECTs per recompute, which is the same trade Screen::Main.set-view already makes for the lookup caches.

…and neither of them can see a memo

Both tokens are about the budget. Neither carries a payee's name, a transaction's memo or its cleared state, and the register shows all three: renaming a payee, retyping a memo or pressing c on a row leaves the digest and the catalogue byte-identical, and a register keyed on those two would repaint nothing.

ws/rev is the third token and the coarsest: an integer bumped by every recompute, which is to say by every successful mutation and by the initial load. The register and the sidebar key on it. It is deliberately blunt β€” it says "something was written", not what β€” which is the right resolution for a pane that rebuilds its rows wholesale anyway, and the wrong one for the envelope grid, which is why the grid keeps the two finer tokens.

Monotonic rather than a content hash: a counter cannot collide, and an undo that restores a previous state still has to repaint.

Reconcile mode is one path, and it clears itself

accounts/reconcile is either Nil or a two-key map naming the account being reconciled and the statement balance typed into the dialog. Three things about it are load-bearing.

It is written with db-replace, never db. The db effect deep-merges, so merging Nil β€” or a map for a different account β€” into a populated one either does nothing or leaves half the old map behind. Leaving reconcile mode has to be an exact replacement.

It is a mode, so it has to end. Switching to another ledger, or to another tab, exits it (accounts/select-account and app/tab-selected clear it themselves). A statement balance is about one account, and a "Reconciling" title left on a register the user has walked away from is a lie the next keystroke acts on. Cleared marks already made are kept either way: they are facts about transactions, not about the mode.

It never survives a reload. Nothing here is persisted β€” the statement balance is a number the user is holding in their hand, not part of the budget.

The one mutation effect

Every gateway call in App::Moneymoor answers with a Failure on invalid input rather than throwing, and every one of them needs the same four things afterwards: check for failure, toast the message, defuse the Failure, recompute. Writing that at thirty call sites is thirty chances to forget the .so and get a re-thrown Failure at some unrelated sink later.

So there is exactly one effect, ws/mutate, and it takes the gateway call as a closure:


# From a keybind or a modal's submit handler:
$store.dispatch('ws/mutate-requested', action => -> {
    $workspace.move-money($period, $from, $to, $pence);
});

# Or, from a handler that has other state to write in the same delta:
$store.register-handler('budget/assign-requested', -> $st, %ev {
    (
        (db => { budget => { selected-category-id => %ev<category-id> } }),
        ('ws/mutate' => { action => -> {
            $workspace.set-assigned(%ev<period>, %ev<category-id>, %ev<amount>);
        } }),
    );
});

On Failure the message is toasted, the Failure is defused, and the recompute is skipped β€” a write that did not happen must not bump the digest, or every subscription in the app repaints to say nothing changed. On success the budget is recomputed through max(current-period, viewed-period) so future periods materialise, the lookup caches are rebuilt, and the digest plus the overspent badge count land in the store.

Effects run after the dispatching handler's own db delta has been applied, so a handler can write the period and let the effect recompute against it in one dispatch.

Recompute is also callable directly

recompute($store) is public because two paths need it outside the mutation flow: the initial load (there is nothing to mutate yet) and period navigation (the through-period moved, but no fact did). Both go through the same code, so a bug in the badge arithmetic cannot show up on one path and not the other.

EVENTS

  • app/init β€” seeds the whole state tree and recomputes. Payload: theme, icons (both optional; they mirror Config).

  • app/tab-selected β€” { name }. Writes app/tab.

  • app/period-prev / app/period-next β€” step app/period along the workspace's scheme and recompute.

  • app/period-set β€” { period }. Jump straight to a period; ignores anything that is not a real period start under the workspace's scheme rather than corrupting the store.

  • budget/toggle-inspector / budget/toggle-hidden β€” flip the two budget-tab view switches. No recompute: neither changes a fact, only which rows get drawn.

  • budget/select-category β€” { category-id }. The grid's cursor. An undefined id is legal and means "the cursor is on a group header", which is not a category.

  • budget/toggle-group β€” { group-id }. Fold or unfold one group in budget/collapsed-groups. Also no recompute.

  • accounts/select-account β€” { account-id }. The sidebar's cursor, and so which ledger the register shows. 0 β€” the default β€” is All Accounts, not "none": a missing or undefined id is written through as 0 rather than left to every read site to guard.

  • accounts/toggle-closed β€” fold or unfold the sidebar's CLOSED (n) bucket. No recompute: closed accounts are always in the derivation, this only decides whether they are drawn.

  • accounts/reconcile-start β€” { account-id, statement }. Enter reconcile mode against a statement balance in pence. An account id of 0 (All Accounts) or an unparseable statement is refused rather than written: reconciling every ledger at once is not a thing.

  • accounts/reconcile-exit β€” leave reconcile mode, keeping every cleared mark made in it.

  • reports/toggle-by-group β€” flip the reports tab between per-envelope and per-group aggregation. No recompute: it changes how the same derivation is added up, not any fact.

  • ws/mutate-requested β€” { action }. The generic doorway to the ws/mutate effect for call sites with nothing else to write; handlers with their own delta should return the effect directly instead.

EFFECTS

  • ws/mutate β€” { action }, the closure described above.

  • ws/recompute β€” no payload. Recompute without mutating; what app/init and the period-nav handlers return.

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.