taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

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:
Apackages/wallet-webui/src/api/balance-history.ts | 165+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/wallet-webui/src/routes/App.tsx | 45++++++++++++++++++++++++++++++++++++++++++---
Mpackages/wallet-webui/src/routes/balance-model.ts | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mpackages/wallet-webui/src/screens/BalanceScreen.tsx | 95++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mpackages/wallet-webui/src/screens/SettingsScreen.tsx | 24+++++++++++++++++++-----
Mpackages/wallet-webui/src/screens/StorybookScreen.tsx | 2++
Apackages/wallet-webui/src/stores/preferences.tsx | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/wallet-webui/src/ui/balance-history.ts | 11+++++++++++
Apackages/wallet-webui/test/balance-history.test.ts | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/wallet-webui/test/balance-model.test.ts | 89++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Apackages/wallet-webui/test/preferences.test.tsx | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/wallet-webui/test/screens.test.tsx | 28++++++++++++++++++++++++++++
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", });