diff --git a/src/lib/components/Amount.svelte b/src/lib/components/Amount.svelte
index b7464ce..7067bd8 100644
--- a/src/lib/components/Amount.svelte
+++ b/src/lib/components/Amount.svelte
@@ -1,5 +1,6 @@
-{formatMoney({ amount, code, decimals, symbol }, signed)}{approx || converted !== null ? "≈" : ""}{shown}
diff --git a/src/lib/components/Money.svelte b/src/lib/components/Money.svelte
new file mode 100644
index 0000000..53e99b8
--- /dev/null
+++ b/src/lib/components/Money.svelte
@@ -0,0 +1,45 @@
+
+
+{#if total}
+
+{:else if inline}
+ {#each parts as part, index (part.code ?? index)}
+ {#if index > 0}
+ +
+ {/if}
+
+ {/each}
+{:else}
+ {#each parts as part, index (part.code ?? index)}
+
+ {/each}
+{/if}
diff --git a/src/lib/components/shell/Palette.svelte b/src/lib/components/shell/Palette.svelte
index 81c7805..0d57b25 100644
--- a/src/lib/components/shell/Palette.svelte
+++ b/src/lib/components/shell/Palette.svelte
@@ -4,6 +4,7 @@
import { lists } from "#lib/api/lists.js";
import { fuzzyFilter } from "#lib/fuzzy.js";
import { keys, prettyKeys } from "#lib/keys.svelte.js";
+ import { display } from "#lib/money/display.svelte.js";
import { allLinks, CREATE_LINKS } from "#lib/nav.js";
import { searches } from "#lib/transactions/searches.svelte.js";
import { ui } from "#lib/ui.svelte.js";
@@ -227,10 +228,26 @@
: []
);
+ const currencyEntries = $derived([
+ {
+ group: "Currency",
+ id: "currency:own",
+ label: "Show amounts in their own currencies",
+ run: () => display.show(null),
+ },
+ ...display.options.map((currency) => ({
+ group: "Currency",
+ id: `currency:${currency.code}`,
+ label: `Show everything in ${currency.code} (${currency.symbol})`,
+ run: () => display.show(currency.code),
+ })),
+ ]);
+
const entries = $derived([
...pageEntries,
...searchEntries,
...staticEntries,
+ ...currencyEntries,
...commandEntries,
...entityEntries,
]);
diff --git a/src/lib/dashboard/primary.svelte.ts b/src/lib/dashboard/primary.svelte.ts
index 16d970d..53b5640 100644
--- a/src/lib/dashboard/primary.svelte.ts
+++ b/src/lib/dashboard/primary.svelte.ts
@@ -2,9 +2,9 @@ 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 KEY = "ffui.dashboard.in-primary";
const OWN_SAMPLE = 100;
const HOUR_MS = 3_600_000;
@@ -25,13 +25,12 @@ export function median(values: number[]): number {
}
class InPrimary {
- on = $state(
- typeof localStorage !== "undefined" && localStorage.getItem(KEY) === "1"
- );
+ get on() {
+ return display.on && display.code === session.primary?.code;
+ }
toggle() {
- this.on = !this.on;
- localStorage.setItem(KEY, this.on ? "1" : "0");
+ display.toggle();
}
}
diff --git a/src/lib/money/display.svelte.ts b/src/lib/money/display.svelte.ts
new file mode 100644
index 0000000..9fbd256
--- /dev/null
+++ b/src/lib/money/display.svelte.ts
@@ -0,0 +1,220 @@
+import { api } from "#lib/api/client.js";
+import { all, queryClient } from "#lib/api/lists.js";
+import type { ExchangeRate, Page, TransactionGroup } from "#lib/api/types.js";
+import { formatMoney, type Money, toNumber } from "#lib/format.js";
+import { session } from "#lib/session.svelte.js";
+
+const KEY = "ffui.display";
+const OWN_SAMPLE = 200;
+const HOUR_MS = 3_600_000;
+
+type Graph = Map>;
+
+export interface Total {
+ amount: number;
+ code: string;
+ converted: boolean;
+ decimals: number;
+ symbol: string;
+}
+
+function link(graph: Graph, from: string, to: string, rate: number) {
+ if (!(rate > 0 && Number.isFinite(rate)) || from === to) {
+ return;
+ }
+ const row = graph.get(from) ?? new Map();
+ if (!row.has(to)) {
+ row.set(to, rate);
+ }
+ graph.set(from, row);
+}
+
+function listed(rates: { attributes: ExchangeRate }[], graph: Graph) {
+ const latest = new Map();
+ for (const { attributes: r } of rates) {
+ const from = r.from_currency_code;
+ const to = r.to_currency_code;
+ const rate = toNumber(r.rate);
+ const date = r.date ?? "";
+ const key = `${from}>${to}`;
+ if (from && to && rate && date >= (latest.get(key)?.date ?? "")) {
+ latest.set(key, { date, rate });
+ }
+ }
+ for (const [key, { rate }] of latest) {
+ const [from, to] = key.split(">");
+ link(graph, from, to, rate);
+ link(graph, to, from, 1 / rate);
+ }
+}
+
+function implied(groups: { attributes: TransactionGroup }[], graph: Graph) {
+ const sums = new Map();
+ for (const split of groups.flatMap((g) => g.attributes.transactions)) {
+ const from = split.currency_code;
+ const to = split.foreign_currency_code;
+ const x = Math.abs(toNumber(split.amount));
+ const y = Math.abs(toNumber(split.foreign_amount));
+ if (from && to && from !== to && x > 0 && y > 0) {
+ const pair = sums.get(`${from}>${to}`) ?? [0, 0];
+ sums.set(`${from}>${to}`, [pair[0] + x, pair[1] + y]);
+ }
+ }
+ for (const [key, [x, y]] of sums) {
+ const [from, to] = key.split(">");
+ link(graph, from, to, y / x);
+ link(graph, to, from, x / y);
+ }
+}
+
+async function loadGraph(): Promise {
+ const [rates, own] = await Promise.all([
+ all("/v1/exchange-rates").catch(() => []),
+ api>("/v1/search/transactions", {
+ query: { limit: OWN_SAMPLE, query: "has_foreign_amount:true" },
+ }).catch(() => ({ data: [] })),
+ ]);
+ const graph: Graph = new Map();
+ listed(rates, graph);
+ implied(own.data, graph);
+ return graph;
+}
+
+function rateIn(graph: Graph, from: string, to: string): number | null {
+ if (from === to) {
+ return 1;
+ }
+ const rates = new Map([[from, 1]]);
+ const queue = [from];
+ for (const code of queue) {
+ const base = rates.get(code) ?? 1;
+ for (const [next, rate] of graph.get(code) ?? []) {
+ if (!rates.has(next)) {
+ rates.set(next, base * rate);
+ queue.push(next);
+ }
+ }
+ if (rates.has(to)) {
+ return rates.get(to) ?? null;
+ }
+ }
+ return null;
+}
+
+function read(): { code: string | null; on: boolean } {
+ try {
+ return JSON.parse(localStorage.getItem(KEY) ?? "") ?? {};
+ } catch {
+ return { code: null, on: false };
+ }
+}
+
+class Display {
+ on = $state(false);
+ chosen = $state(null);
+ graph = $state.raw(null);
+
+ constructor() {
+ if (typeof localStorage !== "undefined") {
+ const saved = read();
+ this.on = saved.on === true;
+ this.chosen = saved.code ?? null;
+ }
+ }
+
+ get code(): string {
+ return this.chosen ?? session.primary?.code ?? "";
+ }
+
+ get target() {
+ const { code } = this;
+ const found = session.currencies.find((c) => c.attributes.code === code);
+ return {
+ code,
+ decimals: found?.attributes.decimal_places ?? 2,
+ symbol: found?.attributes.symbol ?? code,
+ };
+ }
+
+ get active(): boolean {
+ return this.on && this.graph !== null && this.code !== "";
+ }
+
+ get options() {
+ return session.currencies
+ .filter((c) => c.attributes.enabled)
+ .map((c) => c.attributes);
+ }
+
+ private save() {
+ localStorage.setItem(
+ KEY,
+ JSON.stringify({ code: this.chosen, on: this.on })
+ );
+ }
+
+ async load() {
+ if (this.graph) {
+ return;
+ }
+ this.graph = await queryClient.fetchQuery({
+ queryFn: loadGraph,
+ queryKey: ["display-rates"],
+ staleTime: HOUR_MS,
+ });
+ }
+
+ show(code: string | null) {
+ if (code === null) {
+ this.on = false;
+ } else {
+ this.chosen = code;
+ this.on = true;
+ this.load().catch(() => undefined);
+ }
+ this.save();
+ }
+
+ toggle() {
+ this.show(this.on ? null : this.code);
+ }
+
+ rate(from: string | null | undefined): number | null {
+ if (!(this.active && this.graph && from)) {
+ return null;
+ }
+ return rateIn(this.graph, from, this.code);
+ }
+
+ convert(amount: number, from: string | null | undefined): number | null {
+ const rate = this.rate(from);
+ return rate === null ? null : amount * rate;
+ }
+
+ total(parts: Money[]): Total | null {
+ if (!this.active || parts.length === 0) {
+ return null;
+ }
+ let amount = 0;
+ let converted = false;
+ for (const part of parts) {
+ const value = this.convert(toNumber(part.amount), part.code);
+ if (value === null) {
+ return null;
+ }
+ amount += value;
+ converted ||= part.code !== this.code;
+ }
+ return { ...this.target, amount, converted };
+ }
+
+ text(parts: Money[], signed = false, separator = " + "): string {
+ const sum = this.total(parts);
+ if (sum) {
+ return `${sum.converted ? "≈" : ""}${formatMoney(sum, signed)}`;
+ }
+ return parts.map((part) => formatMoney(part, signed)).join(separator);
+ }
+}
+
+export const display = new Display();
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 955e120..d19ad73 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -14,30 +14,11 @@
import PiggyBanksBox from "#lib/components/dashboard/PiggyBanksBox.svelte";
import SubscriptionsBox from "#lib/components/dashboard/SubscriptionsBox.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
- import Checkbox from "#lib/components/ui/checkbox/checkbox.svelte";
import { provideColumn } from "#lib/dashboard/column.svelte.js";
- import { inPrimary, ratesQuery } from "#lib/dashboard/primary.svelte.js";
- import { useKeys } from "#lib/keys.svelte.js";
import { period } from "#lib/period.svelte.js";
- import { session } from "#lib/session.svelte.js";
provideColumn();
- const rates = createQuery(() => ratesQuery());
- const primary = $derived(session.primary?.code ?? "");
- const ownRates = $derived(
- inPrimary.on && Object.values(rates.data ?? {}).some((r) => r.own)
- );
-
- useKeys(() => [
- {
- group: "Dashboard",
- keys: "$",
- label: `Show totals in ${primary}`,
- run: () => inPrimary.toggle(),
- },
- ]);
-
const assets = createQuery(() =>
accountsQuery("asset", period.start, period.end)
);
@@ -49,22 +30,7 @@
});
-
- {#snippet actions()}
-
- inPrimary.toggle()}
- />
-
- $
- {#if ownRates}
- ≈ from your own exchanges
- {/if}
-
- {/snippet}
-
+