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:
1 parent
94466d9be9
commit
617a4700e2
61 files changed
+1307
-678
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"]))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
)}`;
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user