From 617a4700e2df6fb4d89ca1289b520d7bc177e072 Mon Sep 17 00:00:00 2001 From: h Date: Fri, 16 Oct 2026 18:49:16 +0000 Subject: [PATCH] feat: every page follows the one-currency mode Totals, info boxes, section headers, group subtotals, shares and the per-currency charts on the dashboard, transactions, search, accounts, merchants, statement, budgets, categories, tags, groups, piggy banks, reports and the category, tag, budget and merchant pages sum into one converted amount when the mode is on; charts split by currency merge into one lane. Inputs, reconciliation and the Foreign column stay exact. Money moved to or from a debt, loan or mortgage no longer counts as income or spending in the transaction list boxes; it shows as From debts and To debts. The running balance of an incoming foreign-currency transfer shows in the account's currency. --- src/lib/accounts/merchants.svelte.ts | 43 ++++- src/lib/accounts/merchants.ts | 34 +++- src/lib/accounts/statement.ts | 20 +- .../components/accounts/AccountShow.svelte | 40 ++-- .../components/accounts/AccountsIndex.svelte | 115 +++++++----- .../components/accounts/AllAccounts.svelte | 42 +++-- src/lib/components/accounts/Breakdown.svelte | 28 ++- .../components/accounts/MerchantCell.svelte | 7 +- src/lib/components/accounts/Merchants.svelte | 16 +- src/lib/components/accounts/PeriodList.svelte | 39 +++- src/lib/components/accounts/Reconcile.svelte | 2 +- src/lib/components/accounts/Statement.svelte | 19 +- .../accounts/StatementHeader.svelte | 14 +- .../accounts/StatementMonths.svelte | 24 +-- src/lib/components/budgets/LimitCell.svelte | 8 +- src/lib/components/budgets/ShowBudget.svelte | 14 +- src/lib/components/budgets/TopLine.svelte | 29 +-- src/lib/components/budgets/Triage.svelte | 1 + src/lib/components/budgets/data.ts | 71 ++++++- .../components/categories/MoneyList.svelte | 26 ++- .../components/dashboard/AccountsBlock.svelte | 176 ++++++++++-------- .../components/dashboard/AccountsChart.svelte | 85 +++++---- .../components/dashboard/BudgetsChart.svelte | 79 +++++--- .../dashboard/CategoriesChart.svelte | 65 ++++--- src/lib/components/dashboard/DueBox.svelte | 38 ++-- src/lib/components/dashboard/InboxBox.svelte | 8 +- src/lib/components/dashboard/InfoBoxes.svelte | 95 +++++++--- src/lib/components/dashboard/PaceBox.svelte | 46 +++-- .../components/dashboard/PiggyBanksBox.svelte | 17 +- .../dashboard/SubscriptionsBox.svelte | 63 ++++--- src/lib/components/dashboard/data.ts | 21 ++- src/lib/components/dimension/Headline.svelte | 3 +- src/lib/components/groups/Totals.svelte | 31 ++- .../components/piggy-banks/MoneyEditor.svelte | 6 +- src/lib/components/receipt/Place.svelte | 5 +- src/lib/components/receipt/SameDays.svelte | 5 +- .../components/reports/AccountBalances.svelte | 23 ++- .../components/reports/BalanceLines.svelte | 12 +- src/lib/components/reports/Board.svelte | 14 +- .../components/reports/BudgetLimits.svelte | 44 +++-- .../reports/IncomeVsExpenses.svelte | 12 +- .../components/reports/NetWorthLanes.svelte | 53 +++--- .../components/reports/Subscriptions.svelte | 8 +- .../transactions/PeriodSummary.svelte | 54 +++--- .../components/transactions/Periods.svelte | 20 +- .../transactions/TransactionTable.svelte | 48 +++-- src/lib/dashboard/primary.svelte.ts | 136 -------------- src/lib/dimension/model.svelte.ts | 16 +- src/lib/dimension/stats.ts | 28 +++ src/lib/money/lanes.ts | 62 ++++++ src/lib/reports/display.ts | 57 ++++++ src/lib/reports/report.svelte.ts | 47 ++++- src/lib/transactions/ledger.ts | 17 +- src/lib/transactions/split.ts | 34 +++- .../[[start=date]]/[[end=date]]/+page.svelte | 52 ++++-- src/routes/categories/+page.svelte | 29 +-- src/routes/piggy-banks/+page.svelte | 22 ++- .../piggy-banks/show/[id=id]/+page.svelte | 34 +++- src/routes/recurring/+page.svelte | 1 + .../recurring/show/[id=id]/+page.svelte | 3 + .../transactions/show/[id=id]/+page.svelte | 64 +++++-- 61 files changed, 1427 insertions(+), 798 deletions(-) delete mode 100644 src/lib/dashboard/primary.svelte.ts create mode 100644 src/lib/money/lanes.ts create mode 100644 src/lib/reports/display.ts diff --git a/src/lib/accounts/merchants.svelte.ts b/src/lib/accounts/merchants.svelte.ts index 51af343..b922f7e 100644 --- a/src/lib/accounts/merchants.svelte.ts +++ b/src/lib/accounts/merchants.svelte.ts @@ -1,11 +1,13 @@ import { createQuery, keepPreviousData } from "@tanstack/svelte-query"; import { lists } from "#lib/api/lists.js"; import type { TransactionGroup } from "#lib/api/types.js"; -import { ratesQuery } from "#lib/dashboard/primary.svelte.js"; import { today } from "#lib/dates.js"; +import { display } from "#lib/money/display.svelte.js"; import { period } from "#lib/period.svelte.js"; +import type { Flow } from "#lib/reports/flows.js"; import { ledgerQuery, ledgerWindow, ownFlows } from "#lib/reports/ledger.js"; import { pages } from "#lib/reports/queries.js"; +import { impliedRates } from "#lib/reports/rates.js"; import { typicalMonths } from "#lib/reports/typical.js"; import { session } from "#lib/session.svelte.js"; import { board, type Kind, monthDay } from "./merchants.js"; @@ -35,19 +37,43 @@ export function useMerchants(kind: () => Kind) { }), queryKey: ["merchants-period", period.start, period.end], })); - const rates = createQuery(() => ({ ...ratesQuery(), enabled: true })); - const flows = $derived( + const own = $derived( ledger.data && accounts.data ? ownFlows(ledger.data, accounts.data) : [] ); - const periodFlows = $derived.by(() => { + const ownPeriod = $derived.by(() => { if (inside) { - return flows; + return own; } return extra.data && accounts.data ? ownFlows(extra.data, accounts.data) : []; }); + const rates = $derived.by(() => { + if (!display.active) { + return null; + } + const map = new Map(); + for (const code of new Set([...own, ...ownPeriod].map((f) => f.currency))) { + const rate = display.rate(code); + if (rate === null) { + return null; + } + map.set(code, rate); + } + return map; + }); + const merge = $derived(rates ? display.code : null); + const inTarget = (list: Flow[]) => + rates + ? list.map((flow) => ({ + ...flow, + amount: flow.amount * (rates.get(flow.currency) ?? 0), + })) + : list; + const flows = $derived(inTarget(own)); + const periodFlows = $derived(inside ? flows : inTarget(ownPeriod)); + const implied = $derived(impliedRates(ledger.data ?? [])); const day = $derived(monthDay(period.range, now)); const result = $derived.by(() => { if (!(accounts.data && ledger.data && (inside || extra.data))) { @@ -58,14 +84,15 @@ export function useMerchants(kind: () => Kind) { (first, flow) => (flow.date < first ? flow.date : first), now ); - const primary = session.primary?.code ?? ""; + const base = merge ?? session.primary?.code ?? ""; return board(accounts.data, flows, periodFlows, { day, kind: kind(), + merge, months: window.months.filter((m) => m <= month), past: typicalMonths(window.months, period.end, since), range: period.range, - rate: (code) => rates.data?.[code]?.rate ?? (code === primary ? 1 : 0), + rate: (code) => implied(code, base) ?? 0, }); }); @@ -80,7 +107,7 @@ export function useMerchants(kind: () => Kind) { return accounts.error ?? ledger.error ?? extra.error ?? null; }, get loading() { - return result === null || rates.isPending; + return result === null; }, get stale() { return ledger.isFetching || extra.isPlaceholderData; diff --git a/src/lib/accounts/merchants.ts b/src/lib/accounts/merchants.ts index 2ba3b97..dac2563 100644 --- a/src/lib/accounts/merchants.ts +++ b/src/lib/accounts/merchants.ts @@ -13,6 +13,7 @@ const TOP = 5; export interface Line { amount: number; + approx: boolean; base: number; code: string; months: number[]; @@ -40,6 +41,7 @@ export interface Merchant { export interface Total { amount: number; + approx: boolean; base: number; code: string; ratio: number | null; @@ -65,6 +67,7 @@ export interface Board { export interface Context { day: number | null; kind: Kind; + merge: string | null; months: string[]; past: string[]; range: Range; @@ -72,6 +75,8 @@ export interface Context { } const sum = (values: number[]) => values.reduce((a, b) => a + b, 0); +const foreign = (flows: Flow[], code: string) => + flows.some((flow) => flow.currency !== code); function monthly(flows: Flow[]): Map { const map = new Map(); @@ -88,6 +93,7 @@ function lineOf(code: string, year: Flow[], now: Flow[], ctx: Context): Line { const base = ctx.day === null ? 0 : typicalToDay(year, ctx.past, ctx.day); return { amount, + approx: foreign(year, code) || foreign(now, code), base, code, months: ctx.months.slice(-SPARK_MONTHS).map((m) => byMonth.get(m) ?? 0), @@ -131,6 +137,8 @@ function bucket(items: T[], key: (item: T) => string): Map { return map; } +const codeOf = (ctx: Context) => (flow: Flow) => ctx.merge ?? flow.currency; + const latest = (flows: Flow[]) => flows.reduce((last, flow) => (flow.date > last ? flow.date : last), ""); @@ -142,8 +150,8 @@ function merchantOf( now: Flow[], ctx: Context ): Omit { - const yearBy = bucket(year, (flow) => flow.currency); - const nowBy = bucket(now, (flow) => flow.currency); + const yearBy = bucket(year, codeOf(ctx)); + const nowBy = bucket(now, codeOf(ctx)); const codes = new Set([...yearBy.keys(), ...nowBy.keys()]); const quiet = now.length === 0; const lines = [...codes] @@ -221,8 +229,8 @@ export function board( } function totalsOf(year: Flow[], now: Flow[], ctx: Context): Total[] { - const nowBy = bucket(now, (flow) => flow.currency); - const yearBy = bucket(year, (flow) => flow.currency); + const nowBy = bucket(now, codeOf(ctx)); + const yearBy = bucket(year, codeOf(ctx)); return [...nowBy] .map(([code, list]) => { const amount = sum(list.map((flow) => flow.amount)); @@ -232,6 +240,7 @@ function totalsOf(year: Flow[], now: Flow[], ctx: Context): Total[] { : typicalToDay(yearBy.get(code) ?? [], ctx.past, ctx.day); return { amount, + approx: foreign(list, code), base, code, ratio: ctx.day === null ? null : versus(amount, base), @@ -254,13 +263,20 @@ export const sortBy = ORDER[key](a, b) || ORDER.last(a, b) || ORDER.name(a, b); export function sumLines(merchants: Merchant[]): Total[] { - const map = new Map(); + const map = new Map(); for (const line of merchants.flatMap((m) => m.lines)) { - map.set(line.code, (map.get(line.code) ?? 0) + line.amount); + const total = map.get(line.code) ?? { + amount: 0, + approx: false, + base: 0, + code: line.code, + ratio: null, + }; + total.amount += line.amount; + total.approx ||= line.approx; + map.set(line.code, total); } - return [...map] - .filter(([, amount]) => amount > 0) - .map(([code, amount]) => ({ amount, base: 0, code, ratio: null })); + return [...map.values()].filter((total) => total.amount > 0); } export function byCategory(merchants: Merchant[], none: string): Section[] { diff --git a/src/lib/accounts/statement.ts b/src/lib/accounts/statement.ts index 10fa1f3..5e6b912 100644 --- a/src/lib/accounts/statement.ts +++ b/src/lib/accounts/statement.ts @@ -6,7 +6,13 @@ import { eachDay, type Range, } from "#lib/dates.js"; -import { toNumber } from "#lib/format.js"; +import { + compactMoney, + formatMoney, + type Money, + toNumber, +} from "#lib/format.js"; +import { display } from "#lib/money/display.svelte.js"; export type Flow = | "income" @@ -220,3 +226,15 @@ export function terms(side: Side, total: Sums): Term[] { export function reconciledThrough(list: Move[]): string | null { return list.findLast((m) => m.reconciled)?.date ?? null; } + +export function viewOf(cur: Omit) { + const rate = cur.code === display.code ? null : display.rate(cur.code); + const shown = rate === null ? cur : display.target; + const mark = rate === null ? "" : "≈"; + return { + axis: (value: number) => compactMoney(value, shown.symbol ?? ""), + format: (value: number, signed = false) => + `${mark}${formatMoney({ ...shown, amount: value }, signed)}`, + rate: rate ?? 1, + }; +} diff --git a/src/lib/components/accounts/AccountShow.svelte b/src/lib/components/accounts/AccountShow.svelte index 51760ac..1472080 100644 --- a/src/lib/components/accounts/AccountShow.svelte +++ b/src/lib/components/accounts/AccountShow.svelte @@ -2,6 +2,7 @@ import ChevronDown from "@lucide/svelte/icons/chevron-down"; import { createQuery, keepPreviousData } from "@tanstack/svelte-query"; import { DropdownMenu } from "bits-ui"; + import { viewOf } from "#lib/accounts/statement.js"; import { api } from "#lib/api/client.js"; import { all, invalidate } from "#lib/api/lists.js"; import type { Account, One, Page, TransactionGroup } from "#lib/api/types.js"; @@ -26,8 +27,9 @@ import DropdownMenuContent from "#lib/components/ui/dropdown-menu/dropdown-menu-content.svelte"; import DropdownMenuItem from "#lib/components/ui/dropdown-menu/dropdown-menu-item.svelte"; import { contains, daysBetween, today } from "#lib/dates.js"; - import { formatMoney, formatRange } from "#lib/format.js"; + import { formatRange } from "#lib/format.js"; import { useKeys } from "#lib/keys.svelte.js"; + import { display } from "#lib/money/display.svelte.js"; import { period } from "#lib/period.svelte.js"; import { session } from "#lib/session.svelte.js"; import { goto } from "$app/navigation"; @@ -131,19 +133,28 @@ }), queryKey: ["account-chart", id, range.start, range.end, step], })); - const converted = $derived(hasPrimary(chart.data ?? [])); - const series = $derived( - (chart.data ?? []).map((set) => ({ - area: true, - format: money(converted ? setPrimary(set) : currency(set.currency_code)), - key: set.label, - label: set.label, - points: seriesPoints(set, converted), - })) - ); + const converted = $derived(!display.active && hasPrimary(chart.data ?? [])); const cur = $derived( currency(a?.currency_code ?? session.primary?.code ?? "") ); + const view = $derived(display.active ? viewOf(cur) : null); + const series = $derived( + (chart.data ?? []).map((set) => { + const own = viewOf(currency(set.currency_code)); + return { + area: true, + format: view + ? own.format + : money(converted ? setPrimary(set) : currency(set.currency_code)), + key: set.label, + label: set.label, + points: seriesPoints(set, converted).map((p) => ({ + ...p, + y: p.y * own.rate, + })), + }; + }) + ); const pageNo = $derived( Number(page.url.searchParams.get("page") ?? "1") || 1 @@ -207,7 +218,7 @@ return TYPE_TITLE[type].replace(" accounts", " account"); }); const balance = $derived( - a ? formatMoney({ amount: a.current_balance, ...cur }) : "" + a ? display.text([{ amount: a.current_balance, ...cur }]) : "" ); function pageHref(n: number) { @@ -344,9 +355,8 @@

