Skip to content

Commit 630743b

Browse files
authored
Merge pull request #722 from thegreatfeez/main
Enable optimistic updates in Portfolio Chart Widget
2 parents 9c72da9 + 811ee94 commit 630743b

2 files changed

Lines changed: 86 additions & 6 deletions

File tree

frontend/src/components/PaymentMetrics.test.tsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ vi.mock("recharts", () => ({
3636
Legend: () => React.createElement("div"),
3737
}));
3838

39+
function createDeferred<T>() {
40+
let resolve!: (value: T) => void;
41+
const promise = new Promise<T>((res) => {
42+
resolve = res;
43+
});
44+
return { promise, resolve };
45+
}
46+
3947
describe("PaymentMetrics Component", () => {
4048
beforeEach(() => {
4149
vi.resetAllMocks();
@@ -168,4 +176,45 @@ describe("PaymentMetrics Component", () => {
168176
expect(screen.getByText("123")).toBeInTheDocument();
169177
});
170178
});
179+
180+
it("keeps previous chart visible while refreshing selected range", async () => {
181+
const deferredSummary = createDeferred<any>();
182+
const deferredVolume = createDeferred<any>();
183+
184+
(globalThis.fetch as any)
185+
.mockResolvedValueOnce({ ok: true, json: async () => ({ total_volume: 1500, confirmed_count: 42, success_rate: 98.5, data: [] }) })
186+
.mockResolvedValueOnce({
187+
ok: true,
188+
json: async () => ({
189+
assets: ["XLM"],
190+
data: [{ date: "2026-04-20", count: 1, XLM: 10 }],
191+
}),
192+
})
193+
.mockResolvedValueOnce({ ok: true, json: async () => deferredSummary.promise })
194+
.mockResolvedValueOnce({ ok: true, json: async () => deferredVolume.promise });
195+
196+
render(React.createElement(PaymentMetrics));
197+
198+
await waitFor(() => {
199+
expect(screen.getByText("1,500")).toBeInTheDocument();
200+
});
201+
202+
fireEvent.click(screen.getByRole("button", { name: "30D" }));
203+
204+
await waitFor(() => {
205+
expect(screen.getByText("Updating...")).toBeInTheDocument();
206+
});
207+
expect(screen.getByText("1,500")).toBeInTheDocument();
208+
expect(document.querySelector(".animate-pulse")).not.toBeInTheDocument();
209+
210+
deferredSummary.resolve({ total_volume: 1600, confirmed_count: 44, success_rate: 99, data: [] });
211+
deferredVolume.resolve({
212+
assets: ["XLM"],
213+
data: [{ date: "2026-04-21", count: 1, XLM: 12 }],
214+
});
215+
216+
await waitFor(() => {
217+
expect(screen.getByText("1,600")).toBeInTheDocument();
218+
});
219+
});
171220
});

frontend/src/components/PaymentMetrics.tsx

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,14 @@ export default function PaymentMetrics({
9393
const [hiddenAssets, setHiddenAssets] = useState<Set<string>>(new Set());
9494
const [range, setRange] = useState<TimeRange>("7D");
9595
const [loading, setLoading] = useState(true);
96+
const [isRefreshing, setIsRefreshing] = useState(false);
9697
const [error, setError] = useState<string | null>(null);
98+
const [nonBlockingError, setNonBlockingError] = useState<string | null>(null);
9799
const [refreshToken, setRefreshToken] = useState(0);
98100
const apiKey = useMerchantApiKey();
99101
const hydrated = useMerchantHydrated();
100102
const chartContainerRef = useRef<HTMLDivElement>(null);
103+
const hasLoadedDataRef = useRef(false);
101104
const chartTitleId = useId();
102105
const chartDescriptionId = useId();
103106
const chartSummaryId = useId();
@@ -114,9 +117,15 @@ export default function PaymentMetrics({
114117
const controller = new AbortController();
115118
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000";
116119
let isCancelled = false;
117-
118-
setLoading(true);
119-
setError(null);
120+
const hasCachedData = hasLoadedDataRef.current;
121+
122+
setNonBlockingError(null);
123+
if (hasCachedData) {
124+
setIsRefreshing(true);
125+
} else {
126+
setLoading(true);
127+
setError(null);
128+
}
120129

121130
async function fetchMetrics() {
122131
try {
@@ -150,6 +159,7 @@ export default function PaymentMetrics({
150159

151160
setSummary(summaryData);
152161
setVolumeData(volumePayload);
162+
hasLoadedDataRef.current = true;
153163
// Keep only hidden assets that still exist in the refreshed payload.
154164
setHiddenAssets((prev) => {
155165
const available = new Set(volumePayload.assets ?? []);
@@ -159,14 +169,19 @@ export default function PaymentMetrics({
159169
if (fetchError instanceof Error && fetchError.name === "AbortError") {
160170
return;
161171
}
162-
setError(
172+
const nextError =
163173
fetchError instanceof Error
164174
? fetchError.message
165-
: t("fetchMetricsFailed"),
166-
);
175+
: t("fetchMetricsFailed");
176+
if (hasCachedData) {
177+
setNonBlockingError(nextError);
178+
} else {
179+
setError(nextError);
180+
}
167181
} finally {
168182
if (!isCancelled) {
169183
setLoading(false);
184+
setIsRefreshing(false);
170185
}
171186
}
172187
}
@@ -319,6 +334,14 @@ export default function PaymentMetrics({
319334
</div>
320335

321336
<div className="flex flex-wrap items-center gap-2">
337+
{isRefreshing && (
338+
<span
339+
className="rounded-full border border-[#E8E8E8] bg-[#F5F5F5] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wider text-[#6B6B6B]"
340+
aria-live="polite"
341+
>
342+
Updating...
343+
</span>
344+
)}
322345
<div className="flex gap-0.5 rounded-md border border-[#E8E8E8] bg-[#F5F5F5] p-0.5">
323346
{TIME_RANGES.map((nextRange) => (
324347
<button
@@ -338,6 +361,14 @@ export default function PaymentMetrics({
338361
</div>
339362
</div>
340363
</div>
364+
{nonBlockingError && (
365+
<p
366+
className="rounded-md border border-yellow-200 bg-yellow-50 px-3 py-2 text-xs text-yellow-700"
367+
role="status"
368+
>
369+
{nonBlockingError}
370+
</p>
371+
)}
341372

342373
{assets.length > 0 && (
343374
<div

0 commit comments

Comments
 (0)