Skip to content

Commit 99fb7f6

Browse files
ryanbarlow97claude
andcommitted
Keep activity and session paging consistent across filters and failures
Changing the activity filter now drops the old rows and cursor at once, and Load more is disabled while any page loads, so an old cursor can no longer be paged into a new filter. A busy, timed-out or failed page keeps what is shown and offers Try again from the same cursor. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 85f826a commit 99fb7f6

2 files changed

Lines changed: 114 additions & 27 deletions

File tree

‎frontend/app/components/admin/PlayerProfile.test.tsx‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,65 @@ it.each([
141141
render(<PlayerProfile uuid={UUID} />);
142142
expect(await screen.findByText(text)).toBeTruthy();
143143
});
144+
145+
it("never pages an old filter's cursor into a new filter", async () => {
146+
render(<PlayerProfile uuid={UUID} />);
147+
const feed = await screen.findByRole("region", { name: "Recent activity" });
148+
await within(feed).findByRole("button", { name: "Load more" });
149+
150+
let finish: (value: Awaited<ReturnType<typeof getPlayerActivity>>) => void = () => {};
151+
vi.mocked(getPlayerActivity).mockReturnValueOnce(new Promise((resolve) => { finish = resolve; }));
152+
fireEvent.click(within(feed).getByRole("button", { name: "Kills" }));
153+
// While the filtered first page loads, the old rows and their cursor are gone.
154+
expect(within(feed).queryByRole("button", { name: "Load more" })).toBeNull();
155+
expect(within(feed).queryByText("stone")).toBeNull();
156+
157+
finish({
158+
entries: [entry("block:9", { kind: "kill", verb: "killed", target: "cow" })], next: null, searched_to: null,
159+
kinds: KINDS, coreprotect: { status: "available" },
160+
});
161+
expect(await within(feed).findByText("cow")).toBeTruthy();
162+
expect(getPlayerActivity).toHaveBeenLastCalledWith(UUID, { before: null, kinds: ["kill"] });
163+
expect(within(feed).queryByText("stone")).toBeNull();
164+
});
165+
166+
it("keeps rows and the cursor when a later page is unavailable", async () => {
167+
render(<PlayerProfile uuid={UUID} />);
168+
const feed = await screen.findByRole("region", { name: "Recent activity" });
169+
await within(feed).findByText("stone");
170+
171+
vi.mocked(getPlayerActivity).mockResolvedValueOnce({
172+
entries: [], next: null, searched_to: null, kinds: KINDS, coreprotect: { status: "unavailable", reason: "busy" },
173+
});
174+
fireEvent.click(within(feed).getByRole("button", { name: "Load more" }));
175+
expect(await within(feed).findByText(/CoreProtect is busy/)).toBeTruthy();
176+
expect(within(feed).getByText("stone")).toBeTruthy();
177+
178+
vi.mocked(getPlayerActivity).mockResolvedValueOnce({
179+
entries: [entry("block:0", { verb: "placed", target: "oak_door" })], next: null, searched_to: null,
180+
kinds: KINDS, coreprotect: { status: "available" },
181+
});
182+
fireEvent.click(within(feed).getByRole("button", { name: "Try again" }));
183+
expect(await within(feed).findByText("oak_door")).toBeTruthy();
184+
expect(getPlayerActivity).toHaveBeenLastCalledWith(UUID, { before: "cursor-1", kinds: undefined });
185+
expect(within(feed).getByText("stone")).toBeTruthy();
186+
expect(within(feed).queryByText(/CoreProtect is busy/)).toBeNull();
187+
});
188+
189+
it("retries a failed sessions page from the same cursor", async () => {
190+
vi.mocked(getPlayerSessions)
191+
.mockResolvedValueOnce({
192+
sessions: [{ start: { time: NOW - 7200, world: "TFMC_Map", x: 0, y: 64, z: 0 }, end: null, end_kind: "unknown",
193+
duration_seconds: null }],
194+
next: "s-1", history_start: NOW - 864000, coreprotect: { status: "available" },
195+
})
196+
.mockRejectedValueOnce(new AccountApiError("bad_cursor", 400))
197+
.mockResolvedValueOnce({ sessions: [], next: null, coreprotect: { status: "available" } });
198+
render(<PlayerProfile uuid={UUID} />);
199+
const sessions = await screen.findByRole("region", { name: "Sessions" });
200+
fireEvent.click(await within(sessions).findByRole("button", { name: "Load more sessions" }));
201+
expect(await within(sessions).findByText("That page link has expired. Reload to start again.")).toBeTruthy();
202+
expect(within(sessions).getByText("No logout recorded")).toBeTruthy();
203+
fireEvent.click(within(sessions).getByRole("button", { name: "Try again" }));
204+
await waitFor(() => expect(getPlayerSessions).toHaveBeenLastCalledWith(UUID, "s-1"));
205+
});

