From cc5912ece23978c7601e1f2464f5f464e744950f Mon Sep 17 00:00:00 2001 From: Lars Nolden Date: Fri, 11 Sep 2026 23:35:46 +0200 Subject: [PATCH] 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. --- internal/analytics/query.go | 74 +- internal/analytics/store.go | 35 +- internal/analytics/store_test.go | 36 +- web/src/Overview.tsx | 1578 ++++++++++++++++++++++++++---- web/src/api.ts | 54 +- web/src/main.tsx | 4 +- web/src/styles.css | 430 ++++++-- web/src/ui.tsx | 203 ++-- 8 files changed, 2034 insertions(+), 380 deletions(-) diff --git a/internal/analytics/query.go b/internal/analytics/query.go index 8c6d62b..0725c03 100644 --- a/internal/analytics/query.go +++ b/internal/analytics/query.go @@ -7,11 +7,21 @@ import ( "time" ) +// filteredPrefix opens the common table expression every group query reads +// from; the closing parenthesis is supplied with the WHERE clause. +const filteredPrefix = "WITH filtered AS (SELECT t.* FROM transactions t WHERE " + +// categoryGroups runs twice, once per compared interval, so a period-over-period +// delta sees exactly the same ancestor rollup on both sides. +const categoryGroups = `SELECT c.id, c.name, t.currency, '', CAST(SUM(t.amount) AS VARCHAR), COUNT(*) + FROM filtered t JOIN category_ancestors ca ON ca.category_id = t.category_id + JOIN categories c ON c.id = ca.ancestor_id GROUP BY c.id, c.name, t.currency ORDER BY c.id, t.currency` + func (s *Store) Query(ctx context.Context, filter Filter) (Dashboard, error) { empty := Dashboard{ - Totals: []Total{}, Previous: []Total{}, Monthly: []Group{}, - Categories: []Group{}, Tags: []Group{}, Merchants: []Group{}, - Accounts: []Group{}, Recurring: []Group{}, + Totals: []Total{}, Previous: []Total{}, Monthly: []MonthlyPoint{}, + Categories: []Group{}, PreviousCategories: []Group{}, Tags: []Group{}, + Merchants: []Group{}, Accounts: []Group{}, Recurring: []Group{}, Largest: []Group{}, } if err := filter.validate(); err != nil { return empty, err @@ -33,19 +43,21 @@ func (s *Store) Query(ctx context.Context, filter Filter) (Dashboard, error) { if result.Previous, err = queryTotals(ctx, tx, previous); err != nil { return empty, err } + where, args := previous.where() + if result.PreviousCategories, err = queryGroups(ctx, tx, filteredPrefix+where+") "+categoryGroups, args); err != nil { + return empty, fmt.Errorf("query previous categories: %w", err) + } } where, args := filter.where() - prefix := "WITH filtered AS (SELECT t.* FROM transactions t WHERE " + where + ") " + prefix := filteredPrefix + where + ") " + if result.Monthly, err = queryMonthly(ctx, tx, prefix, args); err != nil { + return empty, err + } queries := []struct { output *[]Group query string }{ - {&result.Monthly, `SELECT strftime(booking_date, '%Y-%m'), strftime(booking_date, '%Y-%m'), currency, - strftime(booking_date, '%Y-%m'), CAST(SUM(amount) AS VARCHAR), COUNT(*) - FROM filtered GROUP BY currency, strftime(booking_date, '%Y-%m') ORDER BY 4, 3`}, - {&result.Categories, `SELECT c.id, c.name, t.currency, '', CAST(SUM(t.amount) AS VARCHAR), COUNT(*) - FROM filtered t JOIN category_ancestors ca ON ca.category_id = t.category_id - JOIN categories c ON c.id = ca.ancestor_id GROUP BY c.id, c.name, t.currency ORDER BY c.id, t.currency`}, + {&result.Categories, categoryGroups}, {&result.Tags, `SELECT tag.id, tag.name, t.currency, '', CAST(SUM(t.amount) AS VARCHAR), COUNT(*) FROM filtered t JOIN transaction_tags tt ON tt.transaction_id = t.id JOIN tags tag ON tag.id = tt.tag_id GROUP BY tag.id, tag.name, t.currency ORDER BY tag.id, t.currency`}, @@ -71,6 +83,25 @@ func (s *Store) Query(ctx context.Context, filter Filter) (Dashboard, error) { m.name, c.currency, c.cadence, CAST(c.total AS VARCHAR), c.occurrences FROM candidates c JOIN merchants m ON m.id = c.merchant_id WHERE c.cadence <> '' ORDER BY 1, 3`}, + // One row per payee: a rent paid on time every month is six identical + // rows that explain nothing, so only a merchant's single biggest payment + // competes. Ranked per currency rather than by a plain LIMIT, so one + // busy currency cannot crowd another out of its own list. Both windows + // order by the decimal column, never by its VARCHAR rendering. + {&result.Largest, `, payments AS ( + SELECT t.id, CASE WHEN COALESCE(m.name, '') <> '' THEN m.name ELSE t.raw_description END AS label, + t.currency, CAST(t.booking_date AS VARCHAR) AS day, t.amount AS value, + ROW_NUMBER() OVER (PARTITION BY t.currency, + CASE WHEN t.merchant_id <> '' THEN 'm:' || t.merchant_id ELSE 'x:' || t.id END + ORDER BY t.amount, t.id) AS repeats + FROM filtered t LEFT JOIN merchants m ON m.id = t.merchant_id WHERE t.amount < 0 + ), ranked AS ( + SELECT id, label, currency, day, value, + ROW_NUMBER() OVER (PARTITION BY currency ORDER BY value, id) AS position + FROM payments WHERE repeats = 1 + ) + SELECT id, label, currency, day, CAST(value AS VARCHAR), CAST(1 AS BIGINT) FROM ranked + WHERE position <= 8 ORDER BY currency, position`}, } for _, item := range queries { groups, err := queryGroups(ctx, tx, prefix+item.query, args) @@ -85,6 +116,29 @@ func (s *Store) Query(ctx context.Context, filter Filter) (Dashboard, error) { return result, nil } +// queryMonthly returns one row per month and currency. Months with no activity +// are absent: the caller knows the requested window and fills the gaps. +func queryMonthly(ctx context.Context, tx *sql.Tx, prefix string, args []any) ([]MonthlyPoint, error) { + rows, err := tx.QueryContext(ctx, prefix+`SELECT strftime(booking_date, '%Y-%m'), currency, + CAST(SUM(CASE WHEN amount > 0 THEN amount ELSE CAST(0 AS DECIMAL(24,4)) END) AS VARCHAR), + CAST(SUM(CASE WHEN amount < 0 THEN -amount ELSE CAST(0 AS DECIMAL(24,4)) END) AS VARCHAR), + CAST(SUM(amount) AS VARCHAR), COUNT(*) + FROM filtered GROUP BY currency, strftime(booking_date, '%Y-%m') ORDER BY currency, 1`, args...) + if err != nil { + return nil, fmt.Errorf("query analytics months: %w", err) + } + defer rows.Close() + result := []MonthlyPoint{} + for rows.Next() { + var point MonthlyPoint + if err := rows.Scan(&point.Period, &point.Currency, &point.Income, &point.Expenses, &point.Net, &point.Count); err != nil { + return nil, err + } + result = append(result, point) + } + return result, rows.Err() +} + func queryTotals(ctx context.Context, tx *sql.Tx, filter Filter) ([]Total, error) { where, args := filter.where() rows, err := tx.QueryContext(ctx, `SELECT t.currency, diff --git a/internal/analytics/store.go b/internal/analytics/store.go index 2da6db0..74799c6 100644 --- a/internal/analytics/store.go +++ b/internal/analytics/store.go @@ -42,15 +42,34 @@ type Group struct { Count int64 `json:"count"` } +// MonthlyPoint is one calendar month of one currency. Income and Expenses are +// both positive magnitudes so a chart can draw them on either side of zero; +// Net is their signed difference and the only figure that may be negative. +type MonthlyPoint struct { + Period string `json:"period"` + Currency string `json:"currency"` + Income string `json:"income"` + Expenses string `json:"expenses"` + Net string `json:"net"` + Count int64 `json:"count"` +} + type Dashboard struct { - Totals []Total `json:"totals"` - Previous []Total `json:"previous"` - Monthly []Group `json:"monthly"` - Categories []Group `json:"categories"` - Tags []Group `json:"tags"` - Merchants []Group `json:"merchants"` - Accounts []Group `json:"accounts"` - Recurring []Group `json:"recurring"` + Totals []Total `json:"totals"` + Previous []Total `json:"previous"` + Monthly []MonthlyPoint `json:"monthly"` + // Categories and PreviousCategories share a shape so the two periods can be + // subtracted category by category; PreviousCategories is empty whenever the + // filter has no comparable preceding interval. + Categories []Group `json:"categories"` + PreviousCategories []Group `json:"previous_categories"` + Tags []Group `json:"tags"` + Merchants []Group `json:"merchants"` + Accounts []Group `json:"accounts"` + Recurring []Group `json:"recurring"` + // Largest is the biggest single outflows of the period, one row per + // transaction: Period carries its booking date and Count is always one. + Largest []Group `json:"largest"` } func Open(path string) (*Store, error) { diff --git a/internal/analytics/store_test.go b/internal/analytics/store_test.go index 149ccb4..dedfcb9 100644 --- a/internal/analytics/store_test.go +++ b/internal/analytics/store_test.go @@ -97,6 +97,39 @@ func TestExactTotalsCurrenciesAndTransferExclusion(t *testing.T) { } } +func TestMonthlySplitsDirectionsAndRanksLargestPerCurrency(t *testing.T) { + s := openFixture(t, fixture()) + got := queryFixture(t, s, Filter{From: "2026-02-01", To: "2026-02-28"}) + months := []MonthlyPoint{ + {Period: "2026-02", Currency: "EUR", Income: "100.1235", Expenses: "900719925474.1000", Net: "-900719925373.9765", Count: 4}, + {Period: "2026-02", Currency: "USD", Income: "0.0000", Expenses: "4.2500", Net: "-4.2500", Count: 1}, + } + if !reflect.DeepEqual(got.Monthly, months) { + t.Fatalf("monthly: got %#v, want %#v", got.Monthly, months) + } + // A repeat payee contributes only its biggest payment, and one currency's + // outflows never crowd another currency out of the list. + largest := []Group{ + {ID: "tx_large", Name: "Shop", Currency: "EUR", Period: "2026-02-10", Amount: "-900719925474.0991", Count: 1}, + {ID: "tx_usd", Name: "Shop", Currency: "USD", Period: "2026-02-10", Amount: "-4.2500", Count: 1}, + } + if !reflect.DeepEqual(got.Largest, largest) { + t.Fatalf("largest: got %#v, want %#v", got.Largest, largest) + } + // The comparison period rolls up through the same ancestors as the current one. + previous := []Group{ + {ID: "cat_expenses", Name: "Expenses", Currency: "EUR", Amount: "-25.0000", Count: 1}, + {ID: "cat_food", Name: "Food", Currency: "EUR", Amount: "-25.0000", Count: 1}, + {ID: "cat_living", Name: "Living", Currency: "EUR", Amount: "-25.0000", Count: 1}, + } + if !reflect.DeepEqual(got.PreviousCategories, previous) { + t.Fatalf("previous categories: got %#v, want %#v", got.PreviousCategories, previous) + } + if all := queryFixture(t, s, Filter{}); len(all.PreviousCategories) != 0 { + t.Fatalf("all-time query must have no comparison period: %#v", all.PreviousCategories) + } +} + func TestTagUnionNeverDuplicatesTransactions(t *testing.T) { s := openFixture(t, fixture()) filter := Filter{From: "2026-02-01", To: "2026-02-28", Currency: "EUR", TagID: "tag_shared,tag_work,tag_shared"} @@ -269,7 +302,8 @@ func TestRecurringRequiresStableCadenceAndSeparatesCurrencies(t *testing.T) { func TestEmptyIndexAndInvalidDates(t *testing.T) { s := openFixture(t, domain.NewDataset()) got := queryFixture(t, s, Filter{}) - if got.Totals == nil || got.Previous == nil || got.Monthly == nil || got.Categories == nil || got.Tags == nil || got.Merchants == nil || got.Accounts == nil || got.Recurring == nil { + if got.Totals == nil || got.Previous == nil || got.Monthly == nil || got.Categories == nil || got.PreviousCategories == nil || + got.Tags == nil || got.Merchants == nil || got.Accounts == nil || got.Recurring == nil || got.Largest == nil { t.Fatal("empty collections must encode as arrays") } for _, filter := range []Filter{{From: "2026-02-30"}, {From: "2026-03-01", To: "2026-02-01"}} { diff --git a/web/src/Overview.tsx b/web/src/Overview.tsx index 67bac08..82b0563 100644 --- a/web/src/Overview.tsx +++ b/web/src/Overview.tsx @@ -1,14 +1,242 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { + Activity, ArrowDownLeft, - ArrowUpRight, - Wallet, ArrowRight, + ArrowUpRight, + GitFork, + Layers, + Minus, + PieChart, + PiggyBank, + Receipt, + Repeat, + TrendingDown, TrendingUp, + Wallet, } from "lucide-react"; -import type { Dashboard, Dataset, Filter, Group } from "./api"; -import { money, request } from "./api"; +import type { + Category, + Dashboard, + Dataset, + Filter, + Group, + MonthlyPoint, + Total, +} from "./api"; +import { compactMoney, money, request } from "./api"; import { Empty, ErrorMessage, Filters } from "./ui"; + +const MONTHS = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +]; +// Money in is always the brand green and money out always the danger red, in +// every chart on the page: a colour must never mean two different things. +const INCOME_INK = "#168465"; +const EXPENSE_INK = "#b94343"; +const NET_INK = "#26384d"; +// Spending categories need to be told apart at a glance without shouting over +// the two semantic colours above. +const FLOW_INKS = [ + "#2f7d90", + "#c47f52", + "#6f9a4e", + "#9d6a8a", + "#4a6fa5", + "#b9993f", + "#3f9c85", + "#8b6fb0", + "#a8574d", +]; + +// Amounts stay decimal strings everywhere they are displayed; they become +// numbers only to compute geometry, where a float is already the target. +function num(value: string): number { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function monthName(period: string): string { + return MONTHS[Number(period.slice(5, 7)) - 1] || period; +} + +function monthTitle(period: string): string { + return `${monthName(period)} ${period.slice(0, 4)}`; +} + +// monthSequence walks calendar months inclusively on the ISO string's integer +// parts, so no browser time zone can shift a bucket. +function monthSequence(start: string, end: string): string[] { + const periods: string[] = []; + let year = Number(start.slice(0, 4)); + let month = Number(start.slice(5, 7)); + const lastYear = Number(end.slice(0, 4)); + const lastMonth = Number(end.slice(5, 7)); + while ( + periods.length < 600 && + (year < lastYear || (year === lastYear && month <= lastMonth)) + ) { + periods.push(`${year}-${String(month).padStart(2, "0")}`); + month += 1; + if (month > 12) { + month = 1; + year += 1; + } + } + return periods; +} + +// series makes the month axis continuous: a month with no activity is a real +// answer to "where did my money go", so it is drawn as an explicit zero rather +// than silently closing the gap between its neighbours. +function series( + points: MonthlyPoint[], + currency: string, + from: string, + to: string, +): MonthlyPoint[] { + const known = new Map( + points.filter((p) => p.currency === currency).map((p) => [p.period, p]), + ); + if (!known.size) return []; + const observed = [...known.keys()].sort(); + const thisMonth = new Date().toISOString().slice(0, 7); + const last = observed[observed.length - 1]; + const start = from ? from.slice(0, 7) : observed[0]; + const end = to ? to.slice(0, 7) : thisMonth > last ? thisMonth : last; + const span = start <= end ? monthSequence(start, end) : observed; + return (span.length > 36 ? span.slice(-36) : span).map( + (period) => + known.get(period) || { + period, + currency, + income: "0", + expenses: "0", + net: "0", + count: 0, + }, + ); +} + +function windowLabel(from: string, to: string): string { + if (from && to) return `${monthTitle(from)} – ${monthTitle(to)}`; + if (from) return `${monthTitle(from)} – today`; + if (to) return `everything up to ${monthTitle(to)}`; + return "your entire history"; +} + +// niceStep picks a gridline interval a human would have chosen. +function niceStep(target: number): number { + if (!(target > 0)) return 1; + const magnitude = Math.pow(10, Math.floor(Math.log10(target))); + const leading = target / magnitude; + const rounded = [1, 2, 2.5, 5, 7.5].find((step) => leading <= step) ?? 10; + return rounded * magnitude; +} + +function clip(text: string, pixels: number): string { + const room = Math.max(6, Math.floor(pixels / 6.2)); + return text.length > room ? `${text.slice(0, room - 1)}…` : text; +} + +// Charts are drawn at real pixel size rather than scaled through a viewBox, so +// axis text keeps the same weight as the rest of the page at every width. +function useElementWidth() { + const ref = useRef(null); + const [width, setWidth] = useState(0); + useEffect(() => { + const node = ref.current; + if (!node) return; + // clientWidth includes padding; ResizeObserver reports the content box. + // Seeding with the padding box would draw one frame too wide. + const box = getComputedStyle(node); + setWidth( + node.clientWidth - + parseFloat(box.paddingLeft) - + parseFloat(box.paddingRight), + ); + const observer = new ResizeObserver((entries) => { + for (const entry of entries) setWidth(entry.contentRect.width); + }); + observer.observe(node); + return () => observer.disconnect(); + }, []); + return [ref, width] as const; +} + +interface Slice { + id: string; + name: string; + value: number; + drill: boolean; +} + +// breakdown splits one side of the ledger into the level below its roots, which +// is the coarsest level a user actually named. Values are returned as positive +// magnitudes; sign carries the direction. Category groups already include their +// descendants, so a root's unexplained remainder becomes its own slice. +function breakdown( + categories: Category[], + kind: string, + amountOf: (id: string) => number, + sign: number, +): Slice[] { + const slices: Slice[] = []; + for (const root of categories.filter( + (c) => c.kind === kind && !c.parent_id, + )) { + const total = sign * amountOf(root.id); + if (total <= 0) continue; + let covered = 0; + for (const child of categories.filter((c) => c.parent_id === root.id)) { + const value = sign * amountOf(child.id); + if (value <= 0) continue; + slices.push({ id: child.id, name: child.name, value, drill: true }); + covered += value; + } + if (total - covered > 0.005) + slices.push({ + id: root.id, + name: `${root.name}: other`, + value: total - covered, + drill: true, + }); + } + return slices.sort((a, b) => b.value - a.value); +} + +function condense(slices: Slice[], limit: number): Slice[] { + if (slices.length <= limit) return slices; + const rest = slices.slice(limit - 1); + return [ + ...slices.slice(0, limit - 1), + { + id: "", + name: `${rest.length} smaller categories`, + value: rest.reduce((sum, s) => sum + s.value, 0), + drill: false, + }, + ]; +} + +function amountLookup(groups: Group[], currency: string) { + const byID = new Map(); + for (const g of groups) + if (g.currency === currency) byID.set(g.id, num(g.amount)); + return (id: string) => byID.get(id) ?? 0; +} + export function Overview({ data, revision, @@ -26,6 +254,7 @@ export function Overview({ const [error, setError] = useState(""); const [loading, setLoading] = useState(true); const [retry, setRetry] = useState(0); + const [picked, setPicked] = useState(""); useEffect(() => { const controller = new AbortController(); setLoading(true); @@ -40,10 +269,12 @@ export function Overview({ "previous", "monthly", "categories", + "previous_categories", "tags", "merchants", "accounts", "recurring", + "largest", ] as const) { if (!(key in value)) throw new Error(`Dashboard response is missing ${key}.`); @@ -62,12 +293,38 @@ export function Overview({ }); return () => controller.abort(); }, [revision, filter, retry]); + // One currency at a time: a Sankey or a donut per currency would stack four + // copies of the page, so the busiest currency leads and the rest are a click + // away. Currency is a view choice, not a filter, and never narrows the data. + const currencies = useMemo(() => { + const weight = new Map(); + for (const total of dashboard?.totals || []) + weight.set(total.currency, num(total.income) + num(total.expenses)); + return [...weight.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([code]) => code); + }, [dashboard]); + const currency = + picked && currencies.includes(picked) ? picked : currencies[0] || ""; + const months = useMemo( + () => series(dashboard?.monthly || [], currency, filter.from, filter.to), + [dashboard, currency, filter.from, filter.to], + ); + const total = dashboard?.totals.find((t) => t.currency === currency); + const previous = dashboard?.previous.find((t) => t.currency === currency); + const drill = (patch: Partial) => { + setFilter({ ...filter, ...patch }); + navigate("transactions"); + }; return ( <>
-

