commit 93c79ec3d697c4c78e52070149967851fd801028
parent 38afdd9c04fd54bbdc36206a3ff70d3ae9b9b933
Author: Florian Dold <dold@taler.net>
Date: Wed, 26 Aug 2026 02:52:13 +0200
wallet web UI: load balance history on demand
Diffstat:
12 files changed, 777 insertions(+), 97 deletions(-)
diff --git a/packages/wallet-webui/src/api/balance-history.ts b/packages/wallet-webui/src/api/balance-history.ts
@@ -0,0 +1,165 @@
+import type {
+ GetTransactionsV2Request,
+ ScopeInfo,
+ Transaction,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import type { WalletCoreResponseType } from "@gnu-taler/taler-wallet-core";
+import { useEffect, useMemo } from "preact/hooks";
+import useSWRInfinite from "swr/infinite";
+import { walletKey, type WalletSWRKey } from "./canonical.js";
+import type { WalletConnection } from "./contracts.js";
+import {
+ BALANCE_HISTORY_PERIOD_SECONDS,
+ type BalanceHistoryPeriod,
+} from "../ui/balance-history.js";
+
+export type { BalanceHistoryPeriod } from "../ui/balance-history.js";
+
+const PAGE_SIZE = 128;
+
+type TransactionPage =
+ WalletCoreResponseType<WalletApiOperation.GetTransactionsV2>;
+
+function numericTimestamp(transaction: Transaction): number | undefined {
+ return typeof transaction.timestamp.t_s === "number"
+ ? transaction.timestamp.t_s
+ : undefined;
+}
+
+export function balanceHistoryPageRequest(
+ scopeInfo: ScopeInfo | undefined,
+ enabled: boolean,
+ previousPage?: TransactionPage,
+): GetTransactionsV2Request | undefined {
+ if (!enabled || !scopeInfo) return undefined;
+ const request: GetTransactionsV2Request = {
+ limit: -PAGE_SIZE,
+ filterByState: "done",
+ includeRefreshes: true,
+ scopeInfo,
+ };
+ if (!previousPage) return request;
+ if (previousPage.transactions.length < PAGE_SIZE) return undefined;
+ const oldest = previousPage.transactions.at(-1);
+ if (!oldest) return undefined;
+ return {
+ ...request,
+ offsetTransactionId: oldest.transactionId,
+ offsetTimestamp: oldest.timestamp,
+ };
+}
+
+export function resolveBalanceHistoryPeriod(
+ transactions: Transaction[] | undefined,
+ anchorSeconds: number,
+ explicitPeriod: BalanceHistoryPeriod | undefined,
+): BalanceHistoryPeriod {
+ if (explicitPeriod) return explicitPeriod;
+ const dayCutoff = anchorSeconds - BALANCE_HISTORY_PERIOD_SECONDS.day;
+ return transactions?.some(
+ (transaction) =>
+ (numericTimestamp(transaction) ?? Number.POSITIVE_INFINITY) <= dayCutoff,
+ )
+ ? "week"
+ : "day";
+}
+
+export interface BalanceHistoryQuery {
+ transactions: Transaction[] | undefined;
+ anchorSeconds: number;
+ period: BalanceHistoryPeriod;
+ loading: boolean;
+ error: Error | undefined;
+}
+
+export function useBalanceHistoryQuery(
+ connection: WalletConnection,
+ scopeInfo: ScopeInfo | undefined,
+ enabled: boolean,
+ explicitPeriod: BalanceHistoryPeriod | undefined,
+): BalanceHistoryQuery {
+ const anchorSeconds = Math.floor(Date.now() / 1000);
+ const query = useSWRInfinite<TransactionPage, Error>(
+ (pageIndex, previousPage) => {
+ if (pageIndex > 0 && !previousPage) return null;
+ const request = balanceHistoryPageRequest(
+ scopeInfo,
+ enabled,
+ pageIndex === 0 ? undefined : (previousPage ?? undefined),
+ );
+ if (!request) return null;
+ return walletKey(WalletApiOperation.GetTransactionsV2, request);
+ },
+ async (key: WalletSWRKey) => {
+ const request = JSON.parse(key[2]) as GetTransactionsV2Request;
+ return connection.client.call(
+ WalletApiOperation.GetTransactionsV2,
+ request,
+ );
+ },
+ { revalidateAll: true },
+ );
+ const transactions = useMemo(() => {
+ if (!query.data) return undefined;
+ const byId = new Map<string, Transaction>();
+ for (const page of query.data) {
+ for (const transaction of page.transactions) {
+ byId.set(transaction.transactionId, transaction);
+ }
+ }
+ return [...byId.values()].sort(
+ (left, right) =>
+ (numericTimestamp(right) ?? Number.NEGATIVE_INFINITY) -
+ (numericTimestamp(left) ?? Number.NEGATIVE_INFINITY),
+ );
+ }, [query.data]);
+ const oldestSeconds = transactions
+ ?.map(numericTimestamp)
+ .filter((value): value is number => value !== undefined)
+ .at(-1);
+ const exhausted =
+ query.data !== undefined &&
+ (query.data.at(-1)?.transactions.length ?? 0) < PAGE_SIZE;
+ const period = resolveBalanceHistoryPeriod(
+ transactions,
+ anchorSeconds,
+ explicitPeriod,
+ );
+ const cutoff = anchorSeconds - BALANCE_HISTORY_PERIOD_SECONDS[period];
+ const complete =
+ exhausted || (oldestSeconds !== undefined && oldestSeconds <= cutoff);
+ const loadingMore =
+ query.data !== undefined && query.data.length < query.size;
+ useEffect(() => {
+ if (
+ enabled &&
+ scopeInfo &&
+ query.data &&
+ !query.error &&
+ !complete &&
+ !loadingMore &&
+ !query.isValidating
+ ) {
+ void query.setSize(query.size + 1);
+ }
+ }, [
+ complete,
+ enabled,
+ loadingMore,
+ query,
+ query.data,
+ query.error,
+ query.isValidating,
+ query.setSize,
+ query.size,
+ scopeInfo,
+ ]);
+ return {
+ transactions,
+ anchorSeconds,
+ period,
+ loading: enabled && !query.error && (query.data === undefined || !complete),
+ error: query.error,
+ };
+}
diff --git a/packages/wallet-webui/src/routes/App.tsx b/packages/wallet-webui/src/routes/App.tsx
@@ -48,6 +48,10 @@ import { ServicesContext, useServices } from "./context.js";
import { operationsForNotification } from "../api/notifications.js";
import { useWalletMutation, useWalletQuery } from "../api/wallet.js";
import {
+ useBalanceHistoryQuery,
+ type BalanceHistoryPeriod,
+} from "../api/balance-history.js";
+import {
isProgressRequestCancelled,
useVisibleWalletProgress,
useWalletProgress,
@@ -202,6 +206,10 @@ import {
durabilityGuidance,
useDurability,
} from "../platform/durability.js";
+import {
+ UiPreferencesProvider,
+ useUiPreferences,
+} from "../stores/preferences.js";
const decodeQr = jsQR as unknown as (
data: Uint8ClampedArray,
@@ -223,7 +231,9 @@ function useWalletHashLocation(): ReturnType<typeof useHashLocation> {
export function App(props: AppServices) {
return (
<LanguageProvider>
- <AppContent {...props} />
+ <UiPreferencesProvider>
+ <AppContent {...props} />
+ </UiPreferencesProvider>
</LanguageProvider>
);
}
@@ -634,6 +644,7 @@ function UriInputRoute() {
function BalanceRoute() {
const { connection, demo } = useServices();
const { language } = useLanguage();
+ const uiPreferences = useUiPreferences();
const [, navigate] = useLocation();
const callMutation = useWalletMutation(connection);
const durability = useDurability();
@@ -664,6 +675,18 @@ function BalanceRoute() {
const activitySupported =
selectedBalance?.scopeInfo.type === ScopeType.Global ||
selectedBalance?.scopeInfo.type === ScopeType.Exchange;
+ const [explicitTrendPeriod, setExplicitTrendPeriod] =
+ useState<BalanceHistoryPeriod>();
+ const trendEnabled =
+ uiPreferences.balanceHistoryEnabled &&
+ Boolean(selectedBalance) &&
+ activitySupported;
+ const trendHistory = useBalanceHistoryQuery(
+ connection,
+ selectedBalance?.scopeInfo,
+ trendEnabled,
+ explicitTrendPeriod,
+ );
const activityRequest = {
limit: -30,
...(selectedBalance ? { scopeInfo: selectedBalance.scopeInfo } : {}),
@@ -733,10 +756,22 @@ function BalanceRoute() {
selectedScopeId={selectedBalance?.scopeId}
trend={balanceTrendView(
selectedBalance,
- recent.data?.transactions,
- 15,
+ trendHistory.transactions,
+ trendHistory.period,
+ trendHistory.anchorSeconds,
language,
)}
+ trendEnabled={trendEnabled}
+ trendPeriod={trendHistory.period}
+ trendLoading={trendHistory.loading}
+ trendError={
+ trendHistory.error
+ ? errorFromException(
+ trendHistory.error,
+ i18n.str`Balance history could not be loaded.`,
+ )
+ : undefined
+ }
trendNotice={
selectedBalance?.scopeInfo.type === ScopeType.Exchange
? i18n.str`Estimated history: a transaction involving more than one exchange is counted in full for each exchange, and deleted activity is unavailable.`
@@ -748,6 +783,7 @@ function BalanceRoute() {
pendingTransactions={pending.data?.transactions.map((transaction) =>
transactionHistoryView(transaction, language),
)}
+ onTrendPeriod={setExplicitTrendPeriod}
loading={query.isLoading}
activityLoading={recent.isLoading || pending.isLoading}
error={
@@ -4282,6 +4318,7 @@ async function downloadJson(filename: string, value: unknown): Promise<void> {
function SettingsRoute() {
const { connection, storage, demo, platform } = useServices();
const language = useLanguage();
+ const uiPreferences = useUiPreferences();
const durability = useDurability();
const { mutate } = useSWRConfig();
const [, navigate] = useLocation();
@@ -4792,6 +4829,7 @@ function SettingsRoute() {
browserIntegration={browserIntegration}
browserIntegrationBusy={browserIntegrationBusy}
browserIntegrationMessage={browserIntegrationMessage}
+ balanceHistoryEnabled={uiPreferences.balanceHistoryEnabled}
onExport={() => void exportDb()}
onDiagnostics={() => void exportDiagnostics()}
onImport={(file) => void reviewImport(file)}
@@ -4819,6 +4857,7 @@ function SettingsRoute() {
onSetDeveloperMode={(enabled) => void setDeveloperMode(enabled)}
onWithdrawTestMoney={(currency) => void withdrawTestMoney(currency)}
onSetLanguage={language.setPreference}
+ onSetBalanceHistoryEnabled={uiPreferences.setBalanceHistoryEnabled}
onSetBrowserIntegration={(key, value) =>
void updateBrowserIntegration(key, value)
}
diff --git a/packages/wallet-webui/src/routes/balance-model.ts b/packages/wallet-webui/src/routes/balance-model.ts
@@ -9,11 +9,15 @@ import {
BalanceFlag,
ScopeType,
TransactionMajorState,
+ TransactionType,
} from "@gnu-taler/taler-util";
import type { BalanceView } from "../screens/BalanceScreen.js";
import type { DonauSummaryView } from "../screens/DonauSettingsScreen.js";
import type { BalanceTrendPoint } from "../screens/BalanceScreen.js";
-import { transactionHistoryView } from "./transaction-model.js";
+import {
+ BALANCE_HISTORY_PERIOD_SECONDS,
+ type BalanceHistoryPeriod,
+} from "../ui/balance-history.js";
import {
scopeIdentity,
scopeLabel,
@@ -86,23 +90,56 @@ function numericValue(amount: string): number {
return parsed.value + parsed.fraction / 100_000_000;
}
-function dayBucket(seconds: number): string {
- const date = new Date(seconds * 1000);
- return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
+function bucketLabel(
+ seconds: number,
+ period: BalanceHistoryPeriod,
+ language?: string,
+): string {
+ return new Intl.DateTimeFormat(
+ language,
+ period === "day"
+ ? { hour: "2-digit", minute: "2-digit" }
+ : { day: "2-digit", month: "2-digit" },
+ ).format(new Date(seconds * 1000));
+}
+
+function bucketCount(period: BalanceHistoryPeriod): number {
+ switch (period) {
+ case "day":
+ return 24;
+ case "week":
+ return 7;
+ case "month":
+ return 30;
+ case "quarter":
+ return 13;
+ }
}
-function dayLabel(seconds: number, language?: string): string {
- return new Intl.DateTimeFormat(language, {
- day: "2-digit",
- month: "2-digit",
- }).format(new Date(seconds * 1000));
+function balanceDirection(transaction: Transaction): "credit" | "debit" {
+ switch (transaction.type) {
+ case TransactionType.Withdrawal:
+ case TransactionType.InternalWithdrawal:
+ case TransactionType.Refund:
+ case TransactionType.PeerPullCredit:
+ case TransactionType.PeerPushCredit:
+ case TransactionType.Recoup:
+ return "credit";
+ case TransactionType.Payment:
+ case TransactionType.Refresh:
+ case TransactionType.Deposit:
+ case TransactionType.PeerPullDebit:
+ case TransactionType.PeerPushDebit:
+ case TransactionType.DenomLoss:
+ return "debit";
+ }
}
/**
* Estimate the recent available-balance curve from retained wallet activity.
- * Completed, scope-filtered transactions are grouped by local
- * calendar day. Each bucket records the end-of-day balance and transaction
- * count. Deleted transactions cannot be inferred. For an exchange scope,
+ * Completed, scope-filtered transactions are grouped into a fixed rolling
+ * window. Each bucket records its ending balance and transaction count.
+ * Deleted transactions cannot be inferred. For an exchange scope,
* wallet-core currently reports whether an exchange participated but not the
* delta attributable to that exchange, so a multi-exchange transaction's full
* effective amount is applied to every participating exchange. Pending and
@@ -111,14 +148,25 @@ function dayLabel(seconds: number, language?: string): string {
export function balanceTrendView(
balance: BalanceView | undefined,
transactions: Transaction[] | undefined,
- maximumBuckets = 15,
+ period: BalanceHistoryPeriod,
+ anchorSeconds: number,
language?: string,
): BalanceTrendPoint[] {
if (!balance || !transactions) return [];
const current = Amounts.parseOrThrow(balance.available);
+ const buckets = Array.from({ length: bucketCount(period) }, () => ({
+ activities: [] as Array<{
+ amount: ReturnType<typeof Amounts.parseOrThrow>;
+ direction: "credit" | "debit";
+ seconds: number;
+ }>,
+ }));
+ const windowSeconds = BALANCE_HISTORY_PERIOD_SECONDS[period];
+ const bucketSeconds = windowSeconds / buckets.length;
+ const startSeconds = anchorSeconds - windowSeconds;
const eligible: Array<{
amount: ReturnType<typeof Amounts.parseOrThrow>;
- direction: "credit" | "debit" | "neutral";
+ direction: "credit" | "debit";
seconds: number;
}> = [];
for (const transaction of transactions) {
@@ -129,71 +177,74 @@ export function balanceTrendView(
)
continue;
const effective = Amounts.parse(transaction.amountEffective);
- if (!effective || effective.currency !== current.currency) continue;
+ if (
+ !effective ||
+ effective.currency !== current.currency ||
+ !Amounts.isNonZero(effective)
+ )
+ continue;
const seconds = transaction.timestamp.t_s;
- if (typeof seconds !== "number") continue;
+ if (
+ typeof seconds !== "number" ||
+ seconds < startSeconds ||
+ seconds > anchorSeconds
+ )
+ continue;
eligible.push({
amount: effective,
- direction: transactionHistoryView(transaction, language).direction,
+ direction: balanceDirection(transaction),
seconds,
});
}
+ if (eligible.length === 0) return [];
eligible.sort((a, b) => b.seconds - a.seconds);
-
- const newestBuckets: Array<{
- seconds: number;
- activities: typeof eligible;
- }> = [];
for (const activity of eligible) {
- const newest = newestBuckets.at(-1);
- if (newest && dayBucket(newest.seconds) === dayBucket(activity.seconds)) {
- newest.activities.push(activity);
- continue;
- }
- if (newestBuckets.length >= maximumBuckets) break;
- newestBuckets.push({ seconds: activity.seconds, activities: [activity] });
+ const index = Math.min(
+ buckets.length - 1,
+ Math.max(
+ 0,
+ Math.floor((activity.seconds - startSeconds) / bucketSeconds),
+ ),
+ );
+ buckets[index]!.activities.push(activity);
}
-
let cursor = current;
- for (const bucket of newestBuckets) {
+ const values = Array<number>(buckets.length + 1);
+ values[buckets.length] = numericValue(Amounts.stringify(current));
+ for (let index = buckets.length - 1; index >= 0; index--) {
+ const bucket = buckets[index]!;
for (const activity of bucket.activities) {
cursor =
activity.direction === "credit"
? Amounts.sub(cursor, activity.amount).amount
: Amounts.add(cursor, activity.amount).amount;
}
+ values[index] = numericValue(Amounts.stringify(cursor));
}
const points: BalanceTrendPoint[] = [
{
kind: "start",
- value: numericValue(Amounts.stringify(cursor)),
+ value: values[0]!,
// Translators: Label for the first point on a balance-history chart.
label: i18n.str`Start`,
transactionCount: 0,
},
];
-
- for (const bucket of newestBuckets.reverse()) {
- for (const activity of bucket.activities.toReversed()) {
- cursor =
- activity.direction === "credit"
- ? Amounts.add(cursor, activity.amount).amount
- : Amounts.sub(cursor, activity.amount).amount;
- }
+ // Translators: Label for the current point on a balance-history chart.
+ const currentLabel = i18n.str`Now`;
+ for (let index = 0; index < buckets.length; index++) {
+ const boundarySeconds = startSeconds + (index + 1) * bucketSeconds;
+ const currentPoint = index === buckets.length - 1;
points.push({
- kind: "transactions",
- value: numericValue(Amounts.stringify(cursor)),
- label: dayLabel(bucket.seconds, language),
- timestampIso: new Date(bucket.seconds * 1000).toISOString(),
- transactionCount: bucket.activities.length,
+ kind: currentPoint ? "current" : "transactions",
+ value: values[index + 1]!,
+ label: currentPoint
+ ? currentLabel
+ : bucketLabel(boundarySeconds, period, language),
+ timestampIso: new Date(boundarySeconds * 1000).toISOString(),
+ transactionCount: buckets[index]!.activities.length,
});
}
- points.push({
- kind: "current",
- value: numericValue(Amounts.stringify(current)),
- // Translators: Label for the current point on a balance-history chart.
- label: i18n.str`Now`,
- });
return points;
}
diff --git a/packages/wallet-webui/src/screens/BalanceScreen.tsx b/packages/wallet-webui/src/screens/BalanceScreen.tsx
@@ -7,6 +7,7 @@ import { ErrorCard } from "../ui/ErrorCard.js";
import type { ErrorPresentation } from "../ui/error.js";
import { WalletIcon, type WalletIconName } from "../ui/WalletIcon.js";
import { i18n } from "../i18n/runtime.js";
+import type { BalanceHistoryPeriod } from "../ui/balance-history.js";
export interface BalanceView {
scopeId: string;
@@ -349,6 +350,60 @@ function BalanceTrend(props: {
);
}
+function BalanceTrendSection(props: {
+ enabled: boolean;
+ balance: BalanceView;
+ points: BalanceTrendPoint[];
+ period: BalanceHistoryPeriod;
+ loading: boolean;
+ error?: ErrorPresentation;
+ notice?: string;
+ onPeriod: (period: BalanceHistoryPeriod) => void;
+}) {
+ if (!props.enabled) return null;
+ return (
+ <div class="mt-4 border-t border-outlineVariant pt-3">
+ <div class="flex justify-end">
+ <label>
+ <span class="sr-only">{i18n.str`Balance history period`}</span>
+ <select
+ value={props.period}
+ onChange={(event) =>
+ props.onPeriod(event.currentTarget.value as BalanceHistoryPeriod)
+ }
+ class="min-h-9 rounded-full border border-outlineVariant bg-surface px-3 text-sm text-onSurface"
+ >
+ <option value="day">{i18n.str`Day`}</option>
+ <option value="week">{i18n.str`Week`}</option>
+ <option value="month">{i18n.str`Month`}</option>
+ <option value="quarter">{i18n.str`Quarter`}</option>
+ </select>
+ </label>
+ </div>
+ {props.loading ? (
+ <p class="py-8 text-center text-sm text-secondary" role="status">
+ {i18n.str`Loading balance history…`}
+ </p>
+ ) : props.error ? (
+ <ErrorCard class="mt-3" error={props.error} />
+ ) : props.points.length >= 2 ? (
+ <>
+ <BalanceTrend balance={props.balance} points={props.points} />
+ {props.notice && (
+ <p class="mt-3 text-xs text-secondary" role="note">
+ {props.notice}
+ </p>
+ )}
+ </>
+ ) : (
+ <p class="py-8 text-center text-sm text-secondary">
+ {i18n.str`A balance trend will appear after completed transactions.`}
+ </p>
+ )}
+ </div>
+ );
+}
+
function TransactionRows(props: {
transactions?: TransactionHistoryView[];
empty: string;
@@ -530,6 +585,10 @@ export function BalanceScreen(props: {
selectedScopeId?: string;
trend?: BalanceTrendPoint[];
trendNotice?: string;
+ trendEnabled?: boolean;
+ trendPeriod?: BalanceHistoryPeriod;
+ trendLoading?: boolean;
+ trendError?: ErrorPresentation;
recentTransactions?: TransactionHistoryView[];
pendingTransactions?: TransactionHistoryView[];
loading: boolean;
@@ -537,6 +596,7 @@ export function BalanceScreen(props: {
error?: ErrorPresentation;
activityError?: ErrorPresentation;
onSelectScope?: (scopeId: string) => void;
+ onTrendPeriod?: (period: BalanceHistoryPeriod) => void;
onWithdraw: (scope?: ScopeInfo) => void;
onGetDemoCash: () => void;
gettingDemoCash?: boolean;
@@ -675,15 +735,16 @@ export function BalanceScreen(props: {
<PendingAmounts balance={selected} />
</div>
</div>
- <BalanceTrend balance={selected} points={props.trend ?? []} />
- {props.trendNotice && (props.trend?.length ?? 0) >= 2 && (
- <p class="mt-3 text-xs text-secondary" role="note">
- {props.trendNotice}
- </p>
- )}
- {(props.trend?.length ?? 0) < 2 && (
- <p class="mt-8 border-t border-outlineVariant py-8 text-center text-sm text-secondary">{i18n.str`A balance trend will appear after completed transactions.`}</p>
- )}
+ <BalanceTrendSection
+ enabled={props.trendEnabled === true}
+ balance={selected}
+ points={props.trend ?? []}
+ period={props.trendPeriod ?? "week"}
+ loading={props.trendLoading === true}
+ error={props.trendError}
+ notice={props.trendNotice}
+ onPeriod={props.onTrendPeriod ?? (() => {})}
+ />
</section>
<div class="grid gap-6 xl:grid-cols-[1.3fr_1fr]">
<ActivityCard
@@ -741,12 +802,16 @@ export function BalanceScreen(props: {
<span>{selected.currency}</span>
<strong>{selected.availableValue}</strong>
</h1>
- <BalanceTrend balance={selected} points={props.trend ?? []} />
- {props.trendNotice && (props.trend?.length ?? 0) >= 2 && (
- <p class="mt-3 text-xs" role="note">
- {props.trendNotice}
- </p>
- )}
+ <BalanceTrendSection
+ enabled={props.trendEnabled === true}
+ balance={selected}
+ points={props.trend ?? []}
+ period={props.trendPeriod ?? "week"}
+ loading={props.trendLoading === true}
+ error={props.trendError}
+ notice={props.trendNotice}
+ onPeriod={props.onTrendPeriod ?? (() => {})}
+ />
</div>
<div class="border-t border-outlineVariant px-4 py-3">
<PendingAmounts balance={selected} />
diff --git a/packages/wallet-webui/src/screens/SettingsScreen.tsx b/packages/wallet-webui/src/screens/SettingsScreen.tsx
@@ -64,7 +64,7 @@ function storageMigrationStateLabel(state: string): string {
}
}
-function IntegrationToggle(props: {
+function SettingsToggle(props: {
label: string;
description: string;
checked: boolean;
@@ -184,6 +184,7 @@ export function SettingsScreen(props: {
browserIntegration?: BrowserIntegrationSettingsView;
browserIntegrationBusy?: boolean;
browserIntegrationMessage?: string;
+ balanceHistoryEnabled: boolean;
onExport: () => void;
onDiagnostics: () => void;
onImport: (file: File) => void;
@@ -199,6 +200,7 @@ export function SettingsScreen(props: {
onSetDeveloperMode: (enabled: boolean) => void;
onWithdrawTestMoney: (currency: "KUDOS" | "TESTKUDOS") => void;
onSetLanguage: (language: string) => void;
+ onSetBalanceHistoryEnabled: (enabled: boolean) => void;
onSetBrowserIntegration?: <K extends keyof BrowserIntegrationSettingsView>(
key: K,
value: BrowserIntegrationSettingsView[K],
@@ -255,6 +257,18 @@ export function SettingsScreen(props: {
</select>
</label>
</Card>
+ <Card>
+ <h2 class="font-semibold">{i18n.str`Balance display`}</h2>
+ <div class="mt-3">
+ <SettingsToggle
+ label={i18n.str`Show balance history graph`}
+ description={i18n.str`Load additional completed transaction history to estimate how your available balance changed. This may affect wallet performance.`}
+ checked={props.balanceHistoryEnabled}
+ disabled={false}
+ onChange={props.onSetBalanceHistoryEnabled}
+ />
+ </div>
+ </Card>
{props.browserIntegration && props.onSetBrowserIntegration && (
<Card>
<h2 class="font-semibold">{i18n.str`Browser integration`}</h2>
@@ -262,7 +276,7 @@ export function SettingsScreen(props: {
{i18n.str`Control how websites that explicitly request GNU Taler support can interact with this extension. Presence detection and page API access can reveal that you use a Taler wallet.`}
</p>
<div class="space-y-3">
- <IntegrationToggle
+ <SettingsToggle
label={i18n.str`Open detected Taler actions automatically`}
// Translators: taler-uri and uri are literal web-integration feature names.
description={i18n.str`Open wallet actions advertised by taler-uri metadata. When disabled, the current page's action is available from the extension popup.`}
@@ -272,7 +286,7 @@ export function SettingsScreen(props: {
props.onSetBrowserIntegration!("autoOpen", value)
}
/>
- <IntegrationToggle
+ <SettingsToggle
// Translators: window.taler is a literal JavaScript API name.
label={i18n.str`Inject the window.taler API`}
// Translators: api is the literal name of a requested web-integration feature.
@@ -283,7 +297,7 @@ export function SettingsScreen(props: {
props.onSetBrowserIntegration!("injectApi", value)
}
/>
- <IntegrationToggle
+ <SettingsToggle
label={i18n.str`Allow wallet presence detection`}
// Translators: callback and talerCallback({ present: true }) are literal web API identifiers/code.
description={i18n.str`Let pages requesting callback receive talerCallback({ present: true }).`}
@@ -293,7 +307,7 @@ export function SettingsScreen(props: {
props.onSetBrowserIntegration!("allowCallback", value)
}
/>
- <IntegrationToggle
+ <SettingsToggle
// Translators: taler:// is a literal URI scheme.
label={i18n.str`Handle requested taler:// links`}
// Translators: uri is the literal name of a requested web-integration feature.
diff --git a/packages/wallet-webui/src/screens/StorybookScreen.tsx b/packages/wallet-webui/src/screens/StorybookScreen.tsx
@@ -865,6 +865,7 @@ export function StorybookScreen(props: {
developerMode={false}
developerBusy={false}
testMoneyBusy={false}
+ balanceHistoryEnabled={false}
onExport={() => {}}
onDiagnostics={() => {}}
onImport={() => {}}
@@ -880,6 +881,7 @@ export function StorybookScreen(props: {
onSetDeveloperMode={() => {}}
onWithdrawTestMoney={() => {}}
onSetLanguage={() => {}}
+ onSetBalanceHistoryEnabled={() => {}}
/>
</section>
<section aria-labelledby="import-recovery-story">
diff --git a/packages/wallet-webui/src/stores/preferences.tsx b/packages/wallet-webui/src/stores/preferences.tsx
@@ -0,0 +1,85 @@
+import type { ComponentChildren } from "preact";
+import { createContext } from "preact";
+import { useCallback, useContext, useEffect, useState } from "preact/hooks";
+
+export const BALANCE_HISTORY_ENABLED_KEY =
+ "taler-wallet-webui-balance-history-enabled";
+
+export interface UiPreferenceStorage {
+ getItem(key: string): string | null;
+ setItem(key: string, value: string): void;
+ removeItem(key: string): void;
+}
+
+function preferenceStorage(): UiPreferenceStorage | undefined {
+ try {
+ return typeof window === "undefined" ? undefined : window.localStorage;
+ } catch {
+ return undefined;
+ }
+}
+
+export function readBalanceHistoryEnabled(
+ storage: UiPreferenceStorage | undefined = preferenceStorage(),
+): boolean {
+ if (!storage) return false;
+ try {
+ return storage.getItem(BALANCE_HISTORY_ENABLED_KEY) === "true";
+ } catch {
+ return false;
+ }
+}
+
+export function writeBalanceHistoryEnabled(
+ enabled: boolean,
+ storage: UiPreferenceStorage | undefined = preferenceStorage(),
+): boolean {
+ try {
+ if (enabled) storage?.setItem(BALANCE_HISTORY_ENABLED_KEY, "true");
+ else storage?.removeItem(BALANCE_HISTORY_ENABLED_KEY);
+ } catch {
+ // The preference still applies for this session when persistence is blocked.
+ }
+ return enabled;
+}
+
+interface UiPreferences {
+ balanceHistoryEnabled: boolean;
+ setBalanceHistoryEnabled(enabled: boolean): void;
+}
+
+const UiPreferencesContext = createContext<UiPreferences | undefined>(
+ undefined,
+);
+
+export function UiPreferencesProvider(props: { children: ComponentChildren }) {
+ const [balanceHistoryEnabled, setBalanceHistoryEnabledState] = useState(
+ readBalanceHistoryEnabled,
+ );
+ const setBalanceHistoryEnabled = useCallback((enabled: boolean) => {
+ setBalanceHistoryEnabledState(writeBalanceHistoryEnabled(enabled));
+ }, []);
+ useEffect(() => {
+ if (typeof window === "undefined") return;
+ const update = (event: StorageEvent) => {
+ if (event.key === null || event.key === BALANCE_HISTORY_ENABLED_KEY) {
+ setBalanceHistoryEnabledState(readBalanceHistoryEnabled());
+ }
+ };
+ window.addEventListener("storage", update);
+ return () => window.removeEventListener("storage", update);
+ }, []);
+ return (
+ <UiPreferencesContext.Provider
+ value={{ balanceHistoryEnabled, setBalanceHistoryEnabled }}
+ >
+ {props.children}
+ </UiPreferencesContext.Provider>
+ );
+}
+
+export function useUiPreferences(): UiPreferences {
+ const preferences = useContext(UiPreferencesContext);
+ if (!preferences) throw Error("UI preferences are not available");
+ return preferences;
+}
diff --git a/packages/wallet-webui/src/ui/balance-history.ts b/packages/wallet-webui/src/ui/balance-history.ts
@@ -0,0 +1,11 @@
+export type BalanceHistoryPeriod = "day" | "week" | "month" | "quarter";
+
+export const BALANCE_HISTORY_PERIOD_SECONDS: Record<
+ BalanceHistoryPeriod,
+ number
+> = {
+ day: 24 * 60 * 60,
+ week: 7 * 24 * 60 * 60,
+ month: 30 * 24 * 60 * 60,
+ quarter: 13 * 7 * 24 * 60 * 60,
+};
diff --git a/packages/wallet-webui/test/balance-history.test.ts b/packages/wallet-webui/test/balance-history.test.ts
@@ -0,0 +1,79 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import {
+ ScopeType,
+ TransactionMajorState,
+ TransactionType,
+ type Transaction,
+} from "@gnu-taler/taler-util";
+import {
+ balanceHistoryPageRequest,
+ resolveBalanceHistoryPeriod,
+} from "../src/api/balance-history.js";
+
+function transaction(id: number, seconds: number): Transaction {
+ return {
+ transactionId: `txn:payment:${id}`,
+ type: TransactionType.Payment,
+ timestamp: { t_s: seconds },
+ scopes: [{ type: ScopeType.Global, currency: "CHF" }],
+ txState: { major: TransactionMajorState.Done },
+ stId: id,
+ txActions: [],
+ amountRaw: "CHF:1",
+ amountEffective: "CHF:1",
+ } as unknown as Transaction;
+}
+
+test("balance history issues no request while disabled", () => {
+ assert.equal(
+ balanceHistoryPageRequest(
+ { type: ScopeType.Global, currency: "CHF" },
+ false,
+ ),
+ undefined,
+ );
+});
+
+test("balance history pages completed transactions with refresh fees", () => {
+ const scopeInfo = { type: ScopeType.Global, currency: "CHF" } as const;
+ const first = balanceHistoryPageRequest(scopeInfo, true);
+ assert.equal(first?.limit, -128);
+ assert.equal(first?.filterByState, "done");
+ assert.equal(first?.includeRefreshes, true);
+ assert.deepEqual(first?.scopeInfo, scopeInfo);
+
+ const transactions = Array.from({ length: 128 }, (_, index) =>
+ transaction(index, 2_000_000_000 - index),
+ );
+ const next = balanceHistoryPageRequest(scopeInfo, true, { transactions });
+ assert.equal(next?.offsetTransactionId, transactions.at(-1)?.transactionId);
+ assert.deepEqual(next?.offsetTimestamp, transactions.at(-1)?.timestamp);
+ assert.equal(
+ balanceHistoryPageRequest(scopeInfo, true, {
+ transactions: transactions.slice(0, 127),
+ }),
+ undefined,
+ );
+});
+
+test("balance history defaults to a day unless older data requires a week", () => {
+ const anchor = 2_000_000_000;
+ assert.equal(
+ resolveBalanceHistoryPeriod(
+ [transaction(1, anchor - 60 * 60)],
+ anchor,
+ undefined,
+ ),
+ "day",
+ );
+ assert.equal(
+ resolveBalanceHistoryPeriod(
+ [transaction(1, anchor - 2 * 24 * 60 * 60)],
+ anchor,
+ undefined,
+ ),
+ "week",
+ );
+ assert.equal(resolveBalanceHistoryPeriod([], anchor, "quarter"), "quarter");
+});
diff --git a/packages/wallet-webui/test/balance-model.test.ts b/packages/wallet-webui/test/balance-model.test.ts
@@ -210,33 +210,47 @@ test("balance trend walks completed scoped transaction effects backwards", () =>
}) as unknown as Transaction;
const newest = Date.UTC(2026, 7, 11, 12) / 1000;
const older = Date.UTC(2026, 7, 10, 10) / 1000;
- const points = balanceTrendView(balance, [
- transaction(TransactionType.Withdrawal, "CHF:5", newest),
- transaction(TransactionType.Payment, "CHF:2", older),
- transaction(TransactionType.Payment, "CHF:1", older - 3_600),
- transaction(
- TransactionType.Withdrawal,
- "CHF:99",
- older - 86_400,
- TransactionMajorState.Failed,
- ),
- ]);
+ const points = balanceTrendView(
+ balance,
+ [
+ transaction(TransactionType.Withdrawal, "CHF:5", newest),
+ transaction(TransactionType.Payment, "CHF:2", older),
+ transaction(TransactionType.Payment, "CHF:1", older - 3_600),
+ transaction(
+ TransactionType.Withdrawal,
+ "CHF:99",
+ older - 86_400,
+ TransactionMajorState.Failed,
+ ),
+ ],
+ "week",
+ newest,
+ );
assert.deepEqual(
points.map((point) => point.value),
- [8, 5, 10, 10],
+ [8, 8, 8, 8, 8, 8, 5, 10],
);
assert.deepEqual(
points.map((point) => point.kind),
- ["start", "transactions", "transactions", "current"],
+ [
+ "start",
+ "transactions",
+ "transactions",
+ "transactions",
+ "transactions",
+ "transactions",
+ "transactions",
+ "current",
+ ],
);
assert.deepEqual(
points.map((point) => point.transactionCount),
- [0, 2, 1, undefined],
+ [0, 0, 0, 0, 0, 0, 2, 1],
);
assert.equal(points.at(-1)?.label, "Now");
});
-test("balance trend preserves a start balance when visible history is empty", () => {
+test("balance trend is absent when visible history is empty", () => {
const balance = balancesToView({
balances: [
{
@@ -249,14 +263,43 @@ test("balance trend preserves a start balance when visible history is empty", ()
],
haveProdBalance: true,
}).balances?.[0];
- const points = balanceTrendView(balance, []);
- assert.deepEqual(
- points.map((point) => point.kind),
- ["start", "current"],
- );
+ const points = balanceTrendView(balance, [], "day", 1_800_000_000);
+ assert.deepEqual(points, []);
+});
+
+test("balance trend includes standalone refresh fees and empty hourly buckets", () => {
+ const balance = balancesToView({
+ balances: [
+ {
+ scopeInfo: { type: ScopeType.Global, currency: "CHF" },
+ available: "CHF:9",
+ pendingIncoming: "CHF:0",
+ pendingOutgoing: "CHF:0",
+ flags: [],
+ },
+ ],
+ haveProdBalance: true,
+ }).balances?.[0];
+ const anchor = Date.UTC(2026, 7, 11, 12) / 1000;
+ const refresh = {
+ transactionId: "txn:refresh:fee",
+ type: TransactionType.Refresh,
+ timestamp: { t_s: anchor - 1_800 },
+ scopes: [{ type: ScopeType.Global, currency: "CHF" }],
+ txState: { major: TransactionMajorState.Done },
+ stId: 1,
+ txActions: [],
+ amountRaw: "CHF:0",
+ amountEffective: "CHF:1",
+ } as unknown as Transaction;
+
+ const points = balanceTrendView(balance, [refresh], "day", anchor);
+
+ assert.equal(points.length, 25);
assert.deepEqual(
- points.map((point) => point.value),
- [10, 10],
+ points.slice(0, -1).map((point) => point.value),
+ Array(24).fill(10),
);
- assert.equal(points[0]?.label, "Start");
+ assert.equal(points.at(-1)?.value, 9);
+ assert.equal(points.at(-1)?.transactionCount, 1);
});
diff --git a/packages/wallet-webui/test/preferences.test.tsx b/packages/wallet-webui/test/preferences.test.tsx
@@ -0,0 +1,98 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { Window } from "happy-dom";
+import {
+ BALANCE_HISTORY_ENABLED_KEY,
+ readBalanceHistoryEnabled,
+ writeBalanceHistoryEnabled,
+ type UiPreferenceStorage,
+} from "../src/stores/preferences.js";
+
+class MemoryStorage implements UiPreferenceStorage {
+ readonly values = new Map<string, string>();
+
+ getItem(key: string): string | null {
+ return this.values.get(key) ?? null;
+ }
+
+ setItem(key: string, value: string): void {
+ this.values.set(key, value);
+ }
+
+ removeItem(key: string): void {
+ this.values.delete(key);
+ }
+}
+
+test("balance history is disabled by default and persists only when enabled", () => {
+ const storage = new MemoryStorage();
+ assert.equal(readBalanceHistoryEnabled(storage), false);
+ assert.equal(writeBalanceHistoryEnabled(true, storage), true);
+ assert.equal(storage.getItem(BALANCE_HISTORY_ENABLED_KEY), "true");
+ assert.equal(readBalanceHistoryEnabled(storage), true);
+ assert.equal(writeBalanceHistoryEnabled(false, storage), false);
+ assert.equal(storage.getItem(BALANCE_HISTORY_ENABLED_KEY), null);
+});
+
+test("the UI preference provider applies local and cross-tab changes", async () => {
+ const window = new Window({ url: "https://wallet.example/" });
+ for (const [key, value] of Object.entries({
+ window,
+ document: window.document,
+ Node: window.Node,
+ Element: window.Element,
+ Event: window.Event,
+ StorageEvent: window.StorageEvent,
+ HTMLElement: window.HTMLElement,
+ HTMLButtonElement: window.HTMLButtonElement,
+ MutationObserver: window.MutationObserver,
+ })) {
+ Object.defineProperty(globalThis, key, {
+ configurable: true,
+ writable: true,
+ value,
+ });
+ }
+ const { render, cleanup, act } = await import("@testing-library/preact");
+ const { UiPreferencesProvider, useUiPreferences } =
+ await import("../src/stores/preferences.js");
+ function Harness() {
+ const preferences = useUiPreferences();
+ return (
+ <button
+ type="button"
+ onClick={() =>
+ preferences.setBalanceHistoryEnabled(
+ !preferences.balanceHistoryEnabled,
+ )
+ }
+ >
+ {preferences.balanceHistoryEnabled ? "enabled" : "disabled"}
+ </button>
+ );
+ }
+ const view = render(
+ <UiPreferencesProvider>
+ <Harness />
+ </UiPreferencesProvider>,
+ );
+ const button = view.getByRole("button", { name: "disabled" });
+ await act(() => (button as HTMLButtonElement).click());
+ assert(view.getByRole("button", { name: "enabled" }));
+ assert.equal(
+ window.localStorage.getItem(BALANCE_HISTORY_ENABLED_KEY),
+ "true",
+ );
+
+ window.localStorage.removeItem(BALANCE_HISTORY_ENABLED_KEY);
+ await act(() => {
+ window.dispatchEvent(
+ new window.StorageEvent("storage", {
+ key: BALANCE_HISTORY_ENABLED_KEY,
+ }),
+ );
+ });
+ assert(view.getByRole("button", { name: "disabled" }));
+ cleanup();
+ await window.happyDOM.abort();
+});
diff --git a/packages/wallet-webui/test/screens.test.tsx b/packages/wallet-webui/test/screens.test.tsx
@@ -906,6 +906,10 @@ test("balance capability flags block unavailable money flows and expose shopping
.setup({ document: window.document as unknown as Document })
.click(view.getByRole("button", { name: "Where to pay with CHF" }));
assert.equal(shopped, "CHF");
+ assert.equal(
+ view.queryByRole("combobox", { name: "Balance history period" }),
+ null,
+ );
cleanup();
await window.happyDOM.abort();
});
@@ -917,6 +921,7 @@ test("Penpot balance view selects scopes, opens the balance list, and hands off
.default as unknown as {
setup(options: { document: Document }): {
click(element: Element): Promise<void>;
+ selectOptions(element: Element, values: string): Promise<void>;
};
};
const axe = (await import("axe-core")).default as unknown as {
@@ -924,6 +929,7 @@ test("Penpot balance view selects scopes, opens the balance list, and hands off
};
const selected: string[] = [];
const opened: string[] = [];
+ const trendPeriods: string[] = [];
let withdrawalCurrency = "";
const view = render(
<main>
@@ -985,6 +991,8 @@ test("Penpot balance view selects scopes, opens the balance list, and hands off
},
{ kind: "current", label: "Now", value: 0.01 },
]}
+ trendEnabled
+ trendPeriod="week"
trendNotice="Estimated history: multi-exchange activity can be counted in full."
recentTransactions={[
{
@@ -1002,6 +1010,7 @@ test("Penpot balance view selects scopes, opens the balance list, and hands off
]}
pendingTransactions={[]}
onSelectScope={(scopeId) => selected.push(scopeId)}
+ onTrendPeriod={(period) => trendPeriods.push(period)}
onOpenTransaction={(id) => opened.push(id)}
onWithdraw={(scope) => {
withdrawalCurrency = scope?.currency ?? "";
@@ -1021,6 +1030,15 @@ test("Penpot balance view selects scopes, opens the balance list, and hands off
});
assert.equal(charts.length, 2);
assert.equal(
+ view.getAllByRole("combobox", { name: "Balance history period" }).length,
+ 2,
+ );
+ await user.selectOptions(
+ view.getAllByRole("combobox", { name: "Balance history period" })[0]!,
+ "quarter",
+ );
+ assert.deepEqual(trendPeriods, ["quarter"]);
+ assert.equal(
view.getAllByText(/Estimated history: multi-exchange/).length,
2,
);
@@ -3071,6 +3089,7 @@ test("developer settings require explicit confirmation before clearing the walle
let testCurrency: string | undefined;
let selectedLanguage: string | undefined;
let integrationChange: [string, boolean] | undefined;
+ let balanceHistoryEnabled: boolean | undefined;
const settings = (testMoneyBusy = false, metadataLoaded = true) => (
<main>
<SettingsScreen
@@ -3106,6 +3125,7 @@ test("developer settings require explicit confirmation before clearing the walle
allowCallback: false,
hijackLinks: true,
}}
+ balanceHistoryEnabled={false}
onExport={() => {}}
onDiagnostics={() => {}}
onImport={() => {}}
@@ -3131,6 +3151,9 @@ test("developer settings require explicit confirmation before clearing the walle
onSetLanguage={(language) => {
selectedLanguage = language;
}}
+ onSetBalanceHistoryEnabled={(enabled) => {
+ balanceHistoryEnabled = enabled;
+ }}
onSetBrowserIntegration={(key, value) => {
integrationChange = [key, value];
}}
@@ -3150,6 +3173,11 @@ test("developer settings require explicit confirmation before clearing the walle
);
assert(view.getByRole("heading", { name: "Wallet data protection" }));
assert(view.getByRole("heading", { name: "Browser integration" }));
+ assert(view.getByRole("heading", { name: "Balance display" }));
+ await user.click(
+ view.getByRole("checkbox", { name: "Show balance history graph" }),
+ );
+ assert.equal(balanceHistoryEnabled, true);
const language = view.getByRole("combobox", {
name: "Interface language",
});