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
@@ -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;
}
+2 -1
View File
@@ -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;
+2 -1
View File
@@ -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 '';
}
+5 -2
View File
@@ -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'));
+13 -1
View File
@@ -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(<MemoryRouter><GameCorrectionDialog game={game} open onOpenChange={vi.fn()}
+2 -1
View File
@@ -6,6 +6,7 @@ import {RotateCcw} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {formatDate} from '../../utils/format.js';
import {checkUndo, undoImport} from '../../utils/imports.js';
export default function ImportHistory({imports, onOpen, onChanged}) {
@@ -44,7 +45,7 @@ export default function ImportHistory({imports, onOpen, onChanged}) {
{imports.map((entry) => (
<li key={entry.id} className="flex flex-wrap items-center gap-3 px-5 py-3 text-sm">
<span className="tabular-nums text-muted-foreground">
{new Date(entry.createdAt).toLocaleDateString(i18n.language)}
{formatDate(entry.createdAt, i18n.language)}
</span>
<span className="flex-1">
{t(`IMPORT.SOURCE_${entry.source}`)} · {t('IMPORT.LINES', {count: entry.total})}
+27 -4
View File
@@ -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]);
+2 -1
View File
@@ -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 (
<StatsSection title={t('STATS.HABITS.TITLE')} icon={Rocket}>
+2 -1
View File
@@ -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 (
<StatsSection title={t('STATS.PILE.TITLE')} icon={Inbox}>
+1 -1
View File
@@ -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;