ec99434002e95ad9847712d2849ae2a1b1bc178f
3
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
cc5912ece2 |
Rebuild the overview around where the money actually went
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. |
||
|
|
922ae507bd |
Track investments as broker facts with a position leg
An account now has a kind, and an investment account holds positions as well as
cash. A broker row is not a new entity: it is a bank fact with an optional
position leg, so deduplication, the journal, fact immutability, the DuckDB
projection and the transactions view carry it unchanged. Facts.Amount stays the
cash leg and is zero on the rows that move only a position.
Scalable Capital exports are recognized locally as a fourth format, read by
their own parser because a column mapping cannot describe them: the amount
column is settled cash on a cash row, a gross to be netted on a trade, and a
position valuation that must never touch cash on a corporate action or a depot
transfer. A cash amount is already net of the tax the broker withheld or
refunded, so that tax is recorded on the fact and never subtracted a second
time; treating a corporate action's valuation as money conjures cash, and a
depot switch would do it once per instrument. The share column is signed only
for those two types, so buys and sells take their direction from the type. Every
security row is checked against shares times price at 128-bit width, because a
lost decimal separator survives every other check. An unknown status, type or
assetType, a foreign currency, a missing ISIN, or one failed check rejects the
whole file with the record number.
Instruments live in instruments.finance, keyed by ISIN with an ID derived from
it, so re-importing never registers a security twice. One ISIN appears under
several broker descriptions over the years and sometimes under the ISIN itself:
the most recent real description names it, and an import never renames one that
already exists. A broker also reuses a single reference across every leg of one
event, so transaction identity includes the event and its instrument.
domain.Fallback returns kind "investment" for any fact carrying a position leg,
so no broker row reaches the sign-based branch. That single rule is what stops
an unmatched deposit from counting as income and a broker fee from counting as
household spending; the monthly PRIME fee and its matching credit now cancel in
clearing:investments with no configuration at all. Investment rows are excluded
from spending analytics, from bulk reclassification and from the model, exactly
as transfers are.
Equal competing transfers are paired instead of skipped. Every connected
component of the candidate graph is a complete bipartite graph between two fixed
accounts at one amount and currency, so every pairing produces the same
accounts, kinds and postings and only the displayed counterpart differs.
Refusing to choose was the expensive option: both legs fell through to the
sign-based fallback and appeared as spending and income that never happened.
Pairing follows the nearest booking date, then the transaction ID, so iteration
order decides nothing. POST /api/transactions/{id}/transfer rewrites the old and
the new pair in one commit, because reciprocity is validated and a half-applied
link is an invalid dataset, and the matcher now skips any record classified
manually so a hand-made link or unlink outlives the next import.
Wealth reports each account's cash and positions from the journal rather than
the index, with named checks - row arithmetic, cash never negative, holdings
never negative - because it exists to be compared against the figures a broker
shows on its own screen. A negative holding means the imported history is
partial. Share counts are exact to eight places; a reinvested distribution
quoted to six is rounded to money's four and the residue is reported rather than
hidden. Market prices, market value, net worth over time, FIFO lot accounting,
realised gains and currency conversion are deliberately absent.
|
||
|
|
9843fe0c50 | init |