Public Access
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:
1 parent
aa5832225d
commit
89d0b29698
31 files changed
+238
-81
No files matched your search
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user