Revision history for App::Moneymoor
0.3.1 2026-08-13T01:39:33+01:00
- [2026-08-13] An installed bundle starts in about two seconds
rather than the best part of a minute. The 0.3.0 bundles carried
precompiled bytecode for every module they shipped, and none of
it survived being installed: a bundle's module repository had no
name Rakudo recognised, so its precompilation records named the
machine that built them by absolute path, and the moment the
archive was unpacked anywhere else every one of those paths
stopped existing and the whole dependency closure was compiled
again on the user's machine. Nothing detected it because every
check ran where those paths still existed. Bundles are now built
with the app's modules installed into the bundled runtime's own
vendor repository, which Rakudo names, so the records are
relative and survive the move — and the bundle checker fails a
bundle whose records name a build machine, so this cannot ship
unnoticed twice. There is nothing to do but install 0.3.1: the
defect is baked into a 0.3.0 archive and cannot be repaired in
place.
- [2026-08-13] The installers run Moneymoor once before they
finish, under a line that says so. Whatever the first launch has
to do — paging a bundle off a cold disk, writing first-run state
— now happens while an installer is on screen rather than the
first time you actually want the program. A warm-up that fails
does not fail the install: the bundle is there and its checksum
was verified before anything moved into place.
- [2026-08-13] The documented one-liner works. It named the `main`
branch, this repository's default branch is `master`, and
raw.githubusercontent does not redirect between them, so
`curl … | sh` answered 404 for anyone who followed the README.
Every generated script and both READMEs now name `HEAD`, which
resolves to whatever a repository calls its default branch.
- [2026-08-13] Installing tells you your current terminal cannot
see the command yet, and gives you the line to paste if you do
not want to open a new one. It always meant to; the check asked
whether ~/.local/bin was on PATH after the installer had already
added it to its own environment, so the answer was always yes
and the message never printed.
0.3.0 2026-08-12T23:04:59+01:00
- [2026-08-12] Notcurses::Native dependency floor raised to 0.5.0
— the release whose prebuilt binary packs carry self-built,
licence-audited LGPL dependencies (with corresponding source
attached to each binary release) on every platform. Bundles
built from this version inherit those packs.
- [2026-08-07] Overspending can be carried, per envelope. Rule 3
has always had two answers for a negative balance — a payment
envelope carries it forward, everything else resets to zero and
charges the shortfall to every subsequent period's Ready to
Assign — and which one you got was decided by `kind`. The forcing
rule is the right default and the wrong rule for the envelope you
deliberately run negative: a reimbursable expense, a bill you
front and get back. `categories.carry_overspend` is that choice,
a sixth `ensure-column` whose `DEFAULT 0` is not just a sensible
value for a legacy row but the rule every period of that file was
already derived under, so there is nothing to backfill and no
existing budget changes by a penny. The engine's Pass B now asks
`Model::Category.carries-negative` — payment envelopes by kind,
standard envelopes by flag — which turns the two ternaries that
encoded the exception into the general case. Cash overspending is
zero on a carrying envelope, because the hole is not being
written off and charging Ready to Assign for it as well as
carrying it forward would take the same money out of the budget
twice; credit overspending is untouched by the flag, since that
debt lives on the card and adding it back would double-count
against the master invariant. The flag is retroactive by
construction and that is the settled ruling, not an oversight:
`compute` stores nothing, so turning it on re-derives every
period under the carrying rule, past Ready to Assign figures
included, which is the only honest reading of "this envelope was
always allowed to run negative". A carried negative gets its own
`carried-negative` flag and its own §2 severity — fg-purple, the
same hue as `payment-negative` because the arithmetic is
identical, its own key because the rail words them differently
("Overspend carries forward" against "Payment envelope
negative"): a purple row with no explanation reads as a bug in
the budget rather than as the setting that produced it. The
envelope editor gains a `Carry overspending` checkbox above the
target block, offered on standard rows only — a payment envelope
already carries by kind and Ready to Assign has no available to
carry, so `Gateway::Category.update` refuses the flag on both
with the same "only its sort order and hidden flag" refusal a
rename gets — and the dialog grew a row taller to keep the flex
error line its two rows on a 24-row terminal. The catalogue token
carries the flag too: the derivation can see it, so flipping it
on an envelope that has ever been overspent moves the digest by
itself, but on one that has not it changes only what the editor
renders, which is exactly the class of change that token exists
to catch.
- [2026-08-04] Target kinds reach the screen, which finishes the
program. The envelope editor gains a `Kind` picker — `Refill each
period` / `Set aside each period` / `Goal by period` — and, under
the last of those only, a `Goal period` date and a `Repeat`,
mounted and unmounted through the same swap-container pattern the
budget-period picker uses, so a field is never on the dialog
unless the option that reads it is selected and what was typed in
it survives a round trip. The date's placeholder carries the one
piece of guidance the design settled on — "when the bill is paid"
— because a goal targets the period its payment lands in, and the
date is read as "the period containing it" so a scheme change
re-derives the plan rather than orphaning a key. Blank still
clears a target, and clearing one forces the kind back to
`refill`: "no target" has exactly one shape. Saving splits in two:
name, group and sort order go through `categories.update`
carrying the row's **existing** target tuple unchanged, and the
target itself goes through `Workspace.set-target` and only when it
actually changed. That is not tidiness — `update` writes the whole
row, this dialog builds a fresh model from its own fields, and the
obvious version of it wipes a £50,000 plan every time somebody
fixes a typo in a name. A new envelope is created without a target
and then given one through the same front door, so its plan start
is stamped on the first save rather than the second.
Everything that reads a target now asks `Service::Target` and
nothing else: a bare `=` in the assign field adjusts by
`target-ask` (it used to be a fund-to, which is only the same
question for a refill — a set-aside's gap is measured against
this period's assignments and a goal's against its milestone),
`f` sweeps the same figure across every envelope and its confirm
lines say the milestone each lands on rather than a goal's total,
the grid's Target column shows the current milestone for a goal
(and group headers sum those, so the column still adds up to
something anybody is being asked for), and the detail rail grew a
block per kind: `Target £100.00 /period` and `Funded this period`
for a set-aside; the goal, a schedule line packed to fit the
rail's 26 columns (`by April 2027 · 3 of 9 · every 3 periods`,
broken across lines rather than truncated when a `monthly/14`
label will not share one), and `On track` / `Target met` told
apart for a goal. The catalogue token gained all four new columns
— including `target_start`, since a re-stamp moves every milestone
after it — and the rail's subscription gained the catalogue token,
which fixes a bug that predates this work: a target moves no
money, so the derivation's digest is byte-identical either side of
saving one, and the rail was keyed only on the digest and the
cursor. Saving a target repainted the grid and left the rail
showing the old block until the selection happened to move.
- [2026-08-04] Targets grow kinds, in the engine. A target used to
be one idea — "available should be £N each period" — which is
right for groceries and wrong for everything you are saving up
for: carry-in satisfies the level, so a Christmas fund with £900
in it asks for nothing, and "£50,000 by April" is neither
£50,000 this month nor a refill to £5,000. `target_kind` now
says which of three senses a target is meant in — `refill`
(unchanged, the default, and what every file written before
today reads as with no migration), `set_aside` ("put £N in each
period, whatever the balance") and `by_period` ("reach £N by the
period containing this date") — alongside `target_period`,
`target_start` and `target_repeat`, four more `ensure-column`s
whose defaults describe exactly what a legacy row already meant.
The new `Service::Target` is where they mean something:
`target-ask` is the kind-aware "what would funding this
envelope's plan for this period cost", and it is deliberately
the ONE function the rest of the app keys on, because `=`, `f`,
the grid's colour and the rail's copy all already key on
underfunded. `refill` measures available; `set_aside` measures
**assigned**, so carry-in never silences it, a refund is not
saving, and pulling this period's money back out re-opens the
ask; `by_period` measures a milestone on a straight line from
the stamped plan start to the goal period — `base + (N-base)·i/k`
in integer pence, with the last milestone forced to exactly N
because nine steps to £50,000 is £5,555.55 and nine of those is
not £50,000. Getting ahead makes later periods ask less; raiding
the pot makes the gap reappear **in that same period**, unspread,
whether it was raided by a move or by a spend. The ramp's base is
the envelope's carry-in at the cycle's first period, derived from
the view and never snapshotted, so a pre-funded pot is asked only
for the remaining gap and a retroactive history edit re-derives
the whole schedule. Goals repeat in periods (`R` ≥ 1 puts them at
E, E+R, E+2R…, so quarterly VAT is "every 3" under calendar
months), each cycle re-deriving its base from what was left after
the last bill; and in the goal period the envelope's own outflow
counts toward the milestone, so paying the bill mid-period does
not read as a £3,000 raid for the rest of it while the same spend
one period early still does. `Workspace.set-target` is the front
door, and the only thing in the app that may write
`target_start`: it stamps the current period when a target
becomes by-period or when its amount or goal period changes — a
different plan re-ramps from today — and pointedly does not when
only the repeat changes, which would move a cycle that is already
under way. It also normalises the fields a kind does not use,
because a form carries all of them whichever option is selected;
`Gateway::Category` refuses them instead, since an explicit
caller storing a goal date on a refill target has two ideas about
what it is storing. The gateway stays clock-free and scheme-free
(dates are validated for shape, and `set-target` is handed the
stamp), a system row refuses all five columns exactly as it
refuses a rename, and zero pence is legal only for `refill`,
because zero means "no target" and no target has one shape. This
was the engine half; the bullet above it is the other one, and
with it the target-kinds program is complete.
- [2026-08-04] A budget can now be asked when its period starts,
and told to change its mind — which completes the budget-periods
program. A budget created from the login screen is met, on its
first frame, by "When does your budget period start?": the
calendar month, a day of the month, or every N weeks from a first
payday. It is asked there rather than in the create form because
that form is 24 rows tall on the nose — the height of the
terminal it has to fit — and because the budget behind the
dialog is empty, so whatever is chosen re-buckets nothing at all.
Esc means "the calendar month, then", and nothing is written for
that answer: an absent `period_scheme` IS the calendar month, and
recording the default as a choice would leave a file diff and a
"changed" toast for a change nobody made. Settings gains a period
section — a line saying what the budget is on, in words
(`Calendar month`, `Monthly from the 14th`, `Every 4 weeks from
14 Aug 2026`, from the new `Util::Period.describe`), and `ctrl+p`
to change it. Deliberately not a fifth picker beside the palette
and the currency: those four are display preferences in
`config.json` that a save writes and a rebuild re-renders, and
the period is a property of the budget file whose change rewrites
every assignment row, which must never happen as a side effect of
changing a currency symbol. Changing it on a budget with history
goes through `Workspace.re-bucket-preview` and a confirm made of
its actual numbers — how many assignments move, how many periods
they are in now, how many they will be in, that envelope totals
are preserved, and, when it is not zero, how many will land on
top of another and be added together, which is the one half of
the operation changing back will not undo. Picking the scheme the
budget is already on is a toast rather than a dialog, because
`change-scheme` would accept it and rewrite every row to no
effect; a budget with no assignments skips the confirm for the
same reason fund-all with nothing to fund does. Every apply path
re-seeds `app/period` with the new scheme's current period,
because the store is holding a key from the old one that is in
general not a start under the new one and `app/period-set`'s
guard would drop it — and the recompute that follows carries the
change to the banner, the grid, the Ready-to-Assign pill and the
reports through the digest every subscription already watches, so
none of them needed plumbing. The picker validates before its
supply emits, like every other dialog: a day of 32, a week count
of 0 or a payday that is not a date paints the refusal inside the
still-open dialog, and only the detail fields the selected option
actually reads are on it at all — none for the calendar month,
Day for the day of the month, Weeks and First payday for every N
weeks — with what was typed remembered across a switch, so
flipping between the options and back costs nobody the date they
just entered. That last part is a fix for the shape this shipped
in earlier the same day, found by using it: all three fields
stayed in the tree and were merely blanked, so it was possible to
choose the calendar month, type 14 into the Day box that was
still sitting there, hit `ctrl+s` and be told "Budget period
unchanged" — a correct save (`parse-period-choice` has always
ignored the fields its mode does not use) that reads as a broken
one, because a field that renders, takes focus and accepts a
keystroke *is* an input. Two smaller pieces of the same
confusion went with it: the first option now names the day it
pins (`Calendar month (the 1st)`, since `Calendar month` beside a
Day field invites "a month starting on that day"), and the no-op
toast names the scheme the budget is staying on (`Budget period
unchanged — Calendar month`) instead of only reporting that
nothing happened. The rows that freed up went to the hint under
the fields, which was a one-row `Text` clipping all three of its
sentences mid-clause — including the only place the weekly option
says what it counts periods from, which also said "the payday
below" about a field above it. It is now a wrapping `RichText`
claiming the three rows the longest hint measures at the dialog's
44-column interior, the same at every option so the error line
does not move when the Select does. A day of 1 needs no
special case — "the 1st of the month" and "the calendar month"
are the same scheme, and `.monthly(anchor-day => 1)` normalises
one onto the other. The one thing that can go wrong at the
boundary now also has somewhere to go: a budget file whose stored
scheme cannot be read makes `Workspace.new` throw, and the entry
point catches it, leaves the login screen up and puts the
engine's own message — naming the key and quoting what it found —
on its status line, rather than letting a modal body explode over
a half-switched screen. The copy that used to say "month"
generalises with it: `[` and `]` are the previous and next
period, the hint bar says `period`, fund-all has nothing to do
"this period", the rail says there are no derived moves "this
period" and the grid's note is money "assigned in future
periods". Targets generalise in words as well as in mechanism — a
target is what an envelope wants in one budget period, so
`Gateway::Category` refuses "a target" rather than "a monthly
target" that cannot be negative.
- [2026-08-04] A budget's period scheme is now a property of the
budget file, and changing it moves the money with it.
`Service::Workspace` reads `budget_meta.period_scheme` —
`Util::Period.to-hash` as one line of JSON, keys sorted — before
it builds a single gateway, so the two that validate period keys
are born holding the file's scheme rather than the default. An
absent key still means the calendar month, which is what every
budget written so far has been using; a key that is present but
unreadable *throws*, naming the key and quoting what it found,
because a budget opened under a scheme its owner never chose
buckets every derived figure by the wrong windows and then
refuses every write the user attempts, and a file that will not
open is a problem with an obvious cause where one that opens
wrong is not. For the same reason the scheme is no longer a
constructor argument at all: passing one throws rather than
being quietly ignored. `change-scheme` is the single mutator,
and it does not merely record the new choice — it re-buckets, by
the settled rule that each assignment row moves to whichever new
period contains its old period's *start date*, with rows that
land together summed. Nothing is created, dropped or moved
between categories, only re-labelled, so total assigned per
category is preserved and Ready to Assign and the master
invariant come out exactly as they went in; that is asserted at
runtime as well as in the tests, by re-reading the per-category
totals after the rewrite and refusing — rolling the whole thing
back, `period_scheme` included — if a single penny has drifted.
The read, the rewrite, the assertion and the `budget_meta` write
share one transaction, and the rewrite is a delete-and-reinsert
in `(period start, category)` order rather than an in-place
re-key, because the unique index constrains each statement
rather than the transaction and merging rows has to delete one
of them anyway. It is lossy in the buckets by design — two
periods that merge cannot be told apart afterwards, so changing
back restores the totals and not the split — and `t/21` says so
rather than pretending otherwise. `re-bucket-preview` computes
the same five counts (`rows`, `periods-before`, `rows-after`,
`periods-after`, `merged`) with no writes at all, which is the
material the confirm dialog will be made of. An empty budget
re-buckets nothing and just writes the key, which is how a newly
created budget will adopt a non-default scheme. The invariant
property suite now runs its four properties under three schemes
— `monthly/1`, `monthly/14` and a four-weekly window anchored
inside the dates it draws from — on every one of its eight
seeds, 1080 random operations in all: the same generated facts,
bucketed three ways, because the derivation depends only on each
fact landing in exactly one period and on keys ordering as text.
That soak is what reaches the boundaries a calendar month never
has, a transaction dated on the anchor day itself among them.
- [2026-08-04] The engine is keyed by budget period. Everything
that used to be bucketed by a `'YYYY-MM'` calendar month is now
bucketed by a `'YYYY-MM-DD'` period start, and `Service::Budget`
takes the scheme that decides what that means as `:$scheme`.
Under `monthly/1` — the default, and the only value anything
passes today — every start is `'YYYY-MM-01'`, so this release
derives exactly the same budget from exactly the same facts; the
property suite, which asserts the master invariant, the move
ledger, rule 3's carry algebra and order-independence after every
one of 360 random operations, holds with every expected number
unchanged. That is the point of doing the re-key on its own: the
algebra depends only on facts bucketing into one period each and
on period keys ordering as text, never on how long a period is,
and now the code says so. `CategoryMonth` and `BudgetMonth`
become `CategoryPeriod` and `BudgetPeriod`, `BudgetView.months`
becomes `.periods`, `month-of` / `next-month` / `prev-month` /
`month-range` / `valid-month` are gone in favour of asking the
scheme, and `valid-period` (format only — start-ness needs a
scheme) and `period-range` (the capped run the derivation walks;
`Util::Period.periods-through` stays uncapped, because the cap
guards against a typo'd fact date, which is an engine problem
and not a fact about schemes) take their place.
`Model::Transaction.month` is deleted outright: deriving a
bucket from a date is scheme-dependent, and a scheme-ignorant
model that does it is right for the calendar month and silently
wrong for every other scheme. The gateways validate period keys
as *starts*, not merely as well-formed dates — a key like
`'2026-03-15'`, or an unmigrated `'2026-03'`, opens a phantom
bucket that the derivation sums alongside the real periods, so
the money is neither lost nor visible and the budget stops
adding up in a way no screen explains — and `Service::Workspace`
owns the one scheme object that the two validating gateways and
every `compute` call share. `find-by-month` becomes
`find-by-period` on both gateways, and asks the scheme where the
window ends rather than assuming a month. The UI half lands with
it: the store's `app/month` becomes `app/period` and holds a
start key, `app/month-prev` / `-next` / `-set` become
`app/period-prev` / `-next` / `-set` with a `period` payload,
and every screen, view builder and modal that took a `$month`
takes a `$period` — `viewed-month` → `viewed-period`,
`through-month` → `through-period`, `budget-rows(:$month!)` →
`:$period!`, and so on down. Two of those renames carry a
behaviour change rather than a spelling one. The store's guard
is now *start-ness*, not well-formedness: `app/period-set`
refuses `'2026-03-15'` and an unmigrated `'2026-03'` as flatly
as it refuses `'not-a-period'`, because a well-formed date that
is not a start under the workspace's scheme is exactly the key
that opens a phantom bucket. And the step handlers navigate by
asking that scheme — `next-period` / `prev-period` — rather than
by calling a free function that assumed the calendar, with a
junk key resetting to the current period instead of propagating
a throw out of a dispatch. `Screen::Main::Subscriptions`'
`month-label` is deleted in favour of `Util::Period.label`,
which reproduces its `monthly/1` output byte for byte and its
never-throw contract exactly; the banner, the Ready-to-Assign
pill's frame title and the reports strip's all read the scheme
live off the workspace rather than building one, so the release
that loads a real scheme from the budget file relabels all three
for free. Two things deliberately do *not* follow the rename.
The navigation copy still says "Previous month" / "Next month"
and the hint bar still says `[ ] month`, because under
`monthly/1` that is what they do and "period" is the engine's
word, not the user's. The delete-refusal copy does follow it —
"money assigned to it in any **period**" — because it is
mirroring a gateway guard whose own message now names the
"period(s) of non-zero assignments" it found, and a dialog that
called them months would be describing a different rule from the
toast that follows it. The one visible change is in the
diagnostics dump (Ctrl+G), which prints raw keys on purpose: its
`Month` and `Months N (a … b)` lines become `Period` and
`Periods N (a … b)`, and the keys under them are now
`'YYYY-MM-01'`.
- [2026-08-04] `assignments` is re-keyed on disk, by the
distribution's first *transforming* migration: `month` becomes
`period_start`, and every `'2026-03'` becomes `'2026-03-01'`.
Migrations replay on every connect, and the two patterns that
makes safe — `CREATE IF NOT EXISTS` and `ensure-column` — both
work because they are statements about a desired shape, which
can be asked for twice. A transform is a function applied to
rows: run `period_start || '-01'` twice and you have
`'2026-03-01-01'`. So the fact that it ran has to be recorded,
and a new `budget_meta` key/value table records it as
`schema_rev`, reachable through `DB.get-meta` / `set-meta` (an
absent key reads as the `Str` type object, because absence is an
answer — for the period scheme it will mean "the calendar
month"). The drop-indices / rename / rewrite / stamp sequence
shares one `run-txn`, and SQLite's DDL is transactional, so a
crash mid-migration reopens the file as the old shape or the new
one and never as a mixture. The legacy shape is detected by
asking `PRAGMA table_info` for a `month` column rather than by
trusting the stamp, because the real dogfood file has never had
a `budget_meta` table at all and so reads as unstamped on the
very connect that creates one. `t/20` rebuilds a genuine
pre-period file by hand and asserts the migration penny for
penny, that reopening does not apply it twice, and that the
gateway will not write the old shape afterwards.
- [2026-08-04] `Util::Period`, the first piece of budget periods.
Most people budget pay-to-pay rather than 1st-to-1st, so the
engine's keying dimension becomes the period: `monthly` with an
anchor day of 1–31 (`1` being the calendar month exactly, and
the default, so nothing that exists today changes) or `weekly`
with an every-N-weeks step from a first payday — four-weekly UK
pay being the case no month can fake. A period is named by its
own start date, `'YYYY-MM-DD'`, which makes lexicographic order
chronological order and lets the derivation go on comparing,
sorting and hashing period keys as text; the old `'YYYY-MM'`
months degenerate into `'YYYY-MM-01'`. The monthly sequence is
generated by iterating months and clamping the anchor into each
one, never by adding days: `2026-01-31` → `2026-02-28` →
`2026-03-31` recovers, where anything stepping from the clamped
date walks three days off the user's payday and stays there.
Navigation throws — `next-period` of a string that is not a
start under this scheme means a caller's key drifted, and a
budget bucketed two ways at once surfaces days later as money
that does not add up — while `label` returns `''`, because it is
composed inside a store selector where an exception takes out
the subscription walk. `monthly/1` labels "August 2026" exactly
as `month-label` did; everything else labels its range
("14 Aug – 13 Sep 2026", "14 – 27 Aug 2026"). Pure: no DB, no
I/O and, for now, no consumers — but property-tested first, over
nine scheme shapes and two centuries of seeded random dates, on
partition, fixpoint, monotonicity, next/prev inversion and
contiguity of `periods-through`.
- [2026-08-04] Monthly targets. An envelope can say what it wants
to have available each month (`categories.target_pence`, 0
meaning no target, added by `DB.ensure-column` — its first
caller, so a budget file written before today opens and reads
"no target" rather than failing). The grid grows a fifth
column, blank where there is no target and summed on group
headers; the detail rail gains `Target` plus either
`To fund +£72.50` in amber or `Target met`; the envelope
editor gains the field, where blank clears it. `Service::Budget`
is untouched and stays that way: underfunded is
`max(0, target - available)`, derived in the view layer, and a
target moves no money by itself. `Gateway::Category` refuses a
negative one on both paths and refuses one on a system row with
the same message it refuses a rename with — a payment
envelope's figure is the card's balance and Ready to Assign is
not an envelope. `target-pence` joins the catalogue token,
because the derivation's digest cannot see it and a saved
target that does not repaint the grid is a feature that looks
broken.
- [2026-08-04] The assign field learns `=n` and `=`, and the grid
learns `f`. `=450` means "make this envelope's available £450",
applied as an adjustment by the difference — never as a set,
because assigned and available differ by carry-in, activity and
the derivation's own card moves, so setting assigned to 450 on
an envelope carrying £37.50 would leave £487.50 available and
not what was typed. A bare `=` is `={target}`, and with no
target set it refuses on the dialog's own error line rather
than closing. `=-5` and `=(5)` are refused too: taking money
out is `-5`, which the sign rule already reads. A zero delta
closes with a toast and dispatches nothing at all — a no-op
upsert would still bump `ws/rev` and re-derive the budget to
announce that nothing had changed. `f` is the same idea over
the whole month: a confirm listing every visible, underfunded
envelope with its `+£`, the total, and the resulting Ready to
Assign, applied in ONE `ws/mutate` closure so twenty envelopes
are one derivation and cannot half-happen. It only ever adds —
an envelope over its target is left alone. A total that would
push Ready to Assign below zero is stated in red, in words, and
then permitted: assigning money that has not arrived yet is a
real step on the way to a plan, and the app's job there is to
be unmistakable rather than to forbid.
- [2026-08-04] Settings picks the currency symbol (£, $, €) and
the number format (`1,234.56` or `1.234,56`), and both apply
without a restart. The symbol is display only — nothing
converts, and the engine still has never heard of a currency —
but the number format is not: the decimal mark decides how a
typed figure is READ as well as how it is written, and thousands
are grouped with whichever of `.` / `,` the decimal mark is not.
`Util::Money` holds the pair as module state, set once by `UI`
from the config before the first frame and again by the Settings
save, so all 37 `format-pence` call sites and all 5
`parse-pence` ones are unchanged; the redraw is the rebuild
`apply-theme-live` was already doing. Grouping is accepted only
in exact groups of three, which is what makes `10,00` under a
`.` decimal mark an error rather than a silent £1,000 — that
typo (the other locale's number) is the one a user of this
setting will actually make, and it is 100x the value they meant.
Three digits after the decimal mark says so, and names the
separator that was wanted. Every symbol is still accepted on
input whatever is being displayed, because a pasted `$12.34` has
one meaning. `Config` validates both keys on load and falls back
to £ / `.` on a hand-edited value it does not know: a typo in a
plaintext file must not stop the app opening.
- [2026-08-03] The Reports tab's summary box is as deep as the
summary. It was a fixed four rows — frame, line, line, frame —
with the second content line held in reserve for the wrap the
strip only needs on a narrow terminal, so every ordinary
terminal drew a blank line inside the box and gave the chart one
row fewer than it could have had. `View::ReportRow` grew
`summary-rows-needed`, which answers three rows when the strip
fits its content box and four when it does not (and four when
nothing has been measured yet, which is what an unlaid-out pane
reports); `Screen::Reports` applies it on both paths that can
move the answer — the strip's own repaint, where the month's
figures can gain a digit, and a resize, which previously
returned early whenever the chart's bar cut had not moved and so
never re-checked the wrap on a width-only change.
- [2026-08-03] Deleting an envelope is possible again, and
reachable. The engine refused any category with an assignment
ROW against it, but `Gateway::Assignment.set` upserts a row for
every month you touch — including one you typed a 0 into — so
every envelope that had ever been budgeted to was undeletable
for good, however carefully the user emptied it first. The
refusal now counts months that hold money ("N month(s) of
non-zero assignments"), and the leftover zero rows are swept in
the same transaction as the category. Splits still refuse
outright, in any month, past or future: a transaction
categorized to an envelope is history, and deleting the envelope
would rewrite it. `Gateway::Account.delete` is deliberately
unchanged — it cascades where the category gateway restricts,
and `assignments.category_id` is ON DELETE CASCADE, so budgeting
to a card's payment envelope never wedged the account.
- [2026-08-03] `d` on the budget grid deletes what the cursor is
on, dispatching on the row exactly as `e` does: an envelope gets
the confirm the editor's `ctrl+d` opens, a group header gets the
group confirm (its envelopes survive, ungrouped), and the rows
that are not deletable — a card's payment envelope, the
Ungrouped bucket, the empty state — are told rather than asked.
The footer advertises it. The envelope confirm's copy now states
the rule the engine actually enforces: transactions, or money
assigned in any month, not "any history".
- [2026-08-03] Enter saves the multi-field editors — envelope,
group, account, transaction, transfer, split — through the same
closure `ctrl+s` runs, with the same validation and the same
refusals (a transaction whose splits do not add up will not save
on Enter either). Every field in them is single-line, so Enter
had nothing else to mean in one. It is wired per text field
rather than as an `enter` keybind on the modal, because Selkie
offers a key to the focused widget first and the modal's own
binds last: a modal-level bind would never see Enter from a
field, and the widgets that already own it keep it — Enter opens
a Select, toggles a Checkbox, picks a RadioGroup option and
edits the split under the cursor. Settings is the one dialog
without an Enter-save: it has no text field, only pickers.
Bottom titles now read `enter/^s save`.
- [2026-08-03] Selkie TUI v0.1 (v0.2.0): the whole app, on top of
the headless engine below. Built to `docs/ui-v0.1-spec.md`,
which stays the source of truth for what it is supposed to do.
`bin/moneymoor` launches it; the engine tiers are unchanged.
B A notcurses app with a login handshake in front of
it: `~/.moneymoor/` (overridable with `$MONEYMOOR_HOME`) holds
one encrypted `*.db` per budget, listed most-recently-modified
first, and the passphrase typed into the centred 24x64 dialog is
the SQLCipher key. Both of `DB.connect`'s Failure messages reach
the status line verbatim — "wrong passphrase" and "wrong file"
are different mistakes and lead to different next moves. Behind
it: a banner with the tab and month, a three-tab strip, a
content host whose single child is swapped per tab, and a hint
footer that follows focus. `Ctrl+H` lists the keys for whatever
is focused, `Ctrl+G` opens diagnostics, `Ctrl+,` settings —
eleven palettes and two glyph tiers, applied live.
B `App::Moneymoor::StoreHandlers`
owns the state shape, tab and month navigation, and the single
`ws/mutate` effect every write in the app funnels through: on a
gateway Failure it toasts the engine's own message, defuses the
Failure and leaves the change tokens alone, so a write that did
not happen cannot make the screen repaint. Three tokens, because
one cannot answer every question: `budget/digest` (the
derivation), `budget/catalogue` (the envelope catalogue, which
the digest deliberately cannot see — renaming an envelope leaves
the derivation byte-identical) and `ws/rev` (an integer bumped
per recompute, for the register, whose payees, memos and cleared
states are invisible to both).
B The envelope grid for one month, group
headers and category rows in one `Table` — one cursor, one
scrollbar, and a cursor restored by category id after every
rebuild. Ready-to-Assign above it, a detail rail beside it
writing out Rule 1 term by term (`available = carry-in +
assigned + activity + moved-in - moved-out`, with `moved out`
negated so the column adds up), and six dialogs: assign, move,
explain, the category and group editors, and a hide that asks
first when the envelope still holds money. The two overspend
kinds render differently, as the engine spec requires: cash red,
credit amber.
B A sidebar of ledgers and a register, one
selection between them, both cursors restored by id. Seven
dialogs: a transaction editor with a splits entries list that
refuses to save until the lines add up (and the nested dialog
that adds to it), transfers, the account editor, cleared
cycling, and the three confirms — delete a transaction, close an
account, delete an account and everything it touched. The
running-balance column is single-account-only and appears above
95 columns; the column set is rebuilt when the terminal crosses
that threshold, because `Table` has no per-column visibility.
B `Ctrl+R` asks for the statement balance and
then paints the register's whole frame in the difference's
colour — red until it is zero, green when it lands — because a
`Border` title cannot be styled on its own. Finishing promotes
every cleared transaction on the account inside one `ws/mutate`
closure, so the budget is derived once rather than once per row.
An out-of-balance finish offers a signed `Balance Adjustment`
rather than refusing; declining leaves the mode running with
every mark intact. Switching ledger or tab ends the mode, in the
store handlers, so there is one rule and not one per exit.
B A four-row cash-flow strip over a horizontal
`BarChart`. Spending is `-activity` where activity is negative;
the payment envelopes are excluded, or a card purchase would
appear twice. Transfer legs whose peer is also on-budget are not
cash flow and are dropped; the ones to and from tracking
accounts are. `b` toggles category/group aggregation. Bars do
not scroll, so the chart is cut to the rows it has and the
remainder is counted in the pane's bottom title.
B, copied from App-Mindmoor with the domain
content replaced: eleven palettes with WCAG floors pinned by
test, a fifteen-glyph icon service in Unicode and Nerd Font
tiers, the canonical modal chrome, the hint table, and the
empty states. `App::Moneymoor::Theme` and `Themes` gained the
Pod they never had.
B 28 new files, 44 in all. Every view function is pure
and tested against real derivations from a real SQLCipher
budget; every tab is mounted and driven through a store with no
terminal involved; source-level scans keep each hint context
honest against the keys actually bound, and the Main-to-sibling
module boundary honest against accessors resolved at runtime.
`t/85` holds the distribution itself to account — `provides`
against `lib/`, every module loading in one process, every
module carrying Pod.
B An ordinary tab switch (`Ctrl+2`, or a
click) rebuilt the content host and never moved focus, leaving
the keyboard pointing at a destroyed pane and the footer on the
generic hints until the user pressed Tab; the re-focus lived at
one of the two rebuild call sites instead of inside the rebuild.
And the login screen's `Ctrl+N` was advertised by the hint bar
without being bound to anything.
- [2026-08-02] Initial headless release (v0.1.0): the envelope
budgeting engine, its SQLCipher persistence layer, and the
pure derivation service.
* `App::Moneymoor::DB` — single-connection SQLCipher wrapper
with idempotent `CREATE TABLE IF NOT EXISTS` migrations
re-run on every connect, `!ensure-column` additive column
migrations, wrong-key vs plaintext-file detection,
`busy_timeout`, foreign keys ON, and `run-txn` for
multi-statement atomicity.
* Schema: accounts (cash / credit / tracking), category groups,
categories (standard / payment / rta), payees, transactions,
splits, assignments. Money is integer pence throughout; no
floats anywhere.
* `Model::*` — attribute-only row classes with
`new-from-row(%row)` snake_case → kebab-case mapping and
predicate methods.
* `Gateway::*` — Account (auto-creates a payment category per
credit account, atomically), Category (groups, ordering,
hiding, system-row guards), Payee, Transaction (splits sum
enforcement, transfer pairing), Assignment (per-month upsert
plus atomic `move-money`).
* `Service::Budget` — the pure, DB-free derivation: per-category
available, deterministic credit-card coverage moves, rollover
with the credit-overspend / cash-overspend split, and
Ready-to-Assign including future-month assignments. Emits a
`Move` log so every derived number can be explained.
* `Service::Workspace` — gateways → `compute`, plus the
`set-assigned` / `move-money` mutation helpers.
* `Util::Money` — integer pence ↔ "£12.34" format / parse.
* 16 test files covering the DB layer, every gateway, each
budgeting rule, and a seeded-PRNG property suite that asserts
the master invariant, move-ledger consistency, and
recomputation determinism after every random operation.