‎frontend/app/components/admin/PlayerProfile.tsx‎

Lines changed: 52 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -170,21 +170,25 @@ function Sessions({ uuid }: { uuid: string }) {
170170
const [next, setNext] = useState<string | null>(null);
171171
const [historyStart, setHistoryStart] = useState<number | null>(null);
172172
const [state, setState] = useState<"loading" | "ready" | "more">("loading");
173-
const [error, setError] = useState<string | null>(null);
173+
const [failure, setFailure] = useState<Failure | null>(null);
174174

175175
const fetchPage = useCallback(
176176
async (before: string | null) => {
177177
setState(before ? "more" : "loading");
178-
setError(null);
178+
setFailure(null);
179179
try {
180180
const page = await getPlayerSessions(uuid, before);
181181
const notice = coreProtectMessage(page.coreprotect);
182-
if (notice) setError(notice);
182+
// An unavailable page has no cursor: keep what is shown so the same page can be retried.
183+
if (notice) {
184+
setFailure({ message: notice, before });
185+
return;
186+
}
183187
setRows((old) => (before ? [...old, ...page.sessions] : page.sessions));
184188
setNext(page.next);
185189
if (page.history_start !== undefined) setHistoryStart(page.history_start ?? null);
186190
} catch (err) {
187-
setError(adminErrorMessage(err));
191+
setFailure({ message: adminErrorMessage(err), before });
188192
} finally {
189193
setState("ready");
190194
}
@@ -203,12 +207,7 @@ function Sessions({ uuid }: { uuid: string }) {
203207
<p className="mt-1 text-xs text-[var(--tfmc-stone)]">Session records go back to {formatEpoch(historyStart)}.</p>
204208
) : null}
205209
{state === "loading" ? <p className={`mt-3 ${mutedClass}`}>Loading…</p> : null}
206-
{error ? (
207-
<p className={errorClass} role="alert">
208-
{error}
209-
</p>
210-
) : null}
211-
{state !== "loading" && !rows.length && !error ? <p className={`mt-3 ${mutedClass}`}>No sessions recorded.</p> : null}
210+
{state !== "loading" && !rows.length && !failure ? <p className={`mt-3 ${mutedClass}`}>No sessions recorded.</p> : null}
212211
{rows.length ? (
213212
<div className="mt-3 overflow-x-auto">
214213
<table className="w-full min-w-[34rem] text-left text-sm">
@@ -243,15 +242,32 @@ function Sessions({ uuid }: { uuid: string }) {
243242
</table>
244243
</div>
245244
) : null}
246-
{next ? (
247-
<button type="button" className={moreClass} disabled={state === "more"} onClick={() => void fetchPage(next)}>
245+
{failure ? (
246+
<Retry failure={failure} busy={state !== "ready"} onRetry={() => void fetchPage(failure.before)} />
247+
) : next ? (
248+
<button type="button" className={moreClass} disabled={state !== "ready"} onClick={() => void fetchPage(next)}>
248249
{state === "more" ? "Loading…" : "Load more sessions"}
249250
</button>
250251
) : null}
251252
</section>
252253
);
253254
}
254255

256+
type Failure = { message: string; before: string | null };
257+
258+
function Retry({ failure, busy, onRetry }: { failure: Failure; busy: boolean; onRetry: () => void }) {
259+
return (
260+
<div>
261+
<p className={errorClass} role="alert">
262+
{failure.message}
263+
</p>
264+
<button type="button" className={moreClass} disabled={busy} onClick={onRetry}>
265+
Try again
266+
</button>
267+
</div>
268+
);
269+
}
270+
255271
function describe(entry: ActivityEntry) {
256272
const amount = entry.amount && entry.amount > 1 ? `${entry.amount} × ` : "";
257273
return (
@@ -281,25 +297,28 @@ function Activity({ uuid }: { uuid: string }) {
281297
const [next, setNext] = useState<string | null>(null);
282298
const [searchedTo, setSearchedTo] = useState<number | null>(null);
283299
const [state, setState] = useState<"loading" | "ready" | "more">("loading");
284-
const [error, setError] = useState<string | null>(null);
300+
const [failure, setFailure] = useState<Failure | null>(null);
285301
const request = useRef(0);
286302

287303
const fetchPage = useCallback(
288304
async (before: string | null) => {
289305
const id = ++request.current;
290306
setState(before ? "more" : "loading");
291-
setError(null);
307+
setFailure(null);
292308
try {
293309
const page = await getPlayerActivity(uuid, { before, kinds: kinds.length ? kinds : undefined });
294310
if (id !== request.current) return;
295311
const notice = coreProtectMessage(page.coreprotect);
296-
if (notice) setError(notice);
312+
if (notice) {
313+
setFailure({ message: notice, before });
314+
return;
315+
}
297316
setEntries((old) => (before ? [...old, ...page.entries] : page.entries));
298317
setNext(page.next);
299318
setSearchedTo(page.searched_to);
300319
if (page.kinds.length) setAvailable(page.kinds);
301320
} catch (err) {
302-
if (id === request.current) setError(adminErrorMessage(err));
321+
if (id === request.current) setFailure({ message: adminErrorMessage(err), before });
303322
} finally {
304323
if (id === request.current) setState("ready");
305324
}
@@ -311,8 +330,17 @@ function Activity({ uuid }: { uuid: string }) {
311330
void fetchPage(null);
312331
}, [fetchPage]);
313332

333+
function choose(next: string[]) {
334+
// The old filter's rows and cursor must not be paged into the new one.
335+
setNext(null);
336+
setSearchedTo(null);
337+
setEntries([]);
338+
setFailure(null);
339+
setKinds(next);
340+
}
341+
314342
function toggle(kind: string) {
315-
setKinds((old) => (old.includes(kind) ? old.filter((k) => k !== kind) : [...old, kind]));
343+
choose(kinds.includes(kind) ? kinds.filter((k) => k !== kind) : [...kinds, kind]);
316344
}
317345

318346
const chip = (active: boolean) =>
@@ -327,7 +355,7 @@ function Activity({ uuid }: { uuid: string }) {
327355
<h3 className={headingClass}>Recent activity</h3>
328356
<p className="mt-1 text-xs text-[var(--tfmc-stone)]">From CoreProtect. Chat, command arguments and sign text are not shown.</p>
329357
<div role="group" aria-label="Show" className="mt-3 flex flex-wrap gap-1.5">
330-
<button type="button" aria-pressed={!kinds.length} className={chip(!kinds.length)} onClick={() => setKinds([])}>
358+
<button type="button" aria-pressed={!kinds.length} className={chip(!kinds.length)} onClick={() => choose([])}>
331359
All
332360
</button>
333361
{available.map((kind) => (
@@ -337,12 +365,7 @@ function Activity({ uuid }: { uuid: string }) {
337365
))}
338366
</div>
339367
{state === "loading" ? <p className={`mt-3 ${mutedClass}`}>Loading…</p> : null}
340-
{error ? (
341-
<p className={errorClass} role="alert">
342-
{error}
343-
</p>
344-
) : null}
345-
{state !== "loading" && !entries.length && !error ? (
368+
{state !== "loading" && !entries.length && !failure ? (
346369
<p className={`mt-3 ${mutedClass}`}>{searchedTo ? "Nothing yet." : "No activity recorded."}</p>
347370
) : null}
348371
{entries.length ? (
@@ -361,11 +384,13 @@ function Activity({ uuid }: { uuid: string }) {
361384
))}
362385
</ul>
363386
) : null}
364-
{searchedTo && next ? (
387+
{searchedTo && next && !failure ? (
365388
<p className="mt-3 text-xs text-[var(--tfmc-stone)]">Searched back to {formatEpoch(searchedTo)}.</p>
366389
) : null}
367-
{next ? (
368-
<button type="button" className={moreClass} disabled={state === "more"} onClick={() => void fetchPage(next)}>
390+
{failure ? (
391+
<Retry failure={failure} busy={state !== "ready"} onRetry={() => void fetchPage(failure.before)} />
392+
) : next ? (
393+
<button type="button" className={moreClass} disabled={state !== "ready"} onClick={() => void fetchPage(next)}>
369394
{state === "more" ? "Loading…" : searchedTo ? "Search further back" : "Load more"}
370395
</button>
371396
) : null}

0 commit comments

Comments
 (0)