refactor: item 29 cleanup

F16 F17 F18 F19 F20 F21: import polling backs off and pauses in a hidden tab, one date format module, fake timers in the import tests, error codes instead of raw messages, one safe storage wrapper, dead code.
This commit is contained in:
lobo committed 2026-09-30 15:00:47 +02:00
1 parent aa5832225d
commit 89d0b29698
31 files changed
+238 -81

No files matched your search

+2 -1
View File
@@ -10,6 +10,7 @@ import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {Textarea} from '@/components/ui/textarea';
import HintCard from '../components/HintCard.jsx';
import {formatDateTime} from '../utils/format.js';
import PageContainer from '../components/PageContainer.jsx';
import {createHint, endHint, fetchAdminHints} from '../utils/hints.js';
import {MAX_HINT_LENGTH} from '../utils/limits.js';
@@ -94,7 +95,7 @@ export default function AdminHintsPage() {
};
const change = (field) => (event) => setForm({...form, [field]: event.target.value});
const formatDate = (value) => new Date(value).toLocaleString(i18n.language, {dateStyle: 'short', timeStyle: 'short'});
const formatDate = (value) => formatDateTime(value, i18n.language, {dateStyle: 'short', timeStyle: 'short'});
if (status === 'forbidden') {
return (
+4 -3
View File
@@ -19,6 +19,7 @@ import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import PageContainer from '../components/PageContainer.jsx';
import {deleteUser, fetchUsers, setUserBlocked} from '../utils/admin.js';
import {formatDate} from '../utils/format.js';
import useAbortPrevious from '../hooks/useAbortPrevious.js';
const PAGE_SIZE = 20;
@@ -28,7 +29,7 @@ const REFUSAL_KEY = {
ALREADY_DELETED: 'ADMIN.ERROR_ALREADY_DELETED',
};
const formatDate = (value, locale) => (value ? new Date(value).toLocaleDateString(locale) : null);
const formatDateOrNull = (value, locale) => (value ? formatDate(value, locale) : null);
/**
* PLATFORM_ADMIN only: list, block and delete users. Auth checks the role; a 403 shows "no access".
@@ -140,8 +141,8 @@ export default function AdminUsersPage() {
<p className="truncate font-medium">{user.email}</p>
<p className="truncate text-xs text-muted-foreground">
{[user.displayName,
t('ADMIN.REGISTERED', {date: formatDate(user.registrationDate, i18n.language)}),
t('ADMIN.LAST_LOGIN', {date: formatDate(user.lastLoginDate, i18n.language) ?? t('ADMIN.NEVER')}),
t('ADMIN.REGISTERED', {date: formatDateOrNull(user.registrationDate, i18n.language)}),
t('ADMIN.LAST_LOGIN', {date: formatDateOrNull(user.lastLoginDate, i18n.language) ?? t('ADMIN.NEVER')}),
].filter(Boolean).join(' · ')}
</p>
</div>
-2
View File
@@ -56,7 +56,6 @@ export default function GameLibrary() {
const listStatus = hiddenOnly ? null : status;
const listShortOnly = !hiddenOnly && shortOnly;
const filtered = Boolean(listStatus || q.trim() || listShortOnly || hiddenOnly);
const librarySearch = searchParams.toString();
const [search, setSearch] = useState(q);
const [adding, setAdding] = useState(false);
// Starts true until /games-count answers, so a Steam user does not see the sync controls vanish and come back.
@@ -285,7 +284,6 @@ export default function GameLibrary() {
<li key={g.appid}>
<Link
to={`/library/${g.appid}`}
state={{librarySearch}}
className="group flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3 transition-colors hover:bg-muted/50"
>
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 sm:w-24 shrink-0"/>
+57 -7
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react';
import {act, cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react';
import {MemoryRouter, Route, Routes} from 'react-router-dom';
import {toast} from 'sonner';
import ImportPage from './ImportPage.jsx';
@@ -49,13 +49,17 @@ function renderPage(routes, entry = '/import') {
}
const posted = (method, path) => fetch.mock.calls.find(([url, init]) => init?.method === method && url.endsWith(path));
const polls = (path) => fetch.mock.calls.filter(([url, init]) => !init?.method && url.endsWith(path)).length;
const wait = (ms) => act(() => vi.advanceTimersByTimeAsync(ms));
beforeEach(() => {
vi.useFakeTimers({shouldAdvanceTime: true});
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
vi.useRealTimers();
localStorage.clear();
vi.clearAllMocks();
});
@@ -138,7 +142,8 @@ describe('ImportPage', () => {
});
expect(await screen.findByText('IMPORT.MATCHING')).toBeTruthy();
expect(await screen.findByText('IMPORT.LINE: Hades', {}, {timeout: 3000})).toBeTruthy();
await wait(2000);
expect(await screen.findByText('IMPORT.LINE: Hades')).toBeTruthy();
});
it('preselects the favorite, checks only sure matches, and saves the checked lines', async () => {
@@ -240,8 +245,10 @@ describe('ImportPage', () => {
'GET /5': () => (++polls > 1 ? json(200, ready) : json(200, matching)),
});
expect(await screen.findByText('IMPORT.LINE: Hades', {}, {timeout: 4000})).toBeTruthy();
}, 10000);
await screen.findByText('IMPORT.MATCHING');
await wait(2000);
expect(await screen.findByText('IMPORT.LINE: Hades')).toBeTruthy();
});
it('keeps polling after a 502 while Core restarts', async () => {
let calls = 0;
@@ -251,8 +258,51 @@ describe('ImportPage', () => {
total: 4, matched: 1, chosen: 0, saved: 0, added: 0, ahead: null, attempts: 0})),
}, '/import?id=8');
expect(await screen.findByText('IMPORT.MATCHING', {}, {timeout: 4000})).toBeTruthy();
}, 10000);
await waitFor(() => expect(polls('/8')).toBe(1));
await wait(4000);
expect(await screen.findByText('IMPORT.MATCHING')).toBeTruthy();
});
it('stops polling after repeated 500', async () => {
renderPage({'GET ': json(200, {imports: []}), 'GET /8': json(500, null)}, '/import?id=8');
await waitFor(() => expect(polls('/8')).toBe(1));
await wait(10 * 30_000);
expect(screen.getByText('IMPORT.LOAD_FAILED')).toBeTruthy();
expect(polls('/8')).toBe(10);
await wait(60_000);
expect(polls('/8')).toBe(10);
});
it('doubles the delay after each failure', async () => {
renderPage({'GET ': json(200, {imports: []}), 'GET /8': json(503, null)}, '/import?id=8');
await waitFor(() => expect(polls('/8')).toBe(1));
await wait(3500);
expect(polls('/8')).toBe(1);
await wait(1000);
expect(polls('/8')).toBe(2);
await wait(7000);
expect(polls('/8')).toBe(2);
await wait(1500);
expect(polls('/8')).toBe(3);
});
it('pauses polling in a hidden tab and goes on when it is visible again', async () => {
const hidden = vi.spyOn(document, 'hidden', 'get').mockReturnValue(true);
renderPage({'GET ': json(200, {imports: []}), 'GET /9': json(200, {id: 9, source: 'TEXT', status: 'MATCHING',
total: 2, matched: 1})}, '/import?id=9');
await screen.findByText('IMPORT.MATCHING');
await wait(20_000);
expect(polls('/9')).toBe(1);
hidden.mockReturnValue(false);
await act(async () => document.dispatchEvent(new Event('visibilitychange')));
expect(polls('/9')).toBe(2);
await wait(2000);
expect(polls('/9')).toBe(3);
});
});
describe('review findings', () => {
@@ -271,7 +321,7 @@ describe('review findings', () => {
</MemoryRouter>,
);
expect(await screen.findByLabelText('IMPORT.LINE: Hades', {}, {timeout: 2000})).toBeTruthy();
expect(await screen.findByLabelText('IMPORT.LINE: Hades')).toBeTruthy();
});
});
+2 -1
View File
@@ -9,6 +9,7 @@ import {Skeleton} from '@/components/ui/skeleton';
import {cn} from '@/lib/utils';
import GameCover from '../components/GameCover.jsx';
import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx';
import {formatDay} from '../utils/format.js';
import {GAME_STATUSES} from '../utils/limits.js';
import {formatPlaytime} from '../utils/games.js';
import useSession from '../hooks/useSession.js';
@@ -105,7 +106,7 @@ export default function PublicProfilePage() {
const style = normalizeProfileStyle(profile.style);
const memberSince = profile.memberSince
? new Date(`${profile.memberSince}T12:00:00`).toLocaleDateString(i18n.language, {year: 'numeric', month: 'long'})
? formatDay(profile.memberSince, i18n.language, {year: 'numeric', month: 'long'})
: null;
const layout = normalizeLayout(profile.style?.tiles, PROFILE_TILES, DEFAULT_PROFILE);
const owner = viewer?.displayName?.toLowerCase() === profile.displayName.toLowerCase();
+2 -1
View File
@@ -10,9 +10,10 @@ import GameCover from '../components/GameCover.jsx';
import LibraryTabs from '../components/LibraryTabs.jsx';
import PageContainer from '../components/PageContainer.jsx';
import {apiFetch} from '../utils/api.js';
import {formatDate} from '../utils/format.js';
const formatAdded = (instant, locale) =>
instant ? new Date(instant).toLocaleDateString(locale, {year: 'numeric', month: 'short', day: 'numeric'}) : null;
instant ? formatDate(instant, locale, {year: 'numeric', month: 'short', day: 'numeric'}) : null;
export default function WishlistPage() {
const {t, i18n} = useTranslation();