feat: every page follows the one-currency mode

Totals, info boxes, section headers, group subtotals, shares and the
per-currency charts on the dashboard, transactions, search, accounts,
merchants, statement, budgets, categories, tags, groups, piggy banks,
reports and the category, tag, budget and merchant pages sum into one
converted amount when the mode is on; charts split by currency merge
into one lane. Inputs, reconciliation and the Foreign column stay exact.

Money moved to or from a debt, loan or mortgage no longer counts as
income or spending in the transaction list boxes; it shows as From
debts and To debts. The running balance of an incoming foreign-currency
transfer shows in the account's currency.
This commit is contained in:
hh committed 2026-10-16 18:49:16 +00:00
1 parent 94466d9be9
commit 617a4700e2
61 files changed
+1307 -678

No files matched your search

+35 -8
View File
@@ -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<string, number>();
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;
+25 -9
View File
@@ -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<string, number> {
const map = new Map<string, number>();
@@ -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<T>(items: T[], key: (item: T) => string): Map<string, T[]> {
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<Merchant, "share"> {
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<string, number>();
const map = new Map<string, Total>();
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[] {
+19 -1
View File
@@ -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<Money, "amount">) {
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,
};
}
+25 -15
View File
@@ -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 @@
</p>
{:else}
<LineChart
axisFormat={axis(
converted ? setPrimary(chart.data?.[0] as ChartSet) : cur
)}
axisFormat={view?.axis ??
axis(converted ? setPrimary(chart.data?.[0] as ChartSet) : cur)}
height={300}
label="Balance of {a?.name ?? "the account"}"
{marker}
@@ -17,6 +17,7 @@
type ChartSet,
seriesPoints,
} from "#lib/components/dashboard/data.js";
import Money from "#lib/components/Money.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import {
ContextMenu,
@@ -27,8 +28,9 @@
ContextMenuTrigger,
} from "#lib/components/ui/context-menu/index.js";
import { daysBetween } from "#lib/dates.js";
import { formatDate, formatMoney, toNumber } from "#lib/format.js";
import { formatDate, toNumber } from "#lib/format.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 { goto } from "$app/navigation";
import { page } from "$app/state";
@@ -201,6 +203,8 @@
function sortValue(item: Item<Account>, 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<string, Total>();
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<string, Total>();
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 @@
</script>
{#snippet sums(
t: Total,
list: Total[],
label: string,
head = false
)}
@@ -489,23 +497,23 @@
>{label}</span
>
{#if head}
<Amount
{...t}
amount={t.difference}
<Money
class="ml-2 text-[12px] md:hidden"
inline
parts={parts(list, "difference")}
signed
/>
{/if}
{:else if col.key === "balance"}
<span class="num font-semibold"
>{formatMoney({ ...t, amount: t.balance })}</span
>{display.text(parts(list, "balance"))}</span
>
{:else if col.key === "due"}
<span class="num font-semibold"
>{formatMoney({ ...t, amount: t.due })}</span
>{display.text(parts(list, "due"))}</span
>
{:else if col.key === "difference"}
<Amount {...t} amount={t.difference} class="font-semibold" />
<Money class="font-semibold" parts={parts(list, "difference")} />
{/if}
</td>
{/each}
@@ -596,12 +604,13 @@
</thead>
<tbody>
{#each groups as group (group.title)}
{#if perCurrency}
{@const t = totals.find((x) => x.code === group.title)}
{#if sections}
<tr class="h-8 border-cladd-outline border-t-2">
{#if t}
{@render sums(t, group.title, true)}
{/if}
{@render sums(
totals.filter((t) => t.code === group.title),
group.title,
true
)}
</tr>
{:else if grouped}
<tr class="h-8 border-cladd-outline border-t">
@@ -706,13 +715,19 @@
</tr>
{/if}
</tbody>
{#if totals.length > 0 && !perCurrency}
{#if totals.length > 0 && !sections}
<tfoot class="border-cladd-outline border-t-2">
{#if merged}
<tr class="h-7">
{@render sums(totals, "Total")}
</tr>
{:else}
{#each totals as t (t.code)}
<tr class="h-7">
{@render sums(t, `Total in ${t.code}`)}
{@render sums([t], `Total in ${t.code}`)}
</tr>
{/each}
{/if}
</tfoot>
{/if}
</table>
+27 -15
View File
@@ -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");
</script>
{#snippet cell(
line: Line,
amount: number,
extra: string,
signed = true
)}
<td class="{NUM} {extra}" title={original(line, amount, signed)}>
{money(line, amount, signed)}
</td>
{/snippet}
<Box body="p-0 overflow-x-auto" title="Accounts">
<table
class="w-full min-w-[38rem] border-collapse md:min-w-[46rem] text-[13px] transition-opacity {loading
@@ -200,22 +216,18 @@
>
</td>
<td class="text-cladd-fg-soft max-md:hidden">{line.cur.code}</td>
<td class={NUM}>{money(line, line.opening)}</td>
<td class="{NUM} {real(line, line.income)}">
{money(line, line.income, true)}
</td>
<td class="{NUM} {real(line, line.spent)}">
{money(line, line.spent, true)}
</td>
<td class="{NUM} {tone(line.moved, "text-transfer")}">
{money(line, line.moved, true)}
</td>
{@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}
<td class="{NUM} {tone(line.other, "text-cladd-fg")}">
{money(line, line.other, true)}
</td>
{@render cell(
line,
line.other,
tone(line.other, "text-cladd-fg")
)}
{/if}
<td class="{NUM} font-semibold">{money(line, line.closing)}</td>
{@render cell(line, line.closing, "font-semibold", false)}
<td class="{NUM} text-cladd-fg-soft max-md:hidden">{line.rows}</td>
</tr>
{/each}
+25 -3
View File
@@ -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<string, Bar>();
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,
};
})
);
</script>
@@ -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] {
+9 -7
View File
@@ -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 @@
</script>
{#snippet sums(
list: { amount: number; code: string }[]
list: Total[]
)}
{#each list as t (t.code)}
<div class="num">
{formatMoney({ ...currency(t.code), amount: t.amount })}
</div>
<div class="num">{money(t)}</div>
{/each}
{/snippet}
@@ -506,9 +510,7 @@
>
{#each b.totals as t (t.code)}
<span class="whitespace-nowrap">
<span class="num font-semibold text-[15px]"
>{formatMoney({ ...currency(t.code), amount: t.amount })}</span
>
<span class="num font-semibold text-[15px]">{money(t)}</span>
{#if t.ratio !== null}
<span class={tone(t.ratio)}
>{versusText(t.ratio)}{current ? ` by ${asOf}` : ""}</span
+33 -6
View File
@@ -16,6 +16,7 @@
type ViewRange,
} from "#lib/dates.js";
import { toNumber } from "#lib/format.js";
import { display } from "#lib/money/display.svelte.js";
import { period, periodLabel } from "#lib/period.svelte.js";
const COUNT = 12;
@@ -50,6 +51,7 @@
}));
interface Flow {
approx?: boolean;
code: string;
earned: number;
spent: number;
@@ -76,12 +78,34 @@
}
return map;
});
const merged = $derived.by(() => {
if (!display.active) {
return null;
}
const map = new Map<string, Flow[]>();
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
)}
<li class="border-cladd-outline/60 border-r border-b">
@@ -117,17 +141,20 @@
{/if}
{#each list as flow (flow.code)}
{@const fmt = money(currency(flow.code))}
{@const mark = flow.approx ? "≈" : ""}
<span
class="num flex flex-wrap justify-between gap-x-2 text-[12px]"
>
{#if flow.earned}
<span class="text-income">+{fmt(flow.earned)}</span>
<span class="text-income">{mark}+{fmt(flow.earned)}</span>
{/if}
{#if flow.spent}
<span class="ml-auto text-expense">−{fmt(flow.spent)}</span>
<span class="ml-auto text-expense"
>{mark}−{fmt(flow.spent)}</span
>
{/if}
</span>
{#if flow.code === code}
{#if flow.code === main}
<span aria-hidden="true" class="flex h-1 gap-0.5">
<span class="flex flex-1 justify-end">
<span
+1 -1
View File
@@ -351,7 +351,7 @@
>
</td>
<td class="whitespace-nowrap px-3 text-right">
<Amount amount={row.amount} {...cur} />
<Amount amount={row.amount} exact {...cur} />
</td>
<td class="hidden max-w-0 truncate px-3 text-cladd-fg-soft md:table-cell">
{row.split.source_name}
+9 -10
View File
@@ -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 @@
<div class="transition-opacity {ready ? "" : "opacity-60"}">
{#if line.length > 0}
<LineChart
axisFormat={(value) => compactMoney(value, cur.symbol ?? "")}
axisFormat={view.axis}
height={236}
label="{lineTitle} of {a.name} by day"
marker={contains(chart, now) ? now : undefined}
@@ -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;
</script>
<section
@@ -82,7 +87,7 @@
: ""}"
>
<div
class="grid grid-cols-[1.25rem_minmax(0,1fr)_auto] items-baseline gap-x-2 gap-y-1 lg:flex lg:flex-wrap lg:items-end lg:gap-x-5 lg:gap-y-3"
class="grid grid-cols-[1.25rem_minmax(0,1fr)_auto] items-baseline gap-x-2 gap-y-1 lg:flex lg:flex-wrap lg:items-end lg:gap-x-4 lg:gap-y-3"
>
{#each items as item (item.key)}
<div
@@ -104,6 +109,7 @@
>
<span
class="num text-right font-semibold text-[18px] leading-tight tracking-[-0.02em] lg:col-start-2 lg:row-start-2 lg:text-left lg:text-[22px] {item.tone}"
title={original(item.value)}
>{money(item.value)}</span
>
</div>
@@ -114,7 +120,9 @@
<span class="col-start-2 text-[13px] text-cladd-fg-soft lg:text-[12px]"
>Change</span
>
<span class="num text-right font-semibold text-[15px] {changeTone}"
<span
class="num text-right font-semibold text-[15px] {changeTone}"
title={original(change, true)}
>{money(change, true)}</span
>
</div>
@@ -1,5 +1,10 @@
<script lang="ts">
import { type Side, type Sums, terms } from "#lib/accounts/statement.js";
import {
type Side,
type Sums,
terms,
viewOf,
} from "#lib/accounts/statement.js";
import Columns from "#lib/components/charts/Columns.svelte";
import {
type LegendItem,
@@ -8,12 +13,7 @@
wash,
} from "#lib/components/charts/chart.js";
import Legend from "#lib/components/charts/Legend.svelte";
import {
compactMoney,
currentLocale,
formatMoney,
type Money,
} from "#lib/format.js";
import { currentLocale, type Money } from "#lib/format.js";
const NARROW = 560;
const COLOR = {
@@ -49,8 +49,8 @@
currentLocale(),
style === "long" ? { month: "long", year: "numeric" } : { month: style }
);
const format = (value: number) =>
formatMoney({ ...cur, amount: value }, true);
const view = $derived(viewOf(cur));
const format = (value: number) => view.format(value, true);
const parts = $derived(
months.map((m) => ({
@@ -67,7 +67,9 @@
parts.map((p) => ({
key: p.month,
label: name(p.month, width < NARROW ? "narrow" : "short"),
values: Object.fromEntries(p.terms.map((t) => [t.flow, t.value])),
values: Object.fromEntries(
p.terms.map((t) => [t.flow, t.value * view.rate])
),
}))
);
const read = $derived(hover ?? selected ?? rows.at(-1)?.key ?? null);
@@ -89,7 +91,7 @@
<Legend class="min-w-0 flex-1" items={legend} />
</div>
<Columns
axisFormat={(value) => compactMoney(value, cur.symbol ?? "")}
axisFormat={view.axis}
{format}
height={208}
{keys}
+2 -6
View File
@@ -4,7 +4,7 @@
import { invalidate } from "#lib/api/lists.js";
import type { Range } from "#lib/dates.js";
import { formatMoney } from "#lib/format.js";
import { type Line, parseAmount, saveLimit } from "./data.js";
import { type Line, lineMoney, parseAmount, saveLimit } from "./data.js";
export type Move = 0 | 1 | -1;
@@ -149,11 +149,7 @@
type="button"
>
{line.amount
? formatMoney({
amount: line.amount,
decimals: line.cur.decimals,
symbol: line.cur.symbol,
})
? lineMoney(line, line.amount)
: `${line.cur.symbol} set amount`}
</button>
{/if}
+10 -4
View File
@@ -13,7 +13,12 @@
import { Button } from "#lib/components/ui/button/index.js";
import { DimensionModel } from "#lib/dimension/model.svelte.js";
import { budgetSpec } from "#lib/dimension/spec.js";
import { limitFacts, stripsOf, withLimits } from "#lib/dimension/stats.js";
import {
limitFacts,
limitsIn,
stripsOf,
withLimits,
} from "#lib/dimension/stats.js";
import { useKeys } from "#lib/keys.svelte.js";
import { period } from "#lib/period.svelte.js";
import { longMonth } from "#lib/reports/board.js";
@@ -35,16 +40,17 @@
const name = $derived(budget.data?.data.attributes.name ?? "");
const list = $derived(limits.data ?? []);
const limit = $derived(list.find((l) => l.id === limitId));
const shown = $derived(limitsIn(list, model.into));
const facts = $derived(
model.range
? limitFacts(list, model.main, model.range, model.now).sort(
? limitFacts(shown, model.main, model.range, model.now).sort(
(a, b) =>
Number(b.code === limit?.attributes.currency_code) -
Number(a.code === limit?.attributes.currency_code)
)
: []
);
const strips = $derived(stripsOf(list, model.main, model.months));
const strips = $derived(stripsOf(shown, model.main, model.months));
$effect(() => {
if (limit) {
@@ -136,7 +142,7 @@
{header}
{model}
noun="purchases"
shape={(lanes) => withLimits(lanes, list)}
shape={(lanes) => withLimits(lanes, shown)}
>
{#snippet extra()}
{#if !limits.isPending}
+15 -14
View File
@@ -1,14 +1,15 @@
<script lang="ts">
import Box from "#lib/components/Box.svelte";
import { formatMoney, percent } from "#lib/format.js";
import type { Top } from "./data.js";
import { percent } from "#lib/format.js";
import { mergeTops, type Top, topMoney } from "./data.js";
const CENT = 0.005;
let { tops, onsort }: { tops: Top[]; onsort: () => void } = $props();
const shown = $derived(mergeTops(tops));
const free = $derived(
tops.some(
shown.some(
(top) =>
top.available !== null && Math.abs(top.available - top.budgeted) > CENT
)
@@ -23,9 +24,6 @@
"Without a budget",
].filter(Boolean) as string[]
);
const money = (top: Top, amount: number) =>
formatMoney({ amount, decimals: 0, symbol: top.cur.symbol });
</script>
<Box body="px-3 py-3">
@@ -37,36 +35,39 @@
{#each columns as label (label)}
<span class="text-[12px] text-cladd-fg-soft max-sm:hidden">{label}</span>
{/each}
{#each tops as top, i (top.cur.code)}
{#each shown as top, i (top.cur.code)}
<span
class="col-span-2 self-center font-semibold text-[13px] text-cladd-fg-soft sm:col-span-1 {i >
0
? "max-sm:mt-1 max-sm:border-cladd-outline max-sm:border-t max-sm:pt-3"
: ""}"
>{top.cur.code}</span
>{top.approx ? "≈" : ""}{top.cur.code}</span
>
{#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))}
<div class="min-w-0">
<span class="text-[12px] text-cladd-fg-soft sm:hidden"
>Without a budget</span
@@ -80,7 +81,7 @@
>
<span
class="num font-semibold text-[20px] text-brand tracking-[-0.02em] sm:text-[22px]"
>{money(top, top.outside)}</span
>{topMoney(top, top.outside)}</span
>
{#if top.spent > 0}
<span class="num ml-1 text-[13px] text-cladd-fg-soft"
+1
View File
@@ -508,6 +508,7 @@
amount={split.type === "withdrawal"
? -Number(split.amount)
: split.amount}
code={split.currency_code}
decimals={split.currency_decimal_places}
symbol={split.currency_symbol}
tone={done ? "neutral" : "auto"}
+70 -1
View File
@@ -28,10 +28,12 @@ import {
today,
type ViewRange,
} from "#lib/dates.js";
import { toNumber } from "#lib/format.js";
import { formatMoney, toNumber } from "#lib/format.js";
import { display } from "#lib/money/display.svelte.js";
export interface Line {
amount: number | null;
approx?: boolean;
cur: Cur & { id: string };
key: string;
limit?: Item<BudgetLimit>;
@@ -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;
@@ -1,5 +1,7 @@
<script lang="ts">
import Amount, { type Tone } from "#lib/components/Amount.svelte";
import Money from "#lib/components/Money.svelte";
import { display } from "#lib/money/display.svelte.js";
import type { Sum } from "./sums.js";
let {
@@ -8,6 +10,8 @@
empty = "",
class: className = "",
}: { sums: Sum[]; tone?: Tone; empty?: string; class?: string } = $props();
const merged = $derived(display.total(sums) !== null);
</script>
{#if sums.length === 0}
@@ -16,6 +20,9 @@
{/if}
{:else}
<span class="inline-flex flex-wrap justify-end gap-x-2.5 {className}">
{#if merged}
<Money parts={sums} {tone} />
{:else}
{#each sums as sum (sum.code)}
<Amount
amount={sum.amount}
@@ -25,5 +32,6 @@
{tone}
/>
{/each}
{/if}
</span>
{/if}
@@ -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 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<Account>[],
pick: (a: Account) => string | null | undefined
) => accounts.reduce((s, a) => s + toNumber(pick(a.attributes)), 0);
const converted = (a: Item<Account>) =>
display.convert(
toNumber(a.attributes.current_balance),
a.attributes.currency_code
) ?? 0;
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,
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 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 recentRows = $derived(
new Map(
expanded.map((id, i) => [
@@ -221,26 +238,31 @@
{/snippet}
{#snippet balance(
amount: number,
cur: Cur
money: Money
)}
<span class="num font-medium {amount < 0 ? "text-expense" : "text-cladd-fg"}"
>{formatMoney({ amount, ...cur })}</span
<span
class="num font-medium {toNumber(money.amount) < 0
? "text-expense"
: "text-cladd-fg"}"
title={display.active && money.code !== display.code
? formatMoney(money)
: undefined}
>{display.text([money])}</span
>
{/snippet}
<Box body="p-0" href="/accounts/asset" title="Accounts">
{#snippet tools()}
{#if converted.balance !== null}
{#if total}
<span class="num font-semibold text-[13px] text-cladd-fg"
>{formatPrimary(converted.balance)}</span
>{display.text(balances)}</span
>
{#if converted.change}
{#if change?.amount}
<span
class="num text-[12px] {converted.change < 0
class="num text-[12px] {change.amount < 0
? "text-expense"
: "text-income"}"
>{formatPrimary(converted.change, true)}</span
>{display.text(changes, true)}</span
>
{/if}
{/if}
@@ -271,6 +293,7 @@
</thead>
{#each groups as group (group.code)}
<tbody class="border-cladd-outline border-t">
{#if group.balance && group.change}
<tr class="h-7 bg-cladd-surface-next/60 font-semibold">
<th
class="pl-3 text-left text-[12px] text-cladd-fg"
@@ -279,17 +302,20 @@
{group.code}
</th>
<td class="whitespace-nowrap px-2 text-right">
{@render balance(group.balance, group.cur)}
{@render balance(group.balance)}
</td>
<td class="whitespace-nowrap px-2 text-right">
{#if group.change !== 0}
<Amount amount={group.change} signed {...group.cur} />
{#if toNumber(group.change.amount) !== 0}
<Amount {...group.change} signed />
{/if}
</td>
<td class="hidden pr-3 sm:table-cell">
<div class="flex justify-end">{@render spark(group.trend)}</div>
<div class="flex justify-end">
{@render spark(group.trend)}
</div>
</td>
</tr>
{/if}
{#each group.accounts as account (account.id)}
{@const a = account.attributes}
{@const key = accountKey(account.id)}
@@ -325,11 +351,11 @@
</div>
</td>
<td class="whitespace-nowrap px-2 text-right">
{@render balance(toNumber(a.current_balance), group.cur)}
{@render balance(moneyOf(a, a.current_balance))}
</td>
<td class="whitespace-nowrap px-2 text-right">
{#if toNumber(a.balance_difference) !== 0}
<Amount amount={a.balance_difference} signed {...group.cur} />
<Amount {...moneyOf(a, a.balance_difference)} signed />
{/if}
</td>
<td class="hidden pr-3 sm:table-cell">
@@ -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,34 +59,41 @@
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<string, number>();
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<Panel[]>(() => {
const curOf = (s: ChartSet) => (converted ? setPrimary(s) : setCurrency(s));
return byCurrency(sets, (s) => curOf(s).code).map(([, group]) => {
const cur = curOf(group[0]);
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,
label,
series: [
{
area: true,
format,
key: "net-worth",
label: `Net worth (${cur.code})`,
label: `Net worth (${label})`,
points: sum(included),
strong: true,
},
@@ -93,16 +102,24 @@
}
return {
cur,
label,
series: group.map((s) => ({
area: group.length === 1,
format,
key: s.label,
label: s.label,
points: seriesPoints(s, converted),
points: points(s),
})),
};
});
});
}
const panels = $derived<Panel[]>(
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)}
<div class="flex flex-col gap-1">
{#if panels.length > 1}
{#if panels.length > 1 || merged}
<span
class="font-semibold text-[11px] text-cladd-fg-soft tracking-wide"
>{panel.cur.code}</span
>{panel.label}</span
>
{/if}
<LineChart
@@ -187,7 +204,7 @@
href="/accounts/asset"
>View your asset accounts</a
>
{#if converted}
{#if converted && !merged}
<span class="ml-auto text-cladd-fg-soft"
>Converted to your primary currency</span
>
@@ -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 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);
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<string, { budgeted: number; spent: number }>();
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: s.label,
label: s.label,
key: name,
label: name,
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,
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 = sets.some((x) => toNumber(x.entries.budgeted) > 0);
const limited = [...totals.values()].some((t) => t.budgeted > 0);
const keys = limited ? KEYS : [{ ...KEYS[0], label: "Spent" }];
return { code, keys, limited, rows };
})
return { code, keys, label, limited, rows };
}
const groups = $derived(
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));
</script>
@@ -101,9 +118,9 @@
{:else}
{#each visible as group (group.code)}
<div class="flex flex-col gap-1">
{#if visible.length > 1 || !group.limited}
{#if visible.length > 1 || !group.limited || merged}
<span class="text-[11px] text-cladd-fg-soft">
<span class="font-semibold tracking-wide">{group.code}</span>
<span class="font-semibold tracking-wide">{group.label}</span>
{#if !group.limited}
· spent, no budget amount set in {group.code}
{/if}
@@ -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)));
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) => {
function group(code: string, label: string, cur: Cur, list: ChartSet[]) {
const totals = new Map<string, number>();
for (const s of list) {
const entries = (converted ? s.pc_entries : s.entries) as Record<
string,
string
>;
const id = ids.get(s.label);
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: s.label,
label: s.label,
value: Math.abs(toNumber(entries?.spent)),
key: name,
label: name,
value,
};
})
.filter((b) => b.value > 0)
.sort((a, b) => b.value - a.value);
return { bars, code, cur };
})
return { bars, code, cur, label };
}
const groups = $derived(
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));
</script>
@@ -81,10 +96,10 @@
{:else}
{#each visible as group (group.code)}
<div class="flex min-w-0 flex-col gap-1">
{#if visible.length > 1}
{#if visible.length > 1 || merged}
<span
class="font-semibold text-[11px] text-cladd-fg-soft tracking-wide"
>Spent in {group.code}</span
>Spent in {group.label}</span
>
{/if}
<BarChart
+16 -22
View File
@@ -4,15 +4,11 @@
import type { Bill } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import { 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, toNumber } from "#lib/format.js";
import { display } from "#lib/money/display.svelte.js";
import { goto } from "$app/navigation";
import { byCurrency, type Cur } from "./data.js";
import { byCurrency, type Cur, rough } from "./data.js";
const DAYS = 7;
const HREF = `/subscriptions?horizon=${DAYS}`;
@@ -41,7 +37,6 @@
queryKey: ["bills-due", now, end],
}));
const piggies = createQuery(() => lists.piggyBanks());
const rates = createQuery(() => ratesQuery());
const items = $derived.by<Due[]>(() => {
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 @@
<div
class="num flex flex-wrap gap-x-4 gap-y-1 px-3 py-2 font-semibold text-[15px] text-cladd-fg"
>
{#if converted === null}
{#each totals as t (t.code)}
<span>{money(t.amount, t.cur, t.approx)}</span>
{/each}
{#if display.total(totals)}
<span
>{rough(
totals,
totals.some((t) => t.approx)
)}</span
>
{:else}
<span>{formatPrimary(converted)}</span>
{#each totals as t (t.code)}
<span>{rough([t], t.approx)}</span>
{/each}
{/if}
</div>
<table class="w-full border-collapse text-[13px]">
@@ -177,7 +171,7 @@
<td
class="num whitespace-nowrap pr-3 pl-2 text-right text-cladd-fg"
>
{money(item.amount, item.cur, item.approx)}
{rough([{ ...item.cur, amount: item.amount }], item.approx)}
</td>
</tr>
{/each}
+4 -4
View File
@@ -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 @@
<td
class="whitespace-nowrap py-1 pr-3 pl-2 text-right align-top leading-5"
>
{#if e.money}
<Amount {...e.money} />
{#if e.money.length > 0}
<Money parts={e.money} tone={e.money[0].tone} />
{/if}
</td>
</tr>
+68 -27
View File
@@ -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}
<div class="font-semibold text-[18px] text-cladd-fg-softer sm:text-[22px]">
0
</div>
@@ -116,12 +159,12 @@
<div
class="wrap-anywhere font-semibold text-[18px] text-cladd-fg tracking-[-0.02em] sm:text-[22px]"
>
{fmt(list[0], abs)}
{main}
</div>
{#if list.length > 1}
{#if rest.length > 0}
<div class="num flex flex-wrap gap-x-2 text-[12px] text-cladd-fg-soft">
{#each list.slice(1) as e (e.code)}
<span>{fmt(e, abs)}</span>
{#each rest as text (text)}
<span>{text}</span>
{/each}
</div>
{/if}
@@ -185,10 +228,10 @@
{#if earned || spent}
<div class="num flex flex-wrap gap-x-2 text-[12px]">
{#if earned}
<span class="text-income">+{fmt(earned, true)}</span>
<span class="text-income">{earned}</span>
{/if}
{#if spent}
<span class="text-expense">−{fmt(spent, true)}</span>
<span class="text-expense">{spent}</span>
{/if}
</div>
{/if}
@@ -205,8 +248,8 @@
{/if}
<div class="flex flex-wrap gap-x-2 text-[12px] text-cladd-fg-soft">
<span>Paid:</span>
{#each paid as e (e.code)}
<span class="num">{fmt(e, true)}</span>
{#each lines(paid, true) as text (text)}
<span class="num">{text}</span>
{:else}
<span class="num">0</span>
{/each}
@@ -215,11 +258,9 @@
{#snippet leftToSpend()}
{@render values(left)}
{#if left[0]?.sub}
{#if perDay}
<div class="text-[12px] text-cladd-fg-soft">
{leftState === "none" ? "" : "Per day: "}<span class="num"
>{left[0].sub}</span
>
{leftState === "none" ? "" : "Per day: "}<span class="num">{perDay}</span>
</div>
{/if}
{@render change(compare("left-to-spend", ["more", "less"]))}
+23 -25
View File
@@ -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
);
if (spent === null || rows.length < 2) {
return rows;
}
const known = rows.every((r) => r.typical !== null);
const typical = known
? inPrimaryTotal(
parts((r) => r.typical),
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 }))
)
: null;
return [...rows, { code: "All", format: formatPrimary, spent, typical }];
);
if (!display.active || lists.some((list, i) => list.length && !sums[i])) {
return null;
}
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);
</script>
{#if visible && rows.length > 0}
@@ -166,7 +164,7 @@
{r.typical === null ? "n/a" : r.format(r.typical)}
</td>
<td
class="num whitespace-nowrap px-2 text-right {diff !== null &&
class="num px-2 text-right sm:whitespace-nowrap {diff !== null &&
diff > 0
? "text-expense"
: "text-income"}"
@@ -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({
display.text([
{
amount,
code: p.currency_code,
decimals: p.currency_decimal_places,
symbol: p.currency_symbol,
});
},
]);
</script>
{#if saving.length > 0}
@@ -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,
});
</script>
{#if (bills.data?.length ?? 0) > 0}
@@ -85,25 +100,23 @@
<p class="text-cladd-fg-soft">Nothing expected in this period.</p>
{:else}
<ul class="flex flex-col gap-2">
{#each meters as m (m.cur.code)}
{#each lines as m (m.key)}
<li class="flex flex-col gap-1">
<div
class="flex items-baseline justify-between gap-2 text-[12px]"
>
<span class="text-cladd-fg-soft"
><span class="num text-cladd-fg">{fmt(m.paid, m.cur)}</span>
><span class="num text-cladd-fg">{m.paid}</span>
paid</span
>
<span class="text-cladd-fg-soft"
><span class="num text-cladd-fg"
>{fmt(m.total - m.paid, m.cur)}</span
>
><span class="num text-cladd-fg">{m.left}</span>
to pay</span
>
</div>
<Meter
max={m.total}
segments={[{ color: "var(--ff-series-3)", value: m.paid }]}
max={m.max}
segments={[{ color: "var(--ff-series-3)", value: m.value }]}
/>
</li>
{/each}
@@ -128,12 +141,14 @@
>{d.bill.attributes.name}</span
>
<span class="num text-cladd-fg-soft"
>~{formatMoney({
>{rough([
{
amount: d.amount,
code: d.bill.attributes.currency_code,
decimals: d.bill.attributes.currency_decimal_places,
symbol: d.bill.attributes.currency_symbol,
})}</span
},
])}</span
>
</a>
</li>
+20 -1
View File
@@ -2,7 +2,13 @@ import { api } from "#lib/api/client.js";
import { all } from "#lib/api/lists.js";
import type { Account, BasicSummaryEntry } from "#lib/api/types.js";
import { addDays, today } from "#lib/dates.js";
import { compactMoney, formatMoney, toNumber } from "#lib/format.js";
import {
compactMoney,
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";
@@ -89,6 +95,19 @@ export function byCurrency<T>(
);
}
export function mergeable(codes: string[]): boolean {
return (
display.active &&
codes.some((code) => code !== display.code) &&
codes.every((code) => display.rate(code) !== null)
);
}
export function rough(parts: Money[], approx = true): string {
const text = display.text(parts);
return approx && !text.startsWith("≈") ? `~${text}` : text;
}
export function parseSummary(
raw: Record<string, BasicSummaryEntry> | undefined
): Record<string, SummaryEntry[]> {
+2 -1
View File
@@ -6,6 +6,7 @@
import { formatDate, formatNumber } from "#lib/format.js";
import { period } from "#lib/period.svelte.js";
import { monthOf } from "#lib/reports/board.js";
import { isApprox } from "#lib/reports/display.js";
import { versusText } from "#lib/reports/typical.js";
const WEEK = 7;
@@ -123,7 +124,7 @@
)}
{#if main.projected !== null}
<span
>· on pace for ≈
>· on pace for {isApprox(main.code) ? "" : "≈"}
<span class="num font-semibold text-cladd-fg"
>{fmt(main.code, main.projected, 0)}</span
>,</span
+14 -17
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import Amount from "#lib/components/Amount.svelte";
import Money from "#lib/components/Money.svelte";
import { formatMoney } from "#lib/format.js";
import { display } from "#lib/money/display.svelte.js";
import type { Total } from "./object-groups.js";
let {
@@ -12,24 +13,20 @@
tone?: "income" | "expense" | "plain";
empty?: string;
} = $props();
const merged = $derived(display.total(totals) !== null);
</script>
{#each totals as total (total.code)}
<div>
{#if tone === "plain"}
<span class="num">{formatMoney(total)}</span>
{:else}
<Amount
amount={total.amount}
code={total.code}
decimals={total.decimals}
symbol={total.symbol}
{tone}
/>
{/if}
</div>
{:else}
{#if totals.length === 0}
{#if empty}
<span class="text-cladd-fg-softer">{empty}</span>
{/if}
{/each}
{:else if tone !== "plain"}
<Money parts={totals} {tone} />
{:else if merged}
<div><span class="num">{display.text(totals)}</span></div>
{:else}
{#each totals as total (total.code)}
<div><span class="num">{formatMoney(total)}</span></div>
{/each}
{/if}
@@ -119,7 +119,11 @@
/>
<span class="text-[12px] text-cladd-fg-soft">
up to
<Amount {...piggyMoney(piggy.attributes, link.max)} tone="neutral" />
<Amount
{...piggyMoney(piggy.attributes, link.max)}
exact
tone="neutral"
/>
</span>
</div>
{:else}
+3 -2
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import Box from "#lib/components/Box.svelte";
import { parse } from "#lib/dates.js";
import { formatDate, formatMoney, type Money } from "#lib/format.js";
import { formatDate, type Money } from "#lib/format.js";
import { display } from "#lib/money/display.svelte.js";
import {
cadence,
type Place,
@@ -51,7 +52,7 @@
);
const recent = $derived(past.slice(0, RECENT));
const money = (amount: number) => formatMoney({ ...currency, amount });
const money = (amount: number) => display.text([{ ...currency, amount }]);
function x(visit: Visit) {
const room = Math.max(width - 2 * largest, 0);
+3 -2
View File
@@ -5,7 +5,8 @@
import Amount from "#lib/components/Amount.svelte";
import Box from "#lib/components/Box.svelte";
import { addDays } from "#lib/dates.js";
import { formatDate, formatMoney, type Money } from "#lib/format.js";
import { formatDate, type Money } from "#lib/format.js";
import { display } from "#lib/money/display.svelte.js";
import { journalId, signedAmount, toneOf } from "#lib/transactions/split.js";
const DAYS = 2;
@@ -148,7 +149,7 @@
<td class="num px-3 text-right whitespace-nowrap">
{row.balance === null || row.balance === undefined
? ""
: formatMoney({ ...currency, amount: row.balance })}
: display.text([{ ...currency, amount: row.balance }])}
</td>
</tr>
{/each}
@@ -1,7 +1,9 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { displayCode, inDisplay } from "#lib/reports/display.js";
import { reportQueries } from "#lib/reports/queries.js";
import {
displayMoney,
drillQuery,
money,
navigate,
@@ -34,12 +36,15 @@
.sort((a, b) => a.name.localeCompare(b.name));
});
const sums = $derived.by(() => {
const into = displayCode(new Set(rows.map((row) => row.code)));
const map = new Map<string, { start: number; end: number }>();
for (const row of rows) {
const sum = map.get(row.code) ?? { end: 0, start: 0 };
sum.start += row.start;
sum.end += row.end;
map.set(row.code, sum);
const start = inDisplay(row.start, row.code, into);
const end = inDisplay(row.end, row.code, into);
const sum = map.get(start.code) ?? { end: 0, start: 0 };
sum.start += start.amount;
sum.end += end.amount;
map.set(start.code, sum);
}
const order = report.codes;
return [...map.entries()].sort(
@@ -106,10 +111,14 @@
{row.name}
</button>
</td>
<td class="num px-3 text-right">{money(row.code, row.start)}</td>
<td class="num px-3 text-right">{money(row.code, row.end)}</td>
<td class="num px-3 text-right">
{displayMoney(row.code, row.start)}
</td>
<td class="num px-3 text-right">
{displayMoney(row.code, row.end)}
</td>
<td class="num px-3 text-right {tone(row.end - row.start)}">
{money(row.code, row.end - row.start, true)}
{displayMoney(row.code, row.end - row.start, true)}
</td>
</tr>
{/each}
@@ -4,6 +4,7 @@
import LineChart from "#lib/components/charts/LineChart.svelte";
import { contains, today } from "#lib/dates.js";
import { formatDate } from "#lib/format.js";
import { displayCode, inDisplay, shownCode } from "#lib/reports/display.js";
import { reportQueries } from "#lib/reports/queries.js";
import { money, useReport } from "#lib/reports/report.svelte.js";
import { modeAxis } from "#lib/reports/view.js";
@@ -31,11 +32,16 @@
const charts = $derived.by(() => {
const byCode = new Map<string, LineSeries[]>();
for (const set of chart.data ?? []) {
const code = set.currency_code ?? "";
const sets = chart.data ?? [];
const into = displayCode(
new Set(sets.map((set) => set.currency_code ?? ""))
);
for (const set of sets) {
const from = set.currency_code ?? "";
const code = shownCode(from, into);
const points = Object.entries(set.entries ?? {}).map(([x, y]) => ({
x: x.slice(0, 10),
y: Number(y),
y: inDisplay(Number(y), from, into).amount,
}));
const list = byCode.get(code) ?? [];
if (net && list.length > 0) {
+10 -4
View File
@@ -18,6 +18,7 @@
shiftMonth,
spendingLanes,
} from "#lib/reports/board.js";
import { displayCode, displayFlows } from "#lib/reports/display.js";
import { exchanges } from "#lib/reports/flows.js";
import { ledgerQuery, ledgerWindow, ownFlows } from "#lib/reports/ledger.js";
import { ALL_ASSETS, type ReportParams, ymd } from "#lib/reports/params.js";
@@ -77,15 +78,20 @@
start: range.start,
type: "default",
});
const mine = $derived(
ledger.data && own ? ownFlows(ledger.data, own) : undefined
);
const into = $derived(
mine ? displayCode(new Set(mine.map((flow) => flow.currency))) : null
);
const report = createReport(
() => params,
() => ledger.data
() => ledger.data,
() => into
);
provideReport(report);
const flows = $derived(
ledger.data && own ? ownFlows(ledger.data, own) : undefined
);
const flows = $derived(mine && displayFlows(mine, into));
const board = $derived(
flows ? buildBoard(flows, span.months, year, report.codes, now) : undefined
);
+26 -14
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { formatRange, percent } from "#lib/format.js";
import { inDisplay } from "#lib/reports/display.js";
import { NONE } from "#lib/reports/flows.js";
import { reportQueries } from "#lib/reports/queries.js";
import {
@@ -36,12 +37,31 @@
const withdrawals = flows.filter(
(f) => f.dir === -1 && f.type === "withdrawal"
);
const result: Line[] = limits.data.map((item) => {
const merged = new Map<string, Line>();
for (const item of limits.data) {
const a = item.attributes;
const start = (a.start ?? p.start).slice(0, 10);
const end = (a.end ?? p.end).slice(0, 10);
const code = a.currency_code ?? "";
const spent = withdrawals
const { amount, code } = inDisplay(
Number(a.amount ?? 0),
a.currency_code ?? "",
report.into
);
const key = [a.budget_id, start, end, code].join("|");
const found = merged.get(key);
if (found) {
found.budgeted = (found.budgeted ?? 0) + amount;
continue;
}
merged.set(key, {
budgeted: amount,
budgetId: String(a.budget_id),
code,
end,
key: item.id,
name:
report.itemName("budget", String(a.budget_id)) ?? `#${a.budget_id}`,
spent: withdrawals
.filter(
(f) =>
f.budget?.id === String(a.budget_id) &&
@@ -49,19 +69,11 @@
f.date >= start &&
f.date <= end
)
.reduce((sum, f) => sum + f.amount, 0);
return {
budgeted: Number(a.amount ?? 0),
budgetId: String(a.budget_id),
code,
end,
key: item.id,
name:
report.itemName("budget", String(a.budget_id)) ?? `#${a.budget_id}`,
spent,
.reduce((sum, f) => sum + f.amount, 0),
start,
};
});
}
const result = [...merged.values()];
result.sort(
(a, b) => a.name.localeCompare(b.name) || a.start.localeCompare(b.start)
);
@@ -1,6 +1,7 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { formatRange } from "#lib/format.js";
import { displayFlows } from "#lib/reports/display.js";
import {
currencies,
measureOf,
@@ -28,10 +29,13 @@
const before = $derived(
earlier.data && report.accountsReady
? total(
displayFlows(
toFlows(
earlier.data,
new Set(report.selected),
(id) => report.byId().get(id)?.currency_code
),
report.into
)
)
: undefined
+13 -10
View File
@@ -4,7 +4,9 @@
import type { Lane } from "#lib/components/charts/chart.js";
import Lanes from "#lib/components/charts/Lanes.svelte";
import { addDays, periodOf, today } from "#lib/dates.js";
import { mergeLanes } from "#lib/money/lanes.js";
import { longMonth, monthOf, shortMonth } from "#lib/reports/board.js";
import { plainCode } from "#lib/reports/display.js";
import { buckets } from "#lib/reports/flows.js";
import { reportQueries } from "#lib/reports/queries.js";
import { impliedRates } from "#lib/reports/rates.js";
@@ -71,7 +73,7 @@
}
const rate = $derived(impliedRates(groups));
const primary = $derived(report.codes[0] ?? "");
const primary = $derived(plainCode(report.codes[0] ?? ""));
const measured = $derived(
[...totals.entries()]
@@ -92,10 +94,8 @@
const share = (weight: number | null) =>
weight === null || total === 0 ? null : weight / total;
const lanes = $derived<Lane[]>(
measured
.filter((m) => (share(m.weight) ?? 1) >= MIN_SHARE)
.map((m) => ({
const all = $derived<Lane[]>(
measured.map((m) => ({
axisFormat: modeAxis(m.code),
format: (value: number) => money(m.code, value, false, 0),
height: Math.max(
@@ -103,9 +103,7 @@
Math.round((share(m.weight) ?? 1 / measured.length) * HEIGHT_BUDGET)
),
key: m.code,
keys: [
{ color: "var(--ff-series-1)", key: "value", label: "Net worth" },
],
keys: [{ color: "var(--ff-series-1)", key: "value", label: "Net worth" }],
label: m.code,
rows: months.map((month, i) => ({
key: month,
@@ -116,9 +114,14 @@
})),
}))
);
const small = $derived(
measured.filter((m) => (share(m.weight) ?? 1) < MIN_SHARE)
const merged = $derived(mergeLanes(all));
const joined = $derived(merged !== all);
const big = (m: (typeof measured)[number]) =>
(share(m.weight) ?? 1) >= MIN_SHARE;
const lanes = $derived(
joined ? merged : all.filter((_, i) => big(measured[i]))
);
const small = $derived(joined ? [] : measured.filter((m) => !big(m)));
</script>
<ReportBox
@@ -4,7 +4,7 @@
import { formatDate } from "#lib/format.js";
import { expectedDates } from "#lib/reports/bills.js";
import { reportQueries } from "#lib/reports/queries.js";
import { money, useReport } from "#lib/reports/report.svelte.js";
import { displayMoney, useReport } from "#lib/reports/report.svelte.js";
import ReportBox from "./ReportBox.svelte";
const report = useReport();
@@ -59,10 +59,10 @@
>
</td>
<td class="num px-3 py-1 text-right">
{money(code, Number(row.a.amount_min ?? 0))}
{displayMoney(code, Number(row.a.amount_min ?? 0))}
</td>
<td class="num px-3 py-1 text-right">
{money(code, Number(row.a.amount_max ?? 0))}
{displayMoney(code, Number(row.a.amount_max ?? 0))}
</td>
<td class="num px-3 py-1 text-cladd-fg-soft">
{row.expected.map((d) => formatDate(d, "short")).join(", ")}
@@ -76,7 +76,7 @@
>{formatDate(paid.date, "short")}</a
>
<span class="num text-expense"
>{money(
>{displayMoney(
paid.currency_code ?? code,
-Number(paid.amount ?? 0)
)}</span
@@ -1,8 +1,10 @@
<script lang="ts">
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 @@
]);
</script>
{#snippet amount(
value: Signed
)}
<Amount
amount={value.amount}
code={value.code}
decimals={value.decimals}
symbol={value.symbol}
tone={value.tone === "transfer" ? "transfer" : "auto"}
/>
{/snippet}
<div class="mb-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<Box body="px-3 py-2" title="Overview">
{#if loading && groups.length === 0}
@@ -120,9 +120,7 @@
{#each totals as row (row.label)}
<dt class="text-cladd-fg-soft">{row.label}</dt>
<dd class="flex flex-col items-end">
{#each row.sums as sum (sum.code)}
{@render amount(sum)}
{/each}
<Money parts={row.sums} tone={row.tone} />
</dd>
{/each}
</dl>
@@ -143,9 +141,7 @@
<span class="truncate text-cladd-fg-soft">{entry.name}</span>
{/if}
<span class="flex flex-col items-end">
{#each entry.sums as sum (sum.code)}
{@render amount(sum)}
{/each}
<Money parts={entry.sums} tone={toneOf(entry.sums)} />
</span>
</li>
{:else}
+14 -6
View File
@@ -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}
<a
@@ -81,12 +89,12 @@
>
{#if !covered(item.range)}
<span class="text-cladd-fg-softer">Loading...</span>
{:else if item.totals.count === 0}
{:else if counts[index] === 0}
<span class="text-cladd-fg-softer">No transactions</span>
{:else}
<span class="flex justify-between gap-2">
<span class="text-cladd-fg-soft">Transactions</span>
<span class="num text-cladd-fg">{item.totals.count}</span>
<span class="num text-cladd-fg">{counts[index]}</span>
</span>
{#each fields as [field, label] (field)}
{@const sums = list(item.totals[field])}
@@ -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<string>());
@@ -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<string, Signed[]>();
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}
<span class="num text-cladd-fg-soft">{formatMoney(value)}</span>
{@const original = formatMoney(value)}
{@const shown = display.text([value])}
<span
class="num text-cladd-fg-soft"
title={shown === original ? undefined : original}
>{shown}</span
>
{/if}
{/snippet}
{#snippet sum(
parts: Signed[]
)}
<Money {parts} tone={parts[0]?.tone === "transfer" ? "transfer" : "auto"} />
{/snippet}
{#snippet account(
accountIdValue: string | null | undefined,
name: string | null | undefined,
@@ -429,7 +455,9 @@
{/if}
</td>
<td class="text-right">
{@render money(amount)}
{@render money(
display.active && foreign?.code === display.code ? foreign : amount
)}
{#if foreign}
<div class="num text-[12px] text-cladd-fg-soft lg:hidden">
{formatMoney(foreign)}
@@ -532,8 +560,8 @@
{/if}
</td>
<td class="text-right font-semibold">
{#each block.sums as sum (`${sum.tone}|${sum.code}`)}
<div>{@render money(sum)}</div>
{#each block.sums as parts (parts[0]?.tone)}
<div>{@render sum(parts)}</div>
{/each}
</td>
<td class="max-lg:hidden"></td>
@@ -585,9 +613,7 @@
</div>
</td>
<td class="text-right font-semibold">
{#each sums as sum (sum.code)}
<div>{@render money(sum)}</div>
{/each}
{@render sum(sums)}
</td>
<td class="max-lg:hidden"></td>
{#if accountId}
-136
View File
@@ -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<string, Rate> {
const implied = new Map<string, number[]>();
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<string, Rate> {
const rates: Record<string, Rate> = {};
const dated = new Map<string, string>();
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<Record<string, Rate>> {
const [listed, own] = await Promise.all([
all<ExchangeRate>("/v1/exchange-rates"),
api<Page<TransactionGroup>>("/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<string, Rate> | 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
)}`;
}
+13 -3
View File
@@ -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)
)
);
+28
View File
@@ -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<BudgetLimit>[]): Lane[] {
}));
}
export function limitsIn(
limits: Item<BudgetLimit>[],
into: string | null
): Item<BudgetLimit>[] {
if (!into) {
return limits;
}
const merged = new Map<string, Item<BudgetLimit>>();
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<BudgetLimit>): Range => ({
end: (limit.attributes.end ?? "").slice(0, 10),
start: (limit.attributes.start ?? "").slice(0, 10),
+62
View File
@@ -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<string, number>,
from: Record<string, number> | 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<string, StackKey>();
const rows = new Map<string, StackRow>();
const dots: Record<string, number> = {};
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,
},
];
}
+57
View File
@@ -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>): 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<string, number | null>();
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 };
});
}
+40 -7
View File
@@ -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<Account>[]): 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<TransactionGroup>, start: string, end: string) => {
export function createReport(
params: () => ReportParams,
source?: () => Item<TransactionGroup>[] | undefined
source?: () => Item<TransactionGroup>[] | undefined,
target?: () => string | null
) {
const p = $derived(params());
const kind = $derived(kindOf(p.type));
@@ -373,11 +393,18 @@ export function createReport(
: new Set<string>()
);
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() {
+6 -3
View File
@@ -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({
const money = display.text([
{
amount: split.amount,
code: split.currency_code,
decimals: split.currency_decimal_places,
symbol: split.currency_symbol,
});
},
]);
return {
groups,
id: groups
+28 -2
View File
@@ -127,13 +127,21 @@ export function balanceAfter(split: Split, accountId?: string): Money | null {
if (!accountId) {
return null;
}
const value =
split.source_id === accountId
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;
}
@@ -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,
});
</script>
<PageHeader crumbs={[{ label: "Budgets" }]} title="Budgets">
@@ -484,7 +490,7 @@
<tbody {...props}>
{#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
<span class="num"
>{formatMoney({
>{display.text([
{
amount:
row.budget.attributes.auto_budget_amount,
code: row.budget.attributes.currency_code,
decimals:
row.budget.attributes.currency_decimal_places,
row.budget.attributes
.currency_decimal_places,
symbol: row.budget.attributes.currency_symbol,
})}</span
},
])}</span
>
{PERIOD_WORDS[
row.budget.attributes.auto_budget_period ?? ""
@@ -602,6 +612,8 @@
{#if line !== extra}
<Amount
amount={line.spent}
approx={line.approx}
code={line.cur.code}
decimals={line.cur.decimals}
symbol={line.cur.symbol}
/>
@@ -613,7 +625,7 @@
class="num {line.amount + line.spent < 0
? "text-expense"
: "text-cladd-fg"}"
>{money(line, line.amount + line.spent)}</span
>{lineMoney(line, line.amount + line.spent)}</span
>
{/if}
</td>
@@ -622,7 +634,7 @@
{#if safe !== null}
<span
class={safe > 0 ? "text-cladd-fg" : "text-expense"}
>{money(line, safe)}</span
>{lineMoney(line, safe)}</span
>
{/if}
</td>
@@ -632,7 +644,7 @@
class={line.amount && ends > line.amount
? "text-expense"
: "text-cladd-fg-soft"}
>~{money(line, ends)}</span
>~{lineMoney(line, ends).replace("≈", "")}</span
>
{/if}
</td>
@@ -652,7 +664,7 @@
>
{:else if p && p.over > 0}
<span class="num text-[12px] text-warn"
>{money(line, p.over)}
>{lineMoney(line, p.over)}
over pace</span
>
{:else if p}
+17 -12
View File
@@ -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 @@
</button>
</th>
{#if column.key === "earned"}
<th class="w-[22%] px-3 font-medium">Share of {main} spending</th>
<th class="w-[22%] px-3 font-medium">
Share of {display.active ? "≈" : ""}{main}
spending
</th>
{/if}
{/each}
</tr>
@@ -331,7 +336,7 @@
<MoneyList sums={query.data?.earned ?? []} />
</td>
<td class="px-3">
{@render bar(amountIn(query.data?.spent ?? [], main))}
{@render bar(measure(query.data?.spent ?? []))}
</td>
<td class="hidden md:table-cell"></td>
</tr>
@@ -359,7 +364,7 @@
<td class="px-3 text-right">
<MoneyList sums={row.earned} />
</td>
<td class="px-3">{@render bar(amountIn(row.spent, main))}</td>
<td class="px-3">{@render bar(measure(row.spent))}</td>
<td
class="num hidden px-3 text-right text-[12px] text-cladd-fg-soft md:table-cell"
>
+13 -9
View File
@@ -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 @@
<td class="{cell} {wide} text-right">
{#if toNumber(p.target_amount) > 0}
<span class="num"
>{formatMoney(piggyMoney(p, p.target_amount))}</span
>{display.text([
piggyMoney(p, p.target_amount),
])}</span
>
{/if}
</td>
<td class="{cell} {wide} text-right">
{#if toNumber(p.left_to_save) > 0}
<span class="num"
>{formatMoney(piggyMoney(p, p.left_to_save))}</span
>{display.text([piggyMoney(p, p.left_to_save)])}</span
>
{/if}
</td>
<td class="{cell} hidden text-right lg:table-cell">
{#if p.target_date && toNumber(p.save_per_month) > 0}
<span class="num"
>{formatMoney(piggyMoney(p, p.save_per_month))}</span
>{display.text([
piggyMoney(p, p.save_per_month),
])}</span
>
{/if}
</td>
@@ -442,7 +447,7 @@
</td>
<td class="{cell} {wide} text-right">
<span class="num"
>{formatMoney({ ...money, amount: targets })}</span
>{display.text([{ ...money, amount: targets }])}</span
>
</td>
<td class="{cell} {wide} text-right">
@@ -450,10 +455,9 @@
</td>
<td class="{cell} {wide} text-right">
<span class="num"
>{formatMoney({
...money,
amount: Math.max(0, targets - r.earmarked),
})}</span
>{display.text([
{ ...money, amount: Math.max(0, targets - r.earmarked) },
])}</span
>
</td>
</tr>
@@ -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}
<LineChart
area
axisFormat={(v) => 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 @@
<td class={label}>Target amount</td>
<td class="{value} num">
{target > 0
? formatMoney(piggyMoney(piggy, piggy.target_amount))
? display.text([piggyMoney(piggy, piggy.target_amount)])
: "No target"}
</td>
</tr>
@@ -286,7 +304,7 @@
<tr class={row}>
<td class={label}>Left to save</td>
<td class="{value} num">
{formatMoney(piggyMoney(piggy, piggy.left_to_save))}
{display.text([piggyMoney(piggy, piggy.left_to_save)])}
</td>
</tr>
{/if}
@@ -306,7 +324,7 @@
<tr class={row}>
<td class={label}>Suggested monthly amount to save</td>
<td class="{value} num">
{formatMoney(piggyMoney(piggy, piggy.save_per_month))}
{display.text([piggyMoney(piggy, piggy.save_per_month)])}
</td>
</tr>
{/if}
@@ -381,7 +399,9 @@
>
{amount < 0 ? "Removed" : "Added"}
<span class="num"
>{formatMoney(piggyMoney(piggy, Math.abs(amount)))}</span
>{display.text([
piggyMoney(piggy, Math.abs(amount)),
])}</span
>
</td>
</tr>
+1
View File
@@ -147,6 +147,7 @@
<div>
<Amount
amount={split.amount}
code={split.currency_code}
decimals={split.currency_decimal_places}
symbol={split.currency_symbol}
tone={toneOf(r.type)}
@@ -258,6 +258,7 @@
<td class={tdNum}>
<Amount
amount={tx.amount}
code={tx.currency_code}
decimals={tx.currency_decimal_places}
symbol={tx.currency_symbol}
tone={toneOf(r.type)}
@@ -266,7 +267,9 @@
<span class="text-cladd-fg-soft">
(<Amount
amount={tx.foreign_amount}
code={tx.foreign_currency_code}
decimals={tx.foreign_currency_decimal_places}
exact
symbol={tx.foreign_currency_symbol}
tone="neutral"
/>)
@@ -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,7 +169,8 @@
side === "destination" &&
type === "transfer" &&
Boolean(first.foreign_currency_code);
return formatMoney({
return display.text([
{
amount:
side === "destination" ? Math.max(...values) : Math.min(...values),
code: foreign ? first.foreign_currency_code : first.currency_code,
@@ -176,7 +178,8 @@
? 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<Bill>) {
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}
<span
class="num ml-1.5 text-[max(12px,0.55em)] font-medium text-cladd-fg-soft"
>{text}</span
>
{/if}
{/snippet}
{#snippet account(
split: Split,
side: "source" | "destination"
@@ -402,6 +434,7 @@
>{formatMoney(foreign)}</span
>
{/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)}</span
>
{/each}
{#if totalApprox}
<span class="num font-medium text-[15px] text-cladd-fg-soft"
>{totalApprox}</span
>
{/if}
{:else}
{@render cell("amount", 0)}
{/if}