From 2231c08eeecfdfd31422e3326eac0b691d0bcf3f Mon Sep 17 00:00:00 2001 From: h Date: Fri, 16 Oct 2026 18:15:53 +0000 Subject: [PATCH] feat: show every amount in one currency, chosen in the header, $ toggles it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A selector in the header and the palette's Currency group pick the currency, the primary one by default; the choice persists between pages and $ switches it on and off. Rates come from Firefly's exchange rates, then from the owner's own transactions with a foreign amount, walking through other currencies when there is no direct pair. Amount converts with a ≈ and keeps the original in its title; Money sums several currencies into one converted amount. --- src/lib/components/Amount.svelte | 30 +++- src/lib/components/Money.svelte | 45 +++++ src/lib/components/shell/Palette.svelte | 17 ++ src/lib/dashboard/primary.svelte.ts | 11 +- src/lib/money/display.svelte.ts | 220 ++++++++++++++++++++++++ src/routes/+page.svelte | 36 +--- 6 files changed, 315 insertions(+), 44 deletions(-) create mode 100644 src/lib/components/Money.svelte create mode 100644 src/lib/money/display.svelte.ts 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} -
+