feat: show every amount in one currency, chosen in the header, $ toggles it

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.
This commit is contained in:
hh committed 2026-10-16 18:15:53 +00:00
1 parent 3c1d8a4867
commit 2231c08eee
6 files changed
+315 -44

No files matched your search

+27 -3
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { formatMoney, type Money, toNumber } from "#lib/format.js";
import { display } from "#lib/money/display.svelte.js";
export type Tone = "income" | "expense" | "transfer" | "neutral" | "auto";
@@ -10,10 +11,31 @@
decimals,
tone = "auto",
signed = false,
approx = false,
exact = false,
class: className = "",
}: Money & { tone?: Tone; signed?: boolean; class?: string } = $props();
}: Money & {
tone?: Tone;
signed?: boolean;
approx?: boolean;
exact?: boolean;
class?: string;
} = $props();
const value = $derived(toNumber(amount));
const converted = $derived(
exact || !code || code === display.code
? null
: display.convert(value, code)
);
const original = $derived(
formatMoney({ amount, code, decimals, symbol }, signed)
);
const shown = $derived(
converted === null
? original
: formatMoney({ ...display.target, amount: converted }, signed)
);
const resolved = $derived.by(() => {
if (tone !== "auto") {
return tone;
@@ -33,6 +55,8 @@
);
</script>
<span class="num font-medium {color} {className}"
>{formatMoney({ amount, code, decimals, symbol }, signed)}</span
<span
class="num font-medium {color} {className}"
title={converted === null ? undefined : original}
>{approx || converted !== null ? "≈" : ""}{shown}</span
>
+45
View File
@@ -0,0 +1,45 @@
<script lang="ts">
import type { Money } from "#lib/format.js";
import { display } from "#lib/money/display.svelte.js";
import Amount, { type Tone } from "./Amount.svelte";
let {
parts,
tone = "auto",
signed = false,
inline = false,
class: className = "",
}: {
parts: Money[];
tone?: Tone;
signed?: boolean;
inline?: boolean;
class?: string;
} = $props();
const total = $derived(display.total(parts));
</script>
{#if total}
<Amount
amount={total.amount}
approx={total.converted}
class={className}
code={total.code}
decimals={total.decimals}
{signed}
symbol={total.symbol}
{tone}
/>
{:else if inline}
{#each parts as part, index (part.code ?? index)}
{#if index > 0}
<span class="text-cladd-fg-softer"> + </span>
{/if}
<Amount {...part} class={className} {signed} {tone} />
{/each}
{:else}
{#each parts as part, index (part.code ?? index)}
<div><Amount {...part} class={className} {signed} {tone} /></div>
{/each}
{/if}
+17
View File
@@ -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<Entry[]>([
{
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,
]);
+5 -6
View File
@@ -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();
}
}
+220
View File
@@ -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<string, Map<string, number>>;
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<string, number>();
if (!row.has(to)) {
row.set(to, rate);
}
graph.set(from, row);
}
function listed(rates: { attributes: ExchangeRate }[], graph: Graph) {
const latest = new Map<string, { date: string; rate: number }>();
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<string, [number, number]>();
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<Graph> {
const [rates, own] = await Promise.all([
all<ExchangeRate>("/v1/exchange-rates").catch(() => []),
api<Page<TransactionGroup>>("/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<string | null>(null);
graph = $state.raw<Graph | null>(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();
+1 -35
View File
@@ -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 @@
});
</script>
<PageHeader subtitle="What's playing?" title="Dashboard">
{#snippet actions()}
<div class="flex items-center gap-1.5 text-[12px] text-cladd-fg-soft">
<Checkbox
checked={inPrimary.on}
id="in-primary"
onCheckedChange={() => inPrimary.toggle()}
/>
<label for="in-primary">Show in {primary}</label>
<span class="hidden sm:inline"><span class="kbd">$</span></span>
{#if ownRates}
<span>≈ from your own exchanges</span>
{/if}
</div>
{/snippet}
</PageHeader>
<PageHeader subtitle="What's playing?" title="Dashboard" />
<InfoBoxes />