@@ -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}
|
-
+
|
{row.split.source_name}
diff --git a/src/lib/components/accounts/Statement.svelte b/src/lib/components/accounts/Statement.svelte
index e078ccc..94195ee 100644
--- a/src/lib/components/accounts/Statement.svelte
+++ b/src/lib/components/accounts/Statement.svelte
@@ -12,6 +12,7 @@
signOf,
sums,
terms,
+ viewOf,
} from "#lib/accounts/statement.js";
import { api } from "#lib/api/client.js";
import { all } from "#lib/api/lists.js";
@@ -20,13 +21,7 @@
import LineChart from "#lib/components/charts/LineChart.svelte";
import TransactionTable from "#lib/components/transactions/TransactionTable.svelte";
import { addDays, addMonths, contains, today } from "#lib/dates.js";
- import {
- compactMoney,
- currentLocale,
- formatDate,
- formatMoney,
- toNumber,
- } from "#lib/format.js";
+ import { currentLocale, formatDate, toNumber } from "#lib/format.js";
import { useKeys } from "#lib/keys.svelte.js";
import { period } from "#lib/period.svelte.js";
import { sectionOf } from "#lib/transactions/ledger.js";
@@ -58,6 +53,7 @@
decimals: a.currency_decimal_places,
symbol: a.currency_symbol,
});
+ const view = $derived(viewOf(cur));
const chart = $derived(chartRange(period.range, now));
const span = $derived({
end: [chart.end, period.end, now].sort().at(-1) ?? now,
@@ -106,7 +102,10 @@
const months = $derived(monthsOf(chart, list));
const line = $derived(
anchor
- ? daily(list, anchor, chart).map((p) => ({ x: p.x, y: sign * p.y }))
+ ? daily(list, anchor, chart).map((p) => ({
+ x: p.x,
+ y: sign * p.y * view.rate,
+ }))
: []
);
const groups = $derived(
@@ -126,7 +125,7 @@
const series = $derived([
{
area: true,
- format: (value: number) => formatMoney({ ...cur, amount: value }),
+ format: (value: number) => view.format(value),
key: "balance",
label: lineTitle,
points: line,
@@ -217,7 +216,7 @@
{#if line.length > 0}
compactMoney(value, cur.symbol ?? "")}
+ axisFormat={view.axis}
height={236}
label="{lineTitle} of {a.name} by day"
marker={contains(chart, now) ? now : undefined}
diff --git a/src/lib/components/accounts/StatementHeader.svelte b/src/lib/components/accounts/StatementHeader.svelte
index 50ec48c..d8a992e 100644
--- a/src/lib/components/accounts/StatementHeader.svelte
+++ b/src/lib/components/accounts/StatementHeader.svelte
@@ -2,6 +2,7 @@
import type { Side, Term } from "#lib/accounts/statement.js";
import { addDays } from "#lib/dates.js";
import { formatDate, formatMoney, type Money } from "#lib/format.js";
+ import { display } from "#lib/money/display.svelte.js";
let {
terms,
@@ -71,7 +72,11 @@
},
]);
const money = (amount: number, signed = false) =>
- empty ? " " : formatMoney({ ...cur, amount }, signed);
+ empty ? " " : display.text([{ ...cur, amount }], signed);
+ const original = (amount: number, signed = false) =>
+ display.total([{ ...cur, amount }])?.converted
+ ? formatMoney({ ...cur, amount }, signed)
+ : undefined;
{#each items as item (item.key)}
{money(item.value)}
@@ -114,7 +120,9 @@
Change
- {money(change, true)}
diff --git a/src/lib/components/accounts/StatementMonths.svelte b/src/lib/components/accounts/StatementMonths.svelte
index 2b1895f..fd3cf81 100644
--- a/src/lib/components/accounts/StatementMonths.svelte
+++ b/src/lib/components/accounts/StatementMonths.svelte
@@ -1,5 +1,10 @@
@@ -37,36 +35,39 @@
{#each columns as label (label)}
{label}
{/each}
- {#each tops as top, i (top.cur.code)}
+ {#each shown as top, i (top.cur.code)}
{top.cur.code}{top.approx ? "≈" : ""}{top.cur.code}
{#if free}
{@render stat(
"Available",
- top.available === null ? null : money(top, top.available)
+ top.available === null ? null : topMoney(top, top.available)
)}
{/if}
- {@render stat("Budgeted", top.budgeted ? money(top, top.budgeted) : null)}
+ {@render stat(
+ "Budgeted",
+ top.budgeted ? topMoney(top, top.budgeted) : null
+ )}
{#if free}
{@const unallocated = (top.available ?? 0) - top.budgeted}
{@render stat(
"Left to budget",
- top.available === null ? null : money(top, unallocated),
+ top.available === null ? null : topMoney(top, unallocated),
unallocated < 0 ? "text-expense" : ""
)}
{/if}
{@const left = top.budgeted - top.inBudgets}
{@render stat(
"Left in budgets",
- top.budgeted ? money(top, left) : null,
+ top.budgeted ? topMoney(top, left) : null,
left < 0 ? "text-expense" : ""
)}
- {@render stat("Spent", money(top, top.spent))}
+ {@render stat("Spent", topMoney(top, top.spent))}
Without a budget
{money(top, top.outside)}{topMoney(top, top.outside)}
{#if top.spent > 0}
;
@@ -53,6 +55,7 @@ export interface Pool {
}
export interface Top {
+ approx?: boolean;
available: number | null;
budgeted: number;
cur: Cur;
@@ -263,6 +266,72 @@ export function topLines(
);
}
+function ratesOf(codes: string[]): number[] | null {
+ if (!display.active || codes.every((code) => code === display.code)) {
+ return null;
+ }
+ const rates = codes.map((code) => display.rate(code));
+ return rates.every((rate) => rate !== null) ? rates : null;
+}
+
+const scaled = (values: number[], rates: number[]) =>
+ values.reduce((total, value, i) => total + value * rates[i], 0);
+
+export function mergeLines(lines: Line[], range: Range): Line[] {
+ const rates = ratesOf(lines.map((line) => line.cur.code));
+ if (!rates) {
+ return lines;
+ }
+ const limited = lines.find((line) => line.amount !== null);
+ return [
+ {
+ amount: limited
+ ? scaled(
+ lines.map((line) => line.amount ?? 0),
+ rates
+ )
+ : null,
+ approx: true,
+ cur: { ...display.target, id: "" },
+ key: lines[0].key,
+ range: limited?.range ?? range,
+ spent: scaled(
+ lines.map((line) => line.spent),
+ rates
+ ),
+ },
+ ];
+}
+
+export function mergeTops(tops: Top[]): Top[] {
+ const rates = ratesOf(tops.map((top) => top.cur.code));
+ if (!rates) {
+ return tops;
+ }
+ const sum = (pick: (top: Top) => number) => scaled(tops.map(pick), rates);
+ return [
+ {
+ approx: true,
+ available: tops.some((top) => top.available !== null)
+ ? sum((top) => top.available ?? 0)
+ : null,
+ budgeted: sum((top) => top.budgeted),
+ cur: display.target,
+ inBudgets: sum((top) => top.inBudgets),
+ outside: sum((top) => top.outside),
+ spent: sum((top) => top.spent),
+ },
+ ];
+}
+
+export const lineMoney = (line: Line, amount: number) => {
+ const text = display.text([{ ...line.cur, amount }]);
+ return line.approx ? `≈${text}` : text;
+};
+
+export const topMoney = (top: Top, amount: number) =>
+ `${top.approx ? "≈" : ""}${formatMoney({ amount, decimals: 0, symbol: top.cur.symbol })}`;
+
export function pace(line: Line, now = today()): Pace | null {
if (!(line.amount && contains(line.range, now))) {
return null;
diff --git a/src/lib/components/categories/MoneyList.svelte b/src/lib/components/categories/MoneyList.svelte
index 7e9f463..ecc6693 100644
--- a/src/lib/components/categories/MoneyList.svelte
+++ b/src/lib/components/categories/MoneyList.svelte
@@ -1,5 +1,7 @@
{#if sums.length === 0}
@@ -16,14 +20,18 @@
{/if}
{:else}
- {#each sums as sum (sum.code)}
-
- {/each}
+ {#if merged}
+
+ {:else}
+ {#each sums as sum (sum.code)}
+
+ {/each}
+ {/if}
{/if}
diff --git a/src/lib/components/dashboard/AccountsBlock.svelte b/src/lib/components/dashboard/AccountsBlock.svelte
index fd5150f..6237542 100644
--- a/src/lib/components/dashboard/AccountsBlock.svelte
+++ b/src/lib/components/dashboard/AccountsBlock.svelte
@@ -17,13 +17,14 @@
import Box from "#lib/components/Box.svelte";
import Sparkline from "#lib/components/charts/Sparkline.svelte";
import { type Row, useColumn } from "#lib/dashboard/column.svelte.js";
- import {
- formatPrimary,
- inPrimaryTotal,
- ratesQuery,
- } from "#lib/dashboard/primary.svelte.js";
import { addDays, today } from "#lib/dates.js";
- import { formatDate, formatMoney, toNumber } from "#lib/format.js";
+ import {
+ formatDate,
+ formatMoney,
+ type Money,
+ toNumber,
+ } from "#lib/format.js";
+ import { display } from "#lib/money/display.svelte.js";
import { period } from "#lib/period.svelte.js";
import { session } from "#lib/session.svelte.js";
import {
@@ -37,7 +38,6 @@
accountsChartQuery,
accountsQuery,
byCurrency,
- type Cur,
seriesPoints,
} from "./data.js";
@@ -55,7 +55,6 @@
const chart = createQuery(() =>
accountsChartQuery(addDays(now, -TREND_DAYS), now)
);
- const rates = createQuery(() => ratesQuery());
const recent = createQueries(() => ({
queries: expanded.map((id) => ({
queryFn: () =>
@@ -97,45 +96,63 @@
);
}
- const groups = $derived(
- byCurrency(shown, (a) => a.attributes.currency_code ?? "").map(
- ([code, accounts]) => {
- const first = accounts[0].attributes;
- return {
- accounts,
- balance: accounts.reduce(
- (s, a) => s + toNumber(a.attributes.current_balance),
- 0
- ),
- change: accounts.reduce(
- (s, a) => s + toNumber(a.attributes.balance_difference),
- 0
- ),
- code,
- cur: {
- code,
- decimals: first.currency_decimal_places ?? 2,
- symbol: first.currency_symbol ?? code,
- },
- trend: sumSeries(
- accounts.map((a) => trends.get(a.attributes.name) ?? [])
- ),
- };
- }
- )
- );
-
- const converted = $derived({
- balance: inPrimaryTotal(
- groups.map((g) => ({ amount: g.balance, code: g.code })),
- rates.data
- ),
- change: inPrimaryTotal(
- groups.map((g) => ({ amount: g.change, code: g.code })),
- rates.data
- ),
+ const moneyOf = (a: Account, amount: Money["amount"]): Money => ({
+ amount,
+ code: a.currency_code,
+ decimals: a.currency_decimal_places ?? 2,
+ symbol: a.currency_symbol ?? a.currency_code,
});
+ const balances = $derived(
+ shown.map((a) => moneyOf(a.attributes, a.attributes.current_balance))
+ );
+ const changes = $derived(
+ shown.map((a) => moneyOf(a.attributes, a.attributes.balance_difference))
+ );
+ const total = $derived(display.total(balances));
+ const change = $derived(display.total(changes));
+
+ const sumOf = (
+ accounts: Item[],
+ pick: (a: Account) => string | null | undefined
+ ) => accounts.reduce((s, a) => s + toNumber(pick(a.attributes)), 0);
+
+ const converted = (a: Item) =>
+ display.convert(
+ toNumber(a.attributes.current_balance),
+ a.attributes.currency_code
+ ) ?? 0;
+
+ const groups = $derived(
+ total
+ ? [
+ {
+ accounts: shown.toSorted((a, b) => converted(b) - converted(a)),
+ balance: null,
+ change: null,
+ code: total.code,
+ trend: [],
+ },
+ ]
+ : byCurrency(shown, (a) => a.attributes.currency_code ?? "").map(
+ ([code, accounts]) => ({
+ accounts,
+ balance: moneyOf(
+ accounts[0].attributes,
+ sumOf(accounts, (a) => a.current_balance)
+ ),
+ change: moneyOf(
+ accounts[0].attributes,
+ sumOf(accounts, (a) => a.balance_difference)
+ ),
+ code,
+ trend: sumSeries(
+ accounts.map((a) => trends.get(a.attributes.name) ?? [])
+ ),
+ })
+ )
+ );
+
const recentRows = $derived(
new Map(
expanded.map((id, i) => [
@@ -221,26 +238,31 @@
{/snippet}
{#snippet balance(
- amount: number,
- cur: Cur
+ money: Money
)}
- {formatMoney({ amount, ...cur })}{display.text([money])}
{/snippet}
{#snippet tools()}
- {#if converted.balance !== null}
+ {#if total}
{formatPrimary(converted.balance)}{display.text(balances)}
- {#if converted.change}
+ {#if change?.amount}
{formatPrimary(converted.change, true)}{display.text(changes, true)}
{/if}
{/if}
@@ -271,25 +293,29 @@
{#each groups as group (group.code)}
-
- |
- {group.code}
- |
-
- {@render balance(group.balance, group.cur)}
- |
-
- {#if group.change !== 0}
-
- {/if}
- |
-
- {@render spark(group.trend)}
- |
-
+ {#if group.balance && group.change}
+
+ |
+ {group.code}
+ |
+
+ {@render balance(group.balance)}
+ |
+
+ {#if toNumber(group.change.amount) !== 0}
+
+ {/if}
+ |
+
+
+ {@render spark(group.trend)}
+
+ |
+
+ {/if}
{#each group.accounts as account (account.id)}
{@const a = account.attributes}
{@const key = accountKey(account.id)}
@@ -325,11 +351,11 @@
- {@render balance(toNumber(a.current_balance), group.cur)}
+ {@render balance(moneyOf(a, a.current_balance))}
|
{#if toNumber(a.balance_difference) !== 0}
-
+
{/if}
|
diff --git a/src/lib/components/dashboard/AccountsChart.svelte b/src/lib/components/dashboard/AccountsChart.svelte
index 44888d0..cc09b72 100644
--- a/src/lib/components/dashboard/AccountsChart.svelte
+++ b/src/lib/components/dashboard/AccountsChart.svelte
@@ -4,6 +4,7 @@
import type { LinePoint, LineSeries } from "#lib/components/charts/chart.js";
import LineChart from "#lib/components/charts/LineChart.svelte";
import { contains, today } from "#lib/dates.js";
+ import { display } from "#lib/money/display.svelte.js";
import { period } from "#lib/period.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
@@ -16,6 +17,7 @@
type Cur,
hasPrimary,
hrefWith,
+ mergeable,
money,
seriesPoints,
setCurrency,
@@ -57,52 +59,67 @@
interface Panel {
cur: Cur;
+ label: string;
series: LineSeries[];
}
+ const curOf = (s: ChartSet) => (converted ? setPrimary(s) : setCurrency(s));
+ const merged = $derived(mergeable(sets.map((s) => curOf(s).code)));
+
+ function points(set: ChartSet): LinePoint[] {
+ const rate = merged ? (display.rate(curOf(set).code) ?? 1) : 1;
+ return seriesPoints(set, converted).map((p) => ({ x: p.x, y: p.y * rate }));
+ }
+
function sum(group: ChartSet[]): LinePoint[] {
const totals = new Map();
for (const set of group) {
- for (const p of seriesPoints(set, converted)) {
+ for (const p of points(set)) {
totals.set(p.x, (totals.get(p.x) ?? 0) + p.y);
}
}
return [...totals.entries()].map(([x, y]) => ({ x, y }));
}
- const panels = $derived.by(() => {
- const curOf = (s: ChartSet) => (converted ? setPrimary(s) : setCurrency(s));
- return byCurrency(sets, (s) => curOf(s).code).map(([, group]) => {
- const cur = curOf(group[0]);
- const format = money(cur);
- if (mode === "net-worth") {
- const included = group.filter((s) => !excluded.has(s.label));
- return {
- cur,
- series: [
- {
- area: true,
- format,
- key: "net-worth",
- label: `Net worth (${cur.code})`,
- points: sum(included),
- strong: true,
- },
- ],
- };
- }
+ function panel(cur: Cur, label: string, group: ChartSet[]): Panel {
+ const format = money(cur);
+ if (mode === "net-worth") {
+ const included = group.filter((s) => !excluded.has(s.label));
return {
cur,
- series: group.map((s) => ({
- area: group.length === 1,
- format,
- key: s.label,
- label: s.label,
- points: seriesPoints(s, converted),
- })),
+ label,
+ series: [
+ {
+ area: true,
+ format,
+ key: "net-worth",
+ label: `Net worth (${label})`,
+ points: sum(included),
+ strong: true,
+ },
+ ],
};
- });
- });
+ }
+ return {
+ cur,
+ label,
+ series: group.map((s) => ({
+ area: group.length === 1,
+ format,
+ key: s.label,
+ label: s.label,
+ points: points(s),
+ })),
+ };
+ }
+
+ const panels = $derived(
+ merged
+ ? [panel(display.target, `≈${display.code}`, sets)]
+ : byCurrency(sets, (s) => curOf(s).code).map(([code, group]) =>
+ panel(curOf(group[0]), code, group)
+ )
+ );
const panelHeight = $derived(
Math.max(MIN_PANEL, Math.floor(HEIGHT / Math.max(1, panels.length)))
);
@@ -161,10 +178,10 @@
{:else}
{#each panels as panel (panel.cur.code)}
- {#if panels.length > 1}
+ {#if panels.length > 1 || merged}
{panel.cur.code}{panel.label}
{/if}
View your asset accounts
- {#if converted}
+ {#if converted && !merged}
Converted to your primary currency
diff --git a/src/lib/components/dashboard/BudgetsChart.svelte b/src/lib/components/dashboard/BudgetsChart.svelte
index 4187ffe..597a65c 100644
--- a/src/lib/components/dashboard/BudgetsChart.svelte
+++ b/src/lib/components/dashboard/BudgetsChart.svelte
@@ -11,10 +11,13 @@
} from "#lib/components/charts/chart.js";
import StackedBars from "#lib/components/charts/StackedBars.svelte";
import { toNumber } from "#lib/format.js";
+ import { display } from "#lib/money/display.svelte.js";
import { period } from "#lib/period.svelte.js";
import {
byCurrency,
type ChartSet,
+ type Cur,
+ mergeable,
money,
setCurrency,
span,
@@ -44,36 +47,50 @@
new Map((budgets.data ?? []).map((b) => [b.attributes.name, b.id]))
);
+ const sets = $derived(chart.data ?? []);
+ const merged = $derived(mergeable(sets.map((s) => s.currency_code)));
+
+ function group(code: string, label: string, cur: Cur, list: ChartSet[]) {
+ const format = money(cur);
+ const totals = new Map();
+ for (const s of list) {
+ const rate = merged ? (display.rate(s.currency_code) ?? 1) : 1;
+ const total = totals.get(s.label) ?? { budgeted: 0, spent: 0 };
+ total.budgeted += toNumber(s.entries.budgeted) * rate;
+ total.spent += Math.abs(toNumber(s.entries.spent)) * rate;
+ totals.set(s.label, total);
+ }
+ const rows: StackRow[] = [...totals]
+ .map(([name, { budgeted, spent }]) => {
+ const id = ids.get(name);
+ return {
+ format,
+ href: id ? `/budgets/show/${id}` : undefined,
+ key: name,
+ label: name,
+ note:
+ budgeted > 0
+ ? `${format(spent)} of ${format(budgeted)}`
+ : format(spent),
+ values: {
+ left: budgeted > 0 ? Math.max(0, budgeted - spent) : 0,
+ over: budgeted > 0 ? Math.max(0, spent - budgeted) : 0,
+ spent: budgeted > 0 ? Math.min(spent, budgeted) : spent,
+ },
+ };
+ })
+ .filter((r) => r.values.spent + r.values.left + r.values.over > 0);
+ const limited = [...totals.values()].some((t) => t.budgeted > 0);
+ const keys = limited ? KEYS : [{ ...KEYS[0], label: "Spent" }];
+ return { code, keys, label, limited, rows };
+ }
+
const groups = $derived(
- byCurrency(chart.data ?? [], (s) => s.currency_code).map(([code, sets]) => {
- const format = money(setCurrency(sets[0]));
- const rows: StackRow[] = sets
- .map((s) => {
- const budgeted = toNumber(s.entries.budgeted);
- const spent = Math.abs(toNumber(s.entries.spent));
- const inside = budgeted > 0 ? Math.min(spent, budgeted) : spent;
- const id = ids.get(s.label);
- return {
- format,
- href: id ? `/budgets/show/${id}` : undefined,
- key: s.label,
- label: s.label,
- note:
- budgeted > 0
- ? `${format(spent)} of ${format(budgeted)}`
- : format(spent),
- values: {
- left: Math.max(0, toNumber(s.entries.left)),
- over: Math.max(0, Math.abs(toNumber(s.entries.overspent))),
- spent: inside,
- },
- };
- })
- .filter((r) => r.values.spent + r.values.left + r.values.over > 0);
- const limited = sets.some((x) => toNumber(x.entries.budgeted) > 0);
- const keys = limited ? KEYS : [{ ...KEYS[0], label: "Spent" }];
- return { code, keys, limited, rows };
- })
+ merged
+ ? [group(display.code, `≈${display.code}`, display.target, sets)]
+ : byCurrency(sets, (s) => s.currency_code).map(([code, list]) =>
+ group(code, code, setCurrency(list[0]), list)
+ )
);
const visible = $derived(groups.filter((g) => g.rows.length > 0));
@@ -101,9 +118,9 @@
{:else}
{#each visible as group (group.code)}
- {#if visible.length > 1 || !group.limited}
+ {#if visible.length > 1 || !group.limited || merged}
- {group.code}
+ {group.label}
{#if !group.limited}
· spent, no budget amount set in {group.code}
{/if}
diff --git a/src/lib/components/dashboard/CategoriesChart.svelte b/src/lib/components/dashboard/CategoriesChart.svelte
index 952aea3..9f6da40 100644
--- a/src/lib/components/dashboard/CategoriesChart.svelte
+++ b/src/lib/components/dashboard/CategoriesChart.svelte
@@ -6,12 +6,15 @@
import BarChart from "#lib/components/charts/BarChart.svelte";
import { type Bar, tones } from "#lib/components/charts/chart.js";
import { toNumber } from "#lib/format.js";
+ import { display } from "#lib/money/display.svelte.js";
import { period } from "#lib/period.svelte.js";
import {
axis,
byCurrency,
type ChartSet,
+ type Cur,
hasPrimary,
+ mergeable,
money,
proportional,
setCurrency,
@@ -34,31 +37,43 @@
const ids = $derived(
new Map((categories.data ?? []).map((c) => [c.attributes.name, c.id]))
);
- const converted = $derived(hasPrimary(chart.data ?? []));
+ const sets = $derived(chart.data ?? []);
+ const converted = $derived(hasPrimary(sets));
+ const curOf = (s: ChartSet) => (converted ? setPrimary(s) : setCurrency(s));
+ const merged = $derived(mergeable(sets.map((s) => curOf(s).code)));
+
+ function group(code: string, label: string, cur: Cur, list: ChartSet[]) {
+ const totals = new Map();
+ for (const s of list) {
+ const entries = (converted ? s.pc_entries : s.entries) as Record<
+ string,
+ string
+ >;
+ const rate = merged ? (display.rate(curOf(s).code) ?? 1) : 1;
+ const spent = Math.abs(toNumber(entries?.spent)) * rate;
+ totals.set(s.label, (totals.get(s.label) ?? 0) + spent);
+ }
+ const bars: Bar[] = [...totals]
+ .map(([name, value]) => {
+ const id = ids.get(name);
+ return {
+ href: id ? `/categories/show/${id}` : NO_CATEGORY,
+ key: name,
+ label: name,
+ value,
+ };
+ })
+ .filter((b) => b.value > 0)
+ .sort((a, b) => b.value - a.value);
+ return { bars, code, cur, label };
+ }
const groups = $derived(
- byCurrency(chart.data ?? [], (s) =>
- converted ? setPrimary(s).code : s.currency_code
- ).map(([code, sets]) => {
- const cur = converted ? setPrimary(sets[0]) : setCurrency(sets[0]);
- const bars: Bar[] = sets
- .map((s) => {
- const entries = (converted ? s.pc_entries : s.entries) as Record<
- string,
- string
- >;
- const id = ids.get(s.label);
- return {
- href: id ? `/categories/show/${id}` : NO_CATEGORY,
- key: s.label,
- label: s.label,
- value: Math.abs(toNumber(entries?.spent)),
- };
- })
- .filter((b) => b.value > 0)
- .sort((a, b) => b.value - a.value);
- return { bars, code, cur };
- })
+ merged
+ ? [group(display.code, `≈${display.code}`, display.target, sets)]
+ : byCurrency(sets, (s) => curOf(s).code).map(([code, list]) =>
+ group(code, code, curOf(list[0]), list)
+ )
);
const visible = $derived(groups.filter((g) => g.bars.length > 0));
@@ -81,10 +96,10 @@
{:else}
{#each visible as group (group.code)}
- {#if visible.length > 1}
+ {#if visible.length > 1 || merged}
Spent in {group.code}Spent in {group.label}
{/if}
lists.piggyBanks());
- const rates = createQuery(() => ratesQuery());
const items = $derived.by(() => {
const due = (bills.data ?? [])
@@ -99,19 +94,13 @@
});
const totals = $derived(
- byCurrency(items, (i) => i.cur.code).map(([code, list]) => ({
+ byCurrency(items, (i) => i.cur.code).map(([, list]) => ({
+ ...list[0].cur,
amount: list.reduce((s, i) => s + i.amount, 0),
approx: list.some((i) => i.approx),
- code,
- cur: list[0].cur,
}))
);
- const converted = $derived(inPrimaryTotal(totals, rates.data));
-
- const money = (amount: number, cur: Cur, approx: boolean) =>
- `${approx ? "~" : ""}${formatMoney({ amount, ...cur })}`;
-
function dayLabel(date: string) {
if (date === now) {
return "Today";
@@ -143,12 +132,17 @@
- {#if converted === null}
- {#each totals as t (t.code)}
- {money(t.amount, t.cur, t.approx)}
- {/each}
+ {#if display.total(totals)}
+ {rough(
+ totals,
+ totals.some((t) => t.approx)
+ )}
{:else}
- {formatPrimary(converted)}
+ {#each totals as t (t.code)}
+ {rough([t], t.approx)}
+ {/each}
{/if}
@@ -177,7 +171,7 @@
|
- {money(item.amount, item.cur, item.approx)}
+ {rough([{ ...item.cur, amount: item.amount }], item.approx)}
|
{/each}
diff --git a/src/lib/components/dashboard/InboxBox.svelte b/src/lib/components/dashboard/InboxBox.svelte
index 67b5fe1..ce16ff8 100644
--- a/src/lib/components/dashboard/InboxBox.svelte
+++ b/src/lib/components/dashboard/InboxBox.svelte
@@ -2,8 +2,8 @@
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import type { Page, TransactionGroup } from "#lib/api/types.js";
- import Amount from "#lib/components/Amount.svelte";
import Box from "#lib/components/Box.svelte";
+ import Money from "#lib/components/Money.svelte";
import { useColumn } from "#lib/dashboard/column.svelte.js";
import { reviewed } from "#lib/dashboard/reviewed.svelte.js";
import { addDays, iso } from "#lib/dates.js";
@@ -55,7 +55,7 @@
date: first?.date.slice(0, 10) ?? "",
group,
id: group.id,
- money: sumByCurrency(group.attributes.transactions)[0],
+ money: sumByCurrency(group.attributes.transactions),
note: (first?.notes ?? "").trim().split("\n")[0],
title: groupTitle(group),
};
@@ -171,8 +171,8 @@
- {#if e.money}
-
+ {#if e.money.length > 0}
+
{/if}
|
diff --git a/src/lib/components/dashboard/InfoBoxes.svelte b/src/lib/components/dashboard/InfoBoxes.svelte
index a64edd9..71c4193 100644
--- a/src/lib/components/dashboard/InfoBoxes.svelte
+++ b/src/lib/components/dashboard/InfoBoxes.svelte
@@ -8,8 +8,15 @@
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
import type { Component, Snippet } from "svelte";
import Meter from "#lib/components/charts/Meter.svelte";
- import { parse, periodOf, shiftPeriod, today } from "#lib/dates.js";
+ import {
+ daysBetween,
+ parse,
+ periodOf,
+ shiftPeriod,
+ today,
+ } from "#lib/dates.js";
import { currentLocale, formatMoney } from "#lib/format.js";
+ import { display } from "#lib/money/display.svelte.js";
import { period } from "#lib/period.svelte.js";
import { ALL_ASSETS, reportPath } from "#lib/reports/params.js";
import { parseSummary, type SummaryEntry, summaryQuery } from "./data.js";
@@ -43,35 +50,49 @@
});
const year = $derived(today().slice(0, 4));
- const fmt = (e: SummaryEntry, abs = false) =>
- formatMoney({
- amount: abs ? Math.abs(e.amount) : e.amount,
- code: e.code,
- decimals: e.decimals,
- symbol: e.symbol,
- });
+ const absolute = (list: SummaryEntry[]) =>
+ list.map((e) => ({ ...e, amount: Math.abs(e.amount) }));
+
+ function lines(list: SummaryEntry[], abs = false): string[] {
+ const parts = abs ? absolute(list) : list;
+ return display.total(parts)
+ ? [display.text(parts)]
+ : parts.map((e) => formatMoney(e));
+ }
+
+ function flow(list: SummaryEntry[] | undefined, sign: number) {
+ const parts = (list ?? []).map((e) => ({
+ ...e,
+ amount: sign * Math.abs(e.amount),
+ }));
+ return display.text(display.total(parts) ? parts : parts.slice(0, 1), true);
+ }
function compare(
kind: string,
words: [string, string],
upIsGood = true
): Delta | null {
- const a = now[kind]?.[0];
- const b = a && before[kind]?.find((e) => e.code === a.code);
+ const sum = display.total(now[kind] ?? []);
+ const a = sum ?? now[kind]?.[0];
+ const b = sum
+ ? display.total(before[kind] ?? [])
+ : a && before[kind]?.find((e) => e.code === a.code);
if (!(a && b) || a.amount === b.amount) {
return null;
}
const diff = a.amount - b.amount;
+ const text = formatMoney({ ...a, amount: Math.abs(diff) });
return {
good: diff > 0 === upIsGood,
- text: `${fmt({ ...a, amount: diff }, true)} ${diff > 0 ? words[0] : words[1]}`,
+ text: `${sum?.converted ? "≈" : ""}${text} ${diff > 0 ? words[0] : words[1]}`,
up: diff > 0,
};
}
const balance = $derived(now.balance ?? []);
- const earned = $derived(now.earned?.[0]);
- const spent = $derived(now.spent?.[0]);
+ const earned = $derived(now.earned?.length ? flow(now.earned, 1) : "");
+ const spent = $derived(now.spent?.length ? flow(now.spent, -1) : "");
const unpaid = $derived(now["bills-unpaid"] ?? []);
const paid = $derived(now["bills-paid"] ?? []);
const left = $derived(now["left-to-spend"] ?? []);
@@ -81,7 +102,23 @@
if (left.some((e) => e.noBudgets)) {
return "none";
}
- return left.some((e) => e.amount > 0) ? "ok" : "over";
+ const sum = display.total(left);
+ return (sum ? sum.amount > 0 : left.some((e) => e.amount > 0))
+ ? "ok"
+ : "over";
+ });
+ const perDay = $derived.by(() => {
+ const sum = display.total(left);
+ if (!sum) {
+ return left[0]?.sub ?? "";
+ }
+ const from = today() < period.start ? period.start : today();
+ const days = daysBetween(from, period.end) + 1;
+ if (leftState === "none" || days < 1) {
+ return "";
+ }
+ const amount = formatMoney({ ...sum, amount: sum.amount / days });
+ return `${sum.converted ? "≈" : ""}${amount}`;
});
const leftTitle = $derived(
leftState === "none"
@@ -97,6 +134,11 @@
}[leftState]
);
const paidShare = $derived.by(() => {
+ const done = paid.length ? display.total(absolute(paid))?.amount : 0;
+ const due = unpaid.length ? display.total(absolute(unpaid))?.amount : 0;
+ if (display.active && done !== undefined && due !== undefined) {
+ return { paid: done, total: done + due };
+ }
const code = unpaid[0]?.code ?? paid[0]?.code;
const p = Math.abs(paid.find((e) => e.code === code)?.amount ?? 0);
const u = Math.abs(unpaid.find((e) => e.code === code)?.amount ?? 0);
@@ -108,7 +150,8 @@
list: SummaryEntry[],
abs = false
)}
- {#if list.length === 0}
+ {@const [main, ...rest] = lines(list, abs)}
+ {#if main === undefined}
0
@@ -116,12 +159,12 @@
- {fmt(list[0], abs)}
+ {main}
- {#if list.length > 1}
+ {#if rest.length > 0}
- {#each list.slice(1) as e (e.code)}
- {fmt(e, abs)}
+ {#each rest as text (text)}
+ {text}
{/each}
{/if}
@@ -185,10 +228,10 @@
{#if earned || spent}
{#if earned}
- +{fmt(earned, true)}
+ {earned}
{/if}
{#if spent}
- −{fmt(spent, true)}
+ {spent}
{/if}
{/if}
@@ -205,8 +248,8 @@
{/if}
Paid:
- {#each paid as e (e.code)}
- {fmt(e, true)}
+ {#each lines(paid, true) as text (text)}
+ {text}
{:else}
0
{/each}
@@ -215,11 +258,9 @@
{#snippet leftToSpend()}
{@render values(left)}
- {#if left[0]?.sub}
+ {#if perDay}
- {leftState === "none" ? "" : "Per day: "}{left[0].sub}
+ {leftState === "none" ? "" : "Per day: "}{perDay}
{/if}
{@render change(compare("left-to-spend", ["more", "less"]))}
diff --git a/src/lib/components/dashboard/PaceBox.svelte b/src/lib/components/dashboard/PaceBox.svelte
index 04ac955..fdba84e 100644
--- a/src/lib/components/dashboard/PaceBox.svelte
+++ b/src/lib/components/dashboard/PaceBox.svelte
@@ -2,12 +2,6 @@
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import Box from "#lib/components/Box.svelte";
- import {
- formatPrimary,
- inPrimaryTotal,
- median,
- ratesQuery,
- } from "#lib/dashboard/primary.svelte.js";
import {
addDays,
daysBetween,
@@ -17,7 +11,9 @@
today,
} from "#lib/dates.js";
import { formatDate, formatMoney, toNumber } from "#lib/format.js";
+ import { display } from "#lib/money/display.svelte.js";
import { period } from "#lib/period.svelte.js";
+ import { median } from "#lib/reports/typical.js";
import { byCurrency, currency, type InsightEntry, span } from "./data.js";
let { class: className = "" }: { class?: string } = $props();
@@ -47,7 +43,6 @@
return list;
});
- const rates = createQuery(() => ratesQuery());
const pace = createQuery(() => ({
enabled: visible,
placeholderData: keepPreviousData,
@@ -100,25 +95,28 @@
return `Spent by the ${day}${SUFFIX[ordinals.select(day)] ?? "th"}`;
});
- const lines = $derived.by(() => {
- const parts = (pick: (r: (typeof rows)[number]) => number | null) =>
- rows.map((r) => ({ amount: pick(r) ?? 0, code: r.code }));
- const spent = inPrimaryTotal(
- parts((r) => r.spent),
- rates.data
+ const all = $derived.by(() => {
+ const lists = pace.data ?? [];
+ const sums = lists.map((list) =>
+ display.total(
+ list.map((e) => ({ amount: e.difference, code: e.currency_code }))
+ )
);
- if (spent === null || rows.length < 2) {
- return rows;
+ if (!display.active || lists.some((list, i) => list.length && !sums[i])) {
+ return null;
}
- const known = rows.every((r) => r.typical !== null);
- const typical = known
- ? inPrimaryTotal(
- parts((r) => r.typical),
- rates.data
- )
- : null;
- return [...rows, { code: "All", format: formatPrimary, spent, typical }];
+ const [spent = 0, ...past] = sums.map((sum) => Math.abs(sum?.amount ?? 0));
+ const history = past.filter((v) => v > 0);
+ const approx = sums.some((sum) => sum?.converted);
+ return {
+ code: "All",
+ format: (amount: number) =>
+ `${approx ? "≈" : ""}${formatMoney({ ...display.target, amount, decimals: 0 })}`,
+ spent,
+ typical: history.length >= MIN_MONTHS ? median(history) : null,
+ };
});
+ const lines = $derived(all ? [all] : rows);
{#if visible && rows.length > 0}
@@ -166,7 +164,7 @@
{r.typical === null ? "n/a" : r.format(r.typical)}
0
? "text-expense"
: "text-income"}"
diff --git a/src/lib/components/dashboard/PiggyBanksBox.svelte b/src/lib/components/dashboard/PiggyBanksBox.svelte
index 7c7c155..70d55be 100644
--- a/src/lib/components/dashboard/PiggyBanksBox.svelte
+++ b/src/lib/components/dashboard/PiggyBanksBox.svelte
@@ -4,7 +4,8 @@
import type { PiggyBank } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import Meter from "#lib/components/charts/Meter.svelte";
- import { formatMoney, percent, toNumber } from "#lib/format.js";
+ import { percent, toNumber } from "#lib/format.js";
+ import { display } from "#lib/money/display.svelte.js";
const piggies = createQuery(() => lists.piggyBanks());
@@ -18,12 +19,14 @@
);
const fmt = (p: PiggyBank, amount: string | null | undefined) =>
- formatMoney({
- amount,
- code: p.currency_code,
- decimals: p.currency_decimal_places,
- symbol: p.currency_symbol,
- });
+ display.text([
+ {
+ amount,
+ code: p.currency_code,
+ decimals: p.currency_decimal_places,
+ symbol: p.currency_symbol,
+ },
+ ]);
{#if saving.length > 0}
diff --git a/src/lib/components/dashboard/SubscriptionsBox.svelte b/src/lib/components/dashboard/SubscriptionsBox.svelte
index 18354c9..2a3513b 100644
--- a/src/lib/components/dashboard/SubscriptionsBox.svelte
+++ b/src/lib/components/dashboard/SubscriptionsBox.svelte
@@ -6,8 +6,9 @@
import Meter from "#lib/components/charts/Meter.svelte";
import { today } from "#lib/dates.js";
import { formatDate, formatMoney, toNumber } from "#lib/format.js";
+ import { display } from "#lib/money/display.svelte.js";
import { period } from "#lib/period.svelte.js";
- import { parseSummary, span, summaryQuery } from "./data.js";
+ import { parseSummary, rough, span, summaryQuery } from "./data.js";
const DUE_LIMIT = 6;
@@ -37,6 +38,30 @@
)
.filter((m) => m.total > 0)
);
+ const lines = $derived.by(() => {
+ const paid = meters.map((m) => ({ ...m.cur, amount: m.paid }));
+ const left = meters.map((m) => ({ ...m.cur, amount: m.total - m.paid }));
+ const sumPaid = display.total(paid);
+ const sumLeft = display.total(left);
+ if (sumPaid && sumLeft) {
+ return [
+ {
+ key: display.code,
+ left: display.text(left),
+ max: sumPaid.amount + sumLeft.amount,
+ paid: display.text(paid),
+ value: sumPaid.amount,
+ },
+ ];
+ }
+ return meters.map((m, i) => ({
+ key: m.cur.code,
+ left: formatMoney(left[i]),
+ max: m.total,
+ paid: formatMoney(paid[i]),
+ value: m.paid,
+ }));
+ });
const inPeriod = (dates: (string | null | undefined)[]) =>
dates
.map((d) => (d ?? "").slice(0, 10))
@@ -62,16 +87,6 @@
})
.sort((x, y) => x.date.localeCompare(y.date))
);
- const fmt = (
- amount: number,
- c: { code: string; symbol: string; decimals: number }
- ) =>
- formatMoney({
- amount,
- code: c.code,
- decimals: c.decimals,
- symbol: c.symbol,
- });
{#if (bills.data?.length ?? 0) > 0}
@@ -85,25 +100,23 @@
Nothing expected in this period.
{:else}
|
- {money(code, Number(row.a.amount_min ?? 0))}
+ {displayMoney(code, Number(row.a.amount_min ?? 0))}
|
- {money(code, Number(row.a.amount_max ?? 0))}
+ {displayMoney(code, Number(row.a.amount_max ?? 0))}
|
{row.expected.map((d) => formatDate(d, "short")).join(", ")}
@@ -76,7 +76,7 @@
>{formatDate(paid.date, "short")}
{money(
+ >{displayMoney(
paid.currency_code ?? code,
-Number(paid.amount ?? 0)
)}
import type { Split } from "#lib/api/types.js";
- import Amount from "#lib/components/Amount.svelte";
import Box from "#lib/components/Box.svelte";
+ import Money from "#lib/components/Money.svelte";
+ import { display } from "#lib/money/display.svelte.js";
import {
+ debtMove,
type Group,
type Signed,
sumByCurrency,
@@ -10,9 +12,11 @@
const TOP = 5;
const TOTALS = [
- ["withdrawal", "Spent"],
- ["deposit", "Earned"],
- ["transfer", "Transferred"],
+ ["Spent", "auto", (s: Split) => s.type === "withdrawal" && !debtMove(s)],
+ ["Earned", "auto", (s: Split) => s.type === "deposit" && !debtMove(s)],
+ ["Transferred", "transfer", (s: Split) => s.type === "transfer"],
+ ["From debts", "transfer", (s: Split) => debtMove(s) === "in"],
+ ["To debts", "transfer", (s: Split) => debtMove(s) === "out"],
] as const;
interface Entry {
@@ -28,16 +32,24 @@
}: { groups: Group[]; type: string; loading?: boolean } = $props();
const splits = $derived(groups.flatMap((g) => g.attributes.transactions));
+ const flows = $derived(splits.filter((s) => !debtMove(s)));
const totals = $derived(
- TOTALS.map(([kind, label]) => ({
+ TOTALS.map(([label, tone, test]) => ({
label,
- sums: sumByCurrency(splits.filter((s) => s.type === kind)),
+ sums: sumByCurrency(splits.filter(test)),
+ tone,
})).filter((row) => row.sums.length > 0)
);
+ const toneOf = (sums: Signed[]) =>
+ sums.every((sum) => sum.tone === "transfer") ? "transfer" : "auto";
+
const size = (entry: Entry) =>
- Math.max(...entry.sums.map((sum) => Math.abs(sum.amount)));
+ Math.abs(
+ display.total(entry.sums)?.amount ??
+ Math.max(...entry.sums.map((sum) => Math.abs(sum.amount)))
+ );
function aggregate(
list: Split[],
@@ -65,7 +77,7 @@
const blocks = $derived([
{
title: "Categories",
- ...aggregate(splits, (s) =>
+ ...aggregate(flows, (s) =>
s.category_id
? [s.category_name ?? "", `/categories/show/${s.category_id}`]
: ["(no category)", undefined]
@@ -74,7 +86,7 @@
type === "withdrawal"
? {
title: "Budgets",
- ...aggregate(splits, (s) =>
+ ...aggregate(flows, (s) =>
s.budget_id
? [s.budget_name ?? "", `/budgets/show/${s.budget_id}`]
: ["(no budget)", undefined]
@@ -82,14 +94,14 @@
}
: {
title: "Source accounts",
- ...aggregate(splits, (s) => [
+ ...aggregate(flows, (s) => [
s.source_name ?? "(cash)",
s.source_id ? `/accounts/show/${s.source_id}` : undefined,
]),
},
{
title: "Destination accounts",
- ...aggregate(splits, (s) => [
+ ...aggregate(flows, (s) => [
s.destination_name ?? "(cash)",
s.destination_id ? `/accounts/show/${s.destination_id}` : undefined,
]),
@@ -97,18 +109,6 @@
]);
-{#snippet amount(
- value: Signed
-)}
-
-{/snippet}
-
{#if loading && groups.length === 0}
@@ -120,9 +120,7 @@
{#each totals as row (row.label)}
{row.label}
- {#each row.sums as sum (sum.code)}
- {@render amount(sum)}
- {/each}
+
{/each}
@@ -143,9 +141,7 @@
{entry.name}
{/if}
- {#each entry.sums as sum (sum.code)}
- {@render amount(sum)}
- {/each}
+
{:else}
diff --git a/src/lib/components/transactions/Periods.svelte b/src/lib/components/transactions/Periods.svelte
index 9bb5ec1..380286f 100644
--- a/src/lib/components/transactions/Periods.svelte
+++ b/src/lib/components/transactions/Periods.svelte
@@ -12,7 +12,7 @@
} from "#lib/components/categories/sums.js";
import type { Range } from "#lib/dates.js";
import { period, periodLabel } from "#lib/period.svelte.js";
- import { listPath } from "#lib/transactions/split.js";
+ import { debtMove, listPath } from "#lib/transactions/split.js";
const FIELDS = [
["spent", "Spent", "withdrawal"],
@@ -43,10 +43,18 @@
queryKey: ["transactions-periods", type, first, last],
}));
+ const shown = $derived(
+ ranges.filter((range) => !period.first || range.end >= period.first)
+ );
+ const counts = $derived(
+ byPeriod(rows(query.data?.groups ?? []), shown).map(
+ (item) => item.totals.count
+ )
+ );
const periods = $derived(
byPeriod(
- rows(query.data?.groups ?? []),
- ranges.filter((range) => !period.first || range.end >= period.first)
+ rows(query.data?.groups ?? [], (split) => !debtMove(split)),
+ shown
)
);
const fields = $derived(
@@ -66,7 +74,7 @@
aria-label="Periods"
class="grid min-w-0 auto-rows-[0] grid-cols-[repeat(auto-fill,minmax(10rem,1fr))] grid-rows-[auto] gap-x-2 overflow-hidden min-[1600px]:flex min-[1600px]:flex-col min-[1600px]:gap-2 min-[1600px]:overflow-visible {className}"
>
- {#each periods as item (item.range.start)}
+ {#each periods as item, index (item.range.start)}
{@const current =
item.range.start === period.start && item.range.end === period.end}
{#if !covered(item.range)}
Loading...
- {:else if item.totals.count === 0}
+ {:else if counts[index] === 0}
No transactions
{:else}
Transactions
- {item.totals.count}
+ {counts[index]}
{#each fields as [field, label] (field)}
{@const sums = list(item.totals[field])}
diff --git a/src/lib/components/transactions/TransactionTable.svelte b/src/lib/components/transactions/TransactionTable.svelte
index c99af70..ba898aa 100644
--- a/src/lib/components/transactions/TransactionTable.svelte
+++ b/src/lib/components/transactions/TransactionTable.svelte
@@ -7,6 +7,7 @@
import type { Split } from "#lib/api/types.js";
import Amount from "#lib/components/Amount.svelte";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
+ import Money from "#lib/components/Money.svelte";
import {
ContextMenu,
ContextMenuContent,
@@ -15,9 +16,14 @@
ContextMenuShortcut,
ContextMenuTrigger,
} from "#lib/components/ui/context-menu/index.js";
- import { formatDate, formatMoney, type Money } from "#lib/format.js";
+ import {
+ formatDate,
+ formatMoney,
+ type Money as MoneyValue,
+ } from "#lib/format.js";
import type { Binding } from "#lib/keys.svelte.js";
import { ListNav } from "#lib/list-nav.svelte.js";
+ import { display } from "#lib/money/display.svelte.js";
import { oldUiUrl } from "#lib/spa-paths.js";
import { type Section, subtotals } from "#lib/transactions/ledger.js";
import {
@@ -67,7 +73,7 @@
interface Block extends Section {
groups: Group[];
keys: string[];
- sums: Signed[];
+ sums: Signed[][];
}
let collapsed = $state(new Set());
@@ -106,7 +112,7 @@
}
});
for (const block of byKey.values()) {
- block.sums = subtotals(block.groups, context.accountId);
+ block.sums = byTone(subtotals(block.groups, context.accountId));
}
return starts;
});
@@ -121,6 +127,14 @@
});
});
+ function byTone(sums: Signed[]): Signed[][] {
+ const tones = new Map();
+ for (const sum of sums) {
+ tones.set(sum.tone, [...(tones.get(sum.tone) ?? []), sum]);
+ }
+ return [...tones.values()];
+ }
+
function selectKeys(keys: string[], on: boolean) {
const next = new Set(nav.selected);
for (const key of keys) {
@@ -332,13 +346,25 @@
{/snippet}
{#snippet plain(
- value: Money | null
+ value: MoneyValue | null
)}
{#if value}
- {formatMoney(value)}
+ {@const original = formatMoney(value)}
+ {@const shown = display.text([value])}
+ {shown}
{/if}
{/snippet}
+{#snippet sum(
+ parts: Signed[]
+)}
+
+{/snippet}
+
{#snippet account(
accountIdValue: string | null | undefined,
name: string | null | undefined,
@@ -429,7 +455,9 @@
{/if}
|
- {@render money(amount)}
+ {@render money(
+ display.active && foreign?.code === display.code ? foreign : amount
+ )}
{#if foreign}
{formatMoney(foreign)}
@@ -532,8 +560,8 @@
{/if}
|
- {#each block.sums as sum (`${sum.tone}|${sum.code}`)}
- {@render money(sum)}
+ {#each block.sums as parts (parts[0]?.tone)}
+ {@render sum(parts)}
{/each}
|
|
@@ -585,9 +613,7 @@
- {#each sums as sum (sum.code)}
- {@render money(sum)}
- {/each}
+ {@render sum(sums)}
|
|
{#if accountId}
diff --git a/src/lib/dashboard/primary.svelte.ts b/src/lib/dashboard/primary.svelte.ts
deleted file mode 100644
index 53b5640..0000000
--- a/src/lib/dashboard/primary.svelte.ts
+++ /dev/null
@@ -1,136 +0,0 @@
-import { api } from "#lib/api/client.js";
-import { all } from "#lib/api/lists.js";
-import type { ExchangeRate, Page, TransactionGroup } from "#lib/api/types.js";
-import { formatMoney, toNumber } from "#lib/format.js";
-import { display } from "#lib/money/display.svelte.js";
-import { session } from "#lib/session.svelte.js";
-
-const OWN_SAMPLE = 100;
-const HOUR_MS = 3_600_000;
-
-export interface Rate {
- own: boolean;
- rate: number;
-}
-
-export interface Part {
- amount: number;
- code: string;
-}
-
-export function median(values: number[]): number {
- const sorted = [...values].sort((a, b) => a - b);
- const mid = Math.floor(sorted.length / 2);
- return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
-}
-
-class InPrimary {
- get on() {
- return display.on && display.code === session.primary?.code;
- }
-
- toggle() {
- display.toggle();
- }
-}
-
-export const inPrimary = new InPrimary();
-
-function ownRates(
- groups: { attributes: TransactionGroup }[],
- primary: string
-): Record {
- const implied = new Map();
- const add = (code: string, rate: number) =>
- implied.set(code, [...(implied.get(code) ?? []), rate]);
- for (const s of groups.flatMap((g) => g.attributes.transactions)) {
- const amount = toNumber(s.amount);
- const foreign = toNumber(s.foreign_amount);
- if (!(amount && foreign && s.foreign_currency_code && s.currency_code)) {
- continue;
- }
- if (s.currency_code === primary) {
- add(s.foreign_currency_code, amount / foreign);
- } else if (s.foreign_currency_code === primary) {
- add(s.currency_code, foreign / amount);
- }
- }
- return Object.fromEntries(
- [...implied].map(([code, values]) => [
- code,
- { own: true, rate: median(values) },
- ])
- );
-}
-
-function listedRates(
- listed: { attributes: ExchangeRate }[],
- primary: string
-): Record {
- const rates: Record = {};
- const dated = new Map();
- for (const { attributes: r } of listed) {
- const direct = r.to_currency_code === primary;
- const code = direct ? r.from_currency_code : r.to_currency_code;
- const rate = toNumber(r.rate);
- const date = r.date ?? "";
- const usable = direct || r.from_currency_code === primary;
- if (code && rate && usable && date >= (dated.get(code) ?? "")) {
- dated.set(code, date);
- rates[code] = { own: false, rate: direct ? rate : 1 / rate };
- }
- }
- return rates;
-}
-
-async function loadRates(primary: string): Promise> {
- const [listed, own] = await Promise.all([
- all("/v1/exchange-rates"),
- api>("/v1/search/transactions", {
- query: { limit: OWN_SAMPLE, query: "has_foreign_amount:true" },
- }),
- ]);
- return {
- ...ownRates(own.data, primary),
- ...listedRates(listed, primary),
- [primary]: { own: false, rate: 1 },
- };
-}
-
-export const ratesQuery = () => ({
- enabled: inPrimary.on,
- queryFn: () => loadRates(session.primary?.code ?? ""),
- queryKey: ["dashboard-rates", session.primary?.code ?? ""],
- staleTime: HOUR_MS,
-});
-
-export function inPrimaryTotal(
- parts: Part[],
- rates: Record | undefined
-): number | null {
- if (!(inPrimary.on && rates)) {
- return null;
- }
- let total = 0;
- for (const part of parts) {
- const rate = rates[part.code];
- if (!rate) {
- return null;
- }
- total += part.amount * rate.rate;
- }
- return total;
-}
-
-export function formatPrimary(amount: number, signed = false): string {
- const { primary } = session;
- return `≈${formatMoney(
- {
- amount,
- code: primary?.code,
- decimals: primary?.decimal_places,
- symbol: primary?.symbol,
- },
- signed
- )}`;
-}
diff --git a/src/lib/dimension/model.svelte.ts b/src/lib/dimension/model.svelte.ts
index 3d7cfdd..e4c86cf 100644
--- a/src/lib/dimension/model.svelte.ts
+++ b/src/lib/dimension/model.svelte.ts
@@ -4,6 +4,7 @@ import type { Account, Item, Split, TransactionGroup } from "#lib/api/types.js";
import { contains, type Range, today } from "#lib/dates.js";
import { period } from "#lib/period.svelte.js";
import { monthOf } from "#lib/reports/board.js";
+import { displayCode, displayFlows } from "#lib/reports/display.js";
import { buckets, type Flow } from "#lib/reports/flows.js";
import { ledgerQuery, ledgerWindow, ownFlows } from "#lib/reports/ledger.js";
import { pages } from "#lib/reports/queries.js";
@@ -96,10 +97,19 @@ export class DimensionModel {
return keptOf(from, this.spec.keep, this.range);
});
groups = $derived(this.inPeriod.groups);
- flows = $derived.by(() => flowsOf(this.base, this.accounts.data ?? []));
- periodFlows = $derived.by(() =>
+ rawFlows = $derived.by(() => flowsOf(this.base, this.accounts.data ?? []));
+ rawPeriod = $derived.by(() =>
flowsOf(this.inPeriod, this.accounts.data ?? [])
);
+ into = $derived.by(() =>
+ displayCode(
+ new Set(
+ [...this.rawFlows, ...this.rawPeriod].map((flow) => flow.currency)
+ )
+ )
+ );
+ flows = $derived(displayFlows(this.rawFlows, this.into));
+ periodFlows = $derived(displayFlows(this.rawPeriod, this.into));
dir = $derived(dirOf(this.flows.length > 0 ? this.flows : this.periodFlows));
main = $derived(this.flows.filter((flow) => flow.dir === this.dir));
periodMain = $derived(
@@ -130,7 +140,7 @@ export class DimensionModel {
weight = $derived(
weigh(
[...this.main, ...this.periodMain],
- session.primary?.code ?? "",
+ this.into ?? session.primary?.code ?? "",
impliedRates(this.raw)
)
);
diff --git a/src/lib/dimension/stats.ts b/src/lib/dimension/stats.ts
index 4795085..445c57e 100644
--- a/src/lib/dimension/stats.ts
+++ b/src/lib/dimension/stats.ts
@@ -7,6 +7,7 @@ import {
} from "#lib/components/charts/chart.js";
import { contains, daysBetween, type Range } from "#lib/dates.js";
import { monthOf, shortMonth } from "#lib/reports/board.js";
+import { inDisplay } from "#lib/reports/display.js";
import { type Flow, group, type Totals } from "#lib/reports/flows.js";
import type { Rate } from "#lib/reports/rates.js";
import { money } from "#lib/reports/report.svelte.js";
@@ -230,6 +231,33 @@ export function withLimits(lanes: Lane[], limits: Item[]): Lane[] {
}));
}
+export function limitsIn(
+ limits: Item[],
+ into: string | null
+): Item[] {
+ if (!into) {
+ return limits;
+ }
+ const merged = new Map>();
+ for (const limit of limits) {
+ const a = limit.attributes;
+ const shown = inDisplay(Number(a.amount), a.currency_code ?? "", into);
+ const key = [shown.code, a.start, a.end].join("|");
+ const found = merged.get(key);
+ const amount = shown.amount + Number(found?.attributes.amount ?? 0);
+ const base = found ?? limit;
+ merged.set(key, {
+ ...base,
+ attributes: {
+ ...base.attributes,
+ amount: String(amount),
+ currency_code: shown.code,
+ },
+ });
+ }
+ return [...merged.values()];
+}
+
const limitRangeOf = (limit: Item): Range => ({
end: (limit.attributes.end ?? "").slice(0, 10),
start: (limit.attributes.start ?? "").slice(0, 10),
diff --git a/src/lib/money/lanes.ts b/src/lib/money/lanes.ts
new file mode 100644
index 0000000..ebd0ec6
--- /dev/null
+++ b/src/lib/money/lanes.ts
@@ -0,0 +1,62 @@
+import type { Lane, StackKey, StackRow } from "#lib/components/charts/chart.js";
+import { compactMoney, formatMoney } from "#lib/format.js";
+import { display } from "./display.svelte.js";
+
+function addScaled(
+ into: Record,
+ from: Record | undefined,
+ rate: number
+) {
+ for (const [key, value] of Object.entries(from ?? {})) {
+ into[key] = (into[key] ?? 0) + value * rate;
+ }
+}
+
+export function mergeLanes(
+ lanes: Lane[],
+ codeOf: (lane: Lane) => string = (lane) => lane.key
+): Lane[] {
+ const { code, target } = display;
+ const same = lanes.length === 1 && codeOf(lanes[0]) === code;
+ if (!display.active || lanes.length === 0 || same) {
+ return lanes;
+ }
+ const rates = lanes.map((lane) => display.rate(codeOf(lane)));
+ if (rates.some((rate) => rate === null)) {
+ return lanes;
+ }
+ const keys = new Map();
+ const rows = new Map();
+ const dots: Record = {};
+ let typical = 0;
+ for (const [i, lane] of lanes.entries()) {
+ const rate = rates[i] ?? 1;
+ for (const key of lane.keys) {
+ keys.set(key.key, keys.get(key.key) ?? key);
+ }
+ for (const row of lane.rows) {
+ const merged = rows.get(row.key) ?? { ...row, values: {} };
+ addScaled(merged.values, row.values, rate);
+ rows.set(row.key, merged);
+ }
+ addScaled(dots, lane.dots, rate);
+ typical += (lane.typical ?? 0) * rate;
+ }
+ const heights = lanes.map((lane) => lane.height);
+ return [
+ {
+ axisFormat: (value: number) => compactMoney(value, target.symbol),
+ dots: lanes.some((lane) => lane.dots) ? dots : undefined,
+ format: (value: number) =>
+ `≈${formatMoney({ ...target, amount: value, decimals: 0 })}`,
+ height: heights.includes(undefined)
+ ? undefined
+ : Math.max(...heights.map((height) => height ?? 0)),
+ key: code,
+ keys: [...keys.values()],
+ label: `≈${code}`,
+ rows: [...rows.values()],
+ typical: typical || null,
+ },
+ ];
+}
diff --git a/src/lib/reports/display.ts b/src/lib/reports/display.ts
new file mode 100644
index 0000000..d22c380
--- /dev/null
+++ b/src/lib/reports/display.ts
@@ -0,0 +1,57 @@
+import { display } from "#lib/money/display.svelte.js";
+import type { Flow } from "./flows.js";
+
+const MARK = "≈";
+
+export const isApprox = (code: string) => code.startsWith(MARK);
+
+export const plainCode = (code: string) =>
+ isApprox(code) ? code.slice(MARK.length) : code;
+
+export function displayCode(codes: Iterable): string | null {
+ if (!display.active) {
+ return null;
+ }
+ for (const code of codes) {
+ if (code !== display.code && display.rate(code) !== null) {
+ return `${MARK}${display.code}`;
+ }
+ }
+ return null;
+}
+
+export const shownCode = (code: string, into: string | null) =>
+ into && display.rate(code) !== null ? into : code;
+
+export function inDisplay(
+ amount: number,
+ code: string,
+ into: string | null = displayCode([code])
+): { amount: number; code: string } {
+ const value = into ? display.convert(amount, code) : null;
+ return into && value !== null
+ ? { amount: value, code: into }
+ : { amount, code };
+}
+
+export function displayFlows(
+ flows: Flow[],
+ into = displayCode(new Set(flows.map((flow) => flow.currency)))
+): Flow[] {
+ if (!into) {
+ return flows;
+ }
+ const rates = new Map();
+ const rateOf = (code: string) => {
+ if (!rates.has(code)) {
+ rates.set(code, display.rate(code));
+ }
+ return rates.get(code) ?? null;
+ };
+ return flows.map((flow) => {
+ const rate = rateOf(flow.currency);
+ return rate === null
+ ? flow
+ : { ...flow, amount: flow.amount * rate, currency: into };
+ });
+}
diff --git a/src/lib/reports/report.svelte.ts b/src/lib/reports/report.svelte.ts
index 264a640..c1b92e6 100644
--- a/src/lib/reports/report.svelte.ts
+++ b/src/lib/reports/report.svelte.ts
@@ -6,6 +6,13 @@ import { formatMoney } from "#lib/format.js";
import { session } from "#lib/session.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
+import {
+ displayCode,
+ displayFlows,
+ inDisplay,
+ isApprox,
+ plainCode,
+} from "./display.js";
import {
type Dimension,
exchanges,
@@ -81,11 +88,12 @@ export function allAssetIds(accounts: Item[]): string[] {
}
export function currencyMeta(code: string) {
- const found = session.currencies.find((c) => c.attributes.code === code);
+ const plain = plainCode(code);
+ const found = session.currencies.find((c) => c.attributes.code === plain);
return {
- code,
+ code: plain,
decimals: found?.attributes.decimal_places ?? 2,
- symbol: found?.attributes.symbol ?? code,
+ symbol: found?.attributes.symbol ?? plain,
};
}
@@ -104,10 +112,21 @@ export function money(
symbol: primary.symbol,
}
: currencyMeta(code);
- return formatMoney(
+ const text = formatMoney(
{ amount, ...meta, decimals: decimals ?? meta.decimals },
signed
);
+ return isApprox(code) ? `≈${text}` : text;
+}
+
+export function displayMoney(
+ code: string,
+ amount: number,
+ signed = false,
+ decimals?: number
+) {
+ const shown = inDisplay(amount, code);
+ return money(shown.code, shown.amount, signed, decimals);
}
export function modeLabel(mode: string) {
@@ -267,7 +286,8 @@ const inRange = (group: Item, start: string, end: string) => {
export function createReport(
params: () => ReportParams,
- source?: () => Item[] | undefined
+ source?: () => Item[] | undefined,
+ target?: () => string | null
) {
const p = $derived(params());
const kind = $derived(kindOf(p.type));
@@ -373,11 +393,18 @@ export function createReport(
: new Set()
);
- const flows = $derived(
+ const raw = $derived(
groups && accountsReady
? toFlows(groups, selectedSet, (id) => byId.get(id)?.currency_code)
: undefined
);
+ const into = $derived.by(() => {
+ if (target) {
+ return target();
+ }
+ return raw ? displayCode(new Set(raw.map((flow) => flow.currency))) : null;
+ });
+ const flows = $derived(raw && displayFlows(raw, into));
const swaps = $derived(
groups && accountsReady
@@ -420,7 +447,10 @@ export function createReport(
const codes = $derived.by(() => {
const primary = session.primary?.code;
const all = session.currencies.map((c) => c.attributes.code);
- return primary ? [primary, ...all.filter((c) => c !== primary)] : all;
+ const ordered = primary
+ ? [primary, ...all.filter((c) => c !== primary)]
+ : all;
+ return into ? [into, ...ordered] : ordered;
});
const available = $derived(totals ? modes(totals, codes) : []);
const mode = $derived.by(() => {
@@ -480,6 +510,9 @@ export function createReport(
get groups() {
return groups;
},
+ get into() {
+ return into;
+ },
itemName: (kindKey: string, id: string) =>
itemNames.get(`${kindKey}:${id}`),
get items() {
diff --git a/src/lib/transactions/ledger.ts b/src/lib/transactions/ledger.ts
index c96e3dc..b8d60aa 100644
--- a/src/lib/transactions/ledger.ts
+++ b/src/lib/transactions/ledger.ts
@@ -1,6 +1,7 @@
import type { Split } from "#lib/api/types.js";
import { addDays, addMonths, type Range } from "#lib/dates.js";
-import { formatDate, formatMoney } from "#lib/format.js";
+import { formatDate } from "#lib/format.js";
+import { display } from "#lib/money/display.svelte.js";
import {
accountUrl,
type Group,
@@ -464,12 +465,14 @@ function clusterOf(
): Cluster {
const groups = [...new Set(run.map((item) => item.group))].sort(newestFirst);
const span = (run.at(-1)?.time ?? 0) - (run[0]?.time ?? 0);
- const money = formatMoney({
- amount: split.amount,
- code: split.currency_code,
- decimals: split.currency_decimal_places,
- symbol: split.currency_symbol,
- });
+ const money = display.text([
+ {
+ amount: split.amount,
+ code: split.currency_code,
+ decimals: split.currency_decimal_places,
+ symbol: split.currency_symbol,
+ },
+ ]);
return {
groups,
id: groups
diff --git a/src/lib/transactions/split.ts b/src/lib/transactions/split.ts
index 069011b..5cae93d 100644
--- a/src/lib/transactions/split.ts
+++ b/src/lib/transactions/split.ts
@@ -127,13 +127,21 @@ export function balanceAfter(split: Split, accountId?: string): Money | null {
if (!accountId) {
return null;
}
- const value =
- split.source_id === accountId
- ? split.source_balance_after
- : split.destination_balance_after;
+ const source = split.source_id === accountId;
+ const value = source
+ ? split.source_balance_after
+ : split.destination_balance_after;
if (value === null || value === undefined) {
return null;
}
+ if (!source && split.type === "transfer" && split.foreign_currency_code) {
+ return {
+ amount: value,
+ code: split.foreign_currency_code,
+ decimals: split.foreign_currency_decimal_places,
+ symbol: split.foreign_currency_symbol,
+ };
+ }
return {
amount: value,
code: split.currency_code,
@@ -149,3 +157,21 @@ export function accountUrl(id: string | null | undefined): string | undefined {
export function isCash(type: string | null | undefined): boolean {
return type === "Cash account";
}
+
+const LIABILITY_TYPES = new Set(["Debt", "Loan", "Mortgage"]);
+
+export function debtMove(split: Split): "in" | "out" | null {
+ if (
+ split.type === "deposit" &&
+ LIABILITY_TYPES.has(split.source_type ?? "")
+ ) {
+ return "in";
+ }
+ if (
+ split.type === "withdrawal" &&
+ LIABILITY_TYPES.has(split.destination_type ?? "")
+ ) {
+ return "out";
+ }
+ return null;
+}
diff --git a/src/routes/budgets/[[start=date]]/[[end=date]]/+page.svelte b/src/routes/budgets/[[start=date]]/[[end=date]]/+page.svelte
index 2264bc8..5cdeb49 100644
--- a/src/routes/budgets/[[start=date]]/[[end=date]]/+page.svelte
+++ b/src/routes/budgets/[[start=date]]/[[end=date]]/+page.svelte
@@ -22,7 +22,9 @@
curOf,
isOpen,
type Line,
+ lineMoney,
loadBudgetPage,
+ mergeLines,
pace,
projection,
safePerDay,
@@ -45,9 +47,10 @@
import { setParam } from "#lib/components/categories/url.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { Button } from "#lib/components/ui/button/index.js";
- import { formatMoney, formatRange } from "#lib/format.js";
+ import { formatRange } from "#lib/format.js";
import { type Binding, useKeys } from "#lib/keys.svelte.js";
import { ListNav } from "#lib/list-nav.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 { oldUiUrl } from "#lib/spa-paths.js";
@@ -189,6 +192,16 @@
return [...lines, ...more];
}
+ function shownLines(row: BudgetRow): Line[] {
+ const lines = linesOf(row);
+ const open =
+ adding?.budgetId === row.budget.id ||
+ lines.some(
+ (line) => line.key === editKey || suggestionFor(row, line) !== null
+ );
+ return open ? lines : mergeLines(lines, period.range);
+ }
+
const pending = $derived(
rows.flatMap((row) =>
linesOf(row).flatMap((line) => {
@@ -371,13 +384,6 @@
]
: []),
]);
-
- const money = (line: Line, amount: number) =>
- formatMoney({
- amount,
- decimals: line.cur.decimals,
- symbol: line.cur.symbol,
- });
@@ -484,7 +490,7 @@
{#each rows as row, i (row.budget.id)}
{@const extra = addingLine(row)}
- {@const lines = linesOf(row)}
+ {@const lines = shownLines(row)}
{#each lines as line, j (line.key)}
{@const p = pace(line)}
{@const safe = safePerDay(line)}
@@ -519,13 +525,17 @@
>
auto
{formatMoney({
- amount:
- row.budget.attributes.auto_budget_amount,
- decimals:
- row.budget.attributes.currency_decimal_places,
- symbol: row.budget.attributes.currency_symbol,
- })}{display.text([
+ {
+ amount:
+ row.budget.attributes.auto_budget_amount,
+ code: row.budget.attributes.currency_code,
+ decimals:
+ row.budget.attributes
+ .currency_decimal_places,
+ symbol: row.budget.attributes.currency_symbol,
+ },
+ ])}
{PERIOD_WORDS[
row.budget.attributes.auto_budget_period ?? ""
@@ -602,6 +612,8 @@
{#if line !== extra}
@@ -613,7 +625,7 @@
class="num {line.amount + line.spent < 0
? "text-expense"
: "text-cladd-fg"}"
- >{money(line, line.amount + line.spent)}{lineMoney(line, line.amount + line.spent)}
{/if}
@@ -622,7 +634,7 @@
{#if safe !== null}
0 ? "text-cladd-fg" : "text-expense"}
- >{money(line, safe)}{lineMoney(line, safe)}
{/if}
@@ -632,7 +644,7 @@
class={line.amount && ends > line.amount
? "text-expense"
: "text-cladd-fg-soft"}
- >~{money(line, ends)}~{lineMoney(line, ends).replace("≈", "")}
{/if}
@@ -652,7 +664,7 @@
>
{:else if p && p.over > 0}
{money(line, p.over)}
+ >{lineMoney(line, p.over)}
over pace
{:else if p}
diff --git a/src/routes/categories/+page.svelte b/src/routes/categories/+page.svelte
index 7bc252f..c20c6b2 100644
--- a/src/routes/categories/+page.svelte
+++ b/src/routes/categories/+page.svelte
@@ -19,7 +19,7 @@
} from "#lib/components/categories/data.js";
import ListMenu from "#lib/components/categories/ListMenu.svelte";
import MoneyList from "#lib/components/categories/MoneyList.svelte";
- import { amountIn } from "#lib/components/categories/sums.js";
+ import { amountIn, type Sum } from "#lib/components/categories/sums.js";
import { setParam } from "#lib/components/categories/url.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { Button } from "#lib/components/ui/button/index.js";
@@ -27,6 +27,7 @@
import { fuzzyFilter } from "#lib/fuzzy.js";
import { type Binding, useKeys } from "#lib/keys.svelte.js";
import { ListNav } from "#lib/list-nav.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 { oldUiUrl } from "#lib/spa-paths.js";
@@ -56,17 +57,20 @@
const primary = $derived(session.primary?.code ?? "");
const main = $derived.by(() => {
+ if (display.active) {
+ return display.code;
+ }
const codes = new Set(
(query.data?.rows ?? []).flatMap((r) => r.spent.map((s) => s.code))
);
return codes.has(primary) ? primary : ([...codes][0] ?? primary);
});
+ const measure = (sums: Sum[]) =>
+ display.total(sums)?.amount ?? amountIn(sums, main);
const totalSpent = $derived(
-(
- (query.data?.rows ?? []).reduce(
- (sum, r) => sum + amountIn(r.spent, main),
- 0
- ) + amountIn(query.data?.spent ?? [], main)
+ (query.data?.rows ?? []).reduce((sum, r) => sum + measure(r.spent), 0) +
+ measure(query.data?.spent ?? [])
)
);
const share = (spent: number) => (totalSpent > 0 ? -spent / totalSpent : 0);
@@ -86,11 +90,9 @@
if (sort === "name") {
sorted.sort((a, b) => name(a).localeCompare(name(b)));
} else if (sort === "spent") {
- sorted.sort((a, b) => amountIn(a.spent, main) - amountIn(b.spent, main));
+ sorted.sort((a, b) => measure(a.spent) - measure(b.spent));
} else if (sort === "earned") {
- sorted.sort(
- (a, b) => amountIn(b.earned, main) - amountIn(a.earned, main)
- );
+ sorted.sort((a, b) => measure(b.earned) - measure(a.earned));
} else {
sorted.sort((a, b) =>
(b.item.attributes.last_activity ?? "").localeCompare(
@@ -280,7 +282,10 @@
{#if column.key === "earned"}
- Share of {main} spending |
+
+ Share of {display.active ? "≈" : ""}{main}
+ spending
+ |
{/if}
{/each}
@@ -331,7 +336,7 @@
- {@render bar(amountIn(query.data?.spent ?? [], main))}
+ {@render bar(measure(query.data?.spent ?? []))}
|
|
@@ -359,7 +364,7 @@
|
- {@render bar(amountIn(row.spent, main))} |
+ {@render bar(measure(row.spent))} |
diff --git a/src/routes/piggy-banks/+page.svelte b/src/routes/piggy-banks/+page.svelte
index 01dabdb..2e62c82 100644
--- a/src/routes/piggy-banks/+page.svelte
+++ b/src/routes/piggy-banks/+page.svelte
@@ -32,8 +32,9 @@
ContextMenuSeparator,
ContextMenuTrigger,
} from "#lib/components/ui/context-menu/index.js";
- import { formatDate, formatMoney, percent, toNumber } from "#lib/format.js";
+ import { formatDate, percent, toNumber } from "#lib/format.js";
import { ListNav } from "#lib/list-nav.svelte.js";
+ import { display } from "#lib/money/display.svelte.js";
import { oldUiUrl } from "#lib/spa-paths.js";
import { goto } from "$app/navigation";
@@ -310,21 +311,25 @@
|
{#if toNumber(p.target_amount) > 0}
{formatMoney(piggyMoney(p, p.target_amount))}{display.text([
+ piggyMoney(p, p.target_amount),
+ ])}
{/if}
|
{#if toNumber(p.left_to_save) > 0}
{formatMoney(piggyMoney(p, p.left_to_save))}{display.text([piggyMoney(p, p.left_to_save)])}
{/if}
|
{#if p.target_date && toNumber(p.save_per_month) > 0}
{formatMoney(piggyMoney(p, p.save_per_month))}{display.text([
+ piggyMoney(p, p.save_per_month),
+ ])}
{/if}
|
@@ -442,7 +447,7 @@
{formatMoney({ ...money, amount: targets })}{display.text([{ ...money, amount: targets }])}
|
@@ -450,10 +455,9 @@
|
{formatMoney({
- ...money,
- amount: Math.max(0, targets - r.earmarked),
- })}{display.text([
+ { ...money, amount: Math.max(0, targets - r.earmarked) },
+ ])}
|
diff --git a/src/routes/piggy-banks/show/[id=id]/+page.svelte b/src/routes/piggy-banks/show/[id=id]/+page.svelte
index a29dcd9..2b45499 100644
--- a/src/routes/piggy-banks/show/[id=id]/+page.svelte
+++ b/src/routes/piggy-banks/show/[id=id]/+page.svelte
@@ -33,6 +33,7 @@
toNumber,
} from "#lib/format.js";
import { useKeys } from "#lib/keys.svelte.js";
+ import { display } from "#lib/money/display.svelte.js";
import { oldUiUrl } from "#lib/spa-paths.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
@@ -89,6 +90,22 @@
e.attributes.created_at ||
"";
+ const rate = $derived(
+ piggy?.currency_code === display.code
+ ? null
+ : display.rate(piggy?.currency_code)
+ );
+ const unit = $derived(
+ rate === null
+ ? {
+ approx: "",
+ decimals: piggy?.currency_decimal_places,
+ symbol: piggy?.currency_symbol,
+ }
+ : { ...display.target, approx: "≈" }
+ );
+ const chartMoney = (amount: number) => formatMoney({ ...unit, amount });
+
const history = $derived(
[...(events.data ?? [])].sort((a, b) => when(b).localeCompare(when(a)))
);
@@ -109,8 +126,9 @@
const [from] = [start, first, addDays(today(), -MIN_DAYS)].sort();
byDay.set(from, 0);
byDay.set(today(), running);
+ const scale = rate ?? 1;
return [...byDay]
- .map(([x, y]) => ({ x, y }))
+ .map(([x, y]) => ({ x, y: y * scale }))
.sort((a, b) => a.x.localeCompare(b.x));
});
@@ -200,13 +218,13 @@
{:else}
formatMoney(piggyMoney(piggy, v))}
+ axisFormat={chartMoney}
height={260}
label="Saved over time"
series={[
{
color: tones.income,
- format: (v) => formatMoney(piggyMoney(piggy, v)),
+ format: (v) => `${unit.approx}${chartMoney(v)}`,
key: "saved",
label: "Saved so far",
points,
@@ -256,7 +274,7 @@
Target amount |
{target > 0
- ? formatMoney(piggyMoney(piggy, piggy.target_amount))
+ ? display.text([piggyMoney(piggy, piggy.target_amount)])
: "No target"}
|
@@ -286,7 +304,7 @@
| Left to save |
- {formatMoney(piggyMoney(piggy, piggy.left_to_save))}
+ {display.text([piggyMoney(piggy, piggy.left_to_save)])}
|
{/if}
@@ -306,7 +324,7 @@
| Suggested monthly amount to save |
- {formatMoney(piggyMoney(piggy, piggy.save_per_month))}
+ {display.text([piggyMoney(piggy, piggy.save_per_month)])}
|
{/if}
@@ -381,7 +399,9 @@
>
{amount < 0 ? "Removed" : "Added"}
{formatMoney(piggyMoney(piggy, Math.abs(amount)))}{display.text([
+ piggyMoney(piggy, Math.abs(amount)),
+ ])}
diff --git a/src/routes/recurring/+page.svelte b/src/routes/recurring/+page.svelte
index 4fa35c4..0e703a8 100644
--- a/src/routes/recurring/+page.svelte
+++ b/src/routes/recurring/+page.svelte
@@ -147,6 +147,7 @@
()
diff --git a/src/routes/transactions/show/[id=id]/+page.svelte b/src/routes/transactions/show/[id=id]/+page.svelte
index fdefc59..b051fdf 100644
--- a/src/routes/transactions/show/[id=id]/+page.svelte
+++ b/src/routes/transactions/show/[id=id]/+page.svelte
@@ -50,6 +50,7 @@
import { formatDate, formatDateTime, formatMoney } from "#lib/format.js";
import { useKeys } from "#lib/keys.svelte.js";
import { ListNav } from "#lib/list-nav.svelte.js";
+ import { display } from "#lib/money/display.svelte.js";
import { oldUiUrl } from "#lib/spa-paths.js";
import { type Patch, updateGroups } from "#lib/transactions/mass.js";
import {
@@ -168,15 +169,17 @@
side === "destination" &&
type === "transfer" &&
Boolean(first.foreign_currency_code);
- return formatMoney({
- amount:
- side === "destination" ? Math.max(...values) : Math.min(...values),
- code: foreign ? first.foreign_currency_code : first.currency_code,
- decimals: foreign
- ? first.foreign_currency_decimal_places
- : first.currency_decimal_places,
- symbol: foreign ? first.foreign_currency_symbol : first.currency_symbol,
- });
+ return display.text([
+ {
+ amount:
+ side === "destination" ? Math.max(...values) : Math.min(...values),
+ code: foreign ? first.foreign_currency_code : first.currency_code,
+ decimals: foreign
+ ? first.foreign_currency_decimal_places
+ : first.currency_decimal_places,
+ symbol: foreign ? first.foreign_currency_symbol : first.currency_symbol,
+ },
+ ]);
}
const balances = $derived(
@@ -203,6 +206,13 @@
return [...sums.values()];
});
+ const totalApprox = $derived(
+ approxOf(
+ sumByCurrency(splits),
+ foreignTotals.find((sum) => sum.code === display.code)?.code
+ )
+ );
+
const counterSide = $derived<"source" | "destination" | null>(
{ deposit: "source", withdrawal: "destination" }[type as string] as
| "source"
@@ -261,6 +271,14 @@
linking = "";
}
+ function approxOf(parts: Signed[], foreign?: string | null) {
+ const total = display.total(parts);
+ if (!total?.converted || foreign === display.code) {
+ return "";
+ }
+ return `≈${formatMoney(total, parts[0]?.tone !== "transfer")}`;
+ }
+
function dateLabel(value: string | undefined) {
if (!value) {
return "";
@@ -273,10 +291,11 @@
function billPrice(bill: Item) {
const a = bill.attributes;
- const value = formatMoney(
- billMoney(a, isRange(a) ? average(a) : Number(a.amount_min))
- );
- return `${isRange(a) ? "~" : ""}${value} ${repeats(a)}`;
+ const value = display.text([
+ billMoney(a, isRange(a) ? average(a) : Number(a.amount_min)),
+ ]);
+ const rough = isRange(a) && !value.startsWith("≈");
+ return `${rough ? "~" : ""}${value} ${repeats(a)}`;
}
async function remove() {
@@ -357,6 +376,19 @@
>
{/snippet}
+{#snippet approx(
+ parts: Signed[],
+ foreign?: string | null
+)}
+ {@const text = approxOf(parts, foreign)}
+ {#if text}
+ {text}
+ {/if}
+{/snippet}
+
{#snippet account(
split: Split,
side: "source" | "destination"
@@ -402,6 +434,7 @@
>{formatMoney(foreign)}
{/if}
+ {@render approx([signedAmount(split)], foreign?.code)}
{:else if field === "source" || field === "destination"}
{@render account(split, field)}
{:else if field === "category"}
@@ -713,6 +746,11 @@
>{formatMoney(sum)}
{/each}
+ {#if totalApprox}
+ {totalApprox}
+ {/if}
{:else}
{@render cell("amount", 0)}
{/if}
| |