From 89d0b2969818c75c36c331a7c28bfdd66755bda9 Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 30 Sep 2026 15:00:47 +0200 Subject: [PATCH] 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. --- README.md | 6 +- public/i18n/de.json | 2 +- public/i18n/en.json | 2 +- src/components/AccountInfoCard.jsx | 3 +- src/components/ActivityCard.jsx | 3 +- src/components/GameAchievements.jsx | 3 +- src/components/GameCorrectionDialog.jsx | 7 ++- src/components/GameCorrectionDialog.test.jsx | 14 ++++- src/components/import/ImportHistory.jsx | 3 +- src/components/import/ImportView.jsx | 31 ++++++++-- src/components/stats/HabitsStats.jsx | 3 +- src/components/stats/PileStats.jsx | 3 +- src/components/stats/PileTrend.jsx | 2 +- src/context/PreferencesContext.jsx | 5 +- src/context/PreferencesContext.test.jsx | 15 +++++ src/hooks/useSaveAppearance.test.jsx | 2 +- src/pages/AdminHintsPage.jsx | 3 +- src/pages/AdminUsersPage.jsx | 7 ++- src/pages/GameLibrary.jsx | 2 - src/pages/ImportPage.test.jsx | 64 +++++++++++++++++--- src/pages/PublicProfilePage.jsx | 3 +- src/pages/WishlistPage.jsx | 3 +- src/utils/appearance.js | 11 +--- src/utils/auth.js | 17 +++--- src/utils/auth.test.js | 10 --- src/utils/format.js | 8 +++ src/utils/format.test.js | 9 +++ src/utils/games.js | 4 -- src/utils/settings.js | 19 ++---- src/utils/storage.js | 24 ++++++++ src/utils/storage.test.js | 31 ++++++++++ 31 files changed, 238 insertions(+), 81 deletions(-) create mode 100644 src/utils/format.js create mode 100644 src/utils/format.test.js create mode 100644 src/utils/storage.js create mode 100644 src/utils/storage.test.js diff --git a/README.md b/README.md index 42314a2..44ad382 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,10 @@ flowchart LR - `src/utils/brokenImages.js` reports pictures that fail to load (`GameCover`, achievement icons on the game detail page) to Core. - `useLibrarySync` polls Core's `/api/library/sync-status` while a Steam sync runs, then loads the data again. +- The import page polls a running import every 2 s. After a `5xx` or a network error it waits twice as long (up to + 30 s) and gives up after 10 failures in a row; a hidden tab does not poll. +- All `localStorage` access goes through `src/utils/storage.js`: with site data blocked a read is `null` and a + write is lost, but the app still starts. - `PUT /api/users/settings` replaces dark mode, language, display name and public profile at once, so every save goes through `saveSettings` in `src/utils/settings.js`: one after the other, each on a fresh `GET /api/users/me`. - Pages keep a load error as its i18n key and translate it when rendering, so a language switch loads nothing @@ -211,7 +215,7 @@ npm run build ## Roadmap -See the roadmap in the ToBePlayed-Core readme. +See `ROADMAP.md` in the ToBePlayed-Deployment repo. ## License diff --git a/public/i18n/de.json b/public/i18n/de.json index ed693c6..5c61f8f 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -542,7 +542,7 @@ "BACK_TO_SOURCE": "Zurück zur Quelle", "CORRECTED": "Korrigiert", "SAVED": "Korrektur gespeichert", - "INVALID": "Nicht gespeichert: {{message}}", + "INVALID": "Nicht gespeichert: Der Wert passt nicht zu diesem Feld.", "NO_ACCESS": "Nur Auditoren können Spieldaten korrigieren.", "ERROR": "Die Korrektur konnte nicht gespeichert werden.", "IMAGE_HINT": "https://… (leer: das Steam-Headerbild)", diff --git a/public/i18n/en.json b/public/i18n/en.json index a2145c0..fe67518 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -542,7 +542,7 @@ "BACK_TO_SOURCE": "Back to source", "CORRECTED": "Corrected", "SAVED": "Correction saved", - "INVALID": "Not saved: {{message}}", + "INVALID": "Not saved: the value does not fit this field.", "NO_ACCESS": "Only auditors can correct game data.", "ERROR": "The correction could not be saved.", "IMAGE_HINT": "https://… (empty: the Steam header)", diff --git a/src/components/AccountInfoCard.jsx b/src/components/AccountInfoCard.jsx index 4747ed8..ac70f43 100644 --- a/src/components/AccountInfoCard.jsx +++ b/src/components/AccountInfoCard.jsx @@ -3,11 +3,12 @@ import {UserRound} from 'lucide-react'; import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; import {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; +import {formatDateTime} from '../utils/format.js'; const formatDate = (value, locale) => { if (!value) return '—'; try { - return new Date(value).toLocaleString(locale); + return formatDateTime(value, locale); } catch { return value; } diff --git a/src/components/ActivityCard.jsx b/src/components/ActivityCard.jsx index f885b72..86cf669 100644 --- a/src/components/ActivityCard.jsx +++ b/src/components/ActivityCard.jsx @@ -4,7 +4,8 @@ import {Activity} from 'lucide-react'; import {Card} from '@/components/ui/card'; import GameCover from './GameCover.jsx'; -import {formatDay, formatPlaytime} from '../utils/games.js'; +import {formatPlaytime} from '../utils/games.js'; +import {formatDay} from '../utils/format.js'; const CHART_HEIGHT = 80; const BAR_WIDTH = 10; diff --git a/src/components/GameAchievements.jsx b/src/components/GameAchievements.jsx index b98662c..3d28ff5 100644 --- a/src/components/GameAchievements.jsx +++ b/src/components/GameAchievements.jsx @@ -5,11 +5,12 @@ import {Trophy} from 'lucide-react'; import {Card} from '@/components/ui/card'; import {cn} from '@/lib/utils'; import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js'; +import {formatDate} from '../utils/format.js'; const formatUnlockedAt = (instant, locale) => { if (!instant) return ''; try { - return new Date(instant).toLocaleDateString(locale, {year: 'numeric', month: 'short', day: 'numeric'}); + return formatDate(instant, locale, {year: 'numeric', month: 'short', day: 'numeric'}); } catch { return ''; } diff --git a/src/components/GameCorrectionDialog.jsx b/src/components/GameCorrectionDialog.jsx index 72676a1..0f4ac92 100644 --- a/src/components/GameCorrectionDialog.jsx +++ b/src/components/GameCorrectionDialog.jsx @@ -12,6 +12,8 @@ import {CORRECTABLE_MOODS, IGDB_GENRES, resetCorrection, saveCorrection} from '. import {genreKey} from '../utils/stats.js'; import {MAX_CORRECTION_GENRES, MAX_CORRECTION_LENGTH} from '../utils/limits.js'; +const REFUSAL_KEY = {INVALID_CORRECTION: 'CORRECTION.INVALID'}; + const ENDLESS_CHOICES = [[null, 'UNKNOWN'], [true, 'YES'], [false, 'NO']]; const initialValues = (game) => ({ @@ -68,8 +70,9 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange return; } const data = await response.json().catch(() => ({})); - toast.error(data.code === 'INVALID_CORRECTION' ? t('CORRECTION.INVALID', {message: data.message}) - : response.status === 403 ? t('CORRECTION.NO_ACCESS') : t('CORRECTION.ERROR')); + // Core's message is English and for developers, so it goes to the console only. + if (data.message) console.warn('Correction refused:', data.code, data.message); + toast.error(t(REFUSAL_KEY[data.code] ?? (response.status === 403 ? 'CORRECTION.NO_ACCESS' : 'CORRECTION.ERROR'))); } catch (err) { console.error('Correction error:', err); toast.error(t('INFO.ERROR.NETWORK_FAILURE')); diff --git a/src/components/GameCorrectionDialog.test.jsx b/src/components/GameCorrectionDialog.test.jsx index 361e062..bcabc42 100644 --- a/src/components/GameCorrectionDialog.test.jsx +++ b/src/components/GameCorrectionDialog.test.jsx @@ -79,7 +79,8 @@ describe('GameCorrectionDialog', () => { expect(sent()).toEqual([{url: expect.stringContaining('/corrections/name'), method: 'DELETE', body: undefined}]); }); - it('shows why Core refused a value', async () => { + it('says Core refused a value and logs its message instead of showing it', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); corrections(json(400, {code: 'INVALID_CORRECTION', message: 'image must be https'})); const onChanged = renderDialog(); @@ -87,9 +88,20 @@ describe('GameCorrectionDialog', () => { fireEvent.click(saveButton(1)); await waitFor(() => expect(toast.error).toHaveBeenCalledWith('CORRECTION.INVALID')); + expect(warn).toHaveBeenCalledWith('Correction refused:', 'INVALID_CORRECTION', 'image must be https'); expect(onChanged).not.toHaveBeenCalled(); }); + it('shows the generic error for an unknown code', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + corrections(json(400, {code: 'SOMETHING_NEW', message: 'raw text'})); + renderDialog(); + + fireEvent.click(saveButton(1)); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('CORRECTION.ERROR')); + }); + it('keeps an unsaved edit of another field after the reload', async () => { const onChanged = vi.fn(); const {rerender} = render( (
  • - {new Date(entry.createdAt).toLocaleDateString(i18n.language)} + {formatDate(entry.createdAt, i18n.language)} {t(`IMPORT.SOURCE_${entry.source}`)} · {t('IMPORT.LINES', {count: entry.total})} diff --git a/src/components/import/ImportView.jsx b/src/components/import/ImportView.jsx index 7df5bd0..6bfe4b0 100644 --- a/src/components/import/ImportView.jsx +++ b/src/components/import/ImportView.jsx @@ -11,6 +11,8 @@ import {useCurrentUserContext} from '../../context/CurrentUserContext.jsx'; import {getImport, retryImport, RUNNING, STEPS, stepOf} from '../../utils/imports.js'; const POLL_MS = 2000; +const MAX_BACKOFF_MS = 30_000; +const MAX_FAILURES = 10; // Polls while a worker has the import. export default function ImportView({id, onDone, onChanged}) { @@ -28,36 +30,57 @@ export default function ImportView({id, onDone, onChanged}) { useEffect(() => { let timer; let cancelled = false; + let failures = 0; + let paused = false; + // A hidden tab waits for the visibilitychange instead of polling. + const poll = () => { + if (document.hidden) paused = true; + else load(); + }; + const onVisible = () => { + if (!document.hidden && paused) { + paused = false; + load(); + } + }; + // A 5xx while Core restarts or a network error is asked again, each time twice as late. + const retry = () => { + if (++failures >= MAX_FAILURES) setFailed(true); + else timer = setTimeout(poll, Math.min(POLL_MS * 2 ** failures, MAX_BACKOFF_MS)); + }; const load = async () => { try { const response = await getImport(id); if (cancelled) return; if (response.status === 401) return; - // A 4xx ends the polling; a 5xx while Core restarts is asked again. + // A 4xx ends the polling. if (response.status >= 400 && response.status < 500) { setFailed(true); return; } if (!response.ok) { - timer = setTimeout(load, POLL_MS); + retry(); return; } const next = await response.json(); if (cancelled) return; + failures = 0; // A new status also shows in the history. if (lastStatus.current && lastStatus.current !== next.status) onChanged(); lastStatus.current = next.status; setData(next); - if (RUNNING.includes(next.status)) timer = setTimeout(load, POLL_MS); + if (RUNNING.includes(next.status)) timer = setTimeout(poll, POLL_MS); } catch (err) { console.error('Import load error:', err); - if (!cancelled) timer = setTimeout(load, POLL_MS); + if (!cancelled) retry(); } }; + document.addEventListener('visibilitychange', onVisible); load(); return () => { cancelled = true; clearTimeout(timer); + document.removeEventListener('visibilitychange', onVisible); }; }, [id, version, onChanged]); diff --git a/src/components/stats/HabitsStats.jsx b/src/components/stats/HabitsStats.jsx index 64d21e0..a7c40cc 100644 --- a/src/components/stats/HabitsStats.jsx +++ b/src/components/stats/HabitsStats.jsx @@ -3,13 +3,14 @@ import {Rocket} from 'lucide-react'; import StatsSection from './StatsSection.jsx'; import StatTile from './StatTile.jsx'; +import {formatDay} from '../../utils/format.js'; import {formatPlaytime} from '../../utils/games.js'; import {formatDecimal, formatPercent} from '../../utils/stats.js'; export default function HabitsStats({habits}) { const {t, i18n} = useTranslation(); const day = habits.longestDay - ? new Date(`${habits.longestDay.day}T12:00:00`).toLocaleDateString(i18n.language, {dateStyle: 'medium'}) + ? formatDay(habits.longestDay.day, i18n.language, {dateStyle: 'medium'}) : null; return ( diff --git a/src/components/stats/PileStats.jsx b/src/components/stats/PileStats.jsx index f25e941..71dfa78 100644 --- a/src/components/stats/PileStats.jsx +++ b/src/components/stats/PileStats.jsx @@ -6,11 +6,12 @@ import StatTile from './StatTile.jsx'; import GameRow from './GameRow.jsx'; import {formatPlaytime} from '../../utils/games.js'; import {formatDecimal} from '../../utils/stats.js'; +import {formatDate} from '../../utils/format.js'; export default function PileStats({pile, linked = true}) { const {t, i18n} = useTranslation(); const since = pile.oldestUnplayed - ? new Date(pile.oldestUnplayed.inLibrarySince).toLocaleDateString(i18n.language, {dateStyle: 'medium'}) + ? formatDate(pile.oldestUnplayed.inLibrarySince, i18n.language, {dateStyle: 'medium'}) : null; return ( diff --git a/src/components/stats/PileTrend.jsx b/src/components/stats/PileTrend.jsx index 578444c..5eabd7b 100644 --- a/src/components/stats/PileTrend.jsx +++ b/src/components/stats/PileTrend.jsx @@ -1,5 +1,5 @@ import {useTranslation} from 'react-i18next'; -import {formatDay} from '../../utils/games.js'; +import {formatDay} from '../../utils/format.js'; import {trendPoints} from '../../utils/stats.js'; const WIDTH = 300; diff --git a/src/context/PreferencesContext.jsx b/src/context/PreferencesContext.jsx index 39f7147..1fc4cd7 100644 --- a/src/context/PreferencesContext.jsx +++ b/src/context/PreferencesContext.jsx @@ -12,6 +12,7 @@ import { import {hasSession} from '../utils/auth.js'; import useSession from '../hooks/useSession.js'; import {LANGUAGES, saveSettings} from '../utils/settings.js'; +import {storage} from '../utils/storage.js'; const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode'; // The --background of style.css; the installed app paints its title bar in this color. @@ -23,7 +24,7 @@ export function PreferencesProvider({children}) { const {i18n} = useTranslation(); const [isDarkMode, setIsDarkMode] = useState(() => { - const stored = localStorage.getItem(DARK_MODE_STORAGE_KEY); + const stored = storage.get(DARK_MODE_STORAGE_KEY); return stored === null ? true : stored === 'true'; }); @@ -35,7 +36,7 @@ export function PreferencesProvider({children}) { } document.querySelector('meta[name="theme-color"]') ?.setAttribute('content', isDarkMode ? THEME_COLORS.dark : THEME_COLORS.light); - localStorage.setItem(DARK_MODE_STORAGE_KEY, String(isDarkMode)); + storage.set(DARK_MODE_STORAGE_KEY, String(isDarkMode)); }, [isDarkMode]); const [appearance, setAppearanceState] = useState(readCachedAppearance); diff --git a/src/context/PreferencesContext.test.jsx b/src/context/PreferencesContext.test.jsx index c519f9c..c769a4b 100644 --- a/src/context/PreferencesContext.test.jsx +++ b/src/context/PreferencesContext.test.jsx @@ -37,6 +37,21 @@ const answerMe = (settings) => fetchRoutes({ 'PUT /api/users/settings': json(204), }); +describe('PreferencesProvider storage', () => { + it('renders in dark mode when localStorage throws', () => { + ['getItem', 'setItem'].forEach((method) => vi.spyOn(Storage.prototype, method).mockImplementation(() => { + throw new DOMException('blocked', 'SecurityError'); + })); + try { + render(); + + expect(preferences.isDarkMode).toBe(true); + } finally { + vi.restoreAllMocks(); + } + }); +}); + describe('PreferencesProvider language', () => { it('takes the language saved in Auth', async () => { answerMe({language: 'en', useDarkTheme: true, publicProfile: false}); diff --git a/src/hooks/useSaveAppearance.test.jsx b/src/hooks/useSaveAppearance.test.jsx index 3bda156..5e14d0f 100644 --- a/src/hooks/useSaveAppearance.test.jsx +++ b/src/hooks/useSaveAppearance.test.jsx @@ -101,7 +101,7 @@ describe('useSaveAppearance', () => { await result.current.save({wideLayout: true}); }); - expect(localStorage.getItem('tbp_jwt')).toBeNull(); + expect(localStorage.getItem('tbp_session')).toBeNull(); }); it('ends the session with the default look on a 401, so the next user does not get this one', async () => { diff --git a/src/pages/AdminHintsPage.jsx b/src/pages/AdminHintsPage.jsx index 2ddd7fb..270b089 100644 --- a/src/pages/AdminHintsPage.jsx +++ b/src/pages/AdminHintsPage.jsx @@ -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 ( diff --git a/src/pages/AdminUsersPage.jsx b/src/pages/AdminUsersPage.jsx index e24e5f2..e214e26 100644 --- a/src/pages/AdminUsersPage.jsx +++ b/src/pages/AdminUsersPage.jsx @@ -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() {

    {user.email}

    {[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(' · ')}

    diff --git a/src/pages/GameLibrary.jsx b/src/pages/GameLibrary.jsx index 785dacd..1ad2565 100644 --- a/src/pages/GameLibrary.jsx +++ b/src/pages/GameLibrary.jsx @@ -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() {
  • diff --git a/src/pages/ImportPage.test.jsx b/src/pages/ImportPage.test.jsx index 5670a01..41251a9 100644 --- a/src/pages/ImportPage.test.jsx +++ b/src/pages/ImportPage.test.jsx @@ -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', () => { , ); - expect(await screen.findByLabelText('IMPORT.LINE: Hades', {}, {timeout: 2000})).toBeTruthy(); + expect(await screen.findByLabelText('IMPORT.LINE: Hades')).toBeTruthy(); }); }); diff --git a/src/pages/PublicProfilePage.jsx b/src/pages/PublicProfilePage.jsx index e877498..81c67ef 100644 --- a/src/pages/PublicProfilePage.jsx +++ b/src/pages/PublicProfilePage.jsx @@ -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(); diff --git a/src/pages/WishlistPage.jsx b/src/pages/WishlistPage.jsx index 804248d..bf69ed3 100644 --- a/src/pages/WishlistPage.jsx +++ b/src/pages/WishlistPage.jsx @@ -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(); diff --git a/src/utils/appearance.js b/src/utils/appearance.js index 5802da4..c2cd82c 100644 --- a/src/utils/appearance.js +++ b/src/utils/appearance.js @@ -1,5 +1,6 @@ import {apiFetch} from './api.js'; import {fetchMe, REQUEST_TIMEOUT_MS} from './settings.js'; +import {storage} from './storage.js'; // Auth accepts only these names (AppearanceRequest); style.css has a preset for each. export const ACCENTS = ['violet', 'blue', 'green', 'orange', 'pink', 'red']; @@ -28,19 +29,13 @@ export const normalizeAppearance = (value) => ({ */ export const readCachedAppearance = () => { try { - return normalizeAppearance(JSON.parse(localStorage.getItem(STORAGE_KEY))); + return normalizeAppearance(JSON.parse(storage.get(STORAGE_KEY))); } catch { return DEFAULT_APPEARANCE; } }; -export const cacheAppearance = (appearance) => { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(appearance)); - } catch { - // Private mode or a full storage: the look is only not cached. - } -}; +export const cacheAppearance = (appearance) => storage.set(STORAGE_KEY, JSON.stringify(appearance)); export const applyAppearance = (appearance, root = document.documentElement) => { root.dataset.accent = appearance.accent; diff --git a/src/utils/auth.js b/src/utils/auth.js index 19174b5..71a6916 100644 --- a/src/utils/auth.js +++ b/src/utils/auth.js @@ -1,15 +1,15 @@ +import {storage} from './storage.js'; + // The tokens live in HttpOnly cookies that no script can read. This flag only remembers that a login happened; a // stale flag costs one 401 and a failed refresh, then the login page opens. export const SESSION_KEY = 'tbp_session'; -// Where the token lived before the cookies. Removed on the next login or logout. -const LEGACY_TOKEN_KEY = 'tbp_jwt'; -export const hasSession = () => localStorage.getItem(SESSION_KEY) !== null; +export const hasSession = () => storage.get(SESSION_KEY) !== null; /** * Changes on every login and on every settings save, so a hook that shows the user's Steam data can load again. */ -export const sessionStamp = () => localStorage.getItem(SESSION_KEY); +export const sessionStamp = () => storage.get(SESSION_KEY); const listeners = new Set(); // Why the session of this tab ended: null (never had one, or another tab ended it), 'logout' or 'expired'. @@ -28,14 +28,12 @@ const onStorage = (event) => { export const markSession = () => { // New for every login, also two in the same millisecond: apiFetch tells sessions apart by it. - localStorage.setItem(SESSION_KEY, crypto.randomUUID()); - localStorage.removeItem(LEGACY_TOKEN_KEY); + storage.set(SESSION_KEY, crypto.randomUUID()); change(null); }; export const clearSession = () => { - localStorage.removeItem(SESSION_KEY); - localStorage.removeItem(LEGACY_TOKEN_KEY); + storage.remove(SESSION_KEY); change('logout'); }; @@ -46,8 +44,7 @@ export const clearSession = () => { */ export const expireSession = (stamp = sessionStamp()) => { if (stamp === null || sessionStamp() !== stamp) return; - localStorage.removeItem(SESSION_KEY); - localStorage.removeItem(LEGACY_TOKEN_KEY); + storage.remove(SESSION_KEY); change('expired'); }; diff --git a/src/utils/auth.test.js b/src/utils/auth.test.js index 10f2b3c..67b22fe 100644 --- a/src/utils/auth.test.js +++ b/src/utils/auth.test.js @@ -15,16 +15,6 @@ describe('session flag', () => { expect(hasSession()).toBe(false); }); - it('removes the token of the old localStorage login', () => { - localStorage.setItem('tbp_jwt', 'old'); - markSession(); - expect(localStorage.getItem('tbp_jwt')).toBeNull(); - - localStorage.setItem('tbp_jwt', 'old'); - clearSession(); - expect(localStorage.getItem('tbp_jwt')).toBeNull(); - }); - it('gives a new stamp on every mark', async () => { markSession(); const first = sessionStamp(); diff --git a/src/utils/format.js b/src/utils/format.js new file mode 100644 index 0000000..555fb6f --- /dev/null +++ b/src/utils/format.js @@ -0,0 +1,8 @@ +// A LocalDate from Core ("2026-09-27"), by default as a short day; noon, so no time zone moves it to the day before. +export const formatDay = (day, locale, options = {day: 'numeric', month: 'short'}) => + new Date(`${day}T12:00:00`).toLocaleDateString(locale, options); + +// An instant from the backend (ISO string or epoch millis) as a date, or as date and time. +export const formatDate = (instant, locale, options) => new Date(instant).toLocaleDateString(locale, options); + +export const formatDateTime = (instant, locale, options) => new Date(instant).toLocaleString(locale, options); diff --git a/src/utils/format.test.js b/src/utils/format.test.js new file mode 100644 index 0000000..8b494d3 --- /dev/null +++ b/src/utils/format.test.js @@ -0,0 +1,9 @@ +import {describe, expect, it} from 'vitest'; +import {formatDay} from './format.js'; + +describe('formatDay', () => { + it('formats a Core day as a short day, or with the given options', () => { + expect(formatDay('2026-09-27', 'en-US')).toBe('Sep 27'); + expect(formatDay('2026-09-27', 'en-US', {year: 'numeric', month: 'long'})).toBe('September 2026'); + }); +}); diff --git a/src/utils/games.js b/src/utils/games.js index 92845a6..c43a6f6 100644 --- a/src/utils/games.js +++ b/src/utils/games.js @@ -10,10 +10,6 @@ export const formatPlaytime = (minutes) => { return hours >= 10 ? `${Math.round(hours)}h` : `${hours.toFixed(1)}h`; }; -// A LocalDate from Core ("2026-09-27") as a short day; noon, so no time zone moves it to the day before. -export const formatDay = (day, locale) => - new Date(`${day}T12:00:00`).toLocaleDateString(locale, {day: 'numeric', month: 'short'}); - // 460x215 store header. Not every app has one; callers need an onError fallback. export const coverUrl = (appid) => `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/header.jpg`; diff --git a/src/utils/settings.js b/src/utils/settings.js index 1f24686..3466b0a 100644 --- a/src/utils/settings.js +++ b/src/utils/settings.js @@ -1,4 +1,5 @@ import {apiFetch} from './api.js'; +import {storage} from './storage.js'; export const LANGUAGES = ['en', 'de']; @@ -6,23 +7,13 @@ const LANGUAGE_STORAGE_KEY = 'tbp_language'; export const REQUEST_TIMEOUT_MS = 10000; export const readCachedLanguage = () => { - try { - const stored = localStorage.getItem(LANGUAGE_STORAGE_KEY); - return LANGUAGES.includes(stored) ? stored : null; - } catch { - return null; - } + const stored = storage.get(LANGUAGE_STORAGE_KEY); + return LANGUAGES.includes(stored) ? stored : null; }; -export const cacheLanguage = (language) => { - try { - localStorage.setItem(LANGUAGE_STORAGE_KEY, language); - } catch { - // Private mode or a full storage: the language is only not cached. - } -}; +export const cacheLanguage = (language) => storage.set(LANGUAGE_STORAGE_KEY, language); -export const ME_URL = () => `${import.meta.env.VITE_API_URL}/api/users/me?v=1`; +export const ME_URL = () => `${import.meta.env.VITE_API_URL}/api/users/me`; /** * GET /api/users/me on Auth. The caller handles the response status. diff --git a/src/utils/storage.js b/src/utils/storage.js new file mode 100644 index 0000000..1197415 --- /dev/null +++ b/src/utils/storage.js @@ -0,0 +1,24 @@ +// localStorage throws when site data is blocked (and setItem when it is full). Then nothing is stored and a read is null. +export const storage = { + get(key) { + try { + return localStorage.getItem(key); + } catch { + return null; + } + }, + set(key, value) { + try { + localStorage.setItem(key, value); + } catch { + // not stored + } + }, + remove(key) { + try { + localStorage.removeItem(key); + } catch { + // nothing to remove from a blocked storage + } + }, +}; diff --git a/src/utils/storage.test.js b/src/utils/storage.test.js new file mode 100644 index 0000000..17b0744 --- /dev/null +++ b/src/utils/storage.test.js @@ -0,0 +1,31 @@ +// @vitest-environment jsdom +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {storage} from './storage.js'; + +afterEach(() => { + vi.restoreAllMocks(); + localStorage.clear(); +}); + +const blockStorage = () => ['getItem', 'setItem', 'removeItem'].forEach((method) => + vi.spyOn(Storage.prototype, method).mockImplementation(() => { + throw new DOMException('blocked', 'SecurityError'); + })); + +describe('storage', () => { + it('reads, writes and removes a value', () => { + storage.set('key', 'value'); + expect(storage.get('key')).toBe('value'); + + storage.remove('key'); + expect(storage.get('key')).toBeNull(); + }); + + it('does not throw when localStorage throws, and reads null', () => { + blockStorage(); + + expect(() => storage.set('key', 'value')).not.toThrow(); + expect(() => storage.remove('key')).not.toThrow(); + expect(storage.get('key')).toBeNull(); + }); +});