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
@@ -144,6 +144,10 @@ flowchart LR
|
|||||||
- `src/utils/brokenImages.js` reports pictures that fail to load (`GameCover`, achievement icons on the game
|
- `src/utils/brokenImages.js` reports pictures that fail to load (`GameCover`, achievement icons on the game
|
||||||
detail page) to Core.
|
detail page) to Core.
|
||||||
- `useLibrarySync` polls Core's `/api/library/sync-status` while a Steam sync runs, then loads the data again.
|
- `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
|
- `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`.
|
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
|
- 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
|
## Roadmap
|
||||||
|
|
||||||
See the roadmap in the ToBePlayed-Core readme.
|
See `ROADMAP.md` in the ToBePlayed-Deployment repo.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -542,7 +542,7 @@
|
|||||||
"BACK_TO_SOURCE": "Zurück zur Quelle",
|
"BACK_TO_SOURCE": "Zurück zur Quelle",
|
||||||
"CORRECTED": "Korrigiert",
|
"CORRECTED": "Korrigiert",
|
||||||
"SAVED": "Korrektur gespeichert",
|
"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.",
|
"NO_ACCESS": "Nur Auditoren können Spieldaten korrigieren.",
|
||||||
"ERROR": "Die Korrektur konnte nicht gespeichert werden.",
|
"ERROR": "Die Korrektur konnte nicht gespeichert werden.",
|
||||||
"IMAGE_HINT": "https://… (leer: das Steam-Headerbild)",
|
"IMAGE_HINT": "https://… (leer: das Steam-Headerbild)",
|
||||||
|
|||||||
+1
-1
@@ -542,7 +542,7 @@
|
|||||||
"BACK_TO_SOURCE": "Back to source",
|
"BACK_TO_SOURCE": "Back to source",
|
||||||
"CORRECTED": "Corrected",
|
"CORRECTED": "Corrected",
|
||||||
"SAVED": "Correction saved",
|
"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.",
|
"NO_ACCESS": "Only auditors can correct game data.",
|
||||||
"ERROR": "The correction could not be saved.",
|
"ERROR": "The correction could not be saved.",
|
||||||
"IMAGE_HINT": "https://… (empty: the Steam header)",
|
"IMAGE_HINT": "https://… (empty: the Steam header)",
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import {UserRound} from 'lucide-react';
|
|||||||
|
|
||||||
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||||
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||||
|
import {formatDateTime} from '../utils/format.js';
|
||||||
|
|
||||||
const formatDate = (value, locale) => {
|
const formatDate = (value, locale) => {
|
||||||
if (!value) return '—';
|
if (!value) return '—';
|
||||||
try {
|
try {
|
||||||
return new Date(value).toLocaleString(locale);
|
return formatDateTime(value, locale);
|
||||||
} catch {
|
} catch {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import {Activity} from 'lucide-react';
|
|||||||
|
|
||||||
import {Card} from '@/components/ui/card';
|
import {Card} from '@/components/ui/card';
|
||||||
import GameCover from './GameCover.jsx';
|
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 CHART_HEIGHT = 80;
|
||||||
const BAR_WIDTH = 10;
|
const BAR_WIDTH = 10;
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ import {Trophy} from 'lucide-react';
|
|||||||
import {Card} from '@/components/ui/card';
|
import {Card} from '@/components/ui/card';
|
||||||
import {cn} from '@/lib/utils';
|
import {cn} from '@/lib/utils';
|
||||||
import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js';
|
import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js';
|
||||||
|
import {formatDate} from '../utils/format.js';
|
||||||
|
|
||||||
const formatUnlockedAt = (instant, locale) => {
|
const formatUnlockedAt = (instant, locale) => {
|
||||||
if (!instant) return '';
|
if (!instant) return '';
|
||||||
try {
|
try {
|
||||||
return new Date(instant).toLocaleDateString(locale, {year: 'numeric', month: 'short', day: 'numeric'});
|
return formatDate(instant, locale, {year: 'numeric', month: 'short', day: 'numeric'});
|
||||||
} catch {
|
} catch {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {CORRECTABLE_MOODS, IGDB_GENRES, resetCorrection, saveCorrection} from '.
|
|||||||
import {genreKey} from '../utils/stats.js';
|
import {genreKey} from '../utils/stats.js';
|
||||||
import {MAX_CORRECTION_GENRES, MAX_CORRECTION_LENGTH} from '../utils/limits.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 ENDLESS_CHOICES = [[null, 'UNKNOWN'], [true, 'YES'], [false, 'NO']];
|
||||||
|
|
||||||
const initialValues = (game) => ({
|
const initialValues = (game) => ({
|
||||||
@@ -68,8 +70,9 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await response.json().catch(() => ({}));
|
const data = await response.json().catch(() => ({}));
|
||||||
toast.error(data.code === 'INVALID_CORRECTION' ? t('CORRECTION.INVALID', {message: data.message})
|
// Core's message is English and for developers, so it goes to the console only.
|
||||||
: response.status === 403 ? t('CORRECTION.NO_ACCESS') : t('CORRECTION.ERROR'));
|
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) {
|
} catch (err) {
|
||||||
console.error('Correction error:', err);
|
console.error('Correction error:', err);
|
||||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
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}]);
|
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'}));
|
corrections(json(400, {code: 'INVALID_CORRECTION', message: 'image must be https'}));
|
||||||
const onChanged = renderDialog();
|
const onChanged = renderDialog();
|
||||||
|
|
||||||
@@ -87,9 +88,20 @@ describe('GameCorrectionDialog', () => {
|
|||||||
fireEvent.click(saveButton(1));
|
fireEvent.click(saveButton(1));
|
||||||
|
|
||||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('CORRECTION.INVALID'));
|
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('CORRECTION.INVALID'));
|
||||||
|
expect(warn).toHaveBeenCalledWith('Correction refused:', 'INVALID_CORRECTION', 'image must be https');
|
||||||
expect(onChanged).not.toHaveBeenCalled();
|
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 () => {
|
it('keeps an unsaved edit of another field after the reload', async () => {
|
||||||
const onChanged = vi.fn();
|
const onChanged = vi.fn();
|
||||||
const {rerender} = render(<MemoryRouter><GameCorrectionDialog game={game} open onOpenChange={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 {Badge} from '@/components/ui/badge';
|
||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
import {Card} from '@/components/ui/card';
|
import {Card} from '@/components/ui/card';
|
||||||
|
import {formatDate} from '../../utils/format.js';
|
||||||
import {checkUndo, undoImport} from '../../utils/imports.js';
|
import {checkUndo, undoImport} from '../../utils/imports.js';
|
||||||
|
|
||||||
export default function ImportHistory({imports, onOpen, onChanged}) {
|
export default function ImportHistory({imports, onOpen, onChanged}) {
|
||||||
@@ -44,7 +45,7 @@ export default function ImportHistory({imports, onOpen, onChanged}) {
|
|||||||
{imports.map((entry) => (
|
{imports.map((entry) => (
|
||||||
<li key={entry.id} className="flex flex-wrap items-center gap-3 px-5 py-3 text-sm">
|
<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">
|
<span className="tabular-nums text-muted-foreground">
|
||||||
{new Date(entry.createdAt).toLocaleDateString(i18n.language)}
|
{formatDate(entry.createdAt, i18n.language)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1">
|
<span className="flex-1">
|
||||||
{t(`IMPORT.SOURCE_${entry.source}`)} · {t('IMPORT.LINES', {count: entry.total})}
|
{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';
|
import {getImport, retryImport, RUNNING, STEPS, stepOf} from '../../utils/imports.js';
|
||||||
|
|
||||||
const POLL_MS = 2000;
|
const POLL_MS = 2000;
|
||||||
|
const MAX_BACKOFF_MS = 30_000;
|
||||||
|
const MAX_FAILURES = 10;
|
||||||
|
|
||||||
// Polls while a worker has the import.
|
// Polls while a worker has the import.
|
||||||
export default function ImportView({id, onDone, onChanged}) {
|
export default function ImportView({id, onDone, onChanged}) {
|
||||||
@@ -28,36 +30,57 @@ export default function ImportView({id, onDone, onChanged}) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let timer;
|
let timer;
|
||||||
let cancelled = false;
|
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 () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await getImport(id);
|
const response = await getImport(id);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (response.status === 401) 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) {
|
if (response.status >= 400 && response.status < 500) {
|
||||||
setFailed(true);
|
setFailed(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
timer = setTimeout(load, POLL_MS);
|
retry();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const next = await response.json();
|
const next = await response.json();
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
failures = 0;
|
||||||
// A new status also shows in the history.
|
// A new status also shows in the history.
|
||||||
if (lastStatus.current && lastStatus.current !== next.status) onChanged();
|
if (lastStatus.current && lastStatus.current !== next.status) onChanged();
|
||||||
lastStatus.current = next.status;
|
lastStatus.current = next.status;
|
||||||
setData(next);
|
setData(next);
|
||||||
if (RUNNING.includes(next.status)) timer = setTimeout(load, POLL_MS);
|
if (RUNNING.includes(next.status)) timer = setTimeout(poll, POLL_MS);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Import load error:', err);
|
console.error('Import load error:', err);
|
||||||
if (!cancelled) timer = setTimeout(load, POLL_MS);
|
if (!cancelled) retry();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
document.addEventListener('visibilitychange', onVisible);
|
||||||
load();
|
load();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
|
document.removeEventListener('visibilitychange', onVisible);
|
||||||
};
|
};
|
||||||
}, [id, version, onChanged]);
|
}, [id, version, onChanged]);
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ import {Rocket} from 'lucide-react';
|
|||||||
|
|
||||||
import StatsSection from './StatsSection.jsx';
|
import StatsSection from './StatsSection.jsx';
|
||||||
import StatTile from './StatTile.jsx';
|
import StatTile from './StatTile.jsx';
|
||||||
|
import {formatDay} from '../../utils/format.js';
|
||||||
import {formatPlaytime} from '../../utils/games.js';
|
import {formatPlaytime} from '../../utils/games.js';
|
||||||
import {formatDecimal, formatPercent} from '../../utils/stats.js';
|
import {formatDecimal, formatPercent} from '../../utils/stats.js';
|
||||||
|
|
||||||
export default function HabitsStats({habits}) {
|
export default function HabitsStats({habits}) {
|
||||||
const {t, i18n} = useTranslation();
|
const {t, i18n} = useTranslation();
|
||||||
const day = habits.longestDay
|
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;
|
: null;
|
||||||
return (
|
return (
|
||||||
<StatsSection title={t('STATS.HABITS.TITLE')} icon={Rocket}>
|
<StatsSection title={t('STATS.HABITS.TITLE')} icon={Rocket}>
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import StatTile from './StatTile.jsx';
|
|||||||
import GameRow from './GameRow.jsx';
|
import GameRow from './GameRow.jsx';
|
||||||
import {formatPlaytime} from '../../utils/games.js';
|
import {formatPlaytime} from '../../utils/games.js';
|
||||||
import {formatDecimal} from '../../utils/stats.js';
|
import {formatDecimal} from '../../utils/stats.js';
|
||||||
|
import {formatDate} from '../../utils/format.js';
|
||||||
|
|
||||||
export default function PileStats({pile, linked = true}) {
|
export default function PileStats({pile, linked = true}) {
|
||||||
const {t, i18n} = useTranslation();
|
const {t, i18n} = useTranslation();
|
||||||
const since = pile.oldestUnplayed
|
const since = pile.oldestUnplayed
|
||||||
? new Date(pile.oldestUnplayed.inLibrarySince).toLocaleDateString(i18n.language, {dateStyle: 'medium'})
|
? formatDate(pile.oldestUnplayed.inLibrarySince, i18n.language, {dateStyle: 'medium'})
|
||||||
: null;
|
: null;
|
||||||
return (
|
return (
|
||||||
<StatsSection title={t('STATS.PILE.TITLE')} icon={Inbox}>
|
<StatsSection title={t('STATS.PILE.TITLE')} icon={Inbox}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {formatDay} from '../../utils/games.js';
|
import {formatDay} from '../../utils/format.js';
|
||||||
import {trendPoints} from '../../utils/stats.js';
|
import {trendPoints} from '../../utils/stats.js';
|
||||||
|
|
||||||
const WIDTH = 300;
|
const WIDTH = 300;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import {hasSession} from '../utils/auth.js';
|
import {hasSession} from '../utils/auth.js';
|
||||||
import useSession from '../hooks/useSession.js';
|
import useSession from '../hooks/useSession.js';
|
||||||
import {LANGUAGES, saveSettings} from '../utils/settings.js';
|
import {LANGUAGES, saveSettings} from '../utils/settings.js';
|
||||||
|
import {storage} from '../utils/storage.js';
|
||||||
|
|
||||||
const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode';
|
const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode';
|
||||||
// The --background of style.css; the installed app paints its title bar in this color.
|
// 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 {i18n} = useTranslation();
|
||||||
|
|
||||||
const [isDarkMode, setIsDarkMode] = useState(() => {
|
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';
|
return stored === null ? true : stored === 'true';
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ export function PreferencesProvider({children}) {
|
|||||||
}
|
}
|
||||||
document.querySelector('meta[name="theme-color"]')
|
document.querySelector('meta[name="theme-color"]')
|
||||||
?.setAttribute('content', isDarkMode ? THEME_COLORS.dark : THEME_COLORS.light);
|
?.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]);
|
}, [isDarkMode]);
|
||||||
|
|
||||||
const [appearance, setAppearanceState] = useState(readCachedAppearance);
|
const [appearance, setAppearanceState] = useState(readCachedAppearance);
|
||||||
|
|||||||
@@ -37,6 +37,21 @@ const answerMe = (settings) => fetchRoutes({
|
|||||||
'PUT /api/users/settings': json(204),
|
'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(<PreferencesProvider><Spy/></PreferencesProvider>);
|
||||||
|
|
||||||
|
expect(preferences.isDarkMode).toBe(true);
|
||||||
|
} finally {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('PreferencesProvider language', () => {
|
describe('PreferencesProvider language', () => {
|
||||||
it('takes the language saved in Auth', async () => {
|
it('takes the language saved in Auth', async () => {
|
||||||
answerMe({language: 'en', useDarkTheme: true, publicProfile: false});
|
answerMe({language: 'en', useDarkTheme: true, publicProfile: false});
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ describe('useSaveAppearance', () => {
|
|||||||
await result.current.save({wideLayout: true});
|
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 () => {
|
it('ends the session with the default look on a 401, so the next user does not get this one', async () => {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {Input} from '@/components/ui/input';
|
|||||||
import {Label} from '@/components/ui/label';
|
import {Label} from '@/components/ui/label';
|
||||||
import {Textarea} from '@/components/ui/textarea';
|
import {Textarea} from '@/components/ui/textarea';
|
||||||
import HintCard from '../components/HintCard.jsx';
|
import HintCard from '../components/HintCard.jsx';
|
||||||
|
import {formatDateTime} from '../utils/format.js';
|
||||||
import PageContainer from '../components/PageContainer.jsx';
|
import PageContainer from '../components/PageContainer.jsx';
|
||||||
import {createHint, endHint, fetchAdminHints} from '../utils/hints.js';
|
import {createHint, endHint, fetchAdminHints} from '../utils/hints.js';
|
||||||
import {MAX_HINT_LENGTH} from '../utils/limits.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 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') {
|
if (status === 'forbidden') {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {Input} from '@/components/ui/input';
|
|||||||
import {Label} from '@/components/ui/label';
|
import {Label} from '@/components/ui/label';
|
||||||
import PageContainer from '../components/PageContainer.jsx';
|
import PageContainer from '../components/PageContainer.jsx';
|
||||||
import {deleteUser, fetchUsers, setUserBlocked} from '../utils/admin.js';
|
import {deleteUser, fetchUsers, setUserBlocked} from '../utils/admin.js';
|
||||||
|
import {formatDate} from '../utils/format.js';
|
||||||
import useAbortPrevious from '../hooks/useAbortPrevious.js';
|
import useAbortPrevious from '../hooks/useAbortPrevious.js';
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
@@ -28,7 +29,7 @@ const REFUSAL_KEY = {
|
|||||||
ALREADY_DELETED: 'ADMIN.ERROR_ALREADY_DELETED',
|
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".
|
* 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 font-medium">{user.email}</p>
|
||||||
<p className="truncate text-xs text-muted-foreground">
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
{[user.displayName,
|
{[user.displayName,
|
||||||
t('ADMIN.REGISTERED', {date: formatDate(user.registrationDate, i18n.language)}),
|
t('ADMIN.REGISTERED', {date: formatDateOrNull(user.registrationDate, i18n.language)}),
|
||||||
t('ADMIN.LAST_LOGIN', {date: formatDate(user.lastLoginDate, i18n.language) ?? t('ADMIN.NEVER')}),
|
t('ADMIN.LAST_LOGIN', {date: formatDateOrNull(user.lastLoginDate, i18n.language) ?? t('ADMIN.NEVER')}),
|
||||||
].filter(Boolean).join(' · ')}
|
].filter(Boolean).join(' · ')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ export default function GameLibrary() {
|
|||||||
const listStatus = hiddenOnly ? null : status;
|
const listStatus = hiddenOnly ? null : status;
|
||||||
const listShortOnly = !hiddenOnly && shortOnly;
|
const listShortOnly = !hiddenOnly && shortOnly;
|
||||||
const filtered = Boolean(listStatus || q.trim() || listShortOnly || hiddenOnly);
|
const filtered = Boolean(listStatus || q.trim() || listShortOnly || hiddenOnly);
|
||||||
const librarySearch = searchParams.toString();
|
|
||||||
const [search, setSearch] = useState(q);
|
const [search, setSearch] = useState(q);
|
||||||
const [adding, setAdding] = useState(false);
|
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.
|
// 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}>
|
<li key={g.appid}>
|
||||||
<Link
|
<Link
|
||||||
to={`/library/${g.appid}`}
|
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"
|
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"/>
|
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 sm:w-24 shrink-0"/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
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 {MemoryRouter, Route, Routes} from 'react-router-dom';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import ImportPage from './ImportPage.jsx';
|
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 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(() => {
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({shouldAdvanceTime: true});
|
||||||
localStorage.setItem('tbp_session', '1');
|
localStorage.setItem('tbp_session', '1');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
vi.useRealTimers();
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
@@ -138,7 +142,8 @@ describe('ImportPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(await screen.findByText('IMPORT.MATCHING')).toBeTruthy();
|
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 () => {
|
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)),
|
'GET /5': () => (++polls > 1 ? json(200, ready) : json(200, matching)),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await screen.findByText('IMPORT.LINE: Hades', {}, {timeout: 4000})).toBeTruthy();
|
await screen.findByText('IMPORT.MATCHING');
|
||||||
}, 10000);
|
await wait(2000);
|
||||||
|
expect(await screen.findByText('IMPORT.LINE: Hades')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps polling after a 502 while Core restarts', async () => {
|
it('keeps polling after a 502 while Core restarts', async () => {
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
@@ -251,8 +258,51 @@ describe('ImportPage', () => {
|
|||||||
total: 4, matched: 1, chosen: 0, saved: 0, added: 0, ahead: null, attempts: 0})),
|
total: 4, matched: 1, chosen: 0, saved: 0, added: 0, ahead: null, attempts: 0})),
|
||||||
}, '/import?id=8');
|
}, '/import?id=8');
|
||||||
|
|
||||||
expect(await screen.findByText('IMPORT.MATCHING', {}, {timeout: 4000})).toBeTruthy();
|
await waitFor(() => expect(polls('/8')).toBe(1));
|
||||||
}, 10000);
|
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', () => {
|
describe('review findings', () => {
|
||||||
@@ -271,7 +321,7 @@ describe('review findings', () => {
|
|||||||
</MemoryRouter>,
|
</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 {cn} from '@/lib/utils';
|
||||||
import GameCover from '../components/GameCover.jsx';
|
import GameCover from '../components/GameCover.jsx';
|
||||||
import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx';
|
import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx';
|
||||||
|
import {formatDay} from '../utils/format.js';
|
||||||
import {GAME_STATUSES} from '../utils/limits.js';
|
import {GAME_STATUSES} from '../utils/limits.js';
|
||||||
import {formatPlaytime} from '../utils/games.js';
|
import {formatPlaytime} from '../utils/games.js';
|
||||||
import useSession from '../hooks/useSession.js';
|
import useSession from '../hooks/useSession.js';
|
||||||
@@ -105,7 +106,7 @@ export default function PublicProfilePage() {
|
|||||||
|
|
||||||
const style = normalizeProfileStyle(profile.style);
|
const style = normalizeProfileStyle(profile.style);
|
||||||
const memberSince = profile.memberSince
|
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;
|
: null;
|
||||||
const layout = normalizeLayout(profile.style?.tiles, PROFILE_TILES, DEFAULT_PROFILE);
|
const layout = normalizeLayout(profile.style?.tiles, PROFILE_TILES, DEFAULT_PROFILE);
|
||||||
const owner = viewer?.displayName?.toLowerCase() === profile.displayName.toLowerCase();
|
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 LibraryTabs from '../components/LibraryTabs.jsx';
|
||||||
import PageContainer from '../components/PageContainer.jsx';
|
import PageContainer from '../components/PageContainer.jsx';
|
||||||
import {apiFetch} from '../utils/api.js';
|
import {apiFetch} from '../utils/api.js';
|
||||||
|
import {formatDate} from '../utils/format.js';
|
||||||
|
|
||||||
const formatAdded = (instant, locale) =>
|
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() {
|
export default function WishlistPage() {
|
||||||
const {t, i18n} = useTranslation();
|
const {t, i18n} = useTranslation();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {apiFetch} from './api.js';
|
import {apiFetch} from './api.js';
|
||||||
import {fetchMe, REQUEST_TIMEOUT_MS} from './settings.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.
|
// Auth accepts only these names (AppearanceRequest); style.css has a preset for each.
|
||||||
export const ACCENTS = ['violet', 'blue', 'green', 'orange', 'pink', 'red'];
|
export const ACCENTS = ['violet', 'blue', 'green', 'orange', 'pink', 'red'];
|
||||||
@@ -28,19 +29,13 @@ export const normalizeAppearance = (value) => ({
|
|||||||
*/
|
*/
|
||||||
export const readCachedAppearance = () => {
|
export const readCachedAppearance = () => {
|
||||||
try {
|
try {
|
||||||
return normalizeAppearance(JSON.parse(localStorage.getItem(STORAGE_KEY)));
|
return normalizeAppearance(JSON.parse(storage.get(STORAGE_KEY)));
|
||||||
} catch {
|
} catch {
|
||||||
return DEFAULT_APPEARANCE;
|
return DEFAULT_APPEARANCE;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const cacheAppearance = (appearance) => {
|
export const cacheAppearance = (appearance) => storage.set(STORAGE_KEY, JSON.stringify(appearance));
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(appearance));
|
|
||||||
} catch {
|
|
||||||
// Private mode or a full storage: the look is only not cached.
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const applyAppearance = (appearance, root = document.documentElement) => {
|
export const applyAppearance = (appearance, root = document.documentElement) => {
|
||||||
root.dataset.accent = appearance.accent;
|
root.dataset.accent = appearance.accent;
|
||||||
|
|||||||
+7
-10
@@ -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
|
// 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.
|
// stale flag costs one 401 and a failed refresh, then the login page opens.
|
||||||
export const SESSION_KEY = 'tbp_session';
|
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.
|
* 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();
|
const listeners = new Set();
|
||||||
// Why the session of this tab ended: null (never had one, or another tab ended it), 'logout' or 'expired'.
|
// 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 = () => {
|
export const markSession = () => {
|
||||||
// New for every login, also two in the same millisecond: apiFetch tells sessions apart by it.
|
// New for every login, also two in the same millisecond: apiFetch tells sessions apart by it.
|
||||||
localStorage.setItem(SESSION_KEY, crypto.randomUUID());
|
storage.set(SESSION_KEY, crypto.randomUUID());
|
||||||
localStorage.removeItem(LEGACY_TOKEN_KEY);
|
|
||||||
change(null);
|
change(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const clearSession = () => {
|
export const clearSession = () => {
|
||||||
localStorage.removeItem(SESSION_KEY);
|
storage.remove(SESSION_KEY);
|
||||||
localStorage.removeItem(LEGACY_TOKEN_KEY);
|
|
||||||
change('logout');
|
change('logout');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -46,8 +44,7 @@ export const clearSession = () => {
|
|||||||
*/
|
*/
|
||||||
export const expireSession = (stamp = sessionStamp()) => {
|
export const expireSession = (stamp = sessionStamp()) => {
|
||||||
if (stamp === null || sessionStamp() !== stamp) return;
|
if (stamp === null || sessionStamp() !== stamp) return;
|
||||||
localStorage.removeItem(SESSION_KEY);
|
storage.remove(SESSION_KEY);
|
||||||
localStorage.removeItem(LEGACY_TOKEN_KEY);
|
|
||||||
change('expired');
|
change('expired');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -15,16 +15,6 @@ describe('session flag', () => {
|
|||||||
expect(hasSession()).toBe(false);
|
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 () => {
|
it('gives a new stamp on every mark', async () => {
|
||||||
markSession();
|
markSession();
|
||||||
const first = sessionStamp();
|
const first = sessionStamp();
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,10 +10,6 @@ export const formatPlaytime = (minutes) => {
|
|||||||
return hours >= 10 ? `${Math.round(hours)}h` : `${hours.toFixed(1)}h`;
|
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.
|
// 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`;
|
export const coverUrl = (appid) => `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/header.jpg`;
|
||||||
|
|
||||||
|
|||||||
+5
-14
@@ -1,4 +1,5 @@
|
|||||||
import {apiFetch} from './api.js';
|
import {apiFetch} from './api.js';
|
||||||
|
import {storage} from './storage.js';
|
||||||
|
|
||||||
export const LANGUAGES = ['en', 'de'];
|
export const LANGUAGES = ['en', 'de'];
|
||||||
|
|
||||||
@@ -6,23 +7,13 @@ const LANGUAGE_STORAGE_KEY = 'tbp_language';
|
|||||||
export const REQUEST_TIMEOUT_MS = 10000;
|
export const REQUEST_TIMEOUT_MS = 10000;
|
||||||
|
|
||||||
export const readCachedLanguage = () => {
|
export const readCachedLanguage = () => {
|
||||||
try {
|
const stored = storage.get(LANGUAGE_STORAGE_KEY);
|
||||||
const stored = localStorage.getItem(LANGUAGE_STORAGE_KEY);
|
return LANGUAGES.includes(stored) ? stored : null;
|
||||||
return LANGUAGES.includes(stored) ? stored : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const cacheLanguage = (language) => {
|
export const cacheLanguage = (language) => storage.set(LANGUAGE_STORAGE_KEY, language);
|
||||||
try {
|
|
||||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, language);
|
|
||||||
} catch {
|
|
||||||
// Private mode or a full storage: the language is only not cached.
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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.
|
* GET /api/users/me on Auth. The caller handles the response status.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user