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.
This commit is contained in:
Lars Nolden
2026-09-11 23:35:46 +02:00
parent 762ad3fae5
commit cc5912ece2
8 changed files with 2034 additions and 380 deletions
+1374 -204
View File
File diff suppressed because it is too large Load Diff
+53 -1
View File
@@ -155,15 +155,27 @@ export interface Group {
amount: string;
count: number;
}
// MonthlyPoint mirrors the analytics row: income and expenses are both positive
// magnitudes, net is the only signed figure.
export interface MonthlyPoint {
period: string;
currency: string;
income: string;
expenses: string;
net: string;
count: number;
}
export interface Dashboard {
totals: Total[];
previous: Total[];
monthly: Group[];
monthly: MonthlyPoint[];
categories: Group[];
previous_categories: Group[];
tags: Group[];
merchants: Group[];
accounts: Group[];
recurring: Group[];
largest: Group[];
}
export interface Filter {
from: string;
@@ -418,6 +430,29 @@ export function money(value: string, currency: string): string {
const decimals = (match[3] || "").replace(/0+$/, "").padEnd(2, "0");
return `${match[1] === "-" ? "" : ""}${match[2].replace(/\B(?=(\d{3})+(?!\d))/g, ",")}.${decimals} ${currency}`;
}
// compactMoney is for chart axes and ticks, where an exact figure would not
// fit: it rounds to at most one fractional digit and abbreviates thousands.
// Every figure a user might act on is still rendered by money().
export function compactMoney(value: string, currency = ""): string {
const n = Number(value);
if (!Number.isFinite(n)) return value;
const sign = n < 0 ? "" : "";
const abs = Math.abs(n);
const [scaled, unit]: [number, string] =
abs >= 1e9
? [abs / 1e9, "b"]
: abs >= 1e6
? [abs / 1e6, "m"]
: abs >= 1000
? [abs / 1000, "k"]
: [abs, ""];
const digits = unit ? (scaled < 10 ? 1 : 0) : abs > 0 && abs < 10 ? 2 : 0;
const text = scaled.toLocaleString("en-US", {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
return `${sign}${text}${unit}${currency ? ` ${currency}` : ""}`;
}
export function categoryPath(data: Dataset, id?: string): string {
if (!id) return "No category";
const names: string[] = [];
@@ -439,3 +474,20 @@ export const emptyFilter: Filter = {
tag_id: "",
merchant_id: "",
};
// A six-month window is the default view: long enough to show a trend and a
// seasonal bill, short enough that the current month still matters. The window
// starts on the first day of the month, so month buckets are whole.
export const DEFAULT_MONTHS = 6;
export function monthStart(monthsBack: number): string {
const now = new Date();
const day = new Date(
Date.UTC(now.getFullYear(), now.getMonth() - monthsBack, 1),
);
return day.toISOString().slice(0, 10);
}
export function yearStart(): string {
return `${new Date().getFullYear()}-01-01`;
}
export function defaultFilter(): Filter {
return { ...emptyFilter, from: monthStart(DEFAULT_MONTHS - 1) };
}
+2 -2
View File
@@ -21,7 +21,7 @@ import {
import type { State } from "./api";
import {
APIError,
emptyFilter,
defaultFilter,
localInstant,
normalizeState,
request,
@@ -64,7 +64,7 @@ function App() {
const [refreshing, setRefreshing] = useState(false);
const [notice, setNotice] = useState("");
const [mobileNav, setMobileNav] = useState(false);
const [filter, setFilter] = useState({ ...emptyFilter });
const [filter, setFilter] = useState(defaultFilter);
const acceptState = useCallback((value: State, message?: string) => {
setState(normalizeState(value));
setConflict(false);
+356 -74
View File
@@ -474,7 +474,7 @@ main {
}
.stat-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(178px, 1fr));
gap: 20px;
}
.stat {
@@ -528,15 +528,18 @@ main {
.dashboard-grid.thirds {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.dashboard-grid.flipped {
grid-template-columns: minmax(0, 1fr) minmax(0, 1.35fr);
}
.dashboard-grid.even {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.chart-panel {
overflow: hidden;
}
.dashboard-grid .panel {
height: calc(100% - 24px);
}
.monthly-charts {
padding: 0 24px 25px;
}
.monthly-charts > div + div {
margin-top: 28px;
}
.eyebrow {
display: block;
font-size: 10px;
@@ -545,60 +548,6 @@ main {
font-weight: 650;
color: #819387;
}
.bar-chart {
display: flex;
gap: 13px;
height: 242px;
overflow-x: auto;
margin-top: 12px;
padding: 28px 5px 0;
border-bottom: 1px solid #e9eef1;
background: repeating-linear-gradient(
to top,
transparent 0,
transparent 51px,
#f0f3f6 52px,
#f0f3f6 53px
);
}
.bar-column {
min-width: 43px;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.bar-track {
height: 170px;
width: 100%;
max-width: 48px;
display: flex;
align-items: flex-end;
}
.bar {
background: #63bca0;
border-radius: 4px 4px 0 0;
min-height: 2px;
width: 100%;
transition: height 0.3s;
}
.bar.negative {
background: #afbecd;
}
.bar-value {
font-size: 9px;
position: absolute;
top: -23px;
white-space: nowrap;
color: #748496;
}
.bar-label {
font-size: 9px;
color: #8e99a7;
margin-top: 13px;
white-space: nowrap;
}
.group-list {
padding: 0 24px 16px;
}
@@ -1537,6 +1486,10 @@ footer span:first-child {
grid-template-columns: 1.2fr 1fr;
gap: 18px;
}
.dashboard-grid.flipped,
.dashboard-grid.even {
gap: 18px;
}
.dashboard-grid.thirds {
grid-template-columns: 1fr 1fr;
}
@@ -1553,9 +1506,6 @@ footer span:first-child {
padding-left: 20px;
padding-right: 20px;
}
.bar-value {
font-size: 8px;
}
.description {
max-width: 220px;
}
@@ -1614,7 +1564,9 @@ footer span:first-child {
.stat small {
font-size: 9px;
}
.dashboard-grid {
.dashboard-grid,
.dashboard-grid.flipped,
.dashboard-grid.even {
grid-template-columns: 1fr;
}
.dashboard-grid.thirds {
@@ -1787,15 +1739,6 @@ footer span:first-child {
border-radius: 8px;
margin-bottom: 20px;
}
.monthly-charts {
padding: 0 17px 20px;
}
.bar-chart {
gap: 12px;
}
.bar-track {
max-width: 40px;
}
.group-list {
padding: 0 18px 15px;
}
@@ -2258,3 +2201,342 @@ footer span:first-child {
.category-node .button.subtle {
font-size: 10px;
}
.negative {
color: var(--danger);
}
.link {
border: 0;
background: transparent;
padding: 0;
color: #2b6f8a;
font: inherit;
text-align: left;
border-radius: 3px;
}
.link:hover {
color: var(--emerald-dark);
text-decoration: underline;
}
.filter-bar {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 9px;
margin-bottom: 24px;
box-shadow: 0 1px 2px #1c314705;
}
.range-row {
display: flex;
align-items: center;
gap: 13px;
padding: 14px 18px 0;
}
.range-row .chips {
margin-top: 0;
gap: 5px;
}
.range-row .filter-reset {
margin-left: auto;
}
.filter-bar .filters {
border: 0;
box-shadow: none;
border-radius: 0;
margin-bottom: 0;
padding-top: 13px;
background: transparent;
}
.chip {
border: 1px solid #dde4ea;
background: #fcfdfe;
color: #61717f;
border-radius: 20px;
padding: 5px 12px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.2px;
}
.chip:hover:not(.active) {
border-color: #b9cfc6;
color: #2c6d57;
}
.chip.active {
background: var(--emerald);
border-color: var(--emerald);
color: #fff;
}
.currency-switch {
display: flex;
gap: 6px;
margin-bottom: 18px;
}
.stat-icon.rate {
background: #f3f0fa;
color: #8a7fb0;
}
.stat-trend {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 10px;
color: #8d98a7;
}
.stat-trend strong {
font-weight: 650;
font-variant-numeric: tabular-nums;
}
.stat-trend.better {
color: #2e8064;
}
.stat-trend.worse {
color: #a9554f;
}
/* Charts are drawn at measured pixel width, so the body only needs to be a
positioning context for the hover tooltip and to clip a stale wide SVG. */
.chart-body {
position: relative;
padding: 4px 20px 22px;
overflow: hidden;
}
.chart-body svg {
display: block;
overflow: visible;
}
.chart-axis {
font-size: 10px;
fill: #8e99a7;
font-variant-numeric: tabular-nums;
}
.chart-axis.strong {
font-size: 11px;
font-weight: 600;
fill: #56667b;
}
.chart-tip {
position: absolute;
top: 4px;
transform: translateX(-50%);
background: #16283c;
color: #eef3f7;
border-radius: 7px;
padding: 9px 11px;
font-size: 11px;
min-width: 178px;
pointer-events: none;
box-shadow: 0 6px 18px #10223426;
z-index: 2;
}
.chart-tip strong {
display: block;
font-size: 11px;
font-weight: 650;
margin-bottom: 6px;
}
.chart-tip span {
display: flex;
align-items: center;
gap: 7px;
color: #b9c6d2;
line-height: 1.85;
}
.chart-tip span b {
margin-left: auto;
color: #fff;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.chart-tip i {
width: 8px;
height: 8px;
border-radius: 2px;
flex: none;
}
.chart-tip em {
display: block;
margin-top: 5px;
font-style: normal;
color: #8ea0b1;
font-size: 10px;
}
.flow-node.drill {
cursor: pointer;
}
.flow-node.drill:hover rect {
opacity: 0.75;
}
.flow-node.drill:hover .flow-name {
fill: var(--emerald-dark);
}
.flow-name {
font-size: 11px;
font-weight: 600;
fill: #37495d;
}
.flow-value {
font-size: 10px;
fill: #8b96a4;
font-variant-numeric: tabular-nums;
}
.flow-trunk {
font-size: 11px;
font-weight: 650;
fill: #46586c;
font-variant-numeric: tabular-nums;
}
.share-body {
display: flex;
align-items: center;
gap: 26px;
padding: 6px 24px 24px;
flex-wrap: wrap;
}
.share-body svg {
flex: none;
}
.donut-total {
font-size: 17px;
font-weight: 700;
fill: var(--navy);
font-variant-numeric: tabular-nums;
}
.donut-caption {
font-size: 10px;
letter-spacing: 1.2px;
text-transform: uppercase;
fill: #94a0ad;
}
.share-legend {
flex: 1;
min-width: 190px;
}
.share-row {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
border: 0;
background: transparent;
text-align: left;
padding: 6px 4px;
border-radius: 4px;
font-size: 11px;
color: #50606f;
}
.share-row:hover:not(:disabled) {
background: #f7faf9;
}
.share-row i {
width: 9px;
height: 9px;
border-radius: 2px;
flex: none;
}
.share-row span {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.share-row b {
font-weight: 650;
color: var(--navy);
font-variant-numeric: tabular-nums;
}
.share-row em {
font-style: normal;
color: #8b96a4;
font-size: 10px;
min-width: 84px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.mover-list {
padding: 0 24px 16px;
}
.mover-row {
border: 0;
background: transparent;
width: 100%;
text-align: left;
padding: 9px 0 11px;
display: block;
border-radius: 4px;
}
.mover-row:hover {
background: #f7faf9;
}
.mover-row small {
color: #93a0ad;
font-size: 10px;
font-variant-numeric: tabular-nums;
}
.mover-head {
display: flex;
justify-content: space-between;
gap: 14px;
font-size: 11px;
margin-bottom: 8px;
color: #46586c;
}
.mover-head > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 550;
}
.mover-head strong {
font-size: 11px;
font-weight: 650;
white-space: nowrap;
}
.mover-track {
height: 5px;
background: #eef2f5;
border-radius: 10px;
overflow: hidden;
margin-bottom: 6px;
}
.mover-track span {
display: block;
height: 100%;
border-radius: 10px;
}
.mover-track span.up {
background: #d09090;
}
.mover-track span.down {
background: #7cc0a8;
}
.group-track span.out {
background: #d09090;
}
.stat-notes {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 3px;
}
@media (max-width: 680px) {
.range-row {
flex-wrap: wrap;
padding: 13px 13px 0;
gap: 9px;
}
.range-row .filter-reset {
margin-left: 0;
}
.chart-body {
padding: 4px 12px 18px;
}
.share-body {
padding: 6px 16px 20px;
gap: 16px;
justify-content: center;
}
.mover-list {
padding: 0 18px 15px;
}
.chart-tip {
min-width: 150px;
font-size: 10px;
}
}
+123 -80
View File
@@ -9,7 +9,13 @@ import {
ChevronRight,
} from "lucide-react";
import type { Dataset, Filter } from "./api";
import { categoryPath, emptyFilter } from "./api";
import {
categoryPath,
DEFAULT_MONTHS,
defaultFilter,
monthStart,
yearStart,
} from "./api";
export function Modal({
title,
children,
@@ -403,87 +409,124 @@ export function Filters({
...data.transactions.map((t) => t.facts.currency),
]),
).sort();
// Presets leave `to` open so the window always reaches today; the explicit
// date fields below stay authoritative for anything narrower.
const ranges = [
...[1, 3, DEFAULT_MONTHS, 12].map((months) => ({
label: `${months}M`,
title: months === 1 ? "This month" : `Last ${months} months`,
from: monthStart(months - 1),
to: "",
})),
{ label: "YTD", title: "Year to date", from: yearStart(), to: "" },
{ label: "All", title: "All time", from: "", to: "" },
];
return (
<div className="filters">
<DateField
label="From"
clearable
value={value.from}
max={value.to || undefined}
onChange={(day) => update("from", day)}
/>
<DateField
label="To"
clearable
value={value.to}
min={value.from || undefined}
onChange={(day) => update("to", day)}
/>
<Field label="Currency">
<select
value={value.currency}
onChange={(e) => update("currency", e.target.value)}
<div className="filter-bar">
<div className="range-row">
<span className="eyebrow">Period</span>
<div className="chips">
{ranges.map((range) => {
const active = value.from === range.from && value.to === range.to;
return (
<button
key={range.label}
type="button"
className={`chip ${active ? "active" : ""}`}
aria-pressed={active}
title={range.title}
aria-label={range.title}
onClick={() =>
onChange({ ...value, from: range.from, to: range.to })
}
>
{range.label}
</button>
);
})}
</div>
<button
className="button subtle filter-reset"
onClick={() => onChange(defaultFilter())}
>
<option value="">All currencies</option>
{currencies.map((c) => (
<option key={c}>{c}</option>
))}
</select>
</Field>
<Field label="Account">
<select
value={value.account_id}
onChange={(e) => update("account_id", e.target.value)}
>
<option value="">All accounts</option>
{data.accounts.map((a) => (
<option value={a.id} key={a.id}>
{a.display_name}
</option>
))}
</select>
</Field>
<Field label="Category">
<select
value={value.category_id}
onChange={(e) => update("category_id", e.target.value)}
>
<option value="">All categories</option>
<CategoryOptions data={data} />
</select>
</Field>
<Field label="Tag">
<select
value={value.tag_id}
onChange={(e) => update("tag_id", e.target.value)}
>
<option value="">All tags</option>
{data.tags.map((t) => (
<option value={t.id} key={t.id}>
{t.name}
</option>
))}
</select>
</Field>
<Field label="Merchant">
<select
value={value.merchant_id}
onChange={(e) => update("merchant_id", e.target.value)}
>
<option value="">All merchants</option>
{data.merchants.map((m) => (
<option value={m.id} key={m.id}>
{m.name}
</option>
))}
</select>
</Field>
<button
className="button subtle filter-reset"
onClick={() => onChange({ ...emptyFilter })}
>
Reset
</button>
Reset
</button>
</div>
<div className="filters">
<DateField
label="From"
clearable
value={value.from}
max={value.to || undefined}
onChange={(day) => update("from", day)}
/>
<DateField
label="To"
clearable
value={value.to}
min={value.from || undefined}
onChange={(day) => update("to", day)}
/>
<Field label="Currency">
<select
value={value.currency}
onChange={(e) => update("currency", e.target.value)}
>
<option value="">All currencies</option>
{currencies.map((c) => (
<option key={c}>{c}</option>
))}
</select>
</Field>
<Field label="Account">
<select
value={value.account_id}
onChange={(e) => update("account_id", e.target.value)}
>
<option value="">All accounts</option>
{data.accounts.map((a) => (
<option value={a.id} key={a.id}>
{a.display_name}
</option>
))}
</select>
</Field>
<Field label="Category">
<select
value={value.category_id}
onChange={(e) => update("category_id", e.target.value)}
>
<option value="">All categories</option>
<CategoryOptions data={data} />
</select>
</Field>
<Field label="Tag">
<select
value={value.tag_id}
onChange={(e) => update("tag_id", e.target.value)}
>
<option value="">All tags</option>
{data.tags.map((t) => (
<option value={t.id} key={t.id}>
{t.name}
</option>
))}
</select>
</Field>
<Field label="Merchant">
<select
value={value.merchant_id}
onChange={(e) => update("merchant_id", e.target.value)}
>
<option value="">All merchants</option>
{data.merchants.map((m) => (
<option value={m.id} key={m.id}>
{m.name}
</option>
))}
</select>
</Field>
</div>
</div>
);
}