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)} + /> + + + + + + + + + + + + + + + +
); }