{:else} , key: Key): string | number { const a = item.attributes; + const shown = (value: string | null | undefined) => + display.convert(toNumber(value), a.currency_code) ?? toNumber(value); switch (key) { case "name": return a.name.toLowerCase(); @@ -215,15 +219,15 @@ case "number": return formatIban(a); case "balance": - return toNumber(a.current_balance); + return shown(a.current_balance); case "due": - return toNumber(a.debt_amount); + return shown(a.debt_amount); case "active": return a.active ? 1 : 0; case "activity": return a.last_activity ?? ""; default: - return toNumber(a.balance_difference); + return shown(a.balance_difference); } } @@ -265,11 +269,43 @@ return (a > b ? 1 : -1) * dir; }); }); + interface Total { + balance: number; + code: string; + decimals: number; + difference: number; + due: number; + symbol: string; + } + const totals = $derived.by(() => { + const map = new Map(); + for (const { attributes: a } of matching) { + const code = a.currency_code ?? ""; + const t = map.get(code) ?? { + balance: 0, + code, + decimals: a.currency_decimal_places ?? 2, + difference: 0, + due: 0, + symbol: a.currency_symbol ?? code, + }; + t.balance += toNumber(a.current_balance); + t.difference += toNumber(a.balance_difference); + t.due += toNumber(a.debt_amount); + map.set(code, t); + } + return [...map.values()]; + }); + const parts = (list: Total[], key: "balance" | "difference" | "due") => + list.map((t) => ({ ...t, amount: t[key] })); + const merged = $derived(display.total(parts(totals, "balance")) !== null); + const sections = $derived(perCurrency && !merged); + const grouped = $derived( - perCurrency || sorted.some((item) => item.attributes.object_group_title) + sections || sorted.some((item) => item.attributes.object_group_title) ); const groups = $derived.by(() => { - if (perCurrency) { + if (sections) { return byCurrency( sorted, (item) => item.attributes.currency_code ?? "" @@ -298,34 +334,6 @@ const rows = $derived(groups.flatMap((g) => g.items)); const index = $derived(new Map(rows.map((item, i) => [item.id, i]))); - interface Total { - balance: number; - code: string; - decimals: number; - difference: number; - due: number; - symbol: string; - } - const totals = $derived.by(() => { - const map = new Map(); - for (const { attributes: a } of rows) { - const code = a.currency_code ?? ""; - const t = map.get(code) ?? { - balance: 0, - code, - decimals: a.currency_decimal_places ?? 2, - difference: 0, - due: 0, - symbol: a.currency_symbol ?? code, - }; - t.balance += toNumber(a.current_balance); - t.difference += toNumber(a.balance_difference); - t.due += toNumber(a.debt_amount); - map.set(code, t); - } - return [...map.values()]; - }); - const title = $derived( active ? TYPE_TITLE[type] : `${TYPE_TITLE[type]} (inactive)` ); @@ -471,7 +479,7 @@ {#snippet sums( - t: Total, + list: Total[], label: string, head = false )} @@ -489,23 +497,23 @@ >{label} {#if head} - {/if} {:else if col.key === "balance"} {formatMoney({ ...t, amount: t.balance })}{display.text(parts(list, "balance"))} {:else if col.key === "due"} {formatMoney({ ...t, amount: t.due })}{display.text(parts(list, "due"))} {:else if col.key === "difference"} - + {/if} {/each} @@ -596,12 +604,13 @@ {#each groups as group (group.title)} - {#if perCurrency} - {@const t = totals.find((x) => x.code === group.title)} + {#if sections} - {#if t} - {@render sums(t, group.title, true)} - {/if} + {@render sums( + totals.filter((t) => t.code === group.title), + group.title, + true + )} {:else if grouped} @@ -706,13 +715,19 @@ {/if} - {#if totals.length > 0 && !perCurrency} + {#if totals.length > 0 && !sections} - {#each totals as t (t.code)} + {#if merged} - {@render sums(t, `Total in ${t.code}`)} + {@render sums(totals, "Total")} - {/each} + {:else} + {#each totals as t (t.code)} + + {@render sums([t], `Total in ${t.code}`)} + + {/each} + {/if} {/if} diff --git a/src/lib/components/accounts/AllAccounts.svelte b/src/lib/components/accounts/AllAccounts.svelte index 4335a87..103f6e7 100644 --- a/src/lib/components/accounts/AllAccounts.svelte +++ b/src/lib/components/accounts/AllAccounts.svelte @@ -8,6 +8,7 @@ import { addDays } from "#lib/dates.js"; import { formatMoney, type Money, toNumber } from "#lib/format.js"; import { ListNav } from "#lib/list-nav.svelte.js"; + import { display } from "#lib/money/display.svelte.js"; import { pages, reportQueries } from "#lib/reports/queries.js"; import { goto } from "$app/navigation"; import { showUrl, typeOf } from "./links.js"; @@ -142,7 +143,11 @@ nav.use(); const money = (line: Line, amount: number, signed = false) => - formatMoney({ ...line.cur, amount }, signed); + display.text([{ ...line.cur, amount }], signed); + const original = (line: Line, amount: number, signed = false) => + display.total([{ ...line.cur, amount }])?.converted + ? formatMoney({ ...line.cur, amount }, signed) + : undefined; function tone(value: number, positive: string, negative = positive) { if (Math.abs(value) < CENT) { return "text-cladd-fg-softer"; @@ -156,6 +161,17 @@ : tone(value, "text-income", "text-expense"); +{#snippet cell( + line: Line, + amount: number, + extra: string, + signed = true +)} + + {money(line, amount, signed)} + +{/snippet} + {line.cur.code} - - - - + {@render cell(line, line.opening, "", false)} + {@render cell(line, line.income, real(line, line.income))} + {@render cell(line, line.spent, real(line, line.spent))} + {@render cell(line, line.moved, tone(line.moved, "text-transfer"))} {#if adjusted} - + {@render cell( + line, + line.other, + tone(line.other, "text-cladd-fg") + )} {/if} - + {@render cell(line, line.closing, "font-semibold", false)} {/each} diff --git a/src/lib/components/accounts/Breakdown.svelte b/src/lib/components/accounts/Breakdown.svelte index d846c09..a3f9f71 100644 --- a/src/lib/components/accounts/Breakdown.svelte +++ b/src/lib/components/accounts/Breakdown.svelte @@ -8,6 +8,7 @@ currency, money, } from "#lib/components/dashboard/data.js"; + import { display } from "#lib/money/display.svelte.js"; import type { Group } from "#lib/transactions/split.js"; const TOP = 6; @@ -41,13 +42,29 @@ .filter((s) => s.type === kind) ); + const merged = $derived( + display.active && + splits.every((s) => display.rate(s.currency_code) !== null) + ); + const buckets = $derived<[string, Split[]][]>( + merged + ? [[display.code, splits]] + : byCurrency(splits, (s) => s.currency_code ?? "") + ); + const approx = $derived( + merged && splits.some((s) => s.currency_code !== display.code) + ); + const sections = $derived( - byCurrency(splits, (s) => s.currency_code ?? "").map(([code, list]) => { + buckets.map(([code, list]) => { const sums = new Map(); for (const split of list) { const [label, href] = pick(split); const bar = sums.get(label) ?? { href, key: label, label, value: 0 }; - bar.value += Number(split.amount); + const value = Number(split.amount); + bar.value += merged + ? (display.convert(value, split.currency_code) ?? 0) + : value; sums.set(label, bar); } const sorted = [...sums.values()].sort((a, b) => b.value - a.value); @@ -63,7 +80,12 @@ }, ] : sorted; - return { bars, code, format: money(currency(code)) }; + const format = money(currency(code)); + return { + bars, + code, + format: approx ? (value: number) => `≈${format(value)}` : format, + }; }) ); diff --git a/src/lib/components/accounts/MerchantCell.svelte b/src/lib/components/accounts/MerchantCell.svelte index 81ea920..228afde 100644 --- a/src/lib/components/accounts/MerchantCell.svelte +++ b/src/lib/components/accounts/MerchantCell.svelte @@ -43,7 +43,12 @@ const money = (line: Line, amount: number, decimals?: number) => { const cur = currency(line.code); - return formatMoney({ ...cur, amount, decimals: decimals ?? cur.decimals }); + const text = formatMoney({ + ...cur, + amount, + decimals: decimals ?? cur.decimals, + }); + return line.approx ? `≈${text}` : text; }; function change(line: Line): [string, string] { diff --git a/src/lib/components/accounts/Merchants.svelte b/src/lib/components/accounts/Merchants.svelte index 3897d4a..5f02a92 100644 --- a/src/lib/components/accounts/Merchants.svelte +++ b/src/lib/components/accounts/Merchants.svelte @@ -10,6 +10,7 @@ SORT_KEYS, type SortKey, sortBy, + type Total, } from "#lib/accounts/merchants.js"; import { useMerchants } from "#lib/accounts/merchants.svelte.js"; import { api } from "#lib/api/client.js"; @@ -370,6 +371,11 @@ return `${align} ${wide}`; } + function money(t: Total) { + const shown = formatMoney({ ...currency(t.code), amount: t.amount }); + return t.approx ? `≈${shown}` : shown; + } + function tone(ratio: number | null) { if (ratio === null || Math.abs(ratio) < SAME) { return "text-cladd-fg-soft"; @@ -380,12 +386,10 @@ {#snippet sums( - list: { amount: number; code: string }[] + list: Total[] )} {#each list as t (t.code)} -
- {formatMoney({ ...currency(t.code), amount: t.amount })} -
+
{money(t)}
{/each} {/snippet} @@ -506,9 +510,7 @@ > {#each b.totals as t (t.code)} - {formatMoney({ ...currency(t.code), amount: t.amount })} + {money(t)} {#if t.ratio !== null} {versusText(t.ratio)}{current ? ` by ${asOf}` : ""} { + if (!display.active) { + return null; + } + const map = new Map(); + for (const [key, list] of byStart) { + const flow: Flow = { code: display.code, earned: 0, spent: 0 }; + for (const f of list) { + const rate = display.rate(f.code); + if (rate === null) { + return null; + } + flow.earned += f.earned * rate; + flow.spent += f.spent * rate; + flow.approx ||= f.code !== display.code; + } + map.set(key, [flow]); + } + return map; + }); + const shown = $derived(merged ?? byStart); + const main = $derived(merged ? display.code : code); const scale = $derived( Math.max( 1, - ...[...byStart.values()].flatMap((list) => + ...[...shown.values()].flatMap((list) => list - .filter((f) => f.code === code) + .filter((f) => f.code === main) .map((f) => Math.max(f.earned, f.spent)) ) ) @@ -95,7 +119,7 @@ class="-mr-px grid grid-cols-1 overflow-hidden @xs:grid-cols-2 @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-6" > {#each blocks as block (block.start)} - {@const list = (byStart.get(block.start) ?? []).filter( + {@const list = (shown.get(block.start) ?? []).filter( (f) => f.earned || f.spent )}
  • @@ -117,17 +141,20 @@ {/if} {#each list as flow (flow.code)} {@const fmt = money(currency(flow.code))} + {@const mark = flow.approx ? "≈" : ""} {#if flow.earned} - +{fmt(flow.earned)} + {mark}+{fmt(flow.earned)} {/if} {#if flow.spent} - −{fmt(flow.spent)} + {mark}−{fmt(flow.spent)} {/if} - {#if flow.code === code} + {#if flow.code === main}
  • - - - - - - + {#if group.balance && group.change} + + + + + + + {/if} {#each group.accounts as account (account.id)} {@const a = account.attributes} {@const key = accountKey(account.id)} @@ -325,11 +351,11 @@
    {money(line, line.opening)} - {money(line, line.income, true)} - - {money(line, line.spent, true)} - - {money(line, line.moved, true)} - - {money(line, line.other, true)} - {money(line, line.closing)}{line.rows}
    - +
    - {group.code} - - {@render balance(group.balance, group.cur)} - - {#if group.change !== 0} - - {/if} -
    + {group.code} + + {@render balance(group.balance)} + + {#if toNumber(group.change.amount) !== 0} + + {/if} +
    - {@render balance(toNumber(a.current_balance), group.cur)} + {@render balance(moneyOf(a, a.current_balance))} {#if toNumber(a.balance_difference) !== 0} - + {/if}