Your financial picture

-

A little clarity for the decisions ahead.

+

Where your money went

+

+ Income, spending and what is left, over{" "} + {windowLabel(filter.from, filter.to)}. +

+ ))}
- {dashboard.recurring.length ? ( -
- - - - - - - - - - - {dashboard.recurring.map((g, i) => ( - - - - - - - ))} - -
Merchant / paymentFrequencyOccurrencesObserved total
{g.name}{g.period}{g.count} - {money(g.amount, g.currency)} -
+ )} + {total ? ( + + ) : ( + data.transactions.length > 0 && ( +
+ + Adjust your filters to include more transactions. + +
+ ) + )} + {total && ( + <> +
+
+
+

Monthly cash flow

+

+ Money in above the line, money out below, net as the + line · transfers excluded +

+
+ +
+ +
+
+
+
+

Where the money went

+

+ Every euro of income traced to the category that + consumed it +

+
+ +
+ drill({ category_id: id })} + /> +
+
+ drill({ category_id: id })} + /> + g.currency === currency, + )} + onSelect={(id) => drill({ category_id: id })} + />
- ) : ( -
- No recurring patterns detected in this period. +
+ drill({ category_id: id })} + /> + g.currency === currency, + )} + currency={currency} + onSelect={(day) => drill({ from: day, to: day })} + />
- )} - +
+ g.currency === currency, + )} + onSelect={(id) => drill({ merchant_id: id })} + /> + g.currency === currency, + )} + onSelect={(id) => drill({ account_id: id })} + /> + g.currency === currency, + )} + onSelect={(id) => drill({ tag_id: id })} + /> +
+ g.currency === currency, + )} + currency={currency} + onSelect={(id) => drill({ merchant_id: id })} + /> + + )} ) )} ); } + +function Trend({ + current, + previous, + currency, + lowerIsBetter = false, +}: { + current: number; + previous: number; + currency: string; + lowerIsBetter?: boolean; +}) { + const change = current - previous; + const share = previous !== 0 ? (change / Math.abs(previous)) * 100 : null; + const Icon = + change > 0.005 ? TrendingUp : change < -0.005 ? TrendingDown : Minus; + const better = lowerIsBetter ? change < 0 : change > 0; + const tone = Math.abs(change) < 0.005 ? "flat" : better ? "better" : "worse"; + return ( + + + + {share === null + ? "new" + : // Truncate rather than round: a −99.6 % fall must never be reported + // as the −100 % that would mean the figure went to zero. + `${share > 0 ? "+" : ""}${Math.abs(share) < 10 ? share.toFixed(1) : Math.trunc(share)}%`} + + vs {money(previous.toFixed(2), currency)} + + ); +} + +function StatStrip({ + total, + previous, + months, +}: { + total: Total; + previous?: Total; + months: number; +}) { + const income = num(total.income); + const expenses = num(total.expenses); + const net = num(total.net); + // A ratio against a near-zero denominator is arithmetic, not information: a + // part-month carrying only an interest credit would read "−10268 % kept". + // Below −100 % the outflow was more than twice the income, and that sentence + // is the honest answer, so the number is withheld rather than printed. + const rate = income > 0 ? (net / income) * 100 : null; + const shown = rate !== null && rate >= -100 ? rate : null; + const priorIncome = previous ? num(previous.income) : 0; + const priorRate = + priorIncome > 0 ? (num(previous!.net) / priorIncome) * 100 : null; + const priorShown = priorRate !== null && priorRate >= -100 ? priorRate : null; + const cards = [ + { + key: "income", + label: "Money in", + Icon: ArrowDownLeft, + value: money(total.income, total.currency), + tone: "positive", + note: previous ? ( + + ) : ( + No comparable earlier period + ), + }, + { + key: "expenses", + label: "Money out", + Icon: ArrowUpRight, + value: money(total.expenses, total.currency), + tone: "negative", + note: ( + + {previous && ( + + )} + {months > 0 && ( + + ≈ {money((expenses / months).toFixed(2), total.currency)} a month + + )} + + ), + }, + { + key: "net", + label: "Net cash flow", + Icon: Wallet, + value: money(total.net, total.currency), + tone: net < 0 ? "negative" : "positive", + note: previous ? ( + + ) : ( + No comparable earlier period + ), + }, + { + key: "rate", + label: "Kept of income", + Icon: PiggyBank, + value: shown === null ? "—" : `${Math.trunc(shown)}%`, + tone: shown !== null && shown >= 0 ? "positive" : "negative", + note: + priorShown !== null ? ( + + + was {Math.trunc(priorShown)}% + + ) : rate === null ? ( + No income in this period + ) : shown === null ? ( + Spending outran income more than twofold + ) : ( + Share of income you did not spend + ), + }, + ]; + return ( +
+ {cards.map(({ key, label, Icon, value, tone, note }) => ( +
+
+ {label} + + + +
+ {value} + {note} +
+ ))} +
+ ); +} + +function CashFlow({ + months, + currency, +}: { + months: MonthlyPoint[]; + currency: string; +}) { + const [box, measured] = useElementWidth(); + const [hover, setHover] = useState(-1); + const width = Math.max(measured || 820, 320); + const height = 316; + const pad = { top: 22, right: 16, bottom: 50, left: 64 }; + const plotWidth = Math.max(width - pad.left - pad.right, 80); + const plotHeight = height - pad.top - pad.bottom; + const maxIn = Math.max(0, ...months.map((m) => num(m.income))); + const maxOut = Math.max(0, ...months.map((m) => num(m.expenses))); + const span = maxIn + maxOut || 1; + const scale = plotHeight / span; + const zero = pad.top + maxIn * scale; + const y = (value: number) => zero - value * scale; + const slot = plotWidth / Math.max(months.length, 1); + const bar = Math.max(3, Math.min(34, slot * 0.26)); + // Three divisions on the taller side reads as a grid rather than a frame. + const step = niceStep(Math.max(maxIn, maxOut) / 3); + const ticks = [0]; + for (let value = step, i = 0; value <= maxIn && i < 8; value += step, i++) + ticks.push(value); + for (let value = step, i = 0; value <= maxOut && i < 8; value += step, i++) + ticks.push(-value); + const every = months.length > 18 ? 3 : months.length > 12 ? 2 : 1; + let shown = ""; + const labels = months.map((point, index) => { + const visible = index % every === 0; + const year = point.period.slice(0, 4); + const withYear = visible && year !== shown; + if (visible) shown = year; + return { visible, withYear, year }; + }); + const at = (index: number) => pad.left + slot * index + slot / 2; + const active = hover >= 0 ? months[hover] : null; + return ( +
+ {!months.length ? ( + + Your monthly trend appears after importing transactions. + + ) : ( + <> + + `${monthTitle(m.period)}: in ${m.income}, out ${m.expenses}, net ${m.net}`, + ) + .join("; ")}`} + > + {ticks.map((tick) => ( + + + + {compactMoney(String(tick))} + + + ))} + {months.map((point, index) => { + const income = num(point.income); + const spent = num(point.expenses); + const dim = hover !== -1 && hover !== index; + return ( + + {income > 0 && ( + + )} + {spent > 0 && ( + + )} + + ); + })} + `${at(index)},${y(num(point.net))}`) + .join(" ")} + /> + {months.map((point, index) => ( + + ))} + {months.map((point, index) => + labels[index].visible ? ( + + + {monthName(point.period)} + + {labels[index].withYear && ( + + {labels[index].year} + + )} + + ) : null, + )} + {months.map((point, index) => ( + setHover(index)} + onMouseLeave={() => setHover(-1)} + /> + ))} + + {active && ( +
+ {monthTitle(active.period)} + + + In{money(active.income, currency)} + + + + Out{money(active.expenses, currency)} + + + + Net{money(active.net, currency)} + + + {active.count} transaction{active.count === 1 ? "" : "s"} + +
+ )} + + )} +
+ ); +} + +interface Placed extends Slice { + top: number; + size: number; + label: number; + ink: string; +} + +// stack lays a column of nodes out on one shared value-to-pixel scale, then +// pushes labels apart so a thin slice still reads. Both columns must use the +// same scale or the ribbons would not line up at their two ends. +function stack( + slices: Slice[], + scale: number, + gap: number, + height: number, + top: number, + inks: (index: number) => string, +): { nodes: Placed[]; span: number } { + const sizes = slices.map((slice) => Math.max(2.5, slice.value * scale)); + const span = + sizes.reduce((sum, size) => sum + size, 0) + + gap * Math.max(0, slices.length - 1); + let cursor = top + Math.max(0, (height - span) / 2); + let previousLabel = -Infinity; + const nodes = slices.map((slice, index) => { + const size = sizes[index]; + const node = { + ...slice, + top: cursor, + size, + label: Math.max(cursor + size / 2, previousLabel + 31), + ink: inks(index), + }; + previousLabel = node.label; + cursor += size + gap; + return node; + }); + return { nodes, span }; +} + +function ribbon( + x0: number, + y0: number, + h0: number, + x1: number, + y1: number, + h1: number, +): string { + const mid = (x0 + x1) / 2; + return `M${x0},${y0} C${mid},${y0} ${mid},${y1} ${x1},${y1} L${x1},${y1 + h1} C${mid},${y1 + h1} ${mid},${y0 + h0} ${x0},${y0 + h0} Z`; +} + +function FlowChart({ + data, + groups, + currency, + onSelect, +}: { + data: Dataset; + groups: Group[]; + currency: string; + onSelect: (id: string) => void; +}) { + const [box, measured] = useElementWidth(); + const amountOf = useMemo( + () => amountLookup(groups, currency), + [groups, currency], + ); + const sources = condense( + breakdown(data.categories, "income", amountOf, 1), + 7, + ); + const sinks = condense( + breakdown(data.categories, "expense", amountOf, -1), + 8, + ); + const earned = sources.reduce((sum, s) => sum + s.value, 0); + const spent = sinks.reduce((sum, s) => sum + s.value, 0); + const surplus = earned - spent; + // Both columns must balance: an overspend is money drawn from reserves on the + // left, a surplus is money still unspent on the right. Naming it is the point. + const left: Slice[] = + surplus < -0.005 + ? [ + ...sources, + { + id: "", + name: "Drawn from reserves", + value: -surplus, + drill: false, + }, + ] + : sources; + const right: Slice[] = + surplus > 0.005 + ? [...sinks, { id: "", name: "Left over", value: surplus, drill: false }] + : sinks; + const total = left.reduce((sum, s) => sum + s.value, 0); + const width = Math.max(measured || 900, 340); + const rows = Math.max(left.length, right.length, 1); + const height = Math.min(620, Math.max(300, rows * 56)); + const pad = 30; + const nodeWidth = 13; + const gutter = Math.min(190, Math.max(88, width * 0.2)); + const usable = height - pad * 2; + const scale = Math.max(0, usable - 9 * Math.max(0, rows - 1)) / (total || 1); + const columnX = { + left: gutter, + centre: (width - nodeWidth) / 2, + right: width - gutter - nodeWidth, + }; + const inbound = stack(left, scale, 9, usable, pad, () => INCOME_INK); + const outbound = stack(right, scale, 9, usable, pad, (index) => + right[index].id === "" && surplus > 0.005 && index === right.length - 1 + ? INCOME_INK + : FLOW_INKS[index % FLOW_INKS.length], + ); + const trunk = Math.max(inbound.span, outbound.span); + const trunkTop = pad + Math.max(0, (usable - trunk) / 2); + let inCursor = trunkTop + (trunk - inbound.span) / 2; + const inLinks = inbound.nodes.map((node) => { + const at = inCursor; + inCursor += node.size; + return { node, at }; + }); + let outCursor = trunkTop + (trunk - outbound.span) / 2; + const outLinks = outbound.nodes.map((node) => { + const at = outCursor; + outCursor += node.size; + return { node, at }; + }); + if (!total) + return ( +
+ + Once income and spending share a period, this shows every euro from + where it came to where it went. + +
+ ); + return ( +
+ `${s.name} ${s.value.toFixed(2)}`) + .join(", ")}. Destinations: ${right + .map((s) => `${s.name} ${s.value.toFixed(2)}`) + .join(", ")}.`} + > + {inLinks.map(({ node, at }) => ( + + ))} + {outLinks.map(({ node, at }) => ( + + ))} + + + {compactMoney(String(total), currency)} + + {inbound.nodes.map((node) => ( + onSelect(node.id) : undefined} + > + {`${node.name}: ${money(node.value.toFixed(2), currency)}`} + + + {clip(node.name, gutter - 14)} + + + {compactMoney(String(node.value), currency)} ·{" "} + {Math.round((node.value / total) * 100)}% + + + ))} + {outbound.nodes.map((node) => ( + onSelect(node.id) : undefined} + > + {`${node.name}: ${money(node.value.toFixed(2), currency)}`} + + + {clip(node.name, gutter - 14)} + + + {compactMoney(String(node.value), currency)} ·{" "} + {Math.round((node.value / total) * 100)}% + + + ))} + +
+ ); +} + +function SpendShare({ + data, + groups, + currency, + onSelect, +}: { + data: Dataset; + groups: Group[]; + currency: string; + onSelect: (id: string) => void; +}) { + const amountOf = useMemo( + () => amountLookup(groups, currency), + [groups, currency], + ); + const slices = condense( + breakdown(data.categories, "expense", amountOf, -1), + 8, + ); + const total = slices.reduce((sum, s) => sum + s.value, 0); + const radius = 58; + const stroke = 23; + const size = (radius + stroke / 2) * 2 + 4; + const circumference = 2 * Math.PI * radius; + let offset = 0; + const arcs = slices.map((slice, index) => { + const length = (slice.value / total) * circumference; + const arc = { + slice, + length: Math.max(length - 2, 1), + offset, + ink: FLOW_INKS[index % FLOW_INKS.length], + }; + offset += length; + return arc; + }); + return ( +
+
+
+

Share of spending

+

Top-level categories · click one to see its transactions

+
+ +
+ {total ? ( +
+ + `${s.name} ${Math.round((s.value / total) * 100)} percent`, + ) + .join(", ")}`} + > + {arcs.map(({ slice, length, offset: start, ink }) => ( + + ))} + + {compactMoney(String(total), currency)} + + + spent + + +
+ {arcs.map(({ slice, ink }) => ( + + ))} +
+
+ ) : ( +
+ No spending recorded in this period. +
+ )} +
+ ); +} + +function Movers({ + data, + groups, + previous, + currency, + onSelect, +}: { + data: Dataset; + groups: Group[]; + previous: Group[]; + currency: string; + onSelect: (id: string) => void; +}) { + const rows = useMemo(() => { + const parents = new Set( + data.categories.map((c) => c.parent_id).filter(Boolean), + ); + const now = amountLookup(groups, currency); + const before = amountLookup(previous, currency); + return data.categories + .filter((c) => c.kind === "expense" && !parents.has(c.id)) + .map((c) => { + const current = -now(c.id); + const past = -before(c.id); + return { + id: c.id, + name: c.name, + parent: data.categories.find((p) => p.id === c.parent_id)?.name || "", + current, + past, + change: current - past, + }; + }) + .filter((row) => Math.abs(row.change) >= 0.01) + .sort((a, b) => Math.abs(b.change) - Math.abs(a.change)) + .slice(0, 6); + }, [data.categories, groups, previous, currency]); + const most = Math.max(...rows.map((r) => Math.abs(r.change)), 1); + return ( +
+
+
+

What changed

+

Spending per category against the previous equal period

+
+ +
+ {previous.length && rows.length ? ( +
+ {rows.map((row) => ( + + ))} +
+ ) : ( +
+ {previous.length + ? "Spending held steady across every category." + : "Pick a bounded period to compare it against the one before."} +
+ )} +
+ ); +} + +function Largest({ + rows, + currency, + onSelect, +}: { + rows: Group[]; + currency: string; + onSelect: (day: string) => void; +}) { + const most = Math.max(...rows.map((r) => Math.abs(num(r.amount))), 1); + return ( +
+
+
+

Biggest payments

+

One row per payee, its largest · click one for that day

+
+ +
+ {rows.length ? ( +
+ {rows.map((row) => ( + + ))} +
+ ) : ( +
No outflows in this period.
+ )} +
+ ); +} + +function Recurring({ + rows, + currency, + onSelect, +}: { + rows: Group[]; + currency: string; + onSelect: (id: string) => void; +}) { + // Cadence times the average occurrence is the standing monthly commitment: + // observed, never forecast, so a missed month lowers it honestly. + const perMonth = rows.reduce((sum, row) => { + const each = Math.abs(num(row.amount)) / Math.max(row.count, 1); + const factor = + row.period === "weekly" ? 52 / 12 : row.period === "yearly" ? 1 / 12 : 1; + return sum + each * factor; + }, 0); + return ( +
+
+
+

Recurring patterns

+

Repeated payments detected in the selected period

+
+ {perMonth > 0 ? ( + + ≈ {money(perMonth.toFixed(2), currency)} a month + committed + + ) : ( + Observed, not forecast + )} +
+ {rows.length ? ( +
+ + + + + + + + + + + + {rows.map((row, index) => { + const each = Math.abs(num(row.amount)) / Math.max(row.count, 1); + const factor = + row.period === "weekly" + ? 52 / 12 + : row.period === "yearly" + ? 1 / 12 + : 1; + return ( + + + + + + + + ); + })} + +
Merchant / paymentFrequencyOccurrencesObserved totalPer month
+ + {row.period}{row.count} + {money(row.amount, currency)} + + {money((each * factor).toFixed(2), currency)} +
+
+ ) : ( +
+ No recurring patterns detected in this period. +
+ )} +
+ ); +} + function CategoryTree({ data, groups, @@ -326,7 +1539,10 @@ function CategoryTree({ {category?.name || id} {rows.map((g) => ( - + {money(g.amount, g.currency)} ))} @@ -363,51 +1579,7 @@ function CategoryTree({ ); } -function MonthlyChart({ groups }: { groups: Group[] }) { - if (!groups.length) - return ( - - Your monthly trend appears after importing transactions. - - ); - const currencies = Array.from(new Set(groups.map((g) => g.currency))); - return ( -
- {currencies.map((currency) => { - const rows = groups - .filter((g) => g.currency === currency) - .sort((a, b) => a.period.localeCompare(b.period)); - const max = Math.max(...rows.map((g) => Math.abs(Number(g.amount))), 1); - return ( -
- {currency} -
`${g.period}: ${g.amount}`).join("; ")}`} - > - {rows.map((g, i) => ( -
- {money(g.amount, currency)} -
-
-
- {g.period} -
- ))} -
-
- ); - })} -
- ); -} + function GroupPanel({ title, subtitle, @@ -420,16 +1592,9 @@ function GroupPanel({ onSelect: (id: string) => void; }) { const [expanded, setExpanded] = useState(false); - const maxima: Record = {}; - for (const g of groups) - maxima[g.currency] = Math.max( - maxima[g.currency] || 1, - Math.abs(Number(g.amount)), - ); + const most = Math.max(...groups.map((g) => Math.abs(num(g.amount))), 1); const sorted = [...groups].sort( - (a, b) => - a.currency.localeCompare(b.currency) || - Math.abs(Number(b.amount)) - Math.abs(Number(a.amount)), + (a, b) => Math.abs(num(b.amount)) - Math.abs(num(a.amount)), ); return (
@@ -449,12 +1614,17 @@ function GroupPanel({ >
{g.name || "Unassigned"} - {money(g.amount, g.currency)} + + {money(g.amount, g.currency)} +
diff --git a/web/src/api.ts b/web/src/api.ts index 870554f..f810011 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -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) }; +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 8d105ef..5bf515e 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -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); diff --git a/web/src/styles.css b/web/src/styles.css index 6ea4e24..ef11abb 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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; + } +} diff --git a/web/src/ui.tsx b/web/src/ui.tsx index 4989c20..e407ed4 100644 --- a/web/src/ui.tsx +++ b/web/src/ui.tsx @@ -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 ( -
- update("from", day)} - /> - update("to", day)} - /> - - - - - - - - - - - - - - - - + Reset + +
+
+ update("from", day)} + /> + update("to", day)} + /> + + + + + + + + + + + + + + + +
); }