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}