With the classification select beside the review toggle, the search
shrank to a sliver at phone widths; below 680px it now takes its own
full-width row.
The Transactions toolbar gains a status filter over classification
provenance — manual, AI, merchant rule, transfer match, unclassified —
alongside the existing Needs review toggle. The Source column now
renders the same human labels the filter options use instead of raw
provenance keys, so "openrouter" reads as AI and both fallback shapes
read as Unclassified.
Arrow keys cycle through the listed matches and create rows with
aria-activedescendant tracking, and Enter activates the armed row.
This closes a keyboard-only gap in quick-add: with matches still
listed, Enter deliberately refuses to mint from a half-typed name,
which left the create row reachable only by mouse.
Category and tag inputs across the transaction editor, Analyse
corrections, and merchant defaults now mint missing entries without a
detour through the registry pages. A bare name lands under the kind's
root, "Parent / Name" targets that parent, and typing an existing name
selects it instead of duplicating. Enter only creates when nothing
matches, server rejections surface inline in the dropdown, and
assignment pickers offer leaf categories only — the shape the server
validates.
Mutations now return the accepted state so callers can select the id
the server just minted, and the revision-keyed remounts on Transactions
and the registry pages are gone: they closed the open modal and threw
away pending edits the moment any in-modal creation committed.
A position was a share count. An instrument now carries a market symbol and
the last close fetched for it, so Wealth and the dashboard report cash plus
market value instead of cash alone.
The symbol is chosen by hand and never derived: one ISIN lists on several
exchanges in different currencies, and a price from the wrong listing misstates
wealth without failing any check. The refresh refuses a quote whose currency
differs from the instrument's, keeps the previous quote when a symbol cannot be
priced, and counts an instrument with no symbol as unpriced - naming it in a
check and leaving it out of every total, because cost is not value. The quote
belongs to the job: saving an instrument can neither set nor erase it, and
changing the symbol discards it.
Two things the provider forced. It answers HTTP 429 to every request whose
User-Agent names a programming language, so the client identifies as a browser;
without that header the first call of the day fails. Its closes are 32-bit
floats widened to 64 - 165.26 arrives as 165.25999450683594 - so a figure is
rounded to seven significant digits, which is what 24 mantissa bits carry;
eight would have stored 165.25999 as a price.
Accepted quotes are written in one commit against a revision re-read after the
fetches, and nothing is committed when no quote changed. The automatic run
starts shortly after launch and repeats daily on its own timer, so a sync
backoff cannot delay it and prices arrive with no bank connected.
Verified against live quotes end to end: 80 shares at 125.45 and 40 at 165.26
on 6000.00 cash report 22646.40 with one holding named as unpriced; giving that
holding a symbol through the UI moves the figure to 23530.50, and a second
refresh leaves the revision untouched.
The preview endpoint held one HTTP request open while classifying
serially at three-second pacing, so any real range meant minutes of a
grayed-out button and per-row errors were invisible until the loop
ended. Analyse now starts a single background run against its own
snapshot; a progress endpoint reports analysed counts, proposed
changes and errors as they happen, and the page polls it with a
progress bar, pace-based estimate and a Stop button. Navigating away
no longer orphans the run: the page re-attaches to it on return.
A run that has produced no successful proposal and fails three times
in a row with the identical error stops early and reports that error,
so a wrong key or unsupported model surfaces in seconds instead of
repeating across the whole paced range.
Also normalize a null settings.private_names, which crashed the whole
UI on a workspace that had never saved preferences.
The page reported three totals, a net-per-month bar chart and six ranked lists.
That answers how much moved, never where it went, and the one chart carrying a
shape printed its full formatted amount above every 43px column: eleven values
collided into a single line of text, the dates read as 2025-11, and negative
months were grey while positive ones were green, so the sign of a month was the
one thing the colour did not say. The dashboard now answers four questions in
the order a person asks them - am I ahead, where did it go, what changed, and
what is committed - and every panel is a click into the transactions behind it.
Monthly cash flow becomes a measured SVG chart: income drawn above the zero
line in the brand green, spending below it in the danger red, and net as a line
whose dot takes the colour of its sign. Exact figures move into a hover tooltip
that names the month, both directions, the net and the transaction count, so the
plot area carries a y-axis of about three gridlines a side instead of eleven
overlapping labels, and the month axis prints Nov with the year only where the
year changes. The chart measures its own content box through a ResizeObserver
and draws at real pixel size rather than scaling a viewBox, because scaled axis
text is the wrong weight at every width except one. A month with no activity is
filled in as an explicit zero: it is a real answer, not a gap to close.
A six-month window is the default view, long enough to show a trend and a
seasonal bill and short enough that the current month still matters. The window
starts on the first of a month so the buckets are whole, leaves its upper bound
open so it always reaches today, and lives in the shared filter bar next to
1M/3M/12M/YTD/All, so the transactions page inherits the same framing. Reset
returns to six months rather than to all of history.
Where the money went is a Sankey, because the question is literally a flow: the
income categories a user named, through one trunk, into the categories that
consumed it. Both columns balance by construction - a surplus is a node called
Left over on the right, a deficit is one called Drawn from reserves feeding the
trunk from the left - so an overspend is visible as money entering from outside
the period rather than as a total that silently fails to add up. Ancestor
rollups already include their descendants, so a root's unexplained remainder
becomes its own slice and the columns stay honest. Beside it, a donut ranks the
same spending by share, and the category tree keeps the drill-down it had.
What changed compares spending per leaf category against the preceding interval,
which required the analytics index to return that interval's categories as well:
categoryGroups is now a constant run over both filters, so the two sides of a
delta cannot disagree about how a parent rolls up. Monthly stops being a list of
Group rows carrying only a net and becomes MonthlyPoint, with income and
spending as separate positive magnitudes and net as the only signed figure,
which is what a two-sided chart needs and what a single SUM could not give.
Biggest payments keeps one row per payee. Ranking outflows by amount returned
the same rent six times, which explains nothing; the window now picks each
merchant's single largest payment before the per-currency ranking, and a fact
with no merchant competes as itself. Both windows order by the DECIMAL column
rather than by its VARCHAR rendering, which would sort -0.0009 ahead of
-900719925474.0991. The per-currency partition stays: one busy currency must not
crowd another out of its own list.
Two figures are withheld rather than printed wrong. A savings rate is net over
income, and a part-month carrying only an interest credit read -10268% kept;
below -100% the outflow was more than twice the income and that sentence is the
answer, so the ratio is replaced by it. Period-over-period change truncates
instead of rounding, because a 99.6% fall rendered as -100% claims the figure
went to zero.
Charts are per currency by their nature, and four copies of every panel is not a
dashboard, so the busiest currency leads and a chip row switches between them.
That is a view choice and not a filter: it never narrows the data the totals or
the ranked lists were computed from.
Verified against a running instance on a generated twelve-month, three-account,
two-currency journal. The June tooltip reports in 5,701.80, out 2,611.95, net
3,089.85 over 26 transactions, matching /api/dashboard exactly. A single-month
window with 16.99 of income against 1,761.59 of spending shows the net in red
below the axis, withholds the savings rate, and puts 1.7k of Drawn from reserves
into the trunk against 1.6k of Housing. Clicking the Housing node lands on the
transactions page filtered to Expenses / Housing with eighteen rows and the
period intact, and the whole page stacks and stays legible at 430px.
The sidebar brand mark was a hand-drawn inline path. Replace it with the
delivered vector trace, served from the Vite public directory so the Go
binary embeds it and the mark stays editable as an asset rather than as
JSX. Its eye and chart bars are evenodd cutouts, so they read as the
navy sidebar showing through.
Set the wordmark in Montserrat Bold and the tagline in Montserrat
Medium at 0.4em tracking. The subsets are bundled from @fontsource
instead of linked from Google Fonts: the Content-Security-Policy allows
fonts from 'self' only, and a self-hosted workspace should not fetch its
own brand from a third party. Tracking moves from 2px to em so it holds
at every breakpoint's tagline size.
GET /api/banking/institutions lists the banks Enable Banking can connect
for a country (personal AIS, connectable consents only), with logos
restricted to https Enable Banking hosts to match the CSP image
allowlist. The connect form offers a filterable dropdown with bank
logos, falling back to the previous free-text input when the list is
unavailable or banking is not configured.