commit b2bb8012f047bcb578226109213cf1c18183bf70
parent fd79c569c35812b76d39ba073d78e928f61a4512
Author: Florian Dold <dold@taler.net>
Date: Sat, 5 Sep 2026 18:12:01 +0200
wallet web UI: show coin renewal warnings and holding costs
Show expiration risks, dismissible renewal notices, and expandable cost
bounds in scoped balances. Indicate when a background report is pending.
Report supported browser power observations, using unknown when readings
are missing or inconclusive.
Diffstat:
7 files changed, 401 insertions(+), 0 deletions(-)
diff --git a/packages/wallet-webui/src/platform/lifecycle.ts b/packages/wallet-webui/src/platform/lifecycle.ts
@@ -1,3 +1,4 @@
+import { WalletPowerSource } from "@gnu-taler/taler-util";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
import type { WalletConnection } from "../api/contracts.js";
@@ -11,6 +12,7 @@ export interface BrowserLifecycleEnvironment {
visibilityEvents: LifecycleEventSource;
isOnline(): boolean;
visibilityState(): "hidden" | "visible";
+ observePower?: (report: (power: WalletPowerSource) => void) => () => void;
}
export function watchWalletReconnection(
@@ -30,8 +32,75 @@ export function watchWalletReconnection(
});
}
+interface BatteryObservation extends LifecycleEventSource {
+ charging: boolean;
+ level: number;
+ chargingTime: number;
+ dischargingTime: number;
+}
+
+/** A missing API or its privacy/default full-battery tuple is inconclusive. */
+export function browserPowerSource(
+ battery: BatteryObservation,
+): WalletPowerSource {
+ if (!battery.charging) return WalletPowerSource.Battery;
+ if (
+ battery.level === 1 &&
+ battery.chargingTime === 0 &&
+ battery.dischargingTime === Infinity
+ )
+ return WalletPowerSource.Unknown;
+ return WalletPowerSource.External;
+}
+
+function observeBrowserPower(
+ report: (power: WalletPowerSource) => void,
+): () => void {
+ let stopped = false;
+ let battery: BatteryObservation | undefined;
+ let timer: ReturnType<typeof setInterval> | undefined;
+ const update = () =>
+ report(battery ? browserPowerSource(battery) : WalletPowerSource.Unknown);
+ const source = navigator as Navigator & {
+ getBattery?: () => Promise<BatteryObservation>;
+ };
+ report(WalletPowerSource.Unknown);
+ if (source.getBattery)
+ void source
+ .getBattery()
+ .then((b) => {
+ if (stopped) return;
+ battery = b;
+ for (const event of [
+ "chargingchange",
+ "levelchange",
+ "chargingtimechange",
+ "dischargingtimechange",
+ ])
+ b.addEventListener(event, update);
+ update();
+ timer = setInterval(update, 30_000);
+ })
+ .catch(() => {
+ if (!stopped) report(WalletPowerSource.Unknown);
+ });
+ return () => {
+ stopped = true;
+ if (timer) clearInterval(timer);
+ for (const event of [
+ "chargingchange",
+ "levelchange",
+ "chargingtimechange",
+ "dischargingtimechange",
+ ])
+ battery?.removeEventListener(event, update);
+ report(WalletPowerSource.Unknown);
+ };
+}
+
function defaultEnvironment(): BrowserLifecycleEnvironment {
return {
+ observePower: observeBrowserPower,
networkEvents: window,
visibilityEvents: document,
isOnline: () => navigator.onLine,
@@ -51,6 +120,11 @@ export function watchWalletLifecycle(
): () => void {
let stopped = false;
let lastOnline: boolean | undefined;
+ const stopPower = environment.observePower?.((powerSource) => {
+ void connection.client
+ .call(WalletApiOperation.HintPowerState, { powerSource })
+ .catch(() => undefined);
+ });
const hintNetwork = () => {
const isNetworkAvailable = environment.isOnline();
if (lastOnline === isNetworkAvailable) return;
@@ -76,6 +150,7 @@ export function watchWalletLifecycle(
environment.visibilityEvents.addEventListener("visibilitychange", resume);
hintNetwork();
return () => {
+ stopPower?.();
stopped = true;
environment.networkEvents.removeEventListener("online", hintNetwork);
environment.networkEvents.removeEventListener("offline", hintNetwork);
diff --git a/packages/wallet-webui/src/routes/App.tsx b/packages/wallet-webui/src/routes/App.tsx
@@ -769,6 +769,13 @@ function BalanceRoute() {
};
return (
<BalanceScreen
+ onDismissRenewal={(warningId) => {
+ void callMutation(
+ WalletApiOperation.DismissWalletWarning,
+ { warningId },
+ ["balances"],
+ );
+ }}
balances={view.balances}
selectedScopeId={selectedBalance?.scopeId}
trend={balanceTrendView(
diff --git a/packages/wallet-webui/src/routes/balance-model.ts b/packages/wallet-webui/src/routes/balance-model.ts
@@ -3,6 +3,7 @@ import type {
DonauSummaryItem,
Transaction,
WalletBalance,
+ CashExpirationRisk,
} from "@gnu-taler/taler-util";
import {
Amounts,
@@ -34,6 +35,54 @@ function donauSummaryView(summary: DonauSummaryItem): DonauSummaryView {
amountStatement: summary.amountStatement,
};
}
+function refreshRiskReason(reason: CashExpirationRisk["reason"]): string {
+ switch (reason) {
+ case "pending":
+ return i18n.str`Renewal is pending.`;
+ case "connectivity":
+ return i18n.str`The exchange could not be reached.`;
+ case "exchange-error":
+ return i18n.str`The exchange reported an error during renewal.`;
+ case "no-replacement":
+ return i18n.str`The exchange currently offers no suitable replacement coins.`;
+ case "checking":
+ return i18n.str`Checking whether these coins can be renewed.`;
+ case "invalid-lifetime":
+ return i18n.str`The exchange provided invalid coin validity dates.`;
+ }
+}
+
+function refreshBoundReason(reason: string): string {
+ switch (reason) {
+ case "checking":
+ return i18n.str`Checking whether these coins can be renewed.`;
+ case "expired-funds":
+ return i18n.str`Some funds have expired.`;
+ case "stale-key-information":
+ return i18n.str`Waiting for fresh exchange key information.`;
+ case "unrefreshable-denomination":
+ return i18n.str`Some coins, including small change, cannot be renewed with the current offerings.`;
+ case "invalid-lifetime":
+ return i18n.str`The exchange provided invalid coin validity dates.`;
+ case "nonpositive-refresh-interval":
+ return i18n.str`The current coin lifetimes do not support an annual cost bound.`;
+ case "pending-refresh":
+ case "unsettled-balance":
+ return i18n.str`The balance has operations that have not settled yet.`;
+ default:
+ return i18n.str`There is not enough denomination information to calculate a bound.`;
+ }
+}
+
+function refreshDate(stamp: { t_s: number | "never" }): string {
+ return stamp.t_s === "never"
+ ? i18n.str`Unknown date`
+ : new Intl.DateTimeFormat(undefined, {
+ dateStyle: "medium",
+ timeStyle: "short",
+ }).format(new Date(stamp.t_s * 1000));
+}
+
function balanceView(balance: WalletBalance): BalanceView {
const amount = Amounts.parseOrThrow(balance.available);
let provider: string | undefined;
@@ -59,7 +108,43 @@ function balanceView(balance: WalletBalance): BalanceView {
if (balance.flags.includes(BalanceFlag.OutgoingKyc)) {
warnings.push(i18n.str`Outgoing funds need identity verification`);
}
+ const refresh = balance.refreshInfo;
+ if (refresh?.risks.length)
+ warnings.unshift(i18n.str`Some coins are close to expiration`);
return {
+ ...(refresh
+ ? {
+ refresh: {
+ risks: refresh.risks.map((r) => ({
+ exchange: r.exchangeBaseUrl,
+ amount: Amounts.toPretty(Amounts.parseOrThrow(r.amount)),
+ expiration: refreshDate(r.earliestDepositExpiration),
+ reason: refreshRiskReason(r.reason),
+ })),
+ recoveries: refresh.recoveries.map((r) => ({
+ id: r.warningId,
+ exchange: r.exchangeBaseUrl,
+ amount: Amounts.toPretty(Amounts.parseOrThrow(r.amount)),
+ expiration: refreshDate(r.newDepositExpiration),
+ nextDate: refreshDate(r.nextRelevantDate),
+ })),
+ bound:
+ refresh.annualCostBound.status === "available"
+ ? Amounts.toPretty(
+ Amounts.parseOrThrow(refresh.annualCostBound.amount),
+ )
+ : undefined,
+ unavailableReasons:
+ refresh.annualCostBound.status === "unavailable"
+ ? [
+ ...new Set(
+ refresh.annualCostBound.reasons.map(refreshBoundReason),
+ ),
+ ]
+ : [],
+ },
+ }
+ : {}),
scopeId: scopeIdentity(balance.scopeInfo),
scopeInfo: balance.scopeInfo,
scopeLabel: scopeLabel(balance.scopeInfo),
diff --git a/packages/wallet-webui/src/screens/BalanceScreen.tsx b/packages/wallet-webui/src/screens/BalanceScreen.tsx
@@ -10,6 +10,23 @@ import { i18n } from "../i18n/runtime.js";
import type { BalanceHistoryPeriod } from "../ui/balance-history.js";
export interface BalanceView {
+ refresh?: {
+ risks: {
+ exchange: string;
+ amount: string;
+ expiration: string;
+ reason: string;
+ }[];
+ recoveries: {
+ id: string;
+ exchange: string;
+ amount: string;
+ expiration: string;
+ nextDate: string;
+ }[];
+ bound?: string;
+ unavailableReasons: string[];
+ };
scopeId: string;
scopeInfo: ScopeInfo;
scopeLabel: string;
@@ -92,6 +109,62 @@ function BalanceWarning(props: { balance: BalanceView; compact?: boolean }) {
);
}
+export function RefreshBalanceDetails(props: {
+ balance: BalanceView;
+ onDismiss?: (id: string) => void;
+}) {
+ const refresh = props.balance.refresh;
+ if (!refresh) return null;
+ return (
+ <div class="mt-4 max-w-xl space-y-3 break-words text-sm">
+ {refresh.risks.map((r, index) => (
+ <div
+ key={`${r.exchange}/${index}`}
+ role="status"
+ class="rounded-lg border border-warning p-3"
+ >
+ <p class="font-semibold">{i18n.str`Coins close to expiration`}</p>
+ <p>{i18n.str`${r.amount} at ${r.exchange} expires on ${r.expiration}.`}</p>
+ <p>{r.reason}</p>
+ <p>{i18n.str`Keep the wallet open to allow renewal. Funds can expire if renewal does not complete in time.`}</p>
+ </div>
+ ))}
+ {refresh.recoveries.map((r) => (
+ <div
+ key={r.id}
+ role="status"
+ class="rounded-lg border border-outlineVariant p-3"
+ >
+ <p class="font-semibold">{i18n.str`Coins renewed`}</p>
+ <p>{i18n.str`${r.amount} at ${r.exchange} was close to expiration and has been renewed until ${r.expiration}.`}</p>
+ <p>{i18n.str`Open the wallet again before ${r.nextDate} to check renewal. This date depends on the coins' validity periods.`}</p>
+ {props.onDismiss && (
+ <Button
+ tone="secondary"
+ onClick={() => props.onDismiss!(r.id)}
+ >{i18n.str`Dismiss`}</Button>
+ )}
+ </div>
+ ))}
+ <details class="rounded-lg border border-outlineVariant p-3">
+ <summary class="cursor-pointer font-semibold">{i18n.str`Cost of keeping this balance`}</summary>
+ {refresh.bound !== undefined ? (
+ <p class="mt-2">{i18n.str`Conditional upper bound over 365 days: ${refresh.bound}.`}</p>
+ ) : (
+ <div class="mt-2">
+ <p>{i18n.str`Annual cost bound unavailable.`}</p>
+ {refresh.unavailableReasons.map((r) => (
+ <p key={r}>{r}</p>
+ ))}
+ </div>
+ )}
+ <p class="mt-2">{i18n.str`This is an upper bound, not an expected charge. It assumes compatible coins remain continuously available with today's values, fees and lifetime bounds, and that renewal finishes before withdrawal validity ends.`}</p>
+ <p class="mt-2">{i18n.str`Future exchange offerings are a projection, not a promise. The bound covers keeping the current balance without payments or additional withdrawals. It excludes initial withdrawal fees, payment-related renewal, and losses from expiration or outages.`}</p>
+ </details>
+ </div>
+ );
+}
+
function PendingAmounts(props: { balance: BalanceView; compact?: boolean }) {
const incoming = isNonZero(props.balance.pendingIncoming);
const outgoing = isNonZero(props.balance.pendingOutgoing);
@@ -617,6 +690,7 @@ export function BalanceScreen(props: {
activityLoading?: boolean;
error?: ErrorPresentation;
activityError?: ErrorPresentation;
+ onDismissRenewal?: (id: string) => void;
onSelectScope?: (scopeId: string) => void;
onTrendPeriod?: (period: BalanceHistoryPeriod) => void;
onWithdraw: (scope?: ScopeInfo) => void;
@@ -753,6 +827,10 @@ export function BalanceScreen(props: {
<BalanceAmount balance={selected} />
</h1>
<BalanceWarning balance={selected} />
+ <RefreshBalanceDetails
+ balance={selected}
+ onDismiss={props.onDismissRenewal}
+ />
{selected.legacy && props.onManageLegacyBalance && (
<div class="mt-4">
<Button
@@ -835,6 +913,10 @@ export function BalanceScreen(props: {
<span>{selected.currency}</span>
<BalanceAmount balance={selected} />
</h1>
+ <RefreshBalanceDetails
+ balance={selected}
+ onDismiss={props.onDismissRenewal}
+ />
<BalanceTrendSection
enabled={props.trendEnabled === true}
balance={selected}
diff --git a/packages/wallet-webui/src/testing/demo-wallet.ts b/packages/wallet-webui/src/testing/demo-wallet.ts
@@ -19,6 +19,7 @@ import {
type Transaction,
type TransactionIdStr,
type WalletNotification,
+ type WalletRefreshInfo,
} from "@gnu-taler/taler-util";
import {
WalletApiOperation,
@@ -78,6 +79,7 @@ type DemoExchange = Record<string, unknown> & {
};
type DemoState = {
+ refreshInfo?: WalletRefreshInfo;
balances: Record<string, number>;
pendingIncoming: Record<string, number>;
pendingOutgoing: Record<string, number>;
@@ -321,6 +323,36 @@ function seedState(nowMs: number): DemoState {
},
} as unknown as Transaction;
return {
+ refreshInfo: {
+ risks: [
+ {
+ exchangeBaseUrl,
+ exchangeMasterPub: "DEMOEXCHANGEMASTERPUB",
+ amount: "CHF:3",
+ reason: "pending",
+ earliestDepositExpiration: {
+ t_s: Math.floor(nowMs / 1000) + 7 * 86400,
+ },
+ },
+ ],
+ recoveries: [
+ {
+ warningId: "demo-renewal",
+ exchangeBaseUrl,
+ exchangeMasterPub: "DEMOEXCHANGEMASTERPUB",
+ amount: "CHF:5",
+ oldDepositExpiration: { t_s: Math.floor(nowMs / 1000) + 5 * 86400 },
+ newDepositExpiration: { t_s: Math.floor(nowMs / 1000) + 740 * 86400 },
+ nextRelevantDate: { t_s: Math.floor(nowMs / 1000) + 650 * 86400 },
+ },
+ ],
+ annualCostBound: {
+ status: "available",
+ horizonDays: 365,
+ projection: "stable-current-offerings",
+ amount: "CHF:0.43",
+ },
+ },
balances: { CHF: 4_250, EUR: 1_200 },
pendingIncoming: { CHF: 500, EUR: 0 },
pendingOutgoing: { CHF: 800, EUR: 200 },
@@ -584,10 +616,24 @@ export class DemoWalletConnection implements WalletConnection {
): WalletCoreResponseType<Op> {
const request = rawRequest as Record<string, unknown>;
switch (operation) {
+ case WalletApiOperation.HintPowerState:
+ return {} as never;
+ case WalletApiOperation.DismissWalletWarning:
+ if (this.state.refreshInfo)
+ this.state.refreshInfo.recoveries =
+ this.state.refreshInfo.recoveries.filter(
+ (r) => r.warningId !== request.warningId,
+ );
+ this.emit({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: "demo-renewal",
+ });
+ return {} as never;
case WalletApiOperation.GetBalances:
return {
balances: [
{
+ refreshInfo: this.state.refreshInfo,
scopeInfo: chfScope,
available: amount("CHF", this.state.balances.CHF),
pendingIncoming: amount("CHF", this.state.pendingIncoming.CHF),
diff --git a/packages/wallet-webui/test/lifecycle.test.ts b/packages/wallet-webui/test/lifecycle.test.ts
@@ -144,3 +144,56 @@ test("browser lifecycle hints network changes and revalidates after resume", asy
networkEvents.emit("online");
assert.equal(calls.length, 3);
});
+
+test("power hints are reported and stopped with the browser lifecycle", async () => {
+ const calls: unknown[] = [];
+ const events = new FakeEvents();
+ let report!: (
+ power: import("@gnu-taler/taler-util").WalletPowerSource,
+ ) => void;
+ let powerStopped = false;
+ const { WalletPowerSource } = await import("@gnu-taler/taler-util");
+ const connection = {
+ client: {
+ call: async (op: string, req: unknown) => {
+ if (op === WalletApiOperation.HintPowerState) calls.push(req);
+ return {};
+ },
+ },
+ } as WalletConnection;
+ const stop = watchWalletLifecycle(connection, () => {}, {
+ networkEvents: events,
+ visibilityEvents: events,
+ isOnline: () => true,
+ visibilityState: () => "visible",
+ observePower: (r) => {
+ report = r;
+ return () => {
+ powerStopped = true;
+ };
+ },
+ });
+ report(WalletPowerSource.External);
+ report(WalletPowerSource.Unknown);
+ assert.deepEqual(calls, [
+ { powerSource: "external" },
+ { powerSource: "unknown" },
+ ]);
+ stop();
+ assert.equal(powerStopped, true);
+});
+
+test("default battery readings do not assert external power", async () => {
+ const { browserPowerSource } = await import("../src/platform/lifecycle.js");
+ const battery = Object.assign(new FakeEvents(), {
+ charging: true,
+ level: 1,
+ chargingTime: 0,
+ dischargingTime: Infinity,
+ });
+ assert.equal(browserPowerSource(battery), "unknown");
+ battery.level = 0.5;
+ assert.equal(browserPowerSource(battery), "external");
+ battery.charging = false;
+ assert.equal(browserPowerSource(battery), "battery");
+});
diff --git a/packages/wallet-webui/test/screens.test.tsx b/packages/wallet-webui/test/screens.test.tsx
@@ -4387,3 +4387,56 @@ test("incomplete storage discovery never offers empty initialization", async ()
cleanup();
await window.happyDOM.abort();
});
+
+test("DD71 details show independent risk, recovery and conditional costs", async () => {
+ const window = installDom();
+ const { render, cleanup, act } = await import("@testing-library/preact");
+ const { RefreshBalanceDetails } =
+ await import("../src/screens/BalanceScreen.js");
+ let dismissed: string | undefined;
+ const view = render(
+ <RefreshBalanceDetails
+ balance={
+ {
+ refresh: {
+ risks: [
+ {
+ exchange: "https://exchange.example/",
+ amount: "CHF 3",
+ expiration: "Sep 12, 2026",
+ reason: "Renewal is pending.",
+ },
+ ],
+ recoveries: [
+ {
+ id: "group",
+ exchange: "https://exchange.example/",
+ amount: "CHF 5",
+ expiration: "Sep 1, 2028",
+ nextDate: "Jun 1, 2028",
+ },
+ ],
+ bound: "CHF 0.43",
+ unavailableReasons: [],
+ },
+ } as unknown as import("../src/screens/BalanceScreen.js").BalanceView
+ }
+ onDismiss={(id) => {
+ dismissed = id;
+ }}
+ />,
+ );
+ assert.equal(view.getAllByRole("status").length, 2);
+ assert.match(
+ view.container.textContent!,
+ /Conditional upper bound over 365 days/,
+ );
+ assert.match(view.container.textContent!, /not an expected charge/);
+ assert.match(view.container.textContent!, /Sep 12, 2026/);
+ await act(() => {
+ view.getByRole("button", { name: "Dismiss" }).click();
+ });
+ assert.equal(dismissed, "group");
+ cleanup();
+ await window.happyDOM.close();
+});