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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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})}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
Reference in new issue
Block a user