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
+1427
-798
No files matched your search
@@ -1,11 +1,13 @@
|
|||||||
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
||||||
import { lists } from "#lib/api/lists.js";
|
import { lists } from "#lib/api/lists.js";
|
||||||
import type { TransactionGroup } from "#lib/api/types.js";
|
import type { TransactionGroup } from "#lib/api/types.js";
|
||||||
import { ratesQuery } from "#lib/dashboard/primary.svelte.js";
|
|
||||||
import { today } from "#lib/dates.js";
|
import { today } from "#lib/dates.js";
|
||||||
|
import { display } from "#lib/money/display.svelte.js";
|
||||||
import { period } from "#lib/period.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 { ledgerQuery, ledgerWindow, ownFlows } from "#lib/reports/ledger.js";
|
||||||
import { pages } from "#lib/reports/queries.js";
|
import { pages } from "#lib/reports/queries.js";
|
||||||
|
import { impliedRates } from "#lib/reports/rates.js";
|
||||||
import { typicalMonths } from "#lib/reports/typical.js";
|
import { typicalMonths } from "#lib/reports/typical.js";
|
||||||
import { session } from "#lib/session.svelte.js";
|
import { session } from "#lib/session.svelte.js";
|
||||||
import { board, type Kind, monthDay } from "./merchants.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],
|
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) : []
|
ledger.data && accounts.data ? ownFlows(ledger.data, accounts.data) : []
|
||||||
);
|
);
|
||||||
const periodFlows = $derived.by(() => {
|
const ownPeriod = $derived.by(() => {
|
||||||
if (inside) {
|
if (inside) {
|
||||||
return flows;
|
return own;
|
||||||
}
|
}
|
||||||
return extra.data && accounts.data
|
return extra.data && accounts.data
|
||||||
? ownFlows(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 day = $derived(monthDay(period.range, now));
|
||||||
const result = $derived.by(() => {
|
const result = $derived.by(() => {
|
||||||
if (!(accounts.data && ledger.data && (inside || extra.data))) {
|
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),
|
(first, flow) => (flow.date < first ? flow.date : first),
|
||||||
now
|
now
|
||||||
);
|
);
|
||||||
const primary = session.primary?.code ?? "";
|
const base = merge ?? session.primary?.code ?? "";
|
||||||
return board(accounts.data, flows, periodFlows, {
|
return board(accounts.data, flows, periodFlows, {
|
||||||
day,
|
day,
|
||||||
kind: kind(),
|
kind: kind(),
|
||||||
|
merge,
|
||||||
months: window.months.filter((m) => m <= month),
|
months: window.months.filter((m) => m <= month),
|
||||||
past: typicalMonths(window.months, period.end, since),
|
past: typicalMonths(window.months, period.end, since),
|
||||||
range: period.range,
|
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;
|
return accounts.error ?? ledger.error ?? extra.error ?? null;
|
||||||
},
|
},
|
||||||
get loading() {
|
get loading() {
|
||||||
return result === null || rates.isPending;
|
return result === null;
|
||||||
},
|
},
|
||||||
get stale() {
|
get stale() {
|
||||||
return ledger.isFetching || extra.isPlaceholderData;
|
return ledger.isFetching || extra.isPlaceholderData;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const TOP = 5;
|
|||||||
|
|
||||||
export interface Line {
|
export interface Line {
|
||||||
amount: number;
|
amount: number;
|
||||||
|
approx: boolean;
|
||||||
base: number;
|
base: number;
|
||||||
code: string;
|
code: string;
|
||||||
months: number[];
|
months: number[];
|
||||||
@@ -40,6 +41,7 @@ export interface Merchant {
|
|||||||
|
|
||||||
export interface Total {
|
export interface Total {
|
||||||
amount: number;
|
amount: number;
|
||||||
|
approx: boolean;
|
||||||
base: number;
|
base: number;
|
||||||
code: string;
|
code: string;
|
||||||
ratio: number | null;
|
ratio: number | null;
|
||||||
@@ -65,6 +67,7 @@ export interface Board {
|
|||||||
export interface Context {
|
export interface Context {
|
||||||
day: number | null;
|
day: number | null;
|
||||||
kind: Kind;
|
kind: Kind;
|
||||||
|
merge: string | null;
|
||||||
months: string[];
|
months: string[];
|
||||||
past: string[];
|
past: string[];
|
||||||
range: Range;
|
range: Range;
|
||||||
@@ -72,6 +75,8 @@ export interface Context {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sum = (values: number[]) => values.reduce((a, b) => a + b, 0);
|
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> {
|
function monthly(flows: Flow[]): Map<string, number> {
|
||||||
const map = new 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);
|
const base = ctx.day === null ? 0 : typicalToDay(year, ctx.past, ctx.day);
|
||||||
return {
|
return {
|
||||||
amount,
|
amount,
|
||||||
|
approx: foreign(year, code) || foreign(now, code),
|
||||||
base,
|
base,
|
||||||
code,
|
code,
|
||||||
months: ctx.months.slice(-SPARK_MONTHS).map((m) => byMonth.get(m) ?? 0),
|
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;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const codeOf = (ctx: Context) => (flow: Flow) => ctx.merge ?? flow.currency;
|
||||||
|
|
||||||
const latest = (flows: Flow[]) =>
|
const latest = (flows: Flow[]) =>
|
||||||
flows.reduce((last, flow) => (flow.date > last ? flow.date : last), "");
|
flows.reduce((last, flow) => (flow.date > last ? flow.date : last), "");
|
||||||
|
|
||||||
@@ -142,8 +150,8 @@ function merchantOf(
|
|||||||
now: Flow[],
|
now: Flow[],
|
||||||
ctx: Context
|
ctx: Context
|
||||||
): Omit<Merchant, "share"> {
|
): Omit<Merchant, "share"> {
|
||||||
const yearBy = bucket(year, (flow) => flow.currency);
|
const yearBy = bucket(year, codeOf(ctx));
|
||||||
const nowBy = bucket(now, (flow) => flow.currency);
|
const nowBy = bucket(now, codeOf(ctx));
|
||||||
const codes = new Set([...yearBy.keys(), ...nowBy.keys()]);
|
const codes = new Set([...yearBy.keys(), ...nowBy.keys()]);
|
||||||
const quiet = now.length === 0;
|
const quiet = now.length === 0;
|
||||||
const lines = [...codes]
|
const lines = [...codes]
|
||||||
@@ -221,8 +229,8 @@ export function board(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function totalsOf(year: Flow[], now: Flow[], ctx: Context): Total[] {
|
function totalsOf(year: Flow[], now: Flow[], ctx: Context): Total[] {
|
||||||
const nowBy = bucket(now, (flow) => flow.currency);
|
const nowBy = bucket(now, codeOf(ctx));
|
||||||
const yearBy = bucket(year, (flow) => flow.currency);
|
const yearBy = bucket(year, codeOf(ctx));
|
||||||
return [...nowBy]
|
return [...nowBy]
|
||||||
.map(([code, list]) => {
|
.map(([code, list]) => {
|
||||||
const amount = sum(list.map((flow) => flow.amount));
|
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);
|
: typicalToDay(yearBy.get(code) ?? [], ctx.past, ctx.day);
|
||||||
return {
|
return {
|
||||||
amount,
|
amount,
|
||||||
|
approx: foreign(list, code),
|
||||||
base,
|
base,
|
||||||
code,
|
code,
|
||||||
ratio: ctx.day === null ? null : versus(amount, base),
|
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);
|
ORDER[key](a, b) || ORDER.last(a, b) || ORDER.name(a, b);
|
||||||
|
|
||||||
export function sumLines(merchants: Merchant[]): Total[] {
|
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)) {
|
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]
|
return [...map.values()].filter((total) => total.amount > 0);
|
||||||
.filter(([, amount]) => amount > 0)
|
|
||||||
.map(([code, amount]) => ({ amount, base: 0, code, ratio: null }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function byCategory(merchants: Merchant[], none: string): Section[] {
|
export function byCategory(merchants: Merchant[], none: string): Section[] {
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ import {
|
|||||||
eachDay,
|
eachDay,
|
||||||
type Range,
|
type Range,
|
||||||
} from "#lib/dates.js";
|
} 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 =
|
export type Flow =
|
||||||
| "income"
|
| "income"
|
||||||
@@ -220,3 +226,15 @@ export function terms(side: Side, total: Sums): Term[] {
|
|||||||
export function reconciledThrough(list: Move[]): string | null {
|
export function reconciledThrough(list: Move[]): string | null {
|
||||||
return list.findLast((m) => m.reconciled)?.date ?? 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 ChevronDown from "@lucide/svelte/icons/chevron-down";
|
||||||
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
||||||
import { DropdownMenu } from "bits-ui";
|
import { DropdownMenu } from "bits-ui";
|
||||||
|
import { viewOf } from "#lib/accounts/statement.js";
|
||||||
import { api } from "#lib/api/client.js";
|
import { api } from "#lib/api/client.js";
|
||||||
import { all, invalidate } from "#lib/api/lists.js";
|
import { all, invalidate } from "#lib/api/lists.js";
|
||||||
import type { Account, One, Page, TransactionGroup } from "#lib/api/types.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 DropdownMenuContent from "#lib/components/ui/dropdown-menu/dropdown-menu-content.svelte";
|
||||||
import DropdownMenuItem from "#lib/components/ui/dropdown-menu/dropdown-menu-item.svelte";
|
import DropdownMenuItem from "#lib/components/ui/dropdown-menu/dropdown-menu-item.svelte";
|
||||||
import { contains, daysBetween, today } from "#lib/dates.js";
|
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 { useKeys } from "#lib/keys.svelte.js";
|
||||||
|
import { display } from "#lib/money/display.svelte.js";
|
||||||
import { period } from "#lib/period.svelte.js";
|
import { period } from "#lib/period.svelte.js";
|
||||||
import { session } from "#lib/session.svelte.js";
|
import { session } from "#lib/session.svelte.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
@@ -131,19 +133,28 @@
|
|||||||
}),
|
}),
|
||||||
queryKey: ["account-chart", id, range.start, range.end, step],
|
queryKey: ["account-chart", id, range.start, range.end, step],
|
||||||
}));
|
}));
|
||||||
const converted = $derived(hasPrimary(chart.data ?? []));
|
const converted = $derived(!display.active && 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 cur = $derived(
|
const cur = $derived(
|
||||||
currency(a?.currency_code ?? session.primary?.code ?? "")
|
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(
|
const pageNo = $derived(
|
||||||
Number(page.url.searchParams.get("page") ?? "1") || 1
|
Number(page.url.searchParams.get("page") ?? "1") || 1
|
||||||
@@ -207,7 +218,7 @@
|
|||||||
return TYPE_TITLE[type].replace(" accounts", " account");
|
return TYPE_TITLE[type].replace(" accounts", " account");
|
||||||
});
|
});
|
||||||
const balance = $derived(
|
const balance = $derived(
|
||||||
a ? formatMoney({ amount: a.current_balance, ...cur }) : ""
|
a ? display.text([{ amount: a.current_balance, ...cur }]) : ""
|
||||||
);
|
);
|
||||||
|
|
||||||
function pageHref(n: number) {
|
function pageHref(n: number) {
|
||||||
@@ -344,9 +355,8 @@
|
|||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<LineChart
|
<LineChart
|
||||||
axisFormat={axis(
|
axisFormat={view?.axis ??
|
||||||
converted ? setPrimary(chart.data?.[0] as ChartSet) : cur
|
axis(converted ? setPrimary(chart.data?.[0] as ChartSet) : cur)}
|
||||||
)}
|
|
||||||
height={300}
|
height={300}
|
||||||
label="Balance of {a?.name ?? "the account"}"
|
label="Balance of {a?.name ?? "the account"}"
|
||||||
{marker}
|
{marker}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
type ChartSet,
|
type ChartSet,
|
||||||
seriesPoints,
|
seriesPoints,
|
||||||
} from "#lib/components/dashboard/data.js";
|
} from "#lib/components/dashboard/data.js";
|
||||||
|
import Money from "#lib/components/Money.svelte";
|
||||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
@@ -27,8 +28,9 @@
|
|||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "#lib/components/ui/context-menu/index.js";
|
} from "#lib/components/ui/context-menu/index.js";
|
||||||
import { daysBetween } from "#lib/dates.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 { ListNav } from "#lib/list-nav.svelte.js";
|
||||||
|
import { display } from "#lib/money/display.svelte.js";
|
||||||
import { period } from "#lib/period.svelte.js";
|
import { period } from "#lib/period.svelte.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
@@ -201,6 +203,8 @@
|
|||||||
|
|
||||||
function sortValue(item: Item<Account>, key: Key): string | number {
|
function sortValue(item: Item<Account>, key: Key): string | number {
|
||||||
const a = item.attributes;
|
const a = item.attributes;
|
||||||
|
const shown = (value: string | null | undefined) =>
|
||||||
|
display.convert(toNumber(value), a.currency_code) ?? toNumber(value);
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "name":
|
case "name":
|
||||||
return a.name.toLowerCase();
|
return a.name.toLowerCase();
|
||||||
@@ -215,15 +219,15 @@
|
|||||||
case "number":
|
case "number":
|
||||||
return formatIban(a);
|
return formatIban(a);
|
||||||
case "balance":
|
case "balance":
|
||||||
return toNumber(a.current_balance);
|
return shown(a.current_balance);
|
||||||
case "due":
|
case "due":
|
||||||
return toNumber(a.debt_amount);
|
return shown(a.debt_amount);
|
||||||
case "active":
|
case "active":
|
||||||
return a.active ? 1 : 0;
|
return a.active ? 1 : 0;
|
||||||
case "activity":
|
case "activity":
|
||||||
return a.last_activity ?? "";
|
return a.last_activity ?? "";
|
||||||
default:
|
default:
|
||||||
return toNumber(a.balance_difference);
|
return shown(a.balance_difference);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,11 +269,43 @@
|
|||||||
return (a > b ? 1 : -1) * dir;
|
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(
|
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(() => {
|
const groups = $derived.by(() => {
|
||||||
if (perCurrency) {
|
if (sections) {
|
||||||
return byCurrency(
|
return byCurrency(
|
||||||
sorted,
|
sorted,
|
||||||
(item) => item.attributes.currency_code ?? ""
|
(item) => item.attributes.currency_code ?? ""
|
||||||
@@ -298,34 +334,6 @@
|
|||||||
const rows = $derived(groups.flatMap((g) => g.items));
|
const rows = $derived(groups.flatMap((g) => g.items));
|
||||||
const index = $derived(new Map(rows.map((item, i) => [item.id, i])));
|
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(
|
const title = $derived(
|
||||||
active ? TYPE_TITLE[type] : `${TYPE_TITLE[type]} (inactive)`
|
active ? TYPE_TITLE[type] : `${TYPE_TITLE[type]} (inactive)`
|
||||||
);
|
);
|
||||||
@@ -471,7 +479,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet sums(
|
{#snippet sums(
|
||||||
t: Total,
|
list: Total[],
|
||||||
label: string,
|
label: string,
|
||||||
head = false
|
head = false
|
||||||
)}
|
)}
|
||||||
@@ -489,23 +497,23 @@
|
|||||||
>{label}</span
|
>{label}</span
|
||||||
>
|
>
|
||||||
{#if head}
|
{#if head}
|
||||||
<Amount
|
<Money
|
||||||
{...t}
|
|
||||||
amount={t.difference}
|
|
||||||
class="ml-2 text-[12px] md:hidden"
|
class="ml-2 text-[12px] md:hidden"
|
||||||
|
inline
|
||||||
|
parts={parts(list, "difference")}
|
||||||
signed
|
signed
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if col.key === "balance"}
|
{:else if col.key === "balance"}
|
||||||
<span class="num font-semibold"
|
<span class="num font-semibold"
|
||||||
>{formatMoney({ ...t, amount: t.balance })}</span
|
>{display.text(parts(list, "balance"))}</span
|
||||||
>
|
>
|
||||||
{:else if col.key === "due"}
|
{:else if col.key === "due"}
|
||||||
<span class="num font-semibold"
|
<span class="num font-semibold"
|
||||||
>{formatMoney({ ...t, amount: t.due })}</span
|
>{display.text(parts(list, "due"))}</span
|
||||||
>
|
>
|
||||||
{:else if col.key === "difference"}
|
{:else if col.key === "difference"}
|
||||||
<Amount {...t} amount={t.difference} class="font-semibold" />
|
<Money class="font-semibold" parts={parts(list, "difference")} />
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -596,12 +604,13 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each groups as group (group.title)}
|
{#each groups as group (group.title)}
|
||||||
{#if perCurrency}
|
{#if sections}
|
||||||
{@const t = totals.find((x) => x.code === group.title)}
|
|
||||||
<tr class="h-8 border-cladd-outline border-t-2">
|
<tr class="h-8 border-cladd-outline border-t-2">
|
||||||
{#if t}
|
{@render sums(
|
||||||
{@render sums(t, group.title, true)}
|
totals.filter((t) => t.code === group.title),
|
||||||
{/if}
|
group.title,
|
||||||
|
true
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
{:else if grouped}
|
{:else if grouped}
|
||||||
<tr class="h-8 border-cladd-outline border-t">
|
<tr class="h-8 border-cladd-outline border-t">
|
||||||
@@ -706,13 +715,19 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{/if}
|
{/if}
|
||||||
</tbody>
|
</tbody>
|
||||||
{#if totals.length > 0 && !perCurrency}
|
{#if totals.length > 0 && !sections}
|
||||||
<tfoot class="border-cladd-outline border-t-2">
|
<tfoot class="border-cladd-outline border-t-2">
|
||||||
{#each totals as t (t.code)}
|
{#if merged}
|
||||||
<tr class="h-7">
|
<tr class="h-7">
|
||||||
{@render sums(t, `Total in ${t.code}`)}
|
{@render sums(totals, "Total")}
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{:else}
|
||||||
|
{#each totals as t (t.code)}
|
||||||
|
<tr class="h-7">
|
||||||
|
{@render sums([t], `Total in ${t.code}`)}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
</tfoot>
|
</tfoot>
|
||||||
{/if}
|
{/if}
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { addDays } from "#lib/dates.js";
|
import { addDays } from "#lib/dates.js";
|
||||||
import { formatMoney, type Money, toNumber } from "#lib/format.js";
|
import { formatMoney, type Money, toNumber } from "#lib/format.js";
|
||||||
import { ListNav } from "#lib/list-nav.svelte.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 { pages, reportQueries } from "#lib/reports/queries.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { showUrl, typeOf } from "./links.js";
|
import { showUrl, typeOf } from "./links.js";
|
||||||
@@ -142,7 +143,11 @@
|
|||||||
nav.use();
|
nav.use();
|
||||||
|
|
||||||
const money = (line: Line, amount: number, signed = false) =>
|
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) {
|
function tone(value: number, positive: string, negative = positive) {
|
||||||
if (Math.abs(value) < CENT) {
|
if (Math.abs(value) < CENT) {
|
||||||
return "text-cladd-fg-softer";
|
return "text-cladd-fg-softer";
|
||||||
@@ -156,6 +161,17 @@
|
|||||||
: tone(value, "text-income", "text-expense");
|
: tone(value, "text-income", "text-expense");
|
||||||
</script>
|
</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">
|
<Box body="p-0 overflow-x-auto" title="Accounts">
|
||||||
<table
|
<table
|
||||||
class="w-full min-w-[38rem] border-collapse md:min-w-[46rem] text-[13px] transition-opacity {loading
|
class="w-full min-w-[38rem] border-collapse md:min-w-[46rem] text-[13px] transition-opacity {loading
|
||||||
@@ -200,22 +216,18 @@
|
|||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-cladd-fg-soft max-md:hidden">{line.cur.code}</td>
|
<td class="text-cladd-fg-soft max-md:hidden">{line.cur.code}</td>
|
||||||
<td class={NUM}>{money(line, line.opening)}</td>
|
{@render cell(line, line.opening, "", false)}
|
||||||
<td class="{NUM} {real(line, line.income)}">
|
{@render cell(line, line.income, real(line, line.income))}
|
||||||
{money(line, line.income, true)}
|
{@render cell(line, line.spent, real(line, line.spent))}
|
||||||
</td>
|
{@render cell(line, line.moved, tone(line.moved, "text-transfer"))}
|
||||||
<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>
|
|
||||||
{#if adjusted}
|
{#if adjusted}
|
||||||
<td class="{NUM} {tone(line.other, "text-cladd-fg")}">
|
{@render cell(
|
||||||
{money(line, line.other, true)}
|
line,
|
||||||
</td>
|
line.other,
|
||||||
|
tone(line.other, "text-cladd-fg")
|
||||||
|
)}
|
||||||
{/if}
|
{/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>
|
<td class="{NUM} text-cladd-fg-soft max-md:hidden">{line.rows}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
currency,
|
currency,
|
||||||
money,
|
money,
|
||||||
} from "#lib/components/dashboard/data.js";
|
} from "#lib/components/dashboard/data.js";
|
||||||
|
import { display } from "#lib/money/display.svelte.js";
|
||||||
import type { Group } from "#lib/transactions/split.js";
|
import type { Group } from "#lib/transactions/split.js";
|
||||||
|
|
||||||
const TOP = 6;
|
const TOP = 6;
|
||||||
@@ -41,13 +42,29 @@
|
|||||||
.filter((s) => s.type === kind)
|
.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(
|
const sections = $derived(
|
||||||
byCurrency(splits, (s) => s.currency_code ?? "").map(([code, list]) => {
|
buckets.map(([code, list]) => {
|
||||||
const sums = new Map<string, Bar>();
|
const sums = new Map<string, Bar>();
|
||||||
for (const split of list) {
|
for (const split of list) {
|
||||||
const [label, href] = pick(split);
|
const [label, href] = pick(split);
|
||||||
const bar = sums.get(label) ?? { href, key: label, label, value: 0 };
|
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);
|
sums.set(label, bar);
|
||||||
}
|
}
|
||||||
const sorted = [...sums.values()].sort((a, b) => b.value - a.value);
|
const sorted = [...sums.values()].sort((a, b) => b.value - a.value);
|
||||||
@@ -63,7 +80,12 @@
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: sorted;
|
: sorted;
|
||||||
return { bars, code, format: money(currency(code)) };
|
const format = money(currency(code));
|
||||||
|
return {
|
||||||
|
bars,
|
||||||
|
code,
|
||||||
|
format: approx ? (value: number) => `≈${format(value)}` : format,
|
||||||
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -43,7 +43,12 @@
|
|||||||
|
|
||||||
const money = (line: Line, amount: number, decimals?: number) => {
|
const money = (line: Line, amount: number, decimals?: number) => {
|
||||||
const cur = currency(line.code);
|
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] {
|
function change(line: Line): [string, string] {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
SORT_KEYS,
|
SORT_KEYS,
|
||||||
type SortKey,
|
type SortKey,
|
||||||
sortBy,
|
sortBy,
|
||||||
|
type Total,
|
||||||
} from "#lib/accounts/merchants.js";
|
} from "#lib/accounts/merchants.js";
|
||||||
import { useMerchants } from "#lib/accounts/merchants.svelte.js";
|
import { useMerchants } from "#lib/accounts/merchants.svelte.js";
|
||||||
import { api } from "#lib/api/client.js";
|
import { api } from "#lib/api/client.js";
|
||||||
@@ -370,6 +371,11 @@
|
|||||||
return `${align} ${wide}`;
|
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) {
|
function tone(ratio: number | null) {
|
||||||
if (ratio === null || Math.abs(ratio) < SAME) {
|
if (ratio === null || Math.abs(ratio) < SAME) {
|
||||||
return "text-cladd-fg-soft";
|
return "text-cladd-fg-soft";
|
||||||
@@ -380,12 +386,10 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet sums(
|
{#snippet sums(
|
||||||
list: { amount: number; code: string }[]
|
list: Total[]
|
||||||
)}
|
)}
|
||||||
{#each list as t (t.code)}
|
{#each list as t (t.code)}
|
||||||
<div class="num">
|
<div class="num">{money(t)}</div>
|
||||||
{formatMoney({ ...currency(t.code), amount: t.amount })}
|
|
||||||
</div>
|
|
||||||
{/each}
|
{/each}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
@@ -506,9 +510,7 @@
|
|||||||
>
|
>
|
||||||
{#each b.totals as t (t.code)}
|
{#each b.totals as t (t.code)}
|
||||||
<span class="whitespace-nowrap">
|
<span class="whitespace-nowrap">
|
||||||
<span class="num font-semibold text-[15px]"
|
<span class="num font-semibold text-[15px]">{money(t)}</span>
|
||||||
>{formatMoney({ ...currency(t.code), amount: t.amount })}</span
|
|
||||||
>
|
|
||||||
{#if t.ratio !== null}
|
{#if t.ratio !== null}
|
||||||
<span class={tone(t.ratio)}
|
<span class={tone(t.ratio)}
|
||||||
>{versusText(t.ratio)}{current ? ` by ${asOf}` : ""}</span
|
>{versusText(t.ratio)}{current ? ` by ${asOf}` : ""}</span
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
type ViewRange,
|
type ViewRange,
|
||||||
} from "#lib/dates.js";
|
} from "#lib/dates.js";
|
||||||
import { toNumber } from "#lib/format.js";
|
import { toNumber } from "#lib/format.js";
|
||||||
|
import { display } from "#lib/money/display.svelte.js";
|
||||||
import { period, periodLabel } from "#lib/period.svelte.js";
|
import { period, periodLabel } from "#lib/period.svelte.js";
|
||||||
|
|
||||||
const COUNT = 12;
|
const COUNT = 12;
|
||||||
@@ -50,6 +51,7 @@
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
interface Flow {
|
interface Flow {
|
||||||
|
approx?: boolean;
|
||||||
code: string;
|
code: string;
|
||||||
earned: number;
|
earned: number;
|
||||||
spent: number;
|
spent: number;
|
||||||
@@ -76,12 +78,34 @@
|
|||||||
}
|
}
|
||||||
return map;
|
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(
|
const scale = $derived(
|
||||||
Math.max(
|
Math.max(
|
||||||
1,
|
1,
|
||||||
...[...byStart.values()].flatMap((list) =>
|
...[...shown.values()].flatMap((list) =>
|
||||||
list
|
list
|
||||||
.filter((f) => f.code === code)
|
.filter((f) => f.code === main)
|
||||||
.map((f) => Math.max(f.earned, f.spent))
|
.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"
|
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)}
|
{#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
|
(f) => f.earned || f.spent
|
||||||
)}
|
)}
|
||||||
<li class="border-cladd-outline/60 border-r border-b">
|
<li class="border-cladd-outline/60 border-r border-b">
|
||||||
@@ -117,17 +141,20 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#each list as flow (flow.code)}
|
{#each list as flow (flow.code)}
|
||||||
{@const fmt = money(currency(flow.code))}
|
{@const fmt = money(currency(flow.code))}
|
||||||
|
{@const mark = flow.approx ? "≈" : ""}
|
||||||
<span
|
<span
|
||||||
class="num flex flex-wrap justify-between gap-x-2 text-[12px]"
|
class="num flex flex-wrap justify-between gap-x-2 text-[12px]"
|
||||||
>
|
>
|
||||||
{#if flow.earned}
|
{#if flow.earned}
|
||||||
<span class="text-income">+{fmt(flow.earned)}</span>
|
<span class="text-income">{mark}+{fmt(flow.earned)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if flow.spent}
|
{#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}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
{#if flow.code === code}
|
{#if flow.code === main}
|
||||||
<span aria-hidden="true" class="flex h-1 gap-0.5">
|
<span aria-hidden="true" class="flex h-1 gap-0.5">
|
||||||
<span class="flex flex-1 justify-end">
|
<span class="flex flex-1 justify-end">
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -351,7 +351,7 @@
|
|||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
<td class="whitespace-nowrap px-3 text-right">
|
<td class="whitespace-nowrap px-3 text-right">
|
||||||
<Amount amount={row.amount} {...cur} />
|
<Amount amount={row.amount} exact {...cur} />
|
||||||
</td>
|
</td>
|
||||||
<td class="hidden max-w-0 truncate px-3 text-cladd-fg-soft md:table-cell">
|
<td class="hidden max-w-0 truncate px-3 text-cladd-fg-soft md:table-cell">
|
||||||
{row.split.source_name}
|
{row.split.source_name}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
signOf,
|
signOf,
|
||||||
sums,
|
sums,
|
||||||
terms,
|
terms,
|
||||||
|
viewOf,
|
||||||
} from "#lib/accounts/statement.js";
|
} from "#lib/accounts/statement.js";
|
||||||
import { api } from "#lib/api/client.js";
|
import { api } from "#lib/api/client.js";
|
||||||
import { all } from "#lib/api/lists.js";
|
import { all } from "#lib/api/lists.js";
|
||||||
@@ -20,13 +21,7 @@
|
|||||||
import LineChart from "#lib/components/charts/LineChart.svelte";
|
import LineChart from "#lib/components/charts/LineChart.svelte";
|
||||||
import TransactionTable from "#lib/components/transactions/TransactionTable.svelte";
|
import TransactionTable from "#lib/components/transactions/TransactionTable.svelte";
|
||||||
import { addDays, addMonths, contains, today } from "#lib/dates.js";
|
import { addDays, addMonths, contains, today } from "#lib/dates.js";
|
||||||
import {
|
import { currentLocale, formatDate, toNumber } from "#lib/format.js";
|
||||||
compactMoney,
|
|
||||||
currentLocale,
|
|
||||||
formatDate,
|
|
||||||
formatMoney,
|
|
||||||
toNumber,
|
|
||||||
} from "#lib/format.js";
|
|
||||||
import { useKeys } from "#lib/keys.svelte.js";
|
import { useKeys } from "#lib/keys.svelte.js";
|
||||||
import { period } from "#lib/period.svelte.js";
|
import { period } from "#lib/period.svelte.js";
|
||||||
import { sectionOf } from "#lib/transactions/ledger.js";
|
import { sectionOf } from "#lib/transactions/ledger.js";
|
||||||
@@ -58,6 +53,7 @@
|
|||||||
decimals: a.currency_decimal_places,
|
decimals: a.currency_decimal_places,
|
||||||
symbol: a.currency_symbol,
|
symbol: a.currency_symbol,
|
||||||
});
|
});
|
||||||
|
const view = $derived(viewOf(cur));
|
||||||
const chart = $derived(chartRange(period.range, now));
|
const chart = $derived(chartRange(period.range, now));
|
||||||
const span = $derived({
|
const span = $derived({
|
||||||
end: [chart.end, period.end, now].sort().at(-1) ?? now,
|
end: [chart.end, period.end, now].sort().at(-1) ?? now,
|
||||||
@@ -106,7 +102,10 @@
|
|||||||
const months = $derived(monthsOf(chart, list));
|
const months = $derived(monthsOf(chart, list));
|
||||||
const line = $derived(
|
const line = $derived(
|
||||||
anchor
|
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(
|
const groups = $derived(
|
||||||
@@ -126,7 +125,7 @@
|
|||||||
const series = $derived([
|
const series = $derived([
|
||||||
{
|
{
|
||||||
area: true,
|
area: true,
|
||||||
format: (value: number) => formatMoney({ ...cur, amount: value }),
|
format: (value: number) => view.format(value),
|
||||||
key: "balance",
|
key: "balance",
|
||||||
label: lineTitle,
|
label: lineTitle,
|
||||||
points: line,
|
points: line,
|
||||||
@@ -217,7 +216,7 @@
|
|||||||
<div class="transition-opacity {ready ? "" : "opacity-60"}">
|
<div class="transition-opacity {ready ? "" : "opacity-60"}">
|
||||||
{#if line.length > 0}
|
{#if line.length > 0}
|
||||||
<LineChart
|
<LineChart
|
||||||
axisFormat={(value) => compactMoney(value, cur.symbol ?? "")}
|
axisFormat={view.axis}
|
||||||
height={236}
|
height={236}
|
||||||
label="{lineTitle} of {a.name} by day"
|
label="{lineTitle} of {a.name} by day"
|
||||||
marker={contains(chart, now) ? now : undefined}
|
marker={contains(chart, now) ? now : undefined}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import type { Side, Term } from "#lib/accounts/statement.js";
|
import type { Side, Term } from "#lib/accounts/statement.js";
|
||||||
import { addDays } from "#lib/dates.js";
|
import { addDays } from "#lib/dates.js";
|
||||||
import { formatDate, formatMoney, type Money } from "#lib/format.js";
|
import { formatDate, formatMoney, type Money } from "#lib/format.js";
|
||||||
|
import { display } from "#lib/money/display.svelte.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
terms,
|
terms,
|
||||||
@@ -71,7 +72,11 @@
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const money = (amount: number, signed = false) =>
|
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>
|
</script>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
@@ -82,7 +87,7 @@
|
|||||||
: ""}"
|
: ""}"
|
||||||
>
|
>
|
||||||
<div
|
<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)}
|
{#each items as item (item.key)}
|
||||||
<div
|
<div
|
||||||
@@ -104,6 +109,7 @@
|
|||||||
>
|
>
|
||||||
<span
|
<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}"
|
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
|
>{money(item.value)}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,7 +120,9 @@
|
|||||||
<span class="col-start-2 text-[13px] text-cladd-fg-soft lg:text-[12px]"
|
<span class="col-start-2 text-[13px] text-cladd-fg-soft lg:text-[12px]"
|
||||||
>Change</span
|
>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
|
>{money(change, true)}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
<script lang="ts">
|
<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 Columns from "#lib/components/charts/Columns.svelte";
|
||||||
import {
|
import {
|
||||||
type LegendItem,
|
type LegendItem,
|
||||||
@@ -8,12 +13,7 @@
|
|||||||
wash,
|
wash,
|
||||||
} from "#lib/components/charts/chart.js";
|
} from "#lib/components/charts/chart.js";
|
||||||
import Legend from "#lib/components/charts/Legend.svelte";
|
import Legend from "#lib/components/charts/Legend.svelte";
|
||||||
import {
|
import { currentLocale, type Money } from "#lib/format.js";
|
||||||
compactMoney,
|
|
||||||
currentLocale,
|
|
||||||
formatMoney,
|
|
||||||
type Money,
|
|
||||||
} from "#lib/format.js";
|
|
||||||
|
|
||||||
const NARROW = 560;
|
const NARROW = 560;
|
||||||
const COLOR = {
|
const COLOR = {
|
||||||
@@ -49,8 +49,8 @@
|
|||||||
currentLocale(),
|
currentLocale(),
|
||||||
style === "long" ? { month: "long", year: "numeric" } : { month: style }
|
style === "long" ? { month: "long", year: "numeric" } : { month: style }
|
||||||
);
|
);
|
||||||
const format = (value: number) =>
|
const view = $derived(viewOf(cur));
|
||||||
formatMoney({ ...cur, amount: value }, true);
|
const format = (value: number) => view.format(value, true);
|
||||||
|
|
||||||
const parts = $derived(
|
const parts = $derived(
|
||||||
months.map((m) => ({
|
months.map((m) => ({
|
||||||
@@ -67,7 +67,9 @@
|
|||||||
parts.map((p) => ({
|
parts.map((p) => ({
|
||||||
key: p.month,
|
key: p.month,
|
||||||
label: name(p.month, width < NARROW ? "narrow" : "short"),
|
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);
|
const read = $derived(hover ?? selected ?? rows.at(-1)?.key ?? null);
|
||||||
@@ -89,7 +91,7 @@
|
|||||||
<Legend class="min-w-0 flex-1" items={legend} />
|
<Legend class="min-w-0 flex-1" items={legend} />
|
||||||
</div>
|
</div>
|
||||||
<Columns
|
<Columns
|
||||||
axisFormat={(value) => compactMoney(value, cur.symbol ?? "")}
|
axisFormat={view.axis}
|
||||||
{format}
|
{format}
|
||||||
height={208}
|
height={208}
|
||||||
{keys}
|
{keys}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { invalidate } from "#lib/api/lists.js";
|
import { invalidate } from "#lib/api/lists.js";
|
||||||
import type { Range } from "#lib/dates.js";
|
import type { Range } from "#lib/dates.js";
|
||||||
import { formatMoney } from "#lib/format.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;
|
export type Move = 0 | 1 | -1;
|
||||||
|
|
||||||
@@ -149,11 +149,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{line.amount
|
{line.amount
|
||||||
? formatMoney({
|
? lineMoney(line, line.amount)
|
||||||
amount: line.amount,
|
|
||||||
decimals: line.cur.decimals,
|
|
||||||
symbol: line.cur.symbol,
|
|
||||||
})
|
|
||||||
: `${line.cur.symbol} set amount`}
|
: `${line.cur.symbol} set amount`}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -13,7 +13,12 @@
|
|||||||
import { Button } from "#lib/components/ui/button/index.js";
|
import { Button } from "#lib/components/ui/button/index.js";
|
||||||
import { DimensionModel } from "#lib/dimension/model.svelte.js";
|
import { DimensionModel } from "#lib/dimension/model.svelte.js";
|
||||||
import { budgetSpec } from "#lib/dimension/spec.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 { useKeys } from "#lib/keys.svelte.js";
|
||||||
import { period } from "#lib/period.svelte.js";
|
import { period } from "#lib/period.svelte.js";
|
||||||
import { longMonth } from "#lib/reports/board.js";
|
import { longMonth } from "#lib/reports/board.js";
|
||||||
@@ -35,16 +40,17 @@
|
|||||||
const name = $derived(budget.data?.data.attributes.name ?? "");
|
const name = $derived(budget.data?.data.attributes.name ?? "");
|
||||||
const list = $derived(limits.data ?? []);
|
const list = $derived(limits.data ?? []);
|
||||||
const limit = $derived(list.find((l) => l.id === limitId));
|
const limit = $derived(list.find((l) => l.id === limitId));
|
||||||
|
const shown = $derived(limitsIn(list, model.into));
|
||||||
const facts = $derived(
|
const facts = $derived(
|
||||||
model.range
|
model.range
|
||||||
? limitFacts(list, model.main, model.range, model.now).sort(
|
? limitFacts(shown, model.main, model.range, model.now).sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
Number(b.code === limit?.attributes.currency_code) -
|
Number(b.code === limit?.attributes.currency_code) -
|
||||||
Number(a.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(() => {
|
$effect(() => {
|
||||||
if (limit) {
|
if (limit) {
|
||||||
@@ -136,7 +142,7 @@
|
|||||||
{header}
|
{header}
|
||||||
{model}
|
{model}
|
||||||
noun="purchases"
|
noun="purchases"
|
||||||
shape={(lanes) => withLimits(lanes, list)}
|
shape={(lanes) => withLimits(lanes, shown)}
|
||||||
>
|
>
|
||||||
{#snippet extra()}
|
{#snippet extra()}
|
||||||
{#if !limits.isPending}
|
{#if !limits.isPending}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Box from "#lib/components/Box.svelte";
|
import Box from "#lib/components/Box.svelte";
|
||||||
import { formatMoney, percent } from "#lib/format.js";
|
import { percent } from "#lib/format.js";
|
||||||
import type { Top } from "./data.js";
|
import { mergeTops, type Top, topMoney } from "./data.js";
|
||||||
|
|
||||||
const CENT = 0.005;
|
const CENT = 0.005;
|
||||||
|
|
||||||
let { tops, onsort }: { tops: Top[]; onsort: () => void } = $props();
|
let { tops, onsort }: { tops: Top[]; onsort: () => void } = $props();
|
||||||
|
|
||||||
|
const shown = $derived(mergeTops(tops));
|
||||||
const free = $derived(
|
const free = $derived(
|
||||||
tops.some(
|
shown.some(
|
||||||
(top) =>
|
(top) =>
|
||||||
top.available !== null && Math.abs(top.available - top.budgeted) > CENT
|
top.available !== null && Math.abs(top.available - top.budgeted) > CENT
|
||||||
)
|
)
|
||||||
@@ -23,9 +24,6 @@
|
|||||||
"Without a budget",
|
"Without a budget",
|
||||||
].filter(Boolean) as string[]
|
].filter(Boolean) as string[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const money = (top: Top, amount: number) =>
|
|
||||||
formatMoney({ amount, decimals: 0, symbol: top.cur.symbol });
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Box body="px-3 py-3">
|
<Box body="px-3 py-3">
|
||||||
@@ -37,36 +35,39 @@
|
|||||||
{#each columns as label (label)}
|
{#each columns as label (label)}
|
||||||
<span class="text-[12px] text-cladd-fg-soft max-sm:hidden">{label}</span>
|
<span class="text-[12px] text-cladd-fg-soft max-sm:hidden">{label}</span>
|
||||||
{/each}
|
{/each}
|
||||||
{#each tops as top, i (top.cur.code)}
|
{#each shown as top, i (top.cur.code)}
|
||||||
<span
|
<span
|
||||||
class="col-span-2 self-center font-semibold text-[13px] text-cladd-fg-soft sm:col-span-1 {i >
|
class="col-span-2 self-center font-semibold text-[13px] text-cladd-fg-soft sm:col-span-1 {i >
|
||||||
0
|
0
|
||||||
? "max-sm:mt-1 max-sm:border-cladd-outline max-sm:border-t max-sm:pt-3"
|
? "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}
|
{#if free}
|
||||||
{@render stat(
|
{@render stat(
|
||||||
"Available",
|
"Available",
|
||||||
top.available === null ? null : money(top, top.available)
|
top.available === null ? null : topMoney(top, top.available)
|
||||||
)}
|
)}
|
||||||
{/if}
|
{/if}
|
||||||
{@render stat("Budgeted", top.budgeted ? money(top, top.budgeted) : null)}
|
{@render stat(
|
||||||
|
"Budgeted",
|
||||||
|
top.budgeted ? topMoney(top, top.budgeted) : null
|
||||||
|
)}
|
||||||
{#if free}
|
{#if free}
|
||||||
{@const unallocated = (top.available ?? 0) - top.budgeted}
|
{@const unallocated = (top.available ?? 0) - top.budgeted}
|
||||||
{@render stat(
|
{@render stat(
|
||||||
"Left to budget",
|
"Left to budget",
|
||||||
top.available === null ? null : money(top, unallocated),
|
top.available === null ? null : topMoney(top, unallocated),
|
||||||
unallocated < 0 ? "text-expense" : ""
|
unallocated < 0 ? "text-expense" : ""
|
||||||
)}
|
)}
|
||||||
{/if}
|
{/if}
|
||||||
{@const left = top.budgeted - top.inBudgets}
|
{@const left = top.budgeted - top.inBudgets}
|
||||||
{@render stat(
|
{@render stat(
|
||||||
"Left in budgets",
|
"Left in budgets",
|
||||||
top.budgeted ? money(top, left) : null,
|
top.budgeted ? topMoney(top, left) : null,
|
||||||
left < 0 ? "text-expense" : ""
|
left < 0 ? "text-expense" : ""
|
||||||
)}
|
)}
|
||||||
{@render stat("Spent", money(top, top.spent))}
|
{@render stat("Spent", topMoney(top, top.spent))}
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<span class="text-[12px] text-cladd-fg-soft sm:hidden"
|
<span class="text-[12px] text-cladd-fg-soft sm:hidden"
|
||||||
>Without a budget</span
|
>Without a budget</span
|
||||||
@@ -80,7 +81,7 @@
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="num font-semibold text-[20px] text-brand tracking-[-0.02em] sm:text-[22px]"
|
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}
|
{#if top.spent > 0}
|
||||||
<span class="num ml-1 text-[13px] text-cladd-fg-soft"
|
<span class="num ml-1 text-[13px] text-cladd-fg-soft"
|
||||||
|
|||||||
@@ -508,6 +508,7 @@
|
|||||||
amount={split.type === "withdrawal"
|
amount={split.type === "withdrawal"
|
||||||
? -Number(split.amount)
|
? -Number(split.amount)
|
||||||
: split.amount}
|
: split.amount}
|
||||||
|
code={split.currency_code}
|
||||||
decimals={split.currency_decimal_places}
|
decimals={split.currency_decimal_places}
|
||||||
symbol={split.currency_symbol}
|
symbol={split.currency_symbol}
|
||||||
tone={done ? "neutral" : "auto"}
|
tone={done ? "neutral" : "auto"}
|
||||||
|
|||||||
@@ -28,10 +28,12 @@ import {
|
|||||||
today,
|
today,
|
||||||
type ViewRange,
|
type ViewRange,
|
||||||
} from "#lib/dates.js";
|
} 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 {
|
export interface Line {
|
||||||
amount: number | null;
|
amount: number | null;
|
||||||
|
approx?: boolean;
|
||||||
cur: Cur & { id: string };
|
cur: Cur & { id: string };
|
||||||
key: string;
|
key: string;
|
||||||
limit?: Item<BudgetLimit>;
|
limit?: Item<BudgetLimit>;
|
||||||
@@ -53,6 +55,7 @@ export interface Pool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Top {
|
export interface Top {
|
||||||
|
approx?: boolean;
|
||||||
available: number | null;
|
available: number | null;
|
||||||
budgeted: number;
|
budgeted: number;
|
||||||
cur: Cur;
|
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 {
|
export function pace(line: Line, now = today()): Pace | null {
|
||||||
if (!(line.amount && contains(line.range, now))) {
|
if (!(line.amount && contains(line.range, now))) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Amount, { type Tone } from "#lib/components/Amount.svelte";
|
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";
|
import type { Sum } from "./sums.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -8,6 +10,8 @@
|
|||||||
empty = "",
|
empty = "",
|
||||||
class: className = "",
|
class: className = "",
|
||||||
}: { sums: Sum[]; tone?: Tone; empty?: string; class?: string } = $props();
|
}: { sums: Sum[]; tone?: Tone; empty?: string; class?: string } = $props();
|
||||||
|
|
||||||
|
const merged = $derived(display.total(sums) !== null);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if sums.length === 0}
|
{#if sums.length === 0}
|
||||||
@@ -16,14 +20,18 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<span class="inline-flex flex-wrap justify-end gap-x-2.5 {className}">
|
<span class="inline-flex flex-wrap justify-end gap-x-2.5 {className}">
|
||||||
{#each sums as sum (sum.code)}
|
{#if merged}
|
||||||
<Amount
|
<Money parts={sums} {tone} />
|
||||||
amount={sum.amount}
|
{:else}
|
||||||
code={sum.code}
|
{#each sums as sum (sum.code)}
|
||||||
decimals={sum.decimals}
|
<Amount
|
||||||
symbol={sum.symbol}
|
amount={sum.amount}
|
||||||
{tone}
|
code={sum.code}
|
||||||
/>
|
decimals={sum.decimals}
|
||||||
{/each}
|
symbol={sum.symbol}
|
||||||
|
{tone}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -17,13 +17,14 @@
|
|||||||
import Box from "#lib/components/Box.svelte";
|
import Box from "#lib/components/Box.svelte";
|
||||||
import Sparkline from "#lib/components/charts/Sparkline.svelte";
|
import Sparkline from "#lib/components/charts/Sparkline.svelte";
|
||||||
import { type Row, useColumn } from "#lib/dashboard/column.svelte.js";
|
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 { 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 { period } from "#lib/period.svelte.js";
|
||||||
import { session } from "#lib/session.svelte.js";
|
import { session } from "#lib/session.svelte.js";
|
||||||
import {
|
import {
|
||||||
@@ -37,7 +38,6 @@
|
|||||||
accountsChartQuery,
|
accountsChartQuery,
|
||||||
accountsQuery,
|
accountsQuery,
|
||||||
byCurrency,
|
byCurrency,
|
||||||
type Cur,
|
|
||||||
seriesPoints,
|
seriesPoints,
|
||||||
} from "./data.js";
|
} from "./data.js";
|
||||||
|
|
||||||
@@ -55,7 +55,6 @@
|
|||||||
const chart = createQuery(() =>
|
const chart = createQuery(() =>
|
||||||
accountsChartQuery(addDays(now, -TREND_DAYS), now)
|
accountsChartQuery(addDays(now, -TREND_DAYS), now)
|
||||||
);
|
);
|
||||||
const rates = createQuery(() => ratesQuery());
|
|
||||||
const recent = createQueries(() => ({
|
const recent = createQueries(() => ({
|
||||||
queries: expanded.map((id) => ({
|
queries: expanded.map((id) => ({
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@@ -97,45 +96,63 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const groups = $derived(
|
const moneyOf = (a: Account, amount: Money["amount"]): Money => ({
|
||||||
byCurrency(shown, (a) => a.attributes.currency_code ?? "").map(
|
amount,
|
||||||
([code, accounts]) => {
|
code: a.currency_code,
|
||||||
const first = accounts[0].attributes;
|
decimals: a.currency_decimal_places ?? 2,
|
||||||
return {
|
symbol: a.currency_symbol ?? a.currency_code,
|
||||||
accounts,
|
|
||||||
balance: accounts.reduce(
|
|
||||||
(s, a) => s + toNumber(a.attributes.current_balance),
|
|
||||||
0
|
|
||||||
),
|
|
||||||
change: accounts.reduce(
|
|
||||||
(s, a) => s + toNumber(a.attributes.balance_difference),
|
|
||||||
0
|
|
||||||
),
|
|
||||||
code,
|
|
||||||
cur: {
|
|
||||||
code,
|
|
||||||
decimals: first.currency_decimal_places ?? 2,
|
|
||||||
symbol: first.currency_symbol ?? code,
|
|
||||||
},
|
|
||||||
trend: sumSeries(
|
|
||||||
accounts.map((a) => trends.get(a.attributes.name) ?? [])
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const converted = $derived({
|
|
||||||
balance: inPrimaryTotal(
|
|
||||||
groups.map((g) => ({ amount: g.balance, code: g.code })),
|
|
||||||
rates.data
|
|
||||||
),
|
|
||||||
change: inPrimaryTotal(
|
|
||||||
groups.map((g) => ({ amount: g.change, code: g.code })),
|
|
||||||
rates.data
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const 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(
|
||||||
|
total
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
accounts: shown.toSorted((a, b) => converted(b) - converted(a)),
|
||||||
|
balance: null,
|
||||||
|
change: null,
|
||||||
|
code: total.code,
|
||||||
|
trend: [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: byCurrency(shown, (a) => a.attributes.currency_code ?? "").map(
|
||||||
|
([code, accounts]) => ({
|
||||||
|
accounts,
|
||||||
|
balance: moneyOf(
|
||||||
|
accounts[0].attributes,
|
||||||
|
sumOf(accounts, (a) => a.current_balance)
|
||||||
|
),
|
||||||
|
change: moneyOf(
|
||||||
|
accounts[0].attributes,
|
||||||
|
sumOf(accounts, (a) => a.balance_difference)
|
||||||
|
),
|
||||||
|
code,
|
||||||
|
trend: sumSeries(
|
||||||
|
accounts.map((a) => trends.get(a.attributes.name) ?? [])
|
||||||
|
),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const recentRows = $derived(
|
const recentRows = $derived(
|
||||||
new Map(
|
new Map(
|
||||||
expanded.map((id, i) => [
|
expanded.map((id, i) => [
|
||||||
@@ -221,26 +238,31 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet balance(
|
{#snippet balance(
|
||||||
amount: number,
|
money: Money
|
||||||
cur: Cur
|
|
||||||
)}
|
)}
|
||||||
<span class="num font-medium {amount < 0 ? "text-expense" : "text-cladd-fg"}"
|
<span
|
||||||
>{formatMoney({ amount, ...cur })}</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}
|
{/snippet}
|
||||||
|
|
||||||
<Box body="p-0" href="/accounts/asset" title="Accounts">
|
<Box body="p-0" href="/accounts/asset" title="Accounts">
|
||||||
{#snippet tools()}
|
{#snippet tools()}
|
||||||
{#if converted.balance !== null}
|
{#if total}
|
||||||
<span class="num font-semibold text-[13px] text-cladd-fg"
|
<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
|
<span
|
||||||
class="num text-[12px] {converted.change < 0
|
class="num text-[12px] {change.amount < 0
|
||||||
? "text-expense"
|
? "text-expense"
|
||||||
: "text-income"}"
|
: "text-income"}"
|
||||||
>{formatPrimary(converted.change, true)}</span
|
>{display.text(changes, true)}</span
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -271,25 +293,29 @@
|
|||||||
</thead>
|
</thead>
|
||||||
{#each groups as group (group.code)}
|
{#each groups as group (group.code)}
|
||||||
<tbody class="border-cladd-outline border-t">
|
<tbody class="border-cladd-outline border-t">
|
||||||
<tr class="h-7 bg-cladd-surface-next/60 font-semibold">
|
{#if group.balance && group.change}
|
||||||
<th
|
<tr class="h-7 bg-cladd-surface-next/60 font-semibold">
|
||||||
class="pl-3 text-left text-[12px] text-cladd-fg"
|
<th
|
||||||
scope="rowgroup"
|
class="pl-3 text-left text-[12px] text-cladd-fg"
|
||||||
>
|
scope="rowgroup"
|
||||||
{group.code}
|
>
|
||||||
</th>
|
{group.code}
|
||||||
<td class="whitespace-nowrap px-2 text-right">
|
</th>
|
||||||
{@render balance(group.balance, group.cur)}
|
<td class="whitespace-nowrap px-2 text-right">
|
||||||
</td>
|
{@render balance(group.balance)}
|
||||||
<td class="whitespace-nowrap px-2 text-right">
|
</td>
|
||||||
{#if group.change !== 0}
|
<td class="whitespace-nowrap px-2 text-right">
|
||||||
<Amount amount={group.change} signed {...group.cur} />
|
{#if toNumber(group.change.amount) !== 0}
|
||||||
{/if}
|
<Amount {...group.change} signed />
|
||||||
</td>
|
{/if}
|
||||||
<td class="hidden pr-3 sm:table-cell">
|
</td>
|
||||||
<div class="flex justify-end">{@render spark(group.trend)}</div>
|
<td class="hidden pr-3 sm:table-cell">
|
||||||
</td>
|
<div class="flex justify-end">
|
||||||
</tr>
|
{@render spark(group.trend)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
{#each group.accounts as account (account.id)}
|
{#each group.accounts as account (account.id)}
|
||||||
{@const a = account.attributes}
|
{@const a = account.attributes}
|
||||||
{@const key = accountKey(account.id)}
|
{@const key = accountKey(account.id)}
|
||||||
@@ -325,11 +351,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="whitespace-nowrap px-2 text-right">
|
<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>
|
||||||
<td class="whitespace-nowrap px-2 text-right">
|
<td class="whitespace-nowrap px-2 text-right">
|
||||||
{#if toNumber(a.balance_difference) !== 0}
|
{#if toNumber(a.balance_difference) !== 0}
|
||||||
<Amount amount={a.balance_difference} signed {...group.cur} />
|
<Amount {...moneyOf(a, a.balance_difference)} signed />
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class="hidden pr-3 sm:table-cell">
|
<td class="hidden pr-3 sm:table-cell">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import type { LinePoint, LineSeries } from "#lib/components/charts/chart.js";
|
import type { LinePoint, LineSeries } from "#lib/components/charts/chart.js";
|
||||||
import LineChart from "#lib/components/charts/LineChart.svelte";
|
import LineChart from "#lib/components/charts/LineChart.svelte";
|
||||||
import { contains, today } from "#lib/dates.js";
|
import { contains, today } from "#lib/dates.js";
|
||||||
|
import { display } from "#lib/money/display.svelte.js";
|
||||||
import { period } from "#lib/period.svelte.js";
|
import { period } from "#lib/period.svelte.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
type Cur,
|
type Cur,
|
||||||
hasPrimary,
|
hasPrimary,
|
||||||
hrefWith,
|
hrefWith,
|
||||||
|
mergeable,
|
||||||
money,
|
money,
|
||||||
seriesPoints,
|
seriesPoints,
|
||||||
setCurrency,
|
setCurrency,
|
||||||
@@ -57,52 +59,67 @@
|
|||||||
|
|
||||||
interface Panel {
|
interface Panel {
|
||||||
cur: Cur;
|
cur: Cur;
|
||||||
|
label: string;
|
||||||
series: LineSeries[];
|
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[] {
|
function sum(group: ChartSet[]): LinePoint[] {
|
||||||
const totals = new Map<string, number>();
|
const totals = new Map<string, number>();
|
||||||
for (const set of group) {
|
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);
|
totals.set(p.x, (totals.get(p.x) ?? 0) + p.y);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [...totals.entries()].map(([x, y]) => ({ x, y }));
|
return [...totals.entries()].map(([x, y]) => ({ x, y }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const panels = $derived.by<Panel[]>(() => {
|
function panel(cur: Cur, label: string, group: ChartSet[]): Panel {
|
||||||
const curOf = (s: ChartSet) => (converted ? setPrimary(s) : setCurrency(s));
|
const format = money(cur);
|
||||||
return byCurrency(sets, (s) => curOf(s).code).map(([, group]) => {
|
if (mode === "net-worth") {
|
||||||
const cur = curOf(group[0]);
|
const included = group.filter((s) => !excluded.has(s.label));
|
||||||
const format = money(cur);
|
|
||||||
if (mode === "net-worth") {
|
|
||||||
const included = group.filter((s) => !excluded.has(s.label));
|
|
||||||
return {
|
|
||||||
cur,
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
area: true,
|
|
||||||
format,
|
|
||||||
key: "net-worth",
|
|
||||||
label: `Net worth (${cur.code})`,
|
|
||||||
points: sum(included),
|
|
||||||
strong: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
cur,
|
cur,
|
||||||
series: group.map((s) => ({
|
label,
|
||||||
area: group.length === 1,
|
series: [
|
||||||
format,
|
{
|
||||||
key: s.label,
|
area: true,
|
||||||
label: s.label,
|
format,
|
||||||
points: seriesPoints(s, converted),
|
key: "net-worth",
|
||||||
})),
|
label: `Net worth (${label})`,
|
||||||
|
points: sum(included),
|
||||||
|
strong: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
});
|
}
|
||||||
});
|
return {
|
||||||
|
cur,
|
||||||
|
label,
|
||||||
|
series: group.map((s) => ({
|
||||||
|
area: group.length === 1,
|
||||||
|
format,
|
||||||
|
key: s.label,
|
||||||
|
label: s.label,
|
||||||
|
points: points(s),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const panels = $derived<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(
|
const panelHeight = $derived(
|
||||||
Math.max(MIN_PANEL, Math.floor(HEIGHT / Math.max(1, panels.length)))
|
Math.max(MIN_PANEL, Math.floor(HEIGHT / Math.max(1, panels.length)))
|
||||||
);
|
);
|
||||||
@@ -161,10 +178,10 @@
|
|||||||
{:else}
|
{:else}
|
||||||
{#each panels as panel (panel.cur.code)}
|
{#each panels as panel (panel.cur.code)}
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
{#if panels.length > 1}
|
{#if panels.length > 1 || merged}
|
||||||
<span
|
<span
|
||||||
class="font-semibold text-[11px] text-cladd-fg-soft tracking-wide"
|
class="font-semibold text-[11px] text-cladd-fg-soft tracking-wide"
|
||||||
>{panel.cur.code}</span
|
>{panel.label}</span
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
<LineChart
|
<LineChart
|
||||||
@@ -187,7 +204,7 @@
|
|||||||
href="/accounts/asset"
|
href="/accounts/asset"
|
||||||
>View your asset accounts</a
|
>View your asset accounts</a
|
||||||
>
|
>
|
||||||
{#if converted}
|
{#if converted && !merged}
|
||||||
<span class="ml-auto text-cladd-fg-soft"
|
<span class="ml-auto text-cladd-fg-soft"
|
||||||
>Converted to your primary currency</span
|
>Converted to your primary currency</span
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -11,10 +11,13 @@
|
|||||||
} from "#lib/components/charts/chart.js";
|
} from "#lib/components/charts/chart.js";
|
||||||
import StackedBars from "#lib/components/charts/StackedBars.svelte";
|
import StackedBars from "#lib/components/charts/StackedBars.svelte";
|
||||||
import { toNumber } from "#lib/format.js";
|
import { toNumber } from "#lib/format.js";
|
||||||
|
import { display } from "#lib/money/display.svelte.js";
|
||||||
import { period } from "#lib/period.svelte.js";
|
import { period } from "#lib/period.svelte.js";
|
||||||
import {
|
import {
|
||||||
byCurrency,
|
byCurrency,
|
||||||
type ChartSet,
|
type ChartSet,
|
||||||
|
type Cur,
|
||||||
|
mergeable,
|
||||||
money,
|
money,
|
||||||
setCurrency,
|
setCurrency,
|
||||||
span,
|
span,
|
||||||
@@ -44,36 +47,50 @@
|
|||||||
new Map((budgets.data ?? []).map((b) => [b.attributes.name, b.id]))
|
new Map((budgets.data ?? []).map((b) => [b.attributes.name, b.id]))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const sets = $derived(chart.data ?? []);
|
||||||
|
const merged = $derived(mergeable(sets.map((s) => s.currency_code)));
|
||||||
|
|
||||||
|
function group(code: string, label: string, cur: Cur, list: ChartSet[]) {
|
||||||
|
const format = money(cur);
|
||||||
|
const totals = new Map<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: name,
|
||||||
|
label: name,
|
||||||
|
note:
|
||||||
|
budgeted > 0
|
||||||
|
? `${format(spent)} of ${format(budgeted)}`
|
||||||
|
: format(spent),
|
||||||
|
values: {
|
||||||
|
left: budgeted > 0 ? Math.max(0, budgeted - spent) : 0,
|
||||||
|
over: budgeted > 0 ? Math.max(0, spent - budgeted) : 0,
|
||||||
|
spent: budgeted > 0 ? Math.min(spent, budgeted) : spent,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((r) => r.values.spent + r.values.left + r.values.over > 0);
|
||||||
|
const limited = [...totals.values()].some((t) => t.budgeted > 0);
|
||||||
|
const keys = limited ? KEYS : [{ ...KEYS[0], label: "Spent" }];
|
||||||
|
return { code, keys, label, limited, rows };
|
||||||
|
}
|
||||||
|
|
||||||
const groups = $derived(
|
const groups = $derived(
|
||||||
byCurrency(chart.data ?? [], (s) => s.currency_code).map(([code, sets]) => {
|
merged
|
||||||
const format = money(setCurrency(sets[0]));
|
? [group(display.code, `≈${display.code}`, display.target, sets)]
|
||||||
const rows: StackRow[] = sets
|
: byCurrency(sets, (s) => s.currency_code).map(([code, list]) =>
|
||||||
.map((s) => {
|
group(code, code, setCurrency(list[0]), list)
|
||||||
const budgeted = toNumber(s.entries.budgeted);
|
)
|
||||||
const spent = Math.abs(toNumber(s.entries.spent));
|
|
||||||
const inside = budgeted > 0 ? Math.min(spent, budgeted) : spent;
|
|
||||||
const id = ids.get(s.label);
|
|
||||||
return {
|
|
||||||
format,
|
|
||||||
href: id ? `/budgets/show/${id}` : undefined,
|
|
||||||
key: s.label,
|
|
||||||
label: s.label,
|
|
||||||
note:
|
|
||||||
budgeted > 0
|
|
||||||
? `${format(spent)} of ${format(budgeted)}`
|
|
||||||
: format(spent),
|
|
||||||
values: {
|
|
||||||
left: Math.max(0, toNumber(s.entries.left)),
|
|
||||||
over: Math.max(0, Math.abs(toNumber(s.entries.overspent))),
|
|
||||||
spent: inside,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((r) => r.values.spent + r.values.left + r.values.over > 0);
|
|
||||||
const limited = sets.some((x) => toNumber(x.entries.budgeted) > 0);
|
|
||||||
const keys = limited ? KEYS : [{ ...KEYS[0], label: "Spent" }];
|
|
||||||
return { code, keys, limited, rows };
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
const visible = $derived(groups.filter((g) => g.rows.length > 0));
|
const visible = $derived(groups.filter((g) => g.rows.length > 0));
|
||||||
</script>
|
</script>
|
||||||
@@ -101,9 +118,9 @@
|
|||||||
{:else}
|
{:else}
|
||||||
{#each visible as group (group.code)}
|
{#each visible as group (group.code)}
|
||||||
<div class="flex flex-col gap-1">
|
<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="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}
|
{#if !group.limited}
|
||||||
· spent, no budget amount set in {group.code}
|
· spent, no budget amount set in {group.code}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -6,12 +6,15 @@
|
|||||||
import BarChart from "#lib/components/charts/BarChart.svelte";
|
import BarChart from "#lib/components/charts/BarChart.svelte";
|
||||||
import { type Bar, tones } from "#lib/components/charts/chart.js";
|
import { type Bar, tones } from "#lib/components/charts/chart.js";
|
||||||
import { toNumber } from "#lib/format.js";
|
import { toNumber } from "#lib/format.js";
|
||||||
|
import { display } from "#lib/money/display.svelte.js";
|
||||||
import { period } from "#lib/period.svelte.js";
|
import { period } from "#lib/period.svelte.js";
|
||||||
import {
|
import {
|
||||||
axis,
|
axis,
|
||||||
byCurrency,
|
byCurrency,
|
||||||
type ChartSet,
|
type ChartSet,
|
||||||
|
type Cur,
|
||||||
hasPrimary,
|
hasPrimary,
|
||||||
|
mergeable,
|
||||||
money,
|
money,
|
||||||
proportional,
|
proportional,
|
||||||
setCurrency,
|
setCurrency,
|
||||||
@@ -34,31 +37,43 @@
|
|||||||
const ids = $derived(
|
const ids = $derived(
|
||||||
new Map((categories.data ?? []).map((c) => [c.attributes.name, c.id]))
|
new Map((categories.data ?? []).map((c) => [c.attributes.name, c.id]))
|
||||||
);
|
);
|
||||||
const converted = $derived(hasPrimary(chart.data ?? []));
|
const sets = $derived(chart.data ?? []);
|
||||||
|
const converted = $derived(hasPrimary(sets));
|
||||||
|
const curOf = (s: ChartSet) => (converted ? setPrimary(s) : setCurrency(s));
|
||||||
|
const merged = $derived(mergeable(sets.map((s) => curOf(s).code)));
|
||||||
|
|
||||||
|
function group(code: string, label: string, cur: Cur, list: ChartSet[]) {
|
||||||
|
const totals = new Map<string, number>();
|
||||||
|
for (const s of list) {
|
||||||
|
const entries = (converted ? s.pc_entries : s.entries) as Record<
|
||||||
|
string,
|
||||||
|
string
|
||||||
|
>;
|
||||||
|
const rate = merged ? (display.rate(curOf(s).code) ?? 1) : 1;
|
||||||
|
const spent = Math.abs(toNumber(entries?.spent)) * rate;
|
||||||
|
totals.set(s.label, (totals.get(s.label) ?? 0) + spent);
|
||||||
|
}
|
||||||
|
const bars: Bar[] = [...totals]
|
||||||
|
.map(([name, value]) => {
|
||||||
|
const id = ids.get(name);
|
||||||
|
return {
|
||||||
|
href: id ? `/categories/show/${id}` : NO_CATEGORY,
|
||||||
|
key: name,
|
||||||
|
label: name,
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((b) => b.value > 0)
|
||||||
|
.sort((a, b) => b.value - a.value);
|
||||||
|
return { bars, code, cur, label };
|
||||||
|
}
|
||||||
|
|
||||||
const groups = $derived(
|
const groups = $derived(
|
||||||
byCurrency(chart.data ?? [], (s) =>
|
merged
|
||||||
converted ? setPrimary(s).code : s.currency_code
|
? [group(display.code, `≈${display.code}`, display.target, sets)]
|
||||||
).map(([code, sets]) => {
|
: byCurrency(sets, (s) => curOf(s).code).map(([code, list]) =>
|
||||||
const cur = converted ? setPrimary(sets[0]) : setCurrency(sets[0]);
|
group(code, code, curOf(list[0]), list)
|
||||||
const bars: Bar[] = sets
|
)
|
||||||
.map((s) => {
|
|
||||||
const entries = (converted ? s.pc_entries : s.entries) as Record<
|
|
||||||
string,
|
|
||||||
string
|
|
||||||
>;
|
|
||||||
const id = ids.get(s.label);
|
|
||||||
return {
|
|
||||||
href: id ? `/categories/show/${id}` : NO_CATEGORY,
|
|
||||||
key: s.label,
|
|
||||||
label: s.label,
|
|
||||||
value: Math.abs(toNumber(entries?.spent)),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((b) => b.value > 0)
|
|
||||||
.sort((a, b) => b.value - a.value);
|
|
||||||
return { bars, code, cur };
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
const visible = $derived(groups.filter((g) => g.bars.length > 0));
|
const visible = $derived(groups.filter((g) => g.bars.length > 0));
|
||||||
</script>
|
</script>
|
||||||
@@ -81,10 +96,10 @@
|
|||||||
{:else}
|
{:else}
|
||||||
{#each visible as group (group.code)}
|
{#each visible as group (group.code)}
|
||||||
<div class="flex min-w-0 flex-col gap-1">
|
<div class="flex min-w-0 flex-col gap-1">
|
||||||
{#if visible.length > 1}
|
{#if visible.length > 1 || merged}
|
||||||
<span
|
<span
|
||||||
class="font-semibold text-[11px] text-cladd-fg-soft tracking-wide"
|
class="font-semibold text-[11px] text-cladd-fg-soft tracking-wide"
|
||||||
>Spent in {group.code}</span
|
>Spent in {group.label}</span
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
<BarChart
|
<BarChart
|
||||||
|
|||||||
@@ -4,15 +4,11 @@
|
|||||||
import type { Bill } from "#lib/api/types.js";
|
import type { Bill } from "#lib/api/types.js";
|
||||||
import Box from "#lib/components/Box.svelte";
|
import Box from "#lib/components/Box.svelte";
|
||||||
import { useColumn } from "#lib/dashboard/column.svelte.js";
|
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 { 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 { goto } from "$app/navigation";
|
||||||
import { byCurrency, type Cur } from "./data.js";
|
import { byCurrency, type Cur, rough } from "./data.js";
|
||||||
|
|
||||||
const DAYS = 7;
|
const DAYS = 7;
|
||||||
const HREF = `/subscriptions?horizon=${DAYS}`;
|
const HREF = `/subscriptions?horizon=${DAYS}`;
|
||||||
@@ -41,7 +37,6 @@
|
|||||||
queryKey: ["bills-due", now, end],
|
queryKey: ["bills-due", now, end],
|
||||||
}));
|
}));
|
||||||
const piggies = createQuery(() => lists.piggyBanks());
|
const piggies = createQuery(() => lists.piggyBanks());
|
||||||
const rates = createQuery(() => ratesQuery());
|
|
||||||
|
|
||||||
const items = $derived.by<Due[]>(() => {
|
const items = $derived.by<Due[]>(() => {
|
||||||
const due = (bills.data ?? [])
|
const due = (bills.data ?? [])
|
||||||
@@ -99,19 +94,13 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const totals = $derived(
|
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),
|
amount: list.reduce((s, i) => s + i.amount, 0),
|
||||||
approx: list.some((i) => i.approx),
|
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) {
|
function dayLabel(date: string) {
|
||||||
if (date === now) {
|
if (date === now) {
|
||||||
return "Today";
|
return "Today";
|
||||||
@@ -143,12 +132,17 @@
|
|||||||
<div
|
<div
|
||||||
class="num flex flex-wrap gap-x-4 gap-y-1 px-3 py-2 font-semibold text-[15px] text-cladd-fg"
|
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}
|
{#if display.total(totals)}
|
||||||
{#each totals as t (t.code)}
|
<span
|
||||||
<span>{money(t.amount, t.cur, t.approx)}</span>
|
>{rough(
|
||||||
{/each}
|
totals,
|
||||||
|
totals.some((t) => t.approx)
|
||||||
|
)}</span
|
||||||
|
>
|
||||||
{:else}
|
{:else}
|
||||||
<span>{formatPrimary(converted)}</span>
|
{#each totals as t (t.code)}
|
||||||
|
<span>{rough([t], t.approx)}</span>
|
||||||
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<table class="w-full border-collapse text-[13px]">
|
<table class="w-full border-collapse text-[13px]">
|
||||||
@@ -177,7 +171,7 @@
|
|||||||
<td
|
<td
|
||||||
class="num whitespace-nowrap pr-3 pl-2 text-right text-cladd-fg"
|
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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
import { createQuery } from "@tanstack/svelte-query";
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
import { api } from "#lib/api/client.js";
|
import { api } from "#lib/api/client.js";
|
||||||
import type { Page, TransactionGroup } from "#lib/api/types.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 Box from "#lib/components/Box.svelte";
|
||||||
|
import Money from "#lib/components/Money.svelte";
|
||||||
import { useColumn } from "#lib/dashboard/column.svelte.js";
|
import { useColumn } from "#lib/dashboard/column.svelte.js";
|
||||||
import { reviewed } from "#lib/dashboard/reviewed.svelte.js";
|
import { reviewed } from "#lib/dashboard/reviewed.svelte.js";
|
||||||
import { addDays, iso } from "#lib/dates.js";
|
import { addDays, iso } from "#lib/dates.js";
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
date: first?.date.slice(0, 10) ?? "",
|
date: first?.date.slice(0, 10) ?? "",
|
||||||
group,
|
group,
|
||||||
id: group.id,
|
id: group.id,
|
||||||
money: sumByCurrency(group.attributes.transactions)[0],
|
money: sumByCurrency(group.attributes.transactions),
|
||||||
note: (first?.notes ?? "").trim().split("\n")[0],
|
note: (first?.notes ?? "").trim().split("\n")[0],
|
||||||
title: groupTitle(group),
|
title: groupTitle(group),
|
||||||
};
|
};
|
||||||
@@ -171,8 +171,8 @@
|
|||||||
<td
|
<td
|
||||||
class="whitespace-nowrap py-1 pr-3 pl-2 text-right align-top leading-5"
|
class="whitespace-nowrap py-1 pr-3 pl-2 text-right align-top leading-5"
|
||||||
>
|
>
|
||||||
{#if e.money}
|
{#if e.money.length > 0}
|
||||||
<Amount {...e.money} />
|
<Money parts={e.money} tone={e.money[0].tone} />
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -8,8 +8,15 @@
|
|||||||
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
||||||
import type { Component, Snippet } from "svelte";
|
import type { Component, Snippet } from "svelte";
|
||||||
import Meter from "#lib/components/charts/Meter.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 { currentLocale, formatMoney } from "#lib/format.js";
|
||||||
|
import { display } from "#lib/money/display.svelte.js";
|
||||||
import { period } from "#lib/period.svelte.js";
|
import { period } from "#lib/period.svelte.js";
|
||||||
import { ALL_ASSETS, reportPath } from "#lib/reports/params.js";
|
import { ALL_ASSETS, reportPath } from "#lib/reports/params.js";
|
||||||
import { parseSummary, type SummaryEntry, summaryQuery } from "./data.js";
|
import { parseSummary, type SummaryEntry, summaryQuery } from "./data.js";
|
||||||
@@ -43,35 +50,49 @@
|
|||||||
});
|
});
|
||||||
const year = $derived(today().slice(0, 4));
|
const year = $derived(today().slice(0, 4));
|
||||||
|
|
||||||
const fmt = (e: SummaryEntry, abs = false) =>
|
const absolute = (list: SummaryEntry[]) =>
|
||||||
formatMoney({
|
list.map((e) => ({ ...e, amount: Math.abs(e.amount) }));
|
||||||
amount: abs ? Math.abs(e.amount) : e.amount,
|
|
||||||
code: e.code,
|
function lines(list: SummaryEntry[], abs = false): string[] {
|
||||||
decimals: e.decimals,
|
const parts = abs ? absolute(list) : list;
|
||||||
symbol: e.symbol,
|
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(
|
function compare(
|
||||||
kind: string,
|
kind: string,
|
||||||
words: [string, string],
|
words: [string, string],
|
||||||
upIsGood = true
|
upIsGood = true
|
||||||
): Delta | null {
|
): Delta | null {
|
||||||
const a = now[kind]?.[0];
|
const sum = display.total(now[kind] ?? []);
|
||||||
const b = a && before[kind]?.find((e) => e.code === a.code);
|
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) {
|
if (!(a && b) || a.amount === b.amount) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const diff = a.amount - b.amount;
|
const diff = a.amount - b.amount;
|
||||||
|
const text = formatMoney({ ...a, amount: Math.abs(diff) });
|
||||||
return {
|
return {
|
||||||
good: diff > 0 === upIsGood,
|
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,
|
up: diff > 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const balance = $derived(now.balance ?? []);
|
const balance = $derived(now.balance ?? []);
|
||||||
const earned = $derived(now.earned?.[0]);
|
const earned = $derived(now.earned?.length ? flow(now.earned, 1) : "");
|
||||||
const spent = $derived(now.spent?.[0]);
|
const spent = $derived(now.spent?.length ? flow(now.spent, -1) : "");
|
||||||
const unpaid = $derived(now["bills-unpaid"] ?? []);
|
const unpaid = $derived(now["bills-unpaid"] ?? []);
|
||||||
const paid = $derived(now["bills-paid"] ?? []);
|
const paid = $derived(now["bills-paid"] ?? []);
|
||||||
const left = $derived(now["left-to-spend"] ?? []);
|
const left = $derived(now["left-to-spend"] ?? []);
|
||||||
@@ -81,7 +102,23 @@
|
|||||||
if (left.some((e) => e.noBudgets)) {
|
if (left.some((e) => e.noBudgets)) {
|
||||||
return "none";
|
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(
|
const leftTitle = $derived(
|
||||||
leftState === "none"
|
leftState === "none"
|
||||||
@@ -97,6 +134,11 @@
|
|||||||
}[leftState]
|
}[leftState]
|
||||||
);
|
);
|
||||||
const paidShare = $derived.by(() => {
|
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 code = unpaid[0]?.code ?? paid[0]?.code;
|
||||||
const p = Math.abs(paid.find((e) => e.code === code)?.amount ?? 0);
|
const p = Math.abs(paid.find((e) => e.code === code)?.amount ?? 0);
|
||||||
const u = Math.abs(unpaid.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[],
|
list: SummaryEntry[],
|
||||||
abs = false
|
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]">
|
<div class="font-semibold text-[18px] text-cladd-fg-softer sm:text-[22px]">
|
||||||
0
|
0
|
||||||
</div>
|
</div>
|
||||||
@@ -116,12 +159,12 @@
|
|||||||
<div
|
<div
|
||||||
class="wrap-anywhere font-semibold text-[18px] text-cladd-fg tracking-[-0.02em] sm:text-[22px]"
|
class="wrap-anywhere font-semibold text-[18px] text-cladd-fg tracking-[-0.02em] sm:text-[22px]"
|
||||||
>
|
>
|
||||||
{fmt(list[0], abs)}
|
{main}
|
||||||
</div>
|
</div>
|
||||||
{#if list.length > 1}
|
{#if rest.length > 0}
|
||||||
<div class="num flex flex-wrap gap-x-2 text-[12px] text-cladd-fg-soft">
|
<div class="num flex flex-wrap gap-x-2 text-[12px] text-cladd-fg-soft">
|
||||||
{#each list.slice(1) as e (e.code)}
|
{#each rest as text (text)}
|
||||||
<span>{fmt(e, abs)}</span>
|
<span>{text}</span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -185,10 +228,10 @@
|
|||||||
{#if earned || spent}
|
{#if earned || spent}
|
||||||
<div class="num flex flex-wrap gap-x-2 text-[12px]">
|
<div class="num flex flex-wrap gap-x-2 text-[12px]">
|
||||||
{#if earned}
|
{#if earned}
|
||||||
<span class="text-income">+{fmt(earned, true)}</span>
|
<span class="text-income">{earned}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if spent}
|
{#if spent}
|
||||||
<span class="text-expense">−{fmt(spent, true)}</span>
|
<span class="text-expense">{spent}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -205,8 +248,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<div class="flex flex-wrap gap-x-2 text-[12px] text-cladd-fg-soft">
|
<div class="flex flex-wrap gap-x-2 text-[12px] text-cladd-fg-soft">
|
||||||
<span>Paid:</span>
|
<span>Paid:</span>
|
||||||
{#each paid as e (e.code)}
|
{#each lines(paid, true) as text (text)}
|
||||||
<span class="num">{fmt(e, true)}</span>
|
<span class="num">{text}</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="num">0</span>
|
<span class="num">0</span>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -215,11 +258,9 @@
|
|||||||
|
|
||||||
{#snippet leftToSpend()}
|
{#snippet leftToSpend()}
|
||||||
{@render values(left)}
|
{@render values(left)}
|
||||||
{#if left[0]?.sub}
|
{#if perDay}
|
||||||
<div class="text-[12px] text-cladd-fg-soft">
|
<div class="text-[12px] text-cladd-fg-soft">
|
||||||
{leftState === "none" ? "" : "Per day: "}<span class="num"
|
{leftState === "none" ? "" : "Per day: "}<span class="num">{perDay}</span>
|
||||||
>{left[0].sub}</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{@render change(compare("left-to-spend", ["more", "less"]))}
|
{@render change(compare("left-to-spend", ["more", "less"]))}
|
||||||
|
|||||||
@@ -2,12 +2,6 @@
|
|||||||
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
||||||
import { api } from "#lib/api/client.js";
|
import { api } from "#lib/api/client.js";
|
||||||
import Box from "#lib/components/Box.svelte";
|
import Box from "#lib/components/Box.svelte";
|
||||||
import {
|
|
||||||
formatPrimary,
|
|
||||||
inPrimaryTotal,
|
|
||||||
median,
|
|
||||||
ratesQuery,
|
|
||||||
} from "#lib/dashboard/primary.svelte.js";
|
|
||||||
import {
|
import {
|
||||||
addDays,
|
addDays,
|
||||||
daysBetween,
|
daysBetween,
|
||||||
@@ -17,7 +11,9 @@
|
|||||||
today,
|
today,
|
||||||
} from "#lib/dates.js";
|
} from "#lib/dates.js";
|
||||||
import { formatDate, formatMoney, toNumber } from "#lib/format.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 { period } from "#lib/period.svelte.js";
|
||||||
|
import { median } from "#lib/reports/typical.js";
|
||||||
import { byCurrency, currency, type InsightEntry, span } from "./data.js";
|
import { byCurrency, currency, type InsightEntry, span } from "./data.js";
|
||||||
|
|
||||||
let { class: className = "" }: { class?: string } = $props();
|
let { class: className = "" }: { class?: string } = $props();
|
||||||
@@ -47,7 +43,6 @@
|
|||||||
return list;
|
return list;
|
||||||
});
|
});
|
||||||
|
|
||||||
const rates = createQuery(() => ratesQuery());
|
|
||||||
const pace = createQuery(() => ({
|
const pace = createQuery(() => ({
|
||||||
enabled: visible,
|
enabled: visible,
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
@@ -100,25 +95,28 @@
|
|||||||
return `Spent by the ${day}${SUFFIX[ordinals.select(day)] ?? "th"}`;
|
return `Spent by the ${day}${SUFFIX[ordinals.select(day)] ?? "th"}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const lines = $derived.by(() => {
|
const all = $derived.by(() => {
|
||||||
const parts = (pick: (r: (typeof rows)[number]) => number | null) =>
|
const lists = pace.data ?? [];
|
||||||
rows.map((r) => ({ amount: pick(r) ?? 0, code: r.code }));
|
const sums = lists.map((list) =>
|
||||||
const spent = inPrimaryTotal(
|
display.total(
|
||||||
parts((r) => r.spent),
|
list.map((e) => ({ amount: e.difference, code: e.currency_code }))
|
||||||
rates.data
|
)
|
||||||
);
|
);
|
||||||
if (spent === null || rows.length < 2) {
|
if (!display.active || lists.some((list, i) => list.length && !sums[i])) {
|
||||||
return rows;
|
return null;
|
||||||
}
|
}
|
||||||
const known = rows.every((r) => r.typical !== null);
|
const [spent = 0, ...past] = sums.map((sum) => Math.abs(sum?.amount ?? 0));
|
||||||
const typical = known
|
const history = past.filter((v) => v > 0);
|
||||||
? inPrimaryTotal(
|
const approx = sums.some((sum) => sum?.converted);
|
||||||
parts((r) => r.typical),
|
return {
|
||||||
rates.data
|
code: "All",
|
||||||
)
|
format: (amount: number) =>
|
||||||
: null;
|
`${approx ? "≈" : ""}${formatMoney({ ...display.target, amount, decimals: 0 })}`,
|
||||||
return [...rows, { code: "All", format: formatPrimary, spent, typical }];
|
spent,
|
||||||
|
typical: history.length >= MIN_MONTHS ? median(history) : null,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
const lines = $derived(all ? [all] : rows);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if visible && rows.length > 0}
|
{#if visible && rows.length > 0}
|
||||||
@@ -166,7 +164,7 @@
|
|||||||
{r.typical === null ? "n/a" : r.format(r.typical)}
|
{r.typical === null ? "n/a" : r.format(r.typical)}
|
||||||
</td>
|
</td>
|
||||||
<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
|
diff > 0
|
||||||
? "text-expense"
|
? "text-expense"
|
||||||
: "text-income"}"
|
: "text-income"}"
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
import type { PiggyBank } from "#lib/api/types.js";
|
import type { PiggyBank } from "#lib/api/types.js";
|
||||||
import Box from "#lib/components/Box.svelte";
|
import Box from "#lib/components/Box.svelte";
|
||||||
import Meter from "#lib/components/charts/Meter.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());
|
const piggies = createQuery(() => lists.piggyBanks());
|
||||||
|
|
||||||
@@ -18,12 +19,14 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
const fmt = (p: PiggyBank, amount: string | null | undefined) =>
|
const fmt = (p: PiggyBank, amount: string | null | undefined) =>
|
||||||
formatMoney({
|
display.text([
|
||||||
amount,
|
{
|
||||||
code: p.currency_code,
|
amount,
|
||||||
decimals: p.currency_decimal_places,
|
code: p.currency_code,
|
||||||
symbol: p.currency_symbol,
|
decimals: p.currency_decimal_places,
|
||||||
});
|
symbol: p.currency_symbol,
|
||||||
|
},
|
||||||
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if saving.length > 0}
|
{#if saving.length > 0}
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
import Meter from "#lib/components/charts/Meter.svelte";
|
import Meter from "#lib/components/charts/Meter.svelte";
|
||||||
import { today } from "#lib/dates.js";
|
import { today } from "#lib/dates.js";
|
||||||
import { formatDate, formatMoney, toNumber } from "#lib/format.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 { 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;
|
const DUE_LIMIT = 6;
|
||||||
|
|
||||||
@@ -37,6 +38,30 @@
|
|||||||
)
|
)
|
||||||
.filter((m) => m.total > 0)
|
.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)[]) =>
|
const inPeriod = (dates: (string | null | undefined)[]) =>
|
||||||
dates
|
dates
|
||||||
.map((d) => (d ?? "").slice(0, 10))
|
.map((d) => (d ?? "").slice(0, 10))
|
||||||
@@ -62,16 +87,6 @@
|
|||||||
})
|
})
|
||||||
.sort((x, y) => x.date.localeCompare(y.date))
|
.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>
|
</script>
|
||||||
|
|
||||||
{#if (bills.data?.length ?? 0) > 0}
|
{#if (bills.data?.length ?? 0) > 0}
|
||||||
@@ -85,25 +100,23 @@
|
|||||||
<p class="text-cladd-fg-soft">Nothing expected in this period.</p>
|
<p class="text-cladd-fg-soft">Nothing expected in this period.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="flex flex-col gap-2">
|
<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">
|
<li class="flex flex-col gap-1">
|
||||||
<div
|
<div
|
||||||
class="flex items-baseline justify-between gap-2 text-[12px]"
|
class="flex items-baseline justify-between gap-2 text-[12px]"
|
||||||
>
|
>
|
||||||
<span class="text-cladd-fg-soft"
|
<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
|
paid</span
|
||||||
>
|
>
|
||||||
<span class="text-cladd-fg-soft"
|
<span class="text-cladd-fg-soft"
|
||||||
><span class="num text-cladd-fg"
|
><span class="num text-cladd-fg">{m.left}</span>
|
||||||
>{fmt(m.total - m.paid, m.cur)}</span
|
|
||||||
>
|
|
||||||
to pay</span
|
to pay</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<Meter
|
<Meter
|
||||||
max={m.total}
|
max={m.max}
|
||||||
segments={[{ color: "var(--ff-series-3)", value: m.paid }]}
|
segments={[{ color: "var(--ff-series-3)", value: m.value }]}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -128,12 +141,14 @@
|
|||||||
>{d.bill.attributes.name}</span
|
>{d.bill.attributes.name}</span
|
||||||
>
|
>
|
||||||
<span class="num text-cladd-fg-soft"
|
<span class="num text-cladd-fg-soft"
|
||||||
>~{formatMoney({
|
>{rough([
|
||||||
amount: d.amount,
|
{
|
||||||
code: d.bill.attributes.currency_code,
|
amount: d.amount,
|
||||||
decimals: d.bill.attributes.currency_decimal_places,
|
code: d.bill.attributes.currency_code,
|
||||||
symbol: d.bill.attributes.currency_symbol,
|
decimals: d.bill.attributes.currency_decimal_places,
|
||||||
})}</span
|
symbol: d.bill.attributes.currency_symbol,
|
||||||
|
},
|
||||||
|
])}</span
|
||||||
>
|
>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ import { api } from "#lib/api/client.js";
|
|||||||
import { all } from "#lib/api/lists.js";
|
import { all } from "#lib/api/lists.js";
|
||||||
import type { Account, BasicSummaryEntry } from "#lib/api/types.js";
|
import type { Account, BasicSummaryEntry } from "#lib/api/types.js";
|
||||||
import { addDays, today } from "#lib/dates.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 { period } from "#lib/period.svelte.js";
|
||||||
import { session } from "#lib/session.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(
|
export function parseSummary(
|
||||||
raw: Record<string, BasicSummaryEntry> | undefined
|
raw: Record<string, BasicSummaryEntry> | undefined
|
||||||
): Record<string, SummaryEntry[]> {
|
): Record<string, SummaryEntry[]> {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { formatDate, formatNumber } from "#lib/format.js";
|
import { formatDate, formatNumber } from "#lib/format.js";
|
||||||
import { period } from "#lib/period.svelte.js";
|
import { period } from "#lib/period.svelte.js";
|
||||||
import { monthOf } from "#lib/reports/board.js";
|
import { monthOf } from "#lib/reports/board.js";
|
||||||
|
import { isApprox } from "#lib/reports/display.js";
|
||||||
import { versusText } from "#lib/reports/typical.js";
|
import { versusText } from "#lib/reports/typical.js";
|
||||||
|
|
||||||
const WEEK = 7;
|
const WEEK = 7;
|
||||||
@@ -123,7 +124,7 @@
|
|||||||
)}
|
)}
|
||||||
{#if main.projected !== null}
|
{#if main.projected !== null}
|
||||||
<span
|
<span
|
||||||
>· on pace for ≈
|
>· on pace for {isApprox(main.code) ? "" : "≈"}
|
||||||
<span class="num font-semibold text-cladd-fg"
|
<span class="num font-semibold text-cladd-fg"
|
||||||
>{fmt(main.code, main.projected, 0)}</span
|
>{fmt(main.code, main.projected, 0)}</span
|
||||||
>,</span
|
>,</span
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Amount from "#lib/components/Amount.svelte";
|
import Money from "#lib/components/Money.svelte";
|
||||||
import { formatMoney } from "#lib/format.js";
|
import { formatMoney } from "#lib/format.js";
|
||||||
|
import { display } from "#lib/money/display.svelte.js";
|
||||||
import type { Total } from "./object-groups.js";
|
import type { Total } from "./object-groups.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -12,24 +13,20 @@
|
|||||||
tone?: "income" | "expense" | "plain";
|
tone?: "income" | "expense" | "plain";
|
||||||
empty?: string;
|
empty?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
const merged = $derived(display.total(totals) !== null);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#each totals as total (total.code)}
|
{#if totals.length === 0}
|
||||||
<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 empty}
|
{#if empty}
|
||||||
<span class="text-cladd-fg-softer">{empty}</span>
|
<span class="text-cladd-fg-softer">{empty}</span>
|
||||||
{/if}
|
{/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">
|
<span class="text-[12px] text-cladd-fg-soft">
|
||||||
up to
|
up to
|
||||||
<Amount {...piggyMoney(piggy.attributes, link.max)} tone="neutral" />
|
<Amount
|
||||||
|
{...piggyMoney(piggy.attributes, link.max)}
|
||||||
|
exact
|
||||||
|
tone="neutral"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Box from "#lib/components/Box.svelte";
|
import Box from "#lib/components/Box.svelte";
|
||||||
import { parse } from "#lib/dates.js";
|
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 {
|
import {
|
||||||
cadence,
|
cadence,
|
||||||
type Place,
|
type Place,
|
||||||
@@ -51,7 +52,7 @@
|
|||||||
);
|
);
|
||||||
const recent = $derived(past.slice(0, RECENT));
|
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) {
|
function x(visit: Visit) {
|
||||||
const room = Math.max(width - 2 * largest, 0);
|
const room = Math.max(width - 2 * largest, 0);
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
import Amount from "#lib/components/Amount.svelte";
|
import Amount from "#lib/components/Amount.svelte";
|
||||||
import Box from "#lib/components/Box.svelte";
|
import Box from "#lib/components/Box.svelte";
|
||||||
import { addDays } from "#lib/dates.js";
|
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";
|
import { journalId, signedAmount, toneOf } from "#lib/transactions/split.js";
|
||||||
|
|
||||||
const DAYS = 2;
|
const DAYS = 2;
|
||||||
@@ -148,7 +149,7 @@
|
|||||||
<td class="num px-3 text-right whitespace-nowrap">
|
<td class="num px-3 text-right whitespace-nowrap">
|
||||||
{row.balance === null || row.balance === undefined
|
{row.balance === null || row.balance === undefined
|
||||||
? ""
|
? ""
|
||||||
: formatMoney({ ...currency, amount: row.balance })}
|
: display.text([{ ...currency, amount: row.balance }])}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
|
import { displayCode, inDisplay } from "#lib/reports/display.js";
|
||||||
import { reportQueries } from "#lib/reports/queries.js";
|
import { reportQueries } from "#lib/reports/queries.js";
|
||||||
import {
|
import {
|
||||||
|
displayMoney,
|
||||||
drillQuery,
|
drillQuery,
|
||||||
money,
|
money,
|
||||||
navigate,
|
navigate,
|
||||||
@@ -34,12 +36,15 @@
|
|||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
});
|
});
|
||||||
const sums = $derived.by(() => {
|
const sums = $derived.by(() => {
|
||||||
|
const into = displayCode(new Set(rows.map((row) => row.code)));
|
||||||
const map = new Map<string, { start: number; end: number }>();
|
const map = new Map<string, { start: number; end: number }>();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const sum = map.get(row.code) ?? { end: 0, start: 0 };
|
const start = inDisplay(row.start, row.code, into);
|
||||||
sum.start += row.start;
|
const end = inDisplay(row.end, row.code, into);
|
||||||
sum.end += row.end;
|
const sum = map.get(start.code) ?? { end: 0, start: 0 };
|
||||||
map.set(row.code, sum);
|
sum.start += start.amount;
|
||||||
|
sum.end += end.amount;
|
||||||
|
map.set(start.code, sum);
|
||||||
}
|
}
|
||||||
const order = report.codes;
|
const order = report.codes;
|
||||||
return [...map.entries()].sort(
|
return [...map.entries()].sort(
|
||||||
@@ -106,10 +111,14 @@
|
|||||||
{row.name}
|
{row.name}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td class="num px-3 text-right">{money(row.code, row.start)}</td>
|
<td class="num px-3 text-right">
|
||||||
<td class="num px-3 text-right">{money(row.code, row.end)}</td>
|
{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)}">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import LineChart from "#lib/components/charts/LineChart.svelte";
|
import LineChart from "#lib/components/charts/LineChart.svelte";
|
||||||
import { contains, today } from "#lib/dates.js";
|
import { contains, today } from "#lib/dates.js";
|
||||||
import { formatDate } from "#lib/format.js";
|
import { formatDate } from "#lib/format.js";
|
||||||
|
import { displayCode, inDisplay, shownCode } from "#lib/reports/display.js";
|
||||||
import { reportQueries } from "#lib/reports/queries.js";
|
import { reportQueries } from "#lib/reports/queries.js";
|
||||||
import { money, useReport } from "#lib/reports/report.svelte.js";
|
import { money, useReport } from "#lib/reports/report.svelte.js";
|
||||||
import { modeAxis } from "#lib/reports/view.js";
|
import { modeAxis } from "#lib/reports/view.js";
|
||||||
@@ -31,11 +32,16 @@
|
|||||||
|
|
||||||
const charts = $derived.by(() => {
|
const charts = $derived.by(() => {
|
||||||
const byCode = new Map<string, LineSeries[]>();
|
const byCode = new Map<string, LineSeries[]>();
|
||||||
for (const set of chart.data ?? []) {
|
const sets = chart.data ?? [];
|
||||||
const code = set.currency_code ?? "";
|
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]) => ({
|
const points = Object.entries(set.entries ?? {}).map(([x, y]) => ({
|
||||||
x: x.slice(0, 10),
|
x: x.slice(0, 10),
|
||||||
y: Number(y),
|
y: inDisplay(Number(y), from, into).amount,
|
||||||
}));
|
}));
|
||||||
const list = byCode.get(code) ?? [];
|
const list = byCode.get(code) ?? [];
|
||||||
if (net && list.length > 0) {
|
if (net && list.length > 0) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
shiftMonth,
|
shiftMonth,
|
||||||
spendingLanes,
|
spendingLanes,
|
||||||
} from "#lib/reports/board.js";
|
} from "#lib/reports/board.js";
|
||||||
|
import { displayCode, displayFlows } from "#lib/reports/display.js";
|
||||||
import { exchanges } from "#lib/reports/flows.js";
|
import { exchanges } from "#lib/reports/flows.js";
|
||||||
import { ledgerQuery, ledgerWindow, ownFlows } from "#lib/reports/ledger.js";
|
import { ledgerQuery, ledgerWindow, ownFlows } from "#lib/reports/ledger.js";
|
||||||
import { ALL_ASSETS, type ReportParams, ymd } from "#lib/reports/params.js";
|
import { ALL_ASSETS, type ReportParams, ymd } from "#lib/reports/params.js";
|
||||||
@@ -77,15 +78,20 @@
|
|||||||
start: range.start,
|
start: range.start,
|
||||||
type: "default",
|
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(
|
const report = createReport(
|
||||||
() => params,
|
() => params,
|
||||||
() => ledger.data
|
() => ledger.data,
|
||||||
|
() => into
|
||||||
);
|
);
|
||||||
provideReport(report);
|
provideReport(report);
|
||||||
|
|
||||||
const flows = $derived(
|
const flows = $derived(mine && displayFlows(mine, into));
|
||||||
ledger.data && own ? ownFlows(ledger.data, own) : undefined
|
|
||||||
);
|
|
||||||
const board = $derived(
|
const board = $derived(
|
||||||
flows ? buildBoard(flows, span.months, year, report.codes, now) : undefined
|
flows ? buildBoard(flows, span.months, year, report.codes, now) : undefined
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
import { formatRange, percent } from "#lib/format.js";
|
import { formatRange, percent } from "#lib/format.js";
|
||||||
|
import { inDisplay } from "#lib/reports/display.js";
|
||||||
import { NONE } from "#lib/reports/flows.js";
|
import { NONE } from "#lib/reports/flows.js";
|
||||||
import { reportQueries } from "#lib/reports/queries.js";
|
import { reportQueries } from "#lib/reports/queries.js";
|
||||||
import {
|
import {
|
||||||
@@ -36,32 +37,43 @@
|
|||||||
const withdrawals = flows.filter(
|
const withdrawals = flows.filter(
|
||||||
(f) => f.dir === -1 && f.type === "withdrawal"
|
(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 a = item.attributes;
|
||||||
const start = (a.start ?? p.start).slice(0, 10);
|
const start = (a.start ?? p.start).slice(0, 10);
|
||||||
const end = (a.end ?? p.end).slice(0, 10);
|
const end = (a.end ?? p.end).slice(0, 10);
|
||||||
const code = a.currency_code ?? "";
|
const { amount, code } = inDisplay(
|
||||||
const spent = withdrawals
|
Number(a.amount ?? 0),
|
||||||
.filter(
|
a.currency_code ?? "",
|
||||||
(f) =>
|
report.into
|
||||||
f.budget?.id === String(a.budget_id) &&
|
);
|
||||||
f.currency === code &&
|
const key = [a.budget_id, start, end, code].join("|");
|
||||||
f.date >= start &&
|
const found = merged.get(key);
|
||||||
f.date <= end
|
if (found) {
|
||||||
)
|
found.budgeted = (found.budgeted ?? 0) + amount;
|
||||||
.reduce((sum, f) => sum + f.amount, 0);
|
continue;
|
||||||
return {
|
}
|
||||||
budgeted: Number(a.amount ?? 0),
|
merged.set(key, {
|
||||||
|
budgeted: amount,
|
||||||
budgetId: String(a.budget_id),
|
budgetId: String(a.budget_id),
|
||||||
code,
|
code,
|
||||||
end,
|
end,
|
||||||
key: item.id,
|
key: item.id,
|
||||||
name:
|
name:
|
||||||
report.itemName("budget", String(a.budget_id)) ?? `#${a.budget_id}`,
|
report.itemName("budget", String(a.budget_id)) ?? `#${a.budget_id}`,
|
||||||
spent,
|
spent: withdrawals
|
||||||
|
.filter(
|
||||||
|
(f) =>
|
||||||
|
f.budget?.id === String(a.budget_id) &&
|
||||||
|
f.currency === code &&
|
||||||
|
f.date >= start &&
|
||||||
|
f.date <= end
|
||||||
|
)
|
||||||
|
.reduce((sum, f) => sum + f.amount, 0),
|
||||||
start,
|
start,
|
||||||
};
|
});
|
||||||
});
|
}
|
||||||
|
const result = [...merged.values()];
|
||||||
result.sort(
|
result.sort(
|
||||||
(a, b) => a.name.localeCompare(b.name) || a.start.localeCompare(b.start)
|
(a, b) => a.name.localeCompare(b.name) || a.start.localeCompare(b.start)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
import { formatRange } from "#lib/format.js";
|
import { formatRange } from "#lib/format.js";
|
||||||
|
import { displayFlows } from "#lib/reports/display.js";
|
||||||
import {
|
import {
|
||||||
currencies,
|
currencies,
|
||||||
measureOf,
|
measureOf,
|
||||||
@@ -28,10 +29,13 @@
|
|||||||
const before = $derived(
|
const before = $derived(
|
||||||
earlier.data && report.accountsReady
|
earlier.data && report.accountsReady
|
||||||
? total(
|
? total(
|
||||||
toFlows(
|
displayFlows(
|
||||||
earlier.data,
|
toFlows(
|
||||||
new Set(report.selected),
|
earlier.data,
|
||||||
(id) => report.byId().get(id)?.currency_code
|
new Set(report.selected),
|
||||||
|
(id) => report.byId().get(id)?.currency_code
|
||||||
|
),
|
||||||
|
report.into
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
: undefined
|
: undefined
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
import type { Lane } from "#lib/components/charts/chart.js";
|
import type { Lane } from "#lib/components/charts/chart.js";
|
||||||
import Lanes from "#lib/components/charts/Lanes.svelte";
|
import Lanes from "#lib/components/charts/Lanes.svelte";
|
||||||
import { addDays, periodOf, today } from "#lib/dates.js";
|
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 { longMonth, monthOf, shortMonth } from "#lib/reports/board.js";
|
||||||
|
import { plainCode } from "#lib/reports/display.js";
|
||||||
import { buckets } from "#lib/reports/flows.js";
|
import { buckets } from "#lib/reports/flows.js";
|
||||||
import { reportQueries } from "#lib/reports/queries.js";
|
import { reportQueries } from "#lib/reports/queries.js";
|
||||||
import { impliedRates } from "#lib/reports/rates.js";
|
import { impliedRates } from "#lib/reports/rates.js";
|
||||||
@@ -71,7 +73,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rate = $derived(impliedRates(groups));
|
const rate = $derived(impliedRates(groups));
|
||||||
const primary = $derived(report.codes[0] ?? "");
|
const primary = $derived(plainCode(report.codes[0] ?? ""));
|
||||||
|
|
||||||
const measured = $derived(
|
const measured = $derived(
|
||||||
[...totals.entries()]
|
[...totals.entries()]
|
||||||
@@ -92,33 +94,34 @@
|
|||||||
const share = (weight: number | null) =>
|
const share = (weight: number | null) =>
|
||||||
weight === null || total === 0 ? null : weight / total;
|
weight === null || total === 0 ? null : weight / total;
|
||||||
|
|
||||||
const lanes = $derived<Lane[]>(
|
const all = $derived<Lane[]>(
|
||||||
measured
|
measured.map((m) => ({
|
||||||
.filter((m) => (share(m.weight) ?? 1) >= MIN_SHARE)
|
axisFormat: modeAxis(m.code),
|
||||||
.map((m) => ({
|
format: (value: number) => money(m.code, value, false, 0),
|
||||||
axisFormat: modeAxis(m.code),
|
height: Math.max(
|
||||||
format: (value: number) => money(m.code, value, false, 0),
|
MIN_HEIGHT,
|
||||||
height: Math.max(
|
Math.round((share(m.weight) ?? 1 / measured.length) * HEIGHT_BUDGET)
|
||||||
MIN_HEIGHT,
|
),
|
||||||
Math.round((share(m.weight) ?? 1 / measured.length) * HEIGHT_BUDGET)
|
key: m.code,
|
||||||
|
keys: [{ color: "var(--ff-series-1)", key: "value", label: "Net worth" }],
|
||||||
|
label: m.code,
|
||||||
|
rows: months.map((month, i) => ({
|
||||||
|
key: month,
|
||||||
|
label: shortMonth(month),
|
||||||
|
values: Object.fromEntries(
|
||||||
|
m.values[i] === undefined ? [] : [["value", m.values[i]]]
|
||||||
),
|
),
|
||||||
key: m.code,
|
})),
|
||||||
keys: [
|
}))
|
||||||
{ color: "var(--ff-series-1)", key: "value", label: "Net worth" },
|
|
||||||
],
|
|
||||||
label: m.code,
|
|
||||||
rows: months.map((month, i) => ({
|
|
||||||
key: month,
|
|
||||||
label: shortMonth(month),
|
|
||||||
values: Object.fromEntries(
|
|
||||||
m.values[i] === undefined ? [] : [["value", m.values[i]]]
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
}))
|
|
||||||
);
|
);
|
||||||
const small = $derived(
|
const merged = $derived(mergeLanes(all));
|
||||||
measured.filter((m) => (share(m.weight) ?? 1) < MIN_SHARE)
|
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>
|
</script>
|
||||||
|
|
||||||
<ReportBox
|
<ReportBox
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { formatDate } from "#lib/format.js";
|
import { formatDate } from "#lib/format.js";
|
||||||
import { expectedDates } from "#lib/reports/bills.js";
|
import { expectedDates } from "#lib/reports/bills.js";
|
||||||
import { reportQueries } from "#lib/reports/queries.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";
|
import ReportBox from "./ReportBox.svelte";
|
||||||
|
|
||||||
const report = useReport();
|
const report = useReport();
|
||||||
@@ -59,10 +59,10 @@
|
|||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
<td class="num px-3 py-1 text-right">
|
<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>
|
||||||
<td class="num px-3 py-1 text-right">
|
<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>
|
||||||
<td class="num px-3 py-1 text-cladd-fg-soft">
|
<td class="num px-3 py-1 text-cladd-fg-soft">
|
||||||
{row.expected.map((d) => formatDate(d, "short")).join(", ")}
|
{row.expected.map((d) => formatDate(d, "short")).join(", ")}
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
>{formatDate(paid.date, "short")}</a
|
>{formatDate(paid.date, "short")}</a
|
||||||
>
|
>
|
||||||
<span class="num text-expense"
|
<span class="num text-expense"
|
||||||
>{money(
|
>{displayMoney(
|
||||||
paid.currency_code ?? code,
|
paid.currency_code ?? code,
|
||||||
-Number(paid.amount ?? 0)
|
-Number(paid.amount ?? 0)
|
||||||
)}</span
|
)}</span
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Split } from "#lib/api/types.js";
|
import type { Split } from "#lib/api/types.js";
|
||||||
import Amount from "#lib/components/Amount.svelte";
|
|
||||||
import Box from "#lib/components/Box.svelte";
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import Money from "#lib/components/Money.svelte";
|
||||||
|
import { display } from "#lib/money/display.svelte.js";
|
||||||
import {
|
import {
|
||||||
|
debtMove,
|
||||||
type Group,
|
type Group,
|
||||||
type Signed,
|
type Signed,
|
||||||
sumByCurrency,
|
sumByCurrency,
|
||||||
@@ -10,9 +12,11 @@
|
|||||||
|
|
||||||
const TOP = 5;
|
const TOP = 5;
|
||||||
const TOTALS = [
|
const TOTALS = [
|
||||||
["withdrawal", "Spent"],
|
["Spent", "auto", (s: Split) => s.type === "withdrawal" && !debtMove(s)],
|
||||||
["deposit", "Earned"],
|
["Earned", "auto", (s: Split) => s.type === "deposit" && !debtMove(s)],
|
||||||
["transfer", "Transferred"],
|
["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;
|
] as const;
|
||||||
|
|
||||||
interface Entry {
|
interface Entry {
|
||||||
@@ -28,16 +32,24 @@
|
|||||||
}: { groups: Group[]; type: string; loading?: boolean } = $props();
|
}: { groups: Group[]; type: string; loading?: boolean } = $props();
|
||||||
|
|
||||||
const splits = $derived(groups.flatMap((g) => g.attributes.transactions));
|
const splits = $derived(groups.flatMap((g) => g.attributes.transactions));
|
||||||
|
const flows = $derived(splits.filter((s) => !debtMove(s)));
|
||||||
|
|
||||||
const totals = $derived(
|
const totals = $derived(
|
||||||
TOTALS.map(([kind, label]) => ({
|
TOTALS.map(([label, tone, test]) => ({
|
||||||
label,
|
label,
|
||||||
sums: sumByCurrency(splits.filter((s) => s.type === kind)),
|
sums: sumByCurrency(splits.filter(test)),
|
||||||
|
tone,
|
||||||
})).filter((row) => row.sums.length > 0)
|
})).filter((row) => row.sums.length > 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const toneOf = (sums: Signed[]) =>
|
||||||
|
sums.every((sum) => sum.tone === "transfer") ? "transfer" : "auto";
|
||||||
|
|
||||||
const size = (entry: Entry) =>
|
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(
|
function aggregate(
|
||||||
list: Split[],
|
list: Split[],
|
||||||
@@ -65,7 +77,7 @@
|
|||||||
const blocks = $derived([
|
const blocks = $derived([
|
||||||
{
|
{
|
||||||
title: "Categories",
|
title: "Categories",
|
||||||
...aggregate(splits, (s) =>
|
...aggregate(flows, (s) =>
|
||||||
s.category_id
|
s.category_id
|
||||||
? [s.category_name ?? "", `/categories/show/${s.category_id}`]
|
? [s.category_name ?? "", `/categories/show/${s.category_id}`]
|
||||||
: ["(no category)", undefined]
|
: ["(no category)", undefined]
|
||||||
@@ -74,7 +86,7 @@
|
|||||||
type === "withdrawal"
|
type === "withdrawal"
|
||||||
? {
|
? {
|
||||||
title: "Budgets",
|
title: "Budgets",
|
||||||
...aggregate(splits, (s) =>
|
...aggregate(flows, (s) =>
|
||||||
s.budget_id
|
s.budget_id
|
||||||
? [s.budget_name ?? "", `/budgets/show/${s.budget_id}`]
|
? [s.budget_name ?? "", `/budgets/show/${s.budget_id}`]
|
||||||
: ["(no budget)", undefined]
|
: ["(no budget)", undefined]
|
||||||
@@ -82,14 +94,14 @@
|
|||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
title: "Source accounts",
|
title: "Source accounts",
|
||||||
...aggregate(splits, (s) => [
|
...aggregate(flows, (s) => [
|
||||||
s.source_name ?? "(cash)",
|
s.source_name ?? "(cash)",
|
||||||
s.source_id ? `/accounts/show/${s.source_id}` : undefined,
|
s.source_id ? `/accounts/show/${s.source_id}` : undefined,
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Destination accounts",
|
title: "Destination accounts",
|
||||||
...aggregate(splits, (s) => [
|
...aggregate(flows, (s) => [
|
||||||
s.destination_name ?? "(cash)",
|
s.destination_name ?? "(cash)",
|
||||||
s.destination_id ? `/accounts/show/${s.destination_id}` : undefined,
|
s.destination_id ? `/accounts/show/${s.destination_id}` : undefined,
|
||||||
]),
|
]),
|
||||||
@@ -97,18 +109,6 @@
|
|||||||
]);
|
]);
|
||||||
</script>
|
</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">
|
<div class="mb-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<Box body="px-3 py-2" title="Overview">
|
<Box body="px-3 py-2" title="Overview">
|
||||||
{#if loading && groups.length === 0}
|
{#if loading && groups.length === 0}
|
||||||
@@ -120,9 +120,7 @@
|
|||||||
{#each totals as row (row.label)}
|
{#each totals as row (row.label)}
|
||||||
<dt class="text-cladd-fg-soft">{row.label}</dt>
|
<dt class="text-cladd-fg-soft">{row.label}</dt>
|
||||||
<dd class="flex flex-col items-end">
|
<dd class="flex flex-col items-end">
|
||||||
{#each row.sums as sum (sum.code)}
|
<Money parts={row.sums} tone={row.tone} />
|
||||||
{@render amount(sum)}
|
|
||||||
{/each}
|
|
||||||
</dd>
|
</dd>
|
||||||
{/each}
|
{/each}
|
||||||
</dl>
|
</dl>
|
||||||
@@ -143,9 +141,7 @@
|
|||||||
<span class="truncate text-cladd-fg-soft">{entry.name}</span>
|
<span class="truncate text-cladd-fg-soft">{entry.name}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="flex flex-col items-end">
|
<span class="flex flex-col items-end">
|
||||||
{#each entry.sums as sum (sum.code)}
|
<Money parts={entry.sums} tone={toneOf(entry.sums)} />
|
||||||
{@render amount(sum)}
|
|
||||||
{/each}
|
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
} from "#lib/components/categories/sums.js";
|
} from "#lib/components/categories/sums.js";
|
||||||
import type { Range } from "#lib/dates.js";
|
import type { Range } from "#lib/dates.js";
|
||||||
import { period, periodLabel } from "#lib/period.svelte.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 = [
|
const FIELDS = [
|
||||||
["spent", "Spent", "withdrawal"],
|
["spent", "Spent", "withdrawal"],
|
||||||
@@ -43,10 +43,18 @@
|
|||||||
queryKey: ["transactions-periods", type, first, last],
|
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(
|
const periods = $derived(
|
||||||
byPeriod(
|
byPeriod(
|
||||||
rows(query.data?.groups ?? []),
|
rows(query.data?.groups ?? [], (split) => !debtMove(split)),
|
||||||
ranges.filter((range) => !period.first || range.end >= period.first)
|
shown
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const fields = $derived(
|
const fields = $derived(
|
||||||
@@ -66,7 +74,7 @@
|
|||||||
aria-label="Periods"
|
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}"
|
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 =
|
{@const current =
|
||||||
item.range.start === period.start && item.range.end === period.end}
|
item.range.start === period.start && item.range.end === period.end}
|
||||||
<a
|
<a
|
||||||
@@ -81,12 +89,12 @@
|
|||||||
>
|
>
|
||||||
{#if !covered(item.range)}
|
{#if !covered(item.range)}
|
||||||
<span class="text-cladd-fg-softer">Loading...</span>
|
<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>
|
<span class="text-cladd-fg-softer">No transactions</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="flex justify-between gap-2">
|
<span class="flex justify-between gap-2">
|
||||||
<span class="text-cladd-fg-soft">Transactions</span>
|
<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>
|
</span>
|
||||||
{#each fields as [field, label] (field)}
|
{#each fields as [field, label] (field)}
|
||||||
{@const sums = list(item.totals[field])}
|
{@const sums = list(item.totals[field])}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import type { Split } from "#lib/api/types.js";
|
import type { Split } from "#lib/api/types.js";
|
||||||
import Amount from "#lib/components/Amount.svelte";
|
import Amount from "#lib/components/Amount.svelte";
|
||||||
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
|
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
|
||||||
|
import Money from "#lib/components/Money.svelte";
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
@@ -15,9 +16,14 @@
|
|||||||
ContextMenuShortcut,
|
ContextMenuShortcut,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "#lib/components/ui/context-menu/index.js";
|
} 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 type { Binding } from "#lib/keys.svelte.js";
|
||||||
import { ListNav } from "#lib/list-nav.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 { oldUiUrl } from "#lib/spa-paths.js";
|
||||||
import { type Section, subtotals } from "#lib/transactions/ledger.js";
|
import { type Section, subtotals } from "#lib/transactions/ledger.js";
|
||||||
import {
|
import {
|
||||||
@@ -67,7 +73,7 @@
|
|||||||
interface Block extends Section {
|
interface Block extends Section {
|
||||||
groups: Group[];
|
groups: Group[];
|
||||||
keys: string[];
|
keys: string[];
|
||||||
sums: Signed[];
|
sums: Signed[][];
|
||||||
}
|
}
|
||||||
|
|
||||||
let collapsed = $state(new Set<string>());
|
let collapsed = $state(new Set<string>());
|
||||||
@@ -106,7 +112,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
for (const block of byKey.values()) {
|
for (const block of byKey.values()) {
|
||||||
block.sums = subtotals(block.groups, context.accountId);
|
block.sums = byTone(subtotals(block.groups, context.accountId));
|
||||||
}
|
}
|
||||||
return starts;
|
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) {
|
function selectKeys(keys: string[], on: boolean) {
|
||||||
const next = new Set(nav.selected);
|
const next = new Set(nav.selected);
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
@@ -332,13 +346,25 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet plain(
|
{#snippet plain(
|
||||||
value: Money | null
|
value: MoneyValue | null
|
||||||
)}
|
)}
|
||||||
{#if value}
|
{#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}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet sum(
|
||||||
|
parts: Signed[]
|
||||||
|
)}
|
||||||
|
<Money {parts} tone={parts[0]?.tone === "transfer" ? "transfer" : "auto"} />
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
{#snippet account(
|
{#snippet account(
|
||||||
accountIdValue: string | null | undefined,
|
accountIdValue: string | null | undefined,
|
||||||
name: string | null | undefined,
|
name: string | null | undefined,
|
||||||
@@ -429,7 +455,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
{@render money(amount)}
|
{@render money(
|
||||||
|
display.active && foreign?.code === display.code ? foreign : amount
|
||||||
|
)}
|
||||||
{#if foreign}
|
{#if foreign}
|
||||||
<div class="num text-[12px] text-cladd-fg-soft lg:hidden">
|
<div class="num text-[12px] text-cladd-fg-soft lg:hidden">
|
||||||
{formatMoney(foreign)}
|
{formatMoney(foreign)}
|
||||||
@@ -532,8 +560,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right font-semibold">
|
<td class="text-right font-semibold">
|
||||||
{#each block.sums as sum (`${sum.tone}|${sum.code}`)}
|
{#each block.sums as parts (parts[0]?.tone)}
|
||||||
<div>{@render money(sum)}</div>
|
<div>{@render sum(parts)}</div>
|
||||||
{/each}
|
{/each}
|
||||||
</td>
|
</td>
|
||||||
<td class="max-lg:hidden"></td>
|
<td class="max-lg:hidden"></td>
|
||||||
@@ -585,9 +613,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right font-semibold">
|
<td class="text-right font-semibold">
|
||||||
{#each sums as sum (sum.code)}
|
{@render sum(sums)}
|
||||||
<div>{@render money(sum)}</div>
|
|
||||||
{/each}
|
|
||||||
</td>
|
</td>
|
||||||
<td class="max-lg:hidden"></td>
|
<td class="max-lg:hidden"></td>
|
||||||
{#if accountId}
|
{#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 { contains, type Range, today } from "#lib/dates.js";
|
||||||
import { period } from "#lib/period.svelte.js";
|
import { period } from "#lib/period.svelte.js";
|
||||||
import { monthOf } from "#lib/reports/board.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 { buckets, type Flow } from "#lib/reports/flows.js";
|
||||||
import { ledgerQuery, ledgerWindow, ownFlows } from "#lib/reports/ledger.js";
|
import { ledgerQuery, ledgerWindow, ownFlows } from "#lib/reports/ledger.js";
|
||||||
import { pages } from "#lib/reports/queries.js";
|
import { pages } from "#lib/reports/queries.js";
|
||||||
@@ -96,10 +97,19 @@ export class DimensionModel {
|
|||||||
return keptOf(from, this.spec.keep, this.range);
|
return keptOf(from, this.spec.keep, this.range);
|
||||||
});
|
});
|
||||||
groups = $derived(this.inPeriod.groups);
|
groups = $derived(this.inPeriod.groups);
|
||||||
flows = $derived.by(() => flowsOf(this.base, this.accounts.data ?? []));
|
rawFlows = $derived.by(() => flowsOf(this.base, this.accounts.data ?? []));
|
||||||
periodFlows = $derived.by(() =>
|
rawPeriod = $derived.by(() =>
|
||||||
flowsOf(this.inPeriod, this.accounts.data ?? [])
|
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));
|
dir = $derived(dirOf(this.flows.length > 0 ? this.flows : this.periodFlows));
|
||||||
main = $derived(this.flows.filter((flow) => flow.dir === this.dir));
|
main = $derived(this.flows.filter((flow) => flow.dir === this.dir));
|
||||||
periodMain = $derived(
|
periodMain = $derived(
|
||||||
@@ -130,7 +140,7 @@ export class DimensionModel {
|
|||||||
weight = $derived(
|
weight = $derived(
|
||||||
weigh(
|
weigh(
|
||||||
[...this.main, ...this.periodMain],
|
[...this.main, ...this.periodMain],
|
||||||
session.primary?.code ?? "",
|
this.into ?? session.primary?.code ?? "",
|
||||||
impliedRates(this.raw)
|
impliedRates(this.raw)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from "#lib/components/charts/chart.js";
|
} from "#lib/components/charts/chart.js";
|
||||||
import { contains, daysBetween, type Range } from "#lib/dates.js";
|
import { contains, daysBetween, type Range } from "#lib/dates.js";
|
||||||
import { monthOf, shortMonth } from "#lib/reports/board.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 Flow, group, type Totals } from "#lib/reports/flows.js";
|
||||||
import type { Rate } from "#lib/reports/rates.js";
|
import type { Rate } from "#lib/reports/rates.js";
|
||||||
import { money } from "#lib/reports/report.svelte.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 => ({
|
const limitRangeOf = (limit: Item<BudgetLimit>): Range => ({
|
||||||
end: (limit.attributes.end ?? "").slice(0, 10),
|
end: (limit.attributes.end ?? "").slice(0, 10),
|
||||||
start: (limit.attributes.start ?? "").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 { session } from "#lib/session.svelte.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
|
import {
|
||||||
|
displayCode,
|
||||||
|
displayFlows,
|
||||||
|
inDisplay,
|
||||||
|
isApprox,
|
||||||
|
plainCode,
|
||||||
|
} from "./display.js";
|
||||||
import {
|
import {
|
||||||
type Dimension,
|
type Dimension,
|
||||||
exchanges,
|
exchanges,
|
||||||
@@ -81,11 +88,12 @@ export function allAssetIds(accounts: Item<Account>[]): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function currencyMeta(code: 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 {
|
return {
|
||||||
code,
|
code: plain,
|
||||||
decimals: found?.attributes.decimal_places ?? 2,
|
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,
|
symbol: primary.symbol,
|
||||||
}
|
}
|
||||||
: currencyMeta(code);
|
: currencyMeta(code);
|
||||||
return formatMoney(
|
const text = formatMoney(
|
||||||
{ amount, ...meta, decimals: decimals ?? meta.decimals },
|
{ amount, ...meta, decimals: decimals ?? meta.decimals },
|
||||||
signed
|
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) {
|
export function modeLabel(mode: string) {
|
||||||
@@ -267,7 +286,8 @@ const inRange = (group: Item<TransactionGroup>, start: string, end: string) => {
|
|||||||
|
|
||||||
export function createReport(
|
export function createReport(
|
||||||
params: () => ReportParams,
|
params: () => ReportParams,
|
||||||
source?: () => Item<TransactionGroup>[] | undefined
|
source?: () => Item<TransactionGroup>[] | undefined,
|
||||||
|
target?: () => string | null
|
||||||
) {
|
) {
|
||||||
const p = $derived(params());
|
const p = $derived(params());
|
||||||
const kind = $derived(kindOf(p.type));
|
const kind = $derived(kindOf(p.type));
|
||||||
@@ -373,11 +393,18 @@ export function createReport(
|
|||||||
: new Set<string>()
|
: new Set<string>()
|
||||||
);
|
);
|
||||||
|
|
||||||
const flows = $derived(
|
const raw = $derived(
|
||||||
groups && accountsReady
|
groups && accountsReady
|
||||||
? toFlows(groups, selectedSet, (id) => byId.get(id)?.currency_code)
|
? toFlows(groups, selectedSet, (id) => byId.get(id)?.currency_code)
|
||||||
: undefined
|
: 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(
|
const swaps = $derived(
|
||||||
groups && accountsReady
|
groups && accountsReady
|
||||||
@@ -420,7 +447,10 @@ export function createReport(
|
|||||||
const codes = $derived.by(() => {
|
const codes = $derived.by(() => {
|
||||||
const primary = session.primary?.code;
|
const primary = session.primary?.code;
|
||||||
const all = session.currencies.map((c) => c.attributes.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 available = $derived(totals ? modes(totals, codes) : []);
|
||||||
const mode = $derived.by(() => {
|
const mode = $derived.by(() => {
|
||||||
@@ -480,6 +510,9 @@ export function createReport(
|
|||||||
get groups() {
|
get groups() {
|
||||||
return groups;
|
return groups;
|
||||||
},
|
},
|
||||||
|
get into() {
|
||||||
|
return into;
|
||||||
|
},
|
||||||
itemName: (kindKey: string, id: string) =>
|
itemName: (kindKey: string, id: string) =>
|
||||||
itemNames.get(`${kindKey}:${id}`),
|
itemNames.get(`${kindKey}:${id}`),
|
||||||
get items() {
|
get items() {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Split } from "#lib/api/types.js";
|
import type { Split } from "#lib/api/types.js";
|
||||||
import { addDays, addMonths, type Range } from "#lib/dates.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 {
|
import {
|
||||||
accountUrl,
|
accountUrl,
|
||||||
type Group,
|
type Group,
|
||||||
@@ -464,12 +465,14 @@ function clusterOf(
|
|||||||
): Cluster {
|
): Cluster {
|
||||||
const groups = [...new Set(run.map((item) => item.group))].sort(newestFirst);
|
const groups = [...new Set(run.map((item) => item.group))].sort(newestFirst);
|
||||||
const span = (run.at(-1)?.time ?? 0) - (run[0]?.time ?? 0);
|
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,
|
amount: split.amount,
|
||||||
decimals: split.currency_decimal_places,
|
code: split.currency_code,
|
||||||
symbol: split.currency_symbol,
|
decimals: split.currency_decimal_places,
|
||||||
});
|
symbol: split.currency_symbol,
|
||||||
|
},
|
||||||
|
]);
|
||||||
return {
|
return {
|
||||||
groups,
|
groups,
|
||||||
id: groups
|
id: groups
|
||||||
|
|||||||
@@ -127,13 +127,21 @@ export function balanceAfter(split: Split, accountId?: string): Money | null {
|
|||||||
if (!accountId) {
|
if (!accountId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const value =
|
const source = split.source_id === accountId;
|
||||||
split.source_id === accountId
|
const value = source
|
||||||
? split.source_balance_after
|
? split.source_balance_after
|
||||||
: split.destination_balance_after;
|
: split.destination_balance_after;
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
return null;
|
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 {
|
return {
|
||||||
amount: value,
|
amount: value,
|
||||||
code: split.currency_code,
|
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 {
|
export function isCash(type: string | null | undefined): boolean {
|
||||||
return type === "Cash account";
|
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,
|
curOf,
|
||||||
isOpen,
|
isOpen,
|
||||||
type Line,
|
type Line,
|
||||||
|
lineMoney,
|
||||||
loadBudgetPage,
|
loadBudgetPage,
|
||||||
|
mergeLines,
|
||||||
pace,
|
pace,
|
||||||
projection,
|
projection,
|
||||||
safePerDay,
|
safePerDay,
|
||||||
@@ -45,9 +47,10 @@
|
|||||||
import { setParam } from "#lib/components/categories/url.js";
|
import { setParam } from "#lib/components/categories/url.js";
|
||||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
import { Button } from "#lib/components/ui/button/index.js";
|
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 { type Binding, useKeys } from "#lib/keys.svelte.js";
|
||||||
import { ListNav } from "#lib/list-nav.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 { period } from "#lib/period.svelte.js";
|
||||||
import { session } from "#lib/session.svelte.js";
|
import { session } from "#lib/session.svelte.js";
|
||||||
import { oldUiUrl } from "#lib/spa-paths.js";
|
import { oldUiUrl } from "#lib/spa-paths.js";
|
||||||
@@ -189,6 +192,16 @@
|
|||||||
return [...lines, ...more];
|
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(
|
const pending = $derived(
|
||||||
rows.flatMap((row) =>
|
rows.flatMap((row) =>
|
||||||
linesOf(row).flatMap((line) => {
|
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>
|
</script>
|
||||||
|
|
||||||
<PageHeader crumbs={[{ label: "Budgets" }]} title="Budgets">
|
<PageHeader crumbs={[{ label: "Budgets" }]} title="Budgets">
|
||||||
@@ -484,7 +490,7 @@
|
|||||||
<tbody {...props}>
|
<tbody {...props}>
|
||||||
{#each rows as row, i (row.budget.id)}
|
{#each rows as row, i (row.budget.id)}
|
||||||
{@const extra = addingLine(row)}
|
{@const extra = addingLine(row)}
|
||||||
{@const lines = linesOf(row)}
|
{@const lines = shownLines(row)}
|
||||||
{#each lines as line, j (line.key)}
|
{#each lines as line, j (line.key)}
|
||||||
{@const p = pace(line)}
|
{@const p = pace(line)}
|
||||||
{@const safe = safePerDay(line)}
|
{@const safe = safePerDay(line)}
|
||||||
@@ -519,13 +525,17 @@
|
|||||||
>
|
>
|
||||||
auto
|
auto
|
||||||
<span class="num"
|
<span class="num"
|
||||||
>{formatMoney({
|
>{display.text([
|
||||||
amount:
|
{
|
||||||
row.budget.attributes.auto_budget_amount,
|
amount:
|
||||||
decimals:
|
row.budget.attributes.auto_budget_amount,
|
||||||
row.budget.attributes.currency_decimal_places,
|
code: row.budget.attributes.currency_code,
|
||||||
symbol: row.budget.attributes.currency_symbol,
|
decimals:
|
||||||
})}</span
|
row.budget.attributes
|
||||||
|
.currency_decimal_places,
|
||||||
|
symbol: row.budget.attributes.currency_symbol,
|
||||||
|
},
|
||||||
|
])}</span
|
||||||
>
|
>
|
||||||
{PERIOD_WORDS[
|
{PERIOD_WORDS[
|
||||||
row.budget.attributes.auto_budget_period ?? ""
|
row.budget.attributes.auto_budget_period ?? ""
|
||||||
@@ -602,6 +612,8 @@
|
|||||||
{#if line !== extra}
|
{#if line !== extra}
|
||||||
<Amount
|
<Amount
|
||||||
amount={line.spent}
|
amount={line.spent}
|
||||||
|
approx={line.approx}
|
||||||
|
code={line.cur.code}
|
||||||
decimals={line.cur.decimals}
|
decimals={line.cur.decimals}
|
||||||
symbol={line.cur.symbol}
|
symbol={line.cur.symbol}
|
||||||
/>
|
/>
|
||||||
@@ -613,7 +625,7 @@
|
|||||||
class="num {line.amount + line.spent < 0
|
class="num {line.amount + line.spent < 0
|
||||||
? "text-expense"
|
? "text-expense"
|
||||||
: "text-cladd-fg"}"
|
: "text-cladd-fg"}"
|
||||||
>{money(line, line.amount + line.spent)}</span
|
>{lineMoney(line, line.amount + line.spent)}</span
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
@@ -622,7 +634,7 @@
|
|||||||
{#if safe !== null}
|
{#if safe !== null}
|
||||||
<span
|
<span
|
||||||
class={safe > 0 ? "text-cladd-fg" : "text-expense"}
|
class={safe > 0 ? "text-cladd-fg" : "text-expense"}
|
||||||
>{money(line, safe)}</span
|
>{lineMoney(line, safe)}</span
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
@@ -632,7 +644,7 @@
|
|||||||
class={line.amount && ends > line.amount
|
class={line.amount && ends > line.amount
|
||||||
? "text-expense"
|
? "text-expense"
|
||||||
: "text-cladd-fg-soft"}
|
: "text-cladd-fg-soft"}
|
||||||
>~{money(line, ends)}</span
|
>~{lineMoney(line, ends).replace("≈", "")}</span
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
@@ -652,7 +664,7 @@
|
|||||||
>
|
>
|
||||||
{:else if p && p.over > 0}
|
{:else if p && p.over > 0}
|
||||||
<span class="num text-[12px] text-warn"
|
<span class="num text-[12px] text-warn"
|
||||||
>{money(line, p.over)}
|
>{lineMoney(line, p.over)}
|
||||||
over pace</span
|
over pace</span
|
||||||
>
|
>
|
||||||
{:else if p}
|
{:else if p}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
} from "#lib/components/categories/data.js";
|
} from "#lib/components/categories/data.js";
|
||||||
import ListMenu from "#lib/components/categories/ListMenu.svelte";
|
import ListMenu from "#lib/components/categories/ListMenu.svelte";
|
||||||
import MoneyList from "#lib/components/categories/MoneyList.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 { setParam } from "#lib/components/categories/url.js";
|
||||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
import { Button } from "#lib/components/ui/button/index.js";
|
import { Button } from "#lib/components/ui/button/index.js";
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
import { fuzzyFilter } from "#lib/fuzzy.js";
|
import { fuzzyFilter } from "#lib/fuzzy.js";
|
||||||
import { type Binding, useKeys } from "#lib/keys.svelte.js";
|
import { type Binding, useKeys } from "#lib/keys.svelte.js";
|
||||||
import { ListNav } from "#lib/list-nav.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 { period } from "#lib/period.svelte.js";
|
||||||
import { session } from "#lib/session.svelte.js";
|
import { session } from "#lib/session.svelte.js";
|
||||||
import { oldUiUrl } from "#lib/spa-paths.js";
|
import { oldUiUrl } from "#lib/spa-paths.js";
|
||||||
@@ -56,17 +57,20 @@
|
|||||||
const primary = $derived(session.primary?.code ?? "");
|
const primary = $derived(session.primary?.code ?? "");
|
||||||
|
|
||||||
const main = $derived.by(() => {
|
const main = $derived.by(() => {
|
||||||
|
if (display.active) {
|
||||||
|
return display.code;
|
||||||
|
}
|
||||||
const codes = new Set(
|
const codes = new Set(
|
||||||
(query.data?.rows ?? []).flatMap((r) => r.spent.map((s) => s.code))
|
(query.data?.rows ?? []).flatMap((r) => r.spent.map((s) => s.code))
|
||||||
);
|
);
|
||||||
return codes.has(primary) ? primary : ([...codes][0] ?? primary);
|
return codes.has(primary) ? primary : ([...codes][0] ?? primary);
|
||||||
});
|
});
|
||||||
|
const measure = (sums: Sum[]) =>
|
||||||
|
display.total(sums)?.amount ?? amountIn(sums, main);
|
||||||
const totalSpent = $derived(
|
const totalSpent = $derived(
|
||||||
-(
|
-(
|
||||||
(query.data?.rows ?? []).reduce(
|
(query.data?.rows ?? []).reduce((sum, r) => sum + measure(r.spent), 0) +
|
||||||
(sum, r) => sum + amountIn(r.spent, main),
|
measure(query.data?.spent ?? [])
|
||||||
0
|
|
||||||
) + amountIn(query.data?.spent ?? [], main)
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const share = (spent: number) => (totalSpent > 0 ? -spent / totalSpent : 0);
|
const share = (spent: number) => (totalSpent > 0 ? -spent / totalSpent : 0);
|
||||||
@@ -86,11 +90,9 @@
|
|||||||
if (sort === "name") {
|
if (sort === "name") {
|
||||||
sorted.sort((a, b) => name(a).localeCompare(name(b)));
|
sorted.sort((a, b) => name(a).localeCompare(name(b)));
|
||||||
} else if (sort === "spent") {
|
} 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") {
|
} else if (sort === "earned") {
|
||||||
sorted.sort(
|
sorted.sort((a, b) => measure(b.earned) - measure(a.earned));
|
||||||
(a, b) => amountIn(b.earned, main) - amountIn(a.earned, main)
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
sorted.sort((a, b) =>
|
sorted.sort((a, b) =>
|
||||||
(b.item.attributes.last_activity ?? "").localeCompare(
|
(b.item.attributes.last_activity ?? "").localeCompare(
|
||||||
@@ -280,7 +282,10 @@
|
|||||||
</button>
|
</button>
|
||||||
</th>
|
</th>
|
||||||
{#if column.key === "earned"}
|
{#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}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -331,7 +336,7 @@
|
|||||||
<MoneyList sums={query.data?.earned ?? []} />
|
<MoneyList sums={query.data?.earned ?? []} />
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3">
|
<td class="px-3">
|
||||||
{@render bar(amountIn(query.data?.spent ?? [], main))}
|
{@render bar(measure(query.data?.spent ?? []))}
|
||||||
</td>
|
</td>
|
||||||
<td class="hidden md:table-cell"></td>
|
<td class="hidden md:table-cell"></td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -359,7 +364,7 @@
|
|||||||
<td class="px-3 text-right">
|
<td class="px-3 text-right">
|
||||||
<MoneyList sums={row.earned} />
|
<MoneyList sums={row.earned} />
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3">{@render bar(amountIn(row.spent, main))}</td>
|
<td class="px-3">{@render bar(measure(row.spent))}</td>
|
||||||
<td
|
<td
|
||||||
class="num hidden px-3 text-right text-[12px] text-cladd-fg-soft md:table-cell"
|
class="num hidden px-3 text-right text-[12px] text-cladd-fg-soft md:table-cell"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -32,8 +32,9 @@
|
|||||||
ContextMenuSeparator,
|
ContextMenuSeparator,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "#lib/components/ui/context-menu/index.js";
|
} 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 { ListNav } from "#lib/list-nav.svelte.js";
|
||||||
|
import { display } from "#lib/money/display.svelte.js";
|
||||||
import { oldUiUrl } from "#lib/spa-paths.js";
|
import { oldUiUrl } from "#lib/spa-paths.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
@@ -310,21 +311,25 @@
|
|||||||
<td class="{cell} {wide} text-right">
|
<td class="{cell} {wide} text-right">
|
||||||
{#if toNumber(p.target_amount) > 0}
|
{#if toNumber(p.target_amount) > 0}
|
||||||
<span class="num"
|
<span class="num"
|
||||||
>{formatMoney(piggyMoney(p, p.target_amount))}</span
|
>{display.text([
|
||||||
|
piggyMoney(p, p.target_amount),
|
||||||
|
])}</span
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class="{cell} {wide} text-right">
|
<td class="{cell} {wide} text-right">
|
||||||
{#if toNumber(p.left_to_save) > 0}
|
{#if toNumber(p.left_to_save) > 0}
|
||||||
<span class="num"
|
<span class="num"
|
||||||
>{formatMoney(piggyMoney(p, p.left_to_save))}</span
|
>{display.text([piggyMoney(p, p.left_to_save)])}</span
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class="{cell} hidden text-right lg:table-cell">
|
<td class="{cell} hidden text-right lg:table-cell">
|
||||||
{#if p.target_date && toNumber(p.save_per_month) > 0}
|
{#if p.target_date && toNumber(p.save_per_month) > 0}
|
||||||
<span class="num"
|
<span class="num"
|
||||||
>{formatMoney(piggyMoney(p, p.save_per_month))}</span
|
>{display.text([
|
||||||
|
piggyMoney(p, p.save_per_month),
|
||||||
|
])}</span
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
@@ -442,7 +447,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="{cell} {wide} text-right">
|
<td class="{cell} {wide} text-right">
|
||||||
<span class="num"
|
<span class="num"
|
||||||
>{formatMoney({ ...money, amount: targets })}</span
|
>{display.text([{ ...money, amount: targets }])}</span
|
||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
<td class="{cell} {wide} text-right">
|
<td class="{cell} {wide} text-right">
|
||||||
@@ -450,10 +455,9 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="{cell} {wide} text-right">
|
<td class="{cell} {wide} text-right">
|
||||||
<span class="num"
|
<span class="num"
|
||||||
>{formatMoney({
|
>{display.text([
|
||||||
...money,
|
{ ...money, amount: Math.max(0, targets - r.earmarked) },
|
||||||
amount: Math.max(0, targets - r.earmarked),
|
])}</span
|
||||||
})}</span
|
|
||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
toNumber,
|
toNumber,
|
||||||
} from "#lib/format.js";
|
} from "#lib/format.js";
|
||||||
import { useKeys } from "#lib/keys.svelte.js";
|
import { useKeys } from "#lib/keys.svelte.js";
|
||||||
|
import { display } from "#lib/money/display.svelte.js";
|
||||||
import { oldUiUrl } from "#lib/spa-paths.js";
|
import { oldUiUrl } from "#lib/spa-paths.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
@@ -89,6 +90,22 @@
|
|||||||
e.attributes.created_at ||
|
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(
|
const history = $derived(
|
||||||
[...(events.data ?? [])].sort((a, b) => when(b).localeCompare(when(a)))
|
[...(events.data ?? [])].sort((a, b) => when(b).localeCompare(when(a)))
|
||||||
);
|
);
|
||||||
@@ -109,8 +126,9 @@
|
|||||||
const [from] = [start, first, addDays(today(), -MIN_DAYS)].sort();
|
const [from] = [start, first, addDays(today(), -MIN_DAYS)].sort();
|
||||||
byDay.set(from, 0);
|
byDay.set(from, 0);
|
||||||
byDay.set(today(), running);
|
byDay.set(today(), running);
|
||||||
|
const scale = rate ?? 1;
|
||||||
return [...byDay]
|
return [...byDay]
|
||||||
.map(([x, y]) => ({ x, y }))
|
.map(([x, y]) => ({ x, y: y * scale }))
|
||||||
.sort((a, b) => a.x.localeCompare(b.x));
|
.sort((a, b) => a.x.localeCompare(b.x));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -200,13 +218,13 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<LineChart
|
<LineChart
|
||||||
area
|
area
|
||||||
axisFormat={(v) => formatMoney(piggyMoney(piggy, v))}
|
axisFormat={chartMoney}
|
||||||
height={260}
|
height={260}
|
||||||
label="Saved over time"
|
label="Saved over time"
|
||||||
series={[
|
series={[
|
||||||
{
|
{
|
||||||
color: tones.income,
|
color: tones.income,
|
||||||
format: (v) => formatMoney(piggyMoney(piggy, v)),
|
format: (v) => `${unit.approx}${chartMoney(v)}`,
|
||||||
key: "saved",
|
key: "saved",
|
||||||
label: "Saved so far",
|
label: "Saved so far",
|
||||||
points,
|
points,
|
||||||
@@ -256,7 +274,7 @@
|
|||||||
<td class={label}>Target amount</td>
|
<td class={label}>Target amount</td>
|
||||||
<td class="{value} num">
|
<td class="{value} num">
|
||||||
{target > 0
|
{target > 0
|
||||||
? formatMoney(piggyMoney(piggy, piggy.target_amount))
|
? display.text([piggyMoney(piggy, piggy.target_amount)])
|
||||||
: "No target"}
|
: "No target"}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -286,7 +304,7 @@
|
|||||||
<tr class={row}>
|
<tr class={row}>
|
||||||
<td class={label}>Left to save</td>
|
<td class={label}>Left to save</td>
|
||||||
<td class="{value} num">
|
<td class="{value} num">
|
||||||
{formatMoney(piggyMoney(piggy, piggy.left_to_save))}
|
{display.text([piggyMoney(piggy, piggy.left_to_save)])}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -306,7 +324,7 @@
|
|||||||
<tr class={row}>
|
<tr class={row}>
|
||||||
<td class={label}>Suggested monthly amount to save</td>
|
<td class={label}>Suggested monthly amount to save</td>
|
||||||
<td class="{value} num">
|
<td class="{value} num">
|
||||||
{formatMoney(piggyMoney(piggy, piggy.save_per_month))}
|
{display.text([piggyMoney(piggy, piggy.save_per_month)])}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -381,7 +399,9 @@
|
|||||||
>
|
>
|
||||||
{amount < 0 ? "Removed" : "Added"}
|
{amount < 0 ? "Removed" : "Added"}
|
||||||
<span class="num"
|
<span class="num"
|
||||||
>{formatMoney(piggyMoney(piggy, Math.abs(amount)))}</span
|
>{display.text([
|
||||||
|
piggyMoney(piggy, Math.abs(amount)),
|
||||||
|
])}</span
|
||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -147,6 +147,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<Amount
|
<Amount
|
||||||
amount={split.amount}
|
amount={split.amount}
|
||||||
|
code={split.currency_code}
|
||||||
decimals={split.currency_decimal_places}
|
decimals={split.currency_decimal_places}
|
||||||
symbol={split.currency_symbol}
|
symbol={split.currency_symbol}
|
||||||
tone={toneOf(r.type)}
|
tone={toneOf(r.type)}
|
||||||
|
|||||||
@@ -258,6 +258,7 @@
|
|||||||
<td class={tdNum}>
|
<td class={tdNum}>
|
||||||
<Amount
|
<Amount
|
||||||
amount={tx.amount}
|
amount={tx.amount}
|
||||||
|
code={tx.currency_code}
|
||||||
decimals={tx.currency_decimal_places}
|
decimals={tx.currency_decimal_places}
|
||||||
symbol={tx.currency_symbol}
|
symbol={tx.currency_symbol}
|
||||||
tone={toneOf(r.type)}
|
tone={toneOf(r.type)}
|
||||||
@@ -266,7 +267,9 @@
|
|||||||
<span class="text-cladd-fg-soft">
|
<span class="text-cladd-fg-soft">
|
||||||
(<Amount
|
(<Amount
|
||||||
amount={tx.foreign_amount}
|
amount={tx.foreign_amount}
|
||||||
|
code={tx.foreign_currency_code}
|
||||||
decimals={tx.foreign_currency_decimal_places}
|
decimals={tx.foreign_currency_decimal_places}
|
||||||
|
exact
|
||||||
symbol={tx.foreign_currency_symbol}
|
symbol={tx.foreign_currency_symbol}
|
||||||
tone="neutral"
|
tone="neutral"
|
||||||
/>)
|
/>)
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
import { formatDate, formatDateTime, formatMoney } from "#lib/format.js";
|
import { formatDate, formatDateTime, formatMoney } from "#lib/format.js";
|
||||||
import { useKeys } from "#lib/keys.svelte.js";
|
import { useKeys } from "#lib/keys.svelte.js";
|
||||||
import { ListNav } from "#lib/list-nav.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 { oldUiUrl } from "#lib/spa-paths.js";
|
||||||
import { type Patch, updateGroups } from "#lib/transactions/mass.js";
|
import { type Patch, updateGroups } from "#lib/transactions/mass.js";
|
||||||
import {
|
import {
|
||||||
@@ -168,15 +169,17 @@
|
|||||||
side === "destination" &&
|
side === "destination" &&
|
||||||
type === "transfer" &&
|
type === "transfer" &&
|
||||||
Boolean(first.foreign_currency_code);
|
Boolean(first.foreign_currency_code);
|
||||||
return formatMoney({
|
return display.text([
|
||||||
amount:
|
{
|
||||||
side === "destination" ? Math.max(...values) : Math.min(...values),
|
amount:
|
||||||
code: foreign ? first.foreign_currency_code : first.currency_code,
|
side === "destination" ? Math.max(...values) : Math.min(...values),
|
||||||
decimals: foreign
|
code: foreign ? first.foreign_currency_code : first.currency_code,
|
||||||
? first.foreign_currency_decimal_places
|
decimals: foreign
|
||||||
: first.currency_decimal_places,
|
? first.foreign_currency_decimal_places
|
||||||
symbol: foreign ? first.foreign_currency_symbol : first.currency_symbol,
|
: first.currency_decimal_places,
|
||||||
});
|
symbol: foreign ? first.foreign_currency_symbol : first.currency_symbol,
|
||||||
|
},
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const balances = $derived(
|
const balances = $derived(
|
||||||
@@ -203,6 +206,13 @@
|
|||||||
return [...sums.values()];
|
return [...sums.values()];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const totalApprox = $derived(
|
||||||
|
approxOf(
|
||||||
|
sumByCurrency(splits),
|
||||||
|
foreignTotals.find((sum) => sum.code === display.code)?.code
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const counterSide = $derived<"source" | "destination" | null>(
|
const counterSide = $derived<"source" | "destination" | null>(
|
||||||
{ deposit: "source", withdrawal: "destination" }[type as string] as
|
{ deposit: "source", withdrawal: "destination" }[type as string] as
|
||||||
| "source"
|
| "source"
|
||||||
@@ -261,6 +271,14 @@
|
|||||||
linking = "";
|
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) {
|
function dateLabel(value: string | undefined) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return "";
|
return "";
|
||||||
@@ -273,10 +291,11 @@
|
|||||||
|
|
||||||
function billPrice(bill: Item<Bill>) {
|
function billPrice(bill: Item<Bill>) {
|
||||||
const a = bill.attributes;
|
const a = bill.attributes;
|
||||||
const value = formatMoney(
|
const value = display.text([
|
||||||
billMoney(a, isRange(a) ? average(a) : Number(a.amount_min))
|
billMoney(a, isRange(a) ? average(a) : Number(a.amount_min)),
|
||||||
);
|
]);
|
||||||
return `${isRange(a) ? "~" : ""}${value} ${repeats(a)}`;
|
const rough = isRange(a) && !value.startsWith("≈");
|
||||||
|
return `${rough ? "~" : ""}${value} ${repeats(a)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remove() {
|
async function remove() {
|
||||||
@@ -357,6 +376,19 @@
|
|||||||
>
|
>
|
||||||
{/snippet}
|
{/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(
|
{#snippet account(
|
||||||
split: Split,
|
split: Split,
|
||||||
side: "source" | "destination"
|
side: "source" | "destination"
|
||||||
@@ -402,6 +434,7 @@
|
|||||||
>{formatMoney(foreign)}</span
|
>{formatMoney(foreign)}</span
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
|
{@render approx([signedAmount(split)], foreign?.code)}
|
||||||
{:else if field === "source" || field === "destination"}
|
{:else if field === "source" || field === "destination"}
|
||||||
{@render account(split, field)}
|
{@render account(split, field)}
|
||||||
{:else if field === "category"}
|
{:else if field === "category"}
|
||||||
@@ -713,6 +746,11 @@
|
|||||||
>{formatMoney(sum)}</span
|
>{formatMoney(sum)}</span
|
||||||
>
|
>
|
||||||
{/each}
|
{/each}
|
||||||
|
{#if totalApprox}
|
||||||
|
<span class="num font-medium text-[15px] text-cladd-fg-soft"
|
||||||
|
>{totalApprox}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
{@render cell("amount", 0)}
|
{@render cell("amount", 0)}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in new issue
Block a user