Public Access
Compare commits
2
Commits
de199a20b7
...
edeea5a43d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edeea5a43d | ||
|
|
2b20b389b7 |
No files matched your search
@@ -26,8 +26,8 @@ To Be Played is split into three repositories:
|
|||||||
Steam avatar and a banner from the most played game of the last two weeks, and an achievement card: unlocked
|
Steam avatar and a banner from the most played game of the last two weeks, and an achievement card: unlocked
|
||||||
total, perfect games, average completion and the 5 games closest to 100 %.
|
total, perfect games, average completion and the 5 games closest to 100 %.
|
||||||
- **What to play next** (`/play-next`): three backlog picks for a time budget (up to 5 / 15 / 40 h or any) and a mood
|
- **What to play next** (`/play-next`): three backlog picks for a time budget (up to 5 / 15 / 40 h or any) and a mood
|
||||||
(story, co-op, chill, action, scary from IGDB; speedrun from speedrun.com), high priority first, reroll without
|
(story, co-op, chill, action, scary from IGDB; speedrun from speedrun.com). Each pick says why: like a game you
|
||||||
repeats, and "Start playing" in one click.
|
played a lot, waiting longest, or random. Reroll without repeats, and "Start playing" in one click.
|
||||||
- **Your stats** (`/stats`): totals and statuses; the pile in hours, in weeks at your pace of the last 90 days and in
|
- **Your stats** (`/stats`): totals and statuses; the pile in hours, in weeks at your pace of the last 90 days and in
|
||||||
years of full-time work, and its oldest unplayed game; most played and most loyal games; top genres and moods;
|
years of full-time work, and its oldest unplayed game; most played and most loyal games; top genres and moods;
|
||||||
trips to the moon, the longest gaming day, games started but under one hour; finished games per year; games per platform.
|
trips to the moon, the longest gaming day, games started but under one hour; finished games per year; games per platform.
|
||||||
|
|||||||
+6
-1
@@ -409,7 +409,12 @@
|
|||||||
},
|
},
|
||||||
"PLAY_NEXT": {
|
"PLAY_NEXT": {
|
||||||
"TITLE": "Was spiele ich als Nächstes?",
|
"TITLE": "Was spiele ich als Nächstes?",
|
||||||
"SUBTITLE": "Drei Spiele aus deinem Backlog, hohe Priorität zuerst. Wähle eine Länge und eine Stimmung.",
|
"SUBTITLE": "Drei Spiele aus deinem Backlog: eins wie ein Spiel, das du viel gespielt hast, das am längsten wartende und eins zufällig. Wähle eine Länge und eine Stimmung.",
|
||||||
|
"REASON": {
|
||||||
|
"SIMILAR": "Weil du {{name}} gespielt hast ({{playtime}})",
|
||||||
|
"LONGEST": "Auf deiner Liste seit {{date}}",
|
||||||
|
"RANDOM": "Zufallstreffer"
|
||||||
|
},
|
||||||
"BUDGET_LABEL": "Spieldauer",
|
"BUDGET_LABEL": "Spieldauer",
|
||||||
"BUDGET": {
|
"BUDGET": {
|
||||||
"SHORT": "Bis 5 h",
|
"SHORT": "Bis 5 h",
|
||||||
|
|||||||
+6
-1
@@ -409,7 +409,12 @@
|
|||||||
},
|
},
|
||||||
"PLAY_NEXT": {
|
"PLAY_NEXT": {
|
||||||
"TITLE": "What to play next",
|
"TITLE": "What to play next",
|
||||||
"SUBTITLE": "Three games from your backlog, high priority first. Pick a length and a mood.",
|
"SUBTITLE": "Three games from your backlog: one like a game you played a lot, the one waiting longest, and one at random. Pick a length and a mood.",
|
||||||
|
"REASON": {
|
||||||
|
"SIMILAR": "Because you played {{name}} ({{playtime}})",
|
||||||
|
"LONGEST": "On your list since {{date}}",
|
||||||
|
"RANDOM": "Random pick"
|
||||||
|
},
|
||||||
"BUDGET_LABEL": "Time to beat",
|
"BUDGET_LABEL": "Time to beat",
|
||||||
"BUDGET": {
|
"BUDGET": {
|
||||||
"SHORT": "Up to 5 h",
|
"SHORT": "Up to 5 h",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {useEffect, useRef, useState} from 'react';
|
import {useState} from 'react';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import {CheckCircle2, Circle, Loader2, RotateCcw, XCircle} from 'lucide-react';
|
import {CheckCircle2, Circle, Loader2, RotateCcw, XCircle} from 'lucide-react';
|
||||||
@@ -8,82 +8,16 @@ import {Card} from '@/components/ui/card';
|
|||||||
import {cn} from '@/lib/utils';
|
import {cn} from '@/lib/utils';
|
||||||
import ImportReview from './ImportReview.jsx';
|
import ImportReview from './ImportReview.jsx';
|
||||||
import {useCurrentUserContext} from '../../context/CurrentUserContext.jsx';
|
import {useCurrentUserContext} from '../../context/CurrentUserContext.jsx';
|
||||||
import {getImport, retryImport, RUNNING, STEPS, stepOf} from '../../utils/imports.js';
|
import useImportPolling from '../../hooks/useImportPolling.js';
|
||||||
|
import {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}) {
|
export default function ImportView({id, onDone, onChanged}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const [data, setData] = useState(null);
|
const {data, failed, reload} = useImportPolling(id, onChanged);
|
||||||
const [failed, setFailed] = useState(false);
|
|
||||||
// Grows after a save or a retry: the import is back with a worker, so polling starts again.
|
|
||||||
const [version, setVersion] = useState(0);
|
|
||||||
const reload = () => setVersion((current) => current + 1);
|
|
||||||
const lastStatus = useRef(null);
|
|
||||||
// undefined while loading: the review preselects with it, so it waits.
|
// undefined while loading: the review preselects with it, so it waits.
|
||||||
const {user} = useCurrentUserContext();
|
const {user} = useCurrentUserContext();
|
||||||
const favorite = user === undefined ? undefined : user?.settings?.favoritePlatform ?? null;
|
const favorite = user === undefined ? undefined : user?.settings?.favoritePlatform ?? null;
|
||||||
|
|
||||||
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.
|
|
||||||
if (response.status >= 400 && response.status < 500) {
|
|
||||||
setFailed(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
|
||||||
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(poll, POLL_MS);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Import load error:', err);
|
|
||||||
if (!cancelled) retry();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('visibilitychange', onVisible);
|
|
||||||
load();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
clearTimeout(timer);
|
|
||||||
document.removeEventListener('visibilitychange', onVisible);
|
|
||||||
};
|
|
||||||
}, [id, version, onChanged]);
|
|
||||||
|
|
||||||
if (failed) return <p className="text-sm text-destructive">{t('IMPORT.LOAD_FAILED')}</p>;
|
if (failed) return <p className="text-sm text-destructive">{t('IMPORT.LOAD_FAILED')}</p>;
|
||||||
if (!data || favorite === undefined) return <Loader2 className="animate-spin text-muted-foreground"/>;
|
if (!data || favorite === undefined) return <Loader2 className="animate-spin text-muted-foreground"/>;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import {useEffect, useRef, useState} from 'react';
|
||||||
|
import {getImport, RUNNING} from '../utils/imports.js';
|
||||||
|
|
||||||
|
const POLL_MS = 2000;
|
||||||
|
const MAX_BACKOFF_MS = 30_000;
|
||||||
|
const MAX_FAILURES = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polls one import while a worker has it. `onChanged` runs when the status changes.
|
||||||
|
*/
|
||||||
|
export default function useImportPolling(id, onChanged) {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
// Grows after a save or a retry: the import is back with a worker, so polling starts again.
|
||||||
|
const [version, setVersion] = useState(0);
|
||||||
|
const reload = () => setVersion((current) => current + 1);
|
||||||
|
const lastStatus = useRef(null);
|
||||||
|
|
||||||
|
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.
|
||||||
|
if (response.status >= 400 && response.status < 500) {
|
||||||
|
setFailed(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
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(poll, POLL_MS);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Import load error:', err);
|
||||||
|
if (!cancelled) retry();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', onVisible);
|
||||||
|
load();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
document.removeEventListener('visibilitychange', onVisible);
|
||||||
|
};
|
||||||
|
}, [id, version, onChanged]);
|
||||||
|
|
||||||
|
return {data, failed, reload};
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {act, cleanup, renderHook} from '@testing-library/react';
|
||||||
|
import useImportPolling from './useImportPolling.js';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
|
||||||
|
const ROUTE = 'GET /api/library/imports/7';
|
||||||
|
// A stable callback, as ImportPage passes: a new one per render would restart the polling.
|
||||||
|
const noop = () => {};
|
||||||
|
const tick = (ms) => act(() => vi.advanceTimersByTimeAsync(ms));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
localStorage.setItem('tbp_session', '1');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useImportPolling', () => {
|
||||||
|
it('polls every 2000 ms while the status is running', async () => {
|
||||||
|
const fetch = fetchRoutes({[ROUTE]: json(200, {status: 'MATCHING', matched: 1})});
|
||||||
|
const {result} = renderHook(() => useImportPolling(7, noop));
|
||||||
|
await tick(0);
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.current.data.matched).toBe(1);
|
||||||
|
|
||||||
|
await tick(2000);
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(2);
|
||||||
|
await tick(2000);
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops after DONE and reports the change once', async () => {
|
||||||
|
let answer = json(200, {status: 'MATCHING'});
|
||||||
|
const fetch = fetchRoutes({[ROUTE]: () => answer});
|
||||||
|
const onChanged = vi.fn();
|
||||||
|
const {result} = renderHook(() => useImportPolling(7, onChanged));
|
||||||
|
await tick(0);
|
||||||
|
answer = json(200, {status: 'DONE'});
|
||||||
|
await tick(2000);
|
||||||
|
const calls = fetch.mock.calls.length;
|
||||||
|
|
||||||
|
await tick(30_000);
|
||||||
|
|
||||||
|
expect(fetch.mock.calls.length).toBe(calls);
|
||||||
|
expect(result.current.data.status).toBe('DONE');
|
||||||
|
expect(onChanged).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('backs off on 503 and gives up after 10 failures', async () => {
|
||||||
|
const fetch = fetchRoutes({[ROUTE]: json(503, {})});
|
||||||
|
const {result} = renderHook(() => useImportPolling(7, noop));
|
||||||
|
await tick(0);
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await tick(3999);
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
await tick(1);
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(2);
|
||||||
|
await tick(7999);
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(2);
|
||||||
|
await tick(1);
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
|
await tick(300_000);
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(10);
|
||||||
|
expect(result.current.failed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call again after unmount with a timer pending', async () => {
|
||||||
|
const fetch = fetchRoutes({[ROUTE]: json(200, {status: 'MATCHING'})});
|
||||||
|
const {unmount} = renderHook(() => useImportPolling(7, noop));
|
||||||
|
await tick(0);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
await tick(10_000);
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call again after unmount with a request pending', async () => {
|
||||||
|
let release;
|
||||||
|
const fetch = fetchRoutes({[ROUTE]: () => new Promise((resolve) => { release = resolve; })});
|
||||||
|
const {unmount} = renderHook(() => useImportPolling(7, noop));
|
||||||
|
await tick(0);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
release(json(200, {status: 'MATCHING'}));
|
||||||
|
await tick(10_000);
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reload() restarts the loop: one poll per 2000 ms, not two', async () => {
|
||||||
|
const fetch = fetchRoutes({[ROUTE]: json(200, {status: 'MATCHING'})});
|
||||||
|
const {result} = renderHook(() => useImportPolling(7, noop));
|
||||||
|
await tick(0);
|
||||||
|
|
||||||
|
act(() => result.current.reload());
|
||||||
|
await tick(0);
|
||||||
|
const calls = fetch.mock.calls.length;
|
||||||
|
await tick(2000);
|
||||||
|
|
||||||
|
expect(fetch.mock.calls.length).toBe(calls + 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import {useEffect, useState} from 'react';
|
||||||
|
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||||
|
import {refreshSession} from '../utils/api.js';
|
||||||
|
import {markSession} from '../utils/auth.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the profile for the settings page. After a Steam link (`steamOutcome === 'linked'`) the session is refreshed
|
||||||
|
* first. `loadError` is a translation key.
|
||||||
|
*/
|
||||||
|
export default function useSettingsLoad(steamOutcome) {
|
||||||
|
const {reload: reloadUser} = useCurrentUserContext();
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [loadError, setLoadError] = useState(null);
|
||||||
|
// From this load: the context user can still be empty when the form mounts.
|
||||||
|
const [displayName, setDisplayName] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const load = async () => {
|
||||||
|
if (steamOutcome === 'linked') {
|
||||||
|
// Refresh reads the new Steam ID from the database, so Core gets it in the token at once.
|
||||||
|
await refreshSession().catch(() => false);
|
||||||
|
markSession();
|
||||||
|
}
|
||||||
|
const {ok, status, data} = await reloadUser();
|
||||||
|
if (cancelled) return;
|
||||||
|
if (ok) setDisplayName(data.displayName ?? '');
|
||||||
|
else if (status !== 401) setLoadError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'INFO.ERROR.LOAD_PROFILE');
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
load();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [reloadUser, steamOutcome]);
|
||||||
|
|
||||||
|
return {isLoading, loadError, displayName};
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {cleanup, renderHook, waitFor} from '@testing-library/react';
|
||||||
|
import useSettingsLoad from './useSettingsLoad.js';
|
||||||
|
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
|
||||||
|
const renderLoad = (steamOutcome = null) =>
|
||||||
|
renderHook(() => useSettingsLoad(steamOutcome), {wrapper: CurrentUserProvider});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
localStorage.setItem('tbp_session', '1');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useSettingsLoad', () => {
|
||||||
|
it('returns the display name when /me answers', async () => {
|
||||||
|
fetchRoutes({'GET /api/users/me': json(200, {displayName: 'Lobo', settings: {}})});
|
||||||
|
const {result} = renderLoad();
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||||
|
expect(result.current.displayName).toBe('Lobo');
|
||||||
|
expect(result.current.loadError).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a server error', async () => {
|
||||||
|
fetchRoutes({'GET /api/users/me': json(500, {})});
|
||||||
|
const {result} = renderLoad();
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.loadError).toBe('INFO.ERROR.LOAD_PROFILE'));
|
||||||
|
expect(result.current.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a network failure', async () => {
|
||||||
|
globalThis.fetch = vi.fn().mockRejectedValue(new TypeError('network'));
|
||||||
|
const {result} = renderLoad();
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.loadError).toBe('INFO.ERROR.NETWORK_FAILURE'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no error on 401', async () => {
|
||||||
|
fetchRoutes({'GET /api/users/me': json(401, {}), 'POST /api/auth/refresh': json(401, {})});
|
||||||
|
const {result} = renderLoad();
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||||
|
expect(result.current.loadError).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes the session before /me after a Steam link', async () => {
|
||||||
|
const fetch = fetchRoutes({
|
||||||
|
'POST /api/auth/refresh': json(204, null),
|
||||||
|
'GET /api/users/me': json(200, {displayName: 'Lobo', settings: {}}),
|
||||||
|
});
|
||||||
|
const {result} = renderLoad('linked');
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||||
|
const paths = fetch.mock.calls.map(([url]) => new URL(url, 'http://x').pathname);
|
||||||
|
expect(paths.findIndex((p) => p.endsWith('/auth/refresh'))).toBeLessThan(
|
||||||
|
paths.findIndex((p) => p.endsWith('/users/me')));
|
||||||
|
});
|
||||||
|
it('marksSessionAfterSteamRefreshEvenWhenUnmounted', async () => {
|
||||||
|
let answerRefresh;
|
||||||
|
fetchRoutes({
|
||||||
|
'POST /api/auth/refresh': () => new Promise((resolve) => {
|
||||||
|
answerRefresh = resolve;
|
||||||
|
}),
|
||||||
|
'GET /api/users/me': json(200, {displayName: 'Lobo', settings: {}}),
|
||||||
|
});
|
||||||
|
const {unmount} = renderLoad('linked');
|
||||||
|
await waitFor(() => expect(answerRefresh).toBeDefined());
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
answerRefresh(json(204, null));
|
||||||
|
|
||||||
|
await waitFor(() => expect(localStorage.getItem('tbp_session')).not.toBe('1'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@ import GameCover from '../components/GameCover.jsx';
|
|||||||
import FitText from '../components/FitText.jsx';
|
import FitText from '../components/FitText.jsx';
|
||||||
import PageContainer from '../components/PageContainer.jsx';
|
import PageContainer from '../components/PageContainer.jsx';
|
||||||
import {saveGameStatus} from '../utils/backlog.js';
|
import {saveGameStatus} from '../utils/backlog.js';
|
||||||
|
import {formatDate} from '../utils/format.js';
|
||||||
import {formatPlaytime} from '../utils/games.js';
|
import {formatPlaytime} from '../utils/games.js';
|
||||||
import {request} from '../utils/api.js';
|
import {request} from '../utils/api.js';
|
||||||
import useAbortPrevious from '../hooks/useAbortPrevious.js';
|
import useAbortPrevious from '../hooks/useAbortPrevious.js';
|
||||||
@@ -174,6 +175,20 @@ export default function PlayNextPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reasonText = (reason, t, language) => {
|
||||||
|
if (!reason) return null;
|
||||||
|
if (reason.source === 'SIMILAR') {
|
||||||
|
return t('PLAY_NEXT.REASON.SIMILAR', {
|
||||||
|
name: reason.anchorName ?? `App ${reason.anchorAppid}`,
|
||||||
|
playtime: formatPlaytime(reason.anchorPlaytimeMinutes),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (reason.source === 'LONGEST') {
|
||||||
|
return t('PLAY_NEXT.REASON.LONGEST', {date: formatDate(reason.firstSeenAt, language, {dateStyle: 'medium'})});
|
||||||
|
}
|
||||||
|
return t('PLAY_NEXT.REASON.RANDOM');
|
||||||
|
};
|
||||||
|
|
||||||
const emptyKey = (budget, mood) => {
|
const emptyKey = (budget, mood) => {
|
||||||
// With a length limit, waiting for the background check may never help.
|
// With a length limit, waiting for the background check may never help.
|
||||||
if (mood === 'SPEEDRUN' && !budget.maxMinutes) return 'PLAY_NEXT.EMPTY_SPEEDRUN';
|
if (mood === 'SPEEDRUN' && !budget.maxMinutes) return 'PLAY_NEXT.EMPTY_SPEEDRUN';
|
||||||
@@ -182,9 +197,10 @@ const emptyKey = (budget, mood) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function PickCard({game}) {
|
function PickCard({game}) {
|
||||||
const {t} = useTranslation();
|
const {t, i18n} = useTranslation();
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [started, setStarted] = useState(false);
|
const [started, setStarted] = useState(false);
|
||||||
|
const reason = reasonText(game.reason, t, i18n.language);
|
||||||
|
|
||||||
const startPlaying = async () => {
|
const startPlaying = async () => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -214,6 +230,7 @@ function PickCard({game}) {
|
|||||||
<Link to={`/library/${game.appid}`} className="@container block min-w-0 font-semibold hover:text-primary transition-colors">
|
<Link to={`/library/${game.appid}`} className="@container block min-w-0 font-semibold hover:text-primary transition-colors">
|
||||||
<FitText text={game.name ?? `App ${game.appid}`}/>
|
<FitText text={game.name ?? `App ${game.appid}`}/>
|
||||||
</Link>
|
</Link>
|
||||||
|
{reason && <p className="text-sm text-muted-foreground">{reason}</p>}
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||||
{game.timeToBeatMinutes > 0 && (
|
{game.timeToBeatMinutes > 0 && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
|
|||||||
@@ -224,4 +224,27 @@ describe('PlayNextPage', () => {
|
|||||||
|
|
||||||
expect(screen.queryByRole('button', {name: 'PLAY_NEXT.REROLL'})).not.toBeNull();
|
expect(screen.queryByRole('button', {name: 'PLAY_NEXT.REROLL'})).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows why each game was picked', async () => {
|
||||||
|
mockBackend([[
|
||||||
|
game(1, {reason: {source: 'SIMILAR', anchorAppid: 9, anchorName: 'Celeste', anchorPlaytimeMinutes: 600}}),
|
||||||
|
game(2, {reason: {source: 'LONGEST', firstSeenAt: '2024-03-12T10:00:00Z'}}),
|
||||||
|
game(3, {reason: {source: 'RANDOM'}}),
|
||||||
|
]]);
|
||||||
|
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText('PLAY_NEXT.REASON.SIMILAR')).toBeTruthy();
|
||||||
|
expect(screen.getByText('PLAY_NEXT.REASON.LONGEST')).toBeTruthy();
|
||||||
|
expect(screen.getByText('PLAY_NEXT.REASON.RANDOM')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no reason line for a game without a reason', async () => {
|
||||||
|
mockBackend([[game(1)]]);
|
||||||
|
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await screen.findByText('Game 1');
|
||||||
|
expect(screen.queryByText(/PLAY_NEXT\.REASON/)).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -13,8 +13,7 @@ import PreferencesCard from '../components/PreferencesCard.jsx';
|
|||||||
import ProfileCard from '../components/ProfileCard.jsx';
|
import ProfileCard from '../components/ProfileCard.jsx';
|
||||||
import SteamAccountCard from '../components/SteamAccountCard.jsx';
|
import SteamAccountCard from '../components/SteamAccountCard.jsx';
|
||||||
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||||
import {refreshSession} from '../utils/api.js';
|
import useSettingsLoad from '../hooks/useSettingsLoad.js';
|
||||||
import {markSession} from '../utils/auth.js';
|
|
||||||
|
|
||||||
// The `steam` query parameter Auth adds when "Sign in through Steam" sends the user back.
|
// The `steam` query parameter Auth adds when "Sign in through Steam" sends the user back.
|
||||||
const STEAM_LINK_TOASTS = {
|
const STEAM_LINK_TOASTS = {
|
||||||
@@ -26,14 +25,11 @@ const STEAM_LINK_TOASTS = {
|
|||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const {user: profile, updateUser: updateProfile, reload: reloadUser} = useCurrentUserContext();
|
const {user: profile, updateUser: updateProfile} = useCurrentUserContext();
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [loadError, setLoadError] = useState(null);
|
|
||||||
// From this page's own load: the context user can still be empty when the form mounts.
|
|
||||||
const [displayName, setDisplayName] = useState('');
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
// Read once: removing the parameter below must not load the profile again.
|
// Read once: removing the parameter below must not load the profile again.
|
||||||
const [steamOutcome] = useState(() => searchParams.get('steam'));
|
const [steamOutcome] = useState(() => searchParams.get('steam'));
|
||||||
|
const {isLoading, loadError, displayName} = useSettingsLoad(steamOutcome);
|
||||||
|
|
||||||
// A language change gives a new `t` and runs the effect again; the toast shows once.
|
// A language change gives a new `t` and runs the effect again; the toast shows once.
|
||||||
const steamToastShown = useRef(false);
|
const steamToastShown = useRef(false);
|
||||||
@@ -48,22 +44,6 @@ export default function SettingsPage() {
|
|||||||
}, {replace: true});
|
}, {replace: true});
|
||||||
}, [steamOutcome, setSearchParams, t]);
|
}, [steamOutcome, setSearchParams, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const load = async () => {
|
|
||||||
if (steamOutcome === 'linked') {
|
|
||||||
// Refresh reads the new Steam ID from the database, so Core gets it in the token at once.
|
|
||||||
await refreshSession().catch(() => false);
|
|
||||||
markSession();
|
|
||||||
}
|
|
||||||
const {ok, status, data} = await reloadUser();
|
|
||||||
if (ok) setDisplayName(data.displayName ?? '');
|
|
||||||
else if (status !== 401) setLoadError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'INFO.ERROR.LOAD_PROFILE');
|
|
||||||
setIsLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
load();
|
|
||||||
}, [reloadUser, steamOutcome]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-2xl mx-auto p-4 md:p-8 space-y-6">
|
<div className="w-full max-w-2xl mx-auto p-4 md:p-8 space-y-6">
|
||||||
|
|||||||
Reference in new issue
Block a user