Public Access
feat(library): time to beat, short-games filter and backlog hours
This commit is contained in:
1 parent
fd343da4be
commit
91d5672421
11 files changed
+131
-11
No files matched your search
+9
-3
@@ -107,7 +107,8 @@
|
|||||||
"ACTIVITY": "Spielzeit",
|
"ACTIVITY": "Spielzeit",
|
||||||
"ACTIVITY_TOTAL": "{{time}} in {{days}} Tagen",
|
"ACTIVITY_TOTAL": "{{time}} in {{days}} Tagen",
|
||||||
"ACTIVITY_EMPTY": "Noch keine Spielzeit aufgezeichnet. Ab jetzt zählt jeder Sync mit.",
|
"ACTIVITY_EMPTY": "Noch keine Spielzeit aufgezeichnet. Ab jetzt zählt jeder Sync mit.",
|
||||||
"RECENTLY_PLAYED": "Zuletzt gespielt (2 Wochen)"
|
"RECENTLY_PLAYED": "Zuletzt gespielt (2 Wochen)",
|
||||||
|
"BACKLOG_HOURS": "≈ {{time}} Spielzeit bis alles durch ist"
|
||||||
},
|
},
|
||||||
"LIBRARY": {
|
"LIBRARY": {
|
||||||
"TITLE": "Spielebibliothek",
|
"TITLE": "Spielebibliothek",
|
||||||
@@ -135,7 +136,8 @@
|
|||||||
"SEARCH_PLACEHOLDER": "Spiel suchen …",
|
"SEARCH_PLACEHOLDER": "Spiel suchen …",
|
||||||
"NO_MATCH": "Keine Spiele für diesen Filter.",
|
"NO_MATCH": "Keine Spiele für diesen Filter.",
|
||||||
"SORT_RATING": "Beste Bewertung",
|
"SORT_RATING": "Beste Bewertung",
|
||||||
"SORT_PRIORITY": "Höchste Priorität"
|
"SORT_PRIORITY": "Höchste Priorität",
|
||||||
|
"FILTER_SHORT": "Kurz (bis 10 h)"
|
||||||
},
|
},
|
||||||
"GAME_DETAIL": {
|
"GAME_DETAIL": {
|
||||||
"BACK": "Zurück",
|
"BACK": "Zurück",
|
||||||
@@ -152,7 +154,11 @@
|
|||||||
"NO_ACHIEVEMENTS": "Dieses Spiel hat keine Erfolge auf Steam.",
|
"NO_ACHIEVEMENTS": "Dieses Spiel hat keine Erfolge auf Steam.",
|
||||||
"STATS_HIDDEN": "Statistiken von Steam verborgen — Profil und Spieldetails auf öffentlich stellen, um Erfolge zu sehen.",
|
"STATS_HIDDEN": "Statistiken von Steam verborgen — Profil und Spieldetails auf öffentlich stellen, um Erfolge zu sehen.",
|
||||||
"PLAYED_RECENTLY": "Laut Steam hast du das Spiel in den letzten 2 Wochen {{time}} gespielt, es steht aber noch im Backlog.",
|
"PLAYED_RECENTLY": "Laut Steam hast du das Spiel in den letzten 2 Wochen {{time}} gespielt, es steht aber noch im Backlog.",
|
||||||
"SET_PLAYING": "Auf „Spiele ich“ setzen"
|
"SET_PLAYING": "Auf „Spiele ich“ setzen",
|
||||||
|
"TTB": "Spieldauer laut IGDB",
|
||||||
|
"TTB_HASTILY": "Story",
|
||||||
|
"TTB_NORMALLY": "+ Extras",
|
||||||
|
"TTB_COMPLETELY": "100 %"
|
||||||
},
|
},
|
||||||
"LANDING": {
|
"LANDING": {
|
||||||
"STATS_TITLE": "Community-Statistiken",
|
"STATS_TITLE": "Community-Statistiken",
|
||||||
|
|||||||
+9
-3
@@ -107,7 +107,8 @@
|
|||||||
"ACTIVITY": "Playtime",
|
"ACTIVITY": "Playtime",
|
||||||
"ACTIVITY_TOTAL": "{{time}} in {{days}} days",
|
"ACTIVITY_TOTAL": "{{time}} in {{days}} days",
|
||||||
"ACTIVITY_EMPTY": "No playtime recorded yet. Every sync from now on counts.",
|
"ACTIVITY_EMPTY": "No playtime recorded yet. Every sync from now on counts.",
|
||||||
"RECENTLY_PLAYED": "Recently played (2 weeks)"
|
"RECENTLY_PLAYED": "Recently played (2 weeks)",
|
||||||
|
"BACKLOG_HOURS": "≈ {{time}} of play to clear it"
|
||||||
},
|
},
|
||||||
"LIBRARY": {
|
"LIBRARY": {
|
||||||
"TITLE": "Game Library",
|
"TITLE": "Game Library",
|
||||||
@@ -135,7 +136,8 @@
|
|||||||
"SEARCH_PLACEHOLDER": "Search games …",
|
"SEARCH_PLACEHOLDER": "Search games …",
|
||||||
"NO_MATCH": "No games match this filter.",
|
"NO_MATCH": "No games match this filter.",
|
||||||
"SORT_RATING": "Best rated",
|
"SORT_RATING": "Best rated",
|
||||||
"SORT_PRIORITY": "Highest priority"
|
"SORT_PRIORITY": "Highest priority",
|
||||||
|
"FILTER_SHORT": "Short (up to 10 h)"
|
||||||
},
|
},
|
||||||
"GAME_DETAIL": {
|
"GAME_DETAIL": {
|
||||||
"BACK": "Back",
|
"BACK": "Back",
|
||||||
@@ -152,7 +154,11 @@
|
|||||||
"NO_ACHIEVEMENTS": "This game has no achievements on Steam.",
|
"NO_ACHIEVEMENTS": "This game has no achievements on Steam.",
|
||||||
"STATS_HIDDEN": "Stats hidden by Steam — set your profile and game details to public to see achievements.",
|
"STATS_HIDDEN": "Stats hidden by Steam — set your profile and game details to public to see achievements.",
|
||||||
"PLAYED_RECENTLY": "Steam says you played this game for {{time}} in the last 2 weeks, but it is still in your backlog.",
|
"PLAYED_RECENTLY": "Steam says you played this game for {{time}} in the last 2 weeks, but it is still in your backlog.",
|
||||||
"SET_PLAYING": "Set to \"Playing\""
|
"SET_PLAYING": "Set to \"Playing\"",
|
||||||
|
"TTB": "Time to beat (IGDB)",
|
||||||
|
"TTB_HASTILY": "Story",
|
||||||
|
"TTB_NORMALLY": "+ Extras",
|
||||||
|
"TTB_COMPLETELY": "100%"
|
||||||
},
|
},
|
||||||
"LANDING": {
|
"LANDING": {
|
||||||
"STATS_TITLE": "Community stats",
|
"STATS_TITLE": "Community stats",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {Card} from '@/components/ui/card';
|
|||||||
import GameCover from './GameCover.jsx';
|
import GameCover from './GameCover.jsx';
|
||||||
import {STATUS_META} from './GameStatusBadge.jsx';
|
import {STATUS_META} from './GameStatusBadge.jsx';
|
||||||
import {backlogShare, GAME_STATUSES} from '../utils/backlog.js';
|
import {backlogShare, GAME_STATUSES} from '../utils/backlog.js';
|
||||||
|
import {formatPlaytime} from '../utils/games.js';
|
||||||
|
|
||||||
export default function PileOfShameCard({summary, syncing, onShuffle, shuffling}) {
|
export default function PileOfShameCard({summary, syncing, onShuffle, shuffling}) {
|
||||||
const {t, i18n} = useTranslation();
|
const {t, i18n} = useTranslation();
|
||||||
@@ -36,6 +37,11 @@ export default function PileOfShameCard({summary, syncing, onShuffle, shuffling}
|
|||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
{t('DASHBOARD.NEVER_PLAYED', {count: summary.neverPlayed})}
|
{t('DASHBOARD.NEVER_PLAYED', {count: summary.neverPlayed})}
|
||||||
</p>
|
</p>
|
||||||
|
{summary.backlogMinutes > 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('DASHBOARD.BACKLOG_HOURS', {time: formatPlaytime(summary.backlogMinutes)})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{summary.total > 0 && (
|
{summary.total > 0 && (
|
||||||
|
|||||||
@@ -47,4 +47,16 @@ describe('PileOfShameCard', () => {
|
|||||||
|
|
||||||
expect(screen.getByText('DASHBOARD.BACKLOG_EMPTY')).toBeTruthy();
|
expect(screen.getByText('DASHBOARD.BACKLOG_EMPTY')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows the backlog hours when IGDB knows some', () => {
|
||||||
|
renderCard({summary: summary(2, {backlogMinutes: 7440})});
|
||||||
|
|
||||||
|
expect(screen.getByText('DASHBOARD.BACKLOG_HOURS')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the backlog hours without data', () => {
|
||||||
|
renderCard({summary: summary(2, {backlogMinutes: 0})});
|
||||||
|
|
||||||
|
expect(screen.queryByText('DASHBOARD.BACKLOG_HOURS')).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {Timer} from 'lucide-react';
|
||||||
|
|
||||||
|
import {formatPlaytime} from '../utils/games.js';
|
||||||
|
|
||||||
|
const ROWS = [
|
||||||
|
['hastilyMinutes', 'GAME_DETAIL.TTB_HASTILY'],
|
||||||
|
['normallyMinutes', 'GAME_DETAIL.TTB_NORMALLY'],
|
||||||
|
['completelyMinutes', 'GAME_DETAIL.TTB_COMPLETELY'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IGDB's average time to beat; renders nothing without data.
|
||||||
|
*/
|
||||||
|
export default function TimeToBeat({time}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const known = ROWS.filter(([field]) => time?.[field]);
|
||||||
|
if (known.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
|
||||||
|
<Timer className="size-4 text-primary" aria-label={t('GAME_DETAIL.TTB')}/>
|
||||||
|
{known.map(([field, labelKey]) => (
|
||||||
|
<span key={field}>
|
||||||
|
{t(labelKey)} <span className="font-medium text-foreground tabular-nums">{formatPlaytime(time[field])}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {cleanup, render, screen} from '@testing-library/react';
|
||||||
|
import TimeToBeat from './TimeToBeat.jsx';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
|
||||||
|
describe('TimeToBeat', () => {
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
it('shows the known times', () => {
|
||||||
|
render(<TimeToBeat time={{hastilyMinutes: 510, normallyMinutes: 750, completelyMinutes: null}}/>);
|
||||||
|
|
||||||
|
expect(screen.getByText('8.5h')).toBeTruthy();
|
||||||
|
expect(screen.getByText('13h')).toBeTruthy();
|
||||||
|
expect(screen.queryByText('GAME_DETAIL.TTB_COMPLETELY')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing without data', () => {
|
||||||
|
const {container} = render(<TimeToBeat time={{hastilyMinutes: null, normallyMinutes: null, completelyMinutes: null}}/>);
|
||||||
|
|
||||||
|
expect(container.textContent).toBe('');
|
||||||
|
cleanup();
|
||||||
|
expect(render(<TimeToBeat time={null}/>).container.textContent).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@ import BacklogCard from '../components/BacklogCard.jsx';
|
|||||||
import GameCover from '../components/GameCover.jsx';
|
import GameCover from '../components/GameCover.jsx';
|
||||||
import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
||||||
import PlayingHint from '../components/PlayingHint.jsx';
|
import PlayingHint from '../components/PlayingHint.jsx';
|
||||||
|
import TimeToBeat from '../components/TimeToBeat.jsx';
|
||||||
import {formatPlaytime} from '../utils/games.js';
|
import {formatPlaytime} from '../utils/games.js';
|
||||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||||
|
|
||||||
@@ -134,6 +135,9 @@ export default function GameDetail() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
<GameStatusBadge status={data.status}/>
|
<GameStatusBadge status={data.status}/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<TimeToBeat time={data.timeToBeat}/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild variant="outline" size="sm" className="self-start">
|
<Button asChild variant="outline" size="sm" className="self-start">
|
||||||
<a href={`https://store.steampowered.com/app/${data.appid}`} target="_blank" rel="noopener noreferrer">
|
<a href={`https://store.steampowered.com/app/${data.appid}`} target="_blank" rel="noopener noreferrer">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import useForceSync from '../hooks/useForceSync.js';
|
|||||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||||
import {formatPlaytime} from '../utils/games.js';
|
import {formatPlaytime} from '../utils/games.js';
|
||||||
import {GAME_STATUSES, libraryQuery, parseStatus} from '../utils/backlog.js';
|
import {GAME_STATUSES, libraryQuery, parseStatus} from '../utils/backlog.js';
|
||||||
import {ArrowUpDown, ChevronLeft, ChevronRight, Clock, Library, RefreshCw, Search, SearchX} from 'lucide-react';
|
import {ArrowUpDown, ChevronLeft, ChevronRight, Clock, Library, RefreshCw, Search, SearchX, Timer} 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';
|
||||||
@@ -23,6 +23,8 @@ const PAGE_SIZES = [20, 40, 80];
|
|||||||
const DEFAULT_SIZE = 20;
|
const DEFAULT_SIZE = 20;
|
||||||
const DEFAULT_SORT = 'playtimeMinutes,desc';
|
const DEFAULT_SORT = 'playtimeMinutes,desc';
|
||||||
const SEARCH_DEBOUNCE_MS = 300;
|
const SEARCH_DEBOUNCE_MS = 300;
|
||||||
|
// "Short" games: IGDB's normal time to beat of at most 10 hours.
|
||||||
|
const SHORT_GAME_MINUTES = 600;
|
||||||
const SORT_OPTIONS = [
|
const SORT_OPTIONS = [
|
||||||
{value: 'playtimeMinutes,desc', labelKey: 'LIBRARY.SORT_PLAYTIME_DESC'},
|
{value: 'playtimeMinutes,desc', labelKey: 'LIBRARY.SORT_PLAYTIME_DESC'},
|
||||||
{value: 'playtimeMinutes,asc', labelKey: 'LIBRARY.SORT_PLAYTIME_ASC'},
|
{value: 'playtimeMinutes,asc', labelKey: 'LIBRARY.SORT_PLAYTIME_ASC'},
|
||||||
@@ -45,7 +47,8 @@ export default function GameLibrary() {
|
|||||||
const sort = SORT_OPTIONS.some((o) => o.value === sortParam) ? sortParam : DEFAULT_SORT;
|
const sort = SORT_OPTIONS.some((o) => o.value === sortParam) ? sortParam : DEFAULT_SORT;
|
||||||
const status = parseStatus(searchParams.get('status'));
|
const status = parseStatus(searchParams.get('status'));
|
||||||
const q = searchParams.get('q') ?? '';
|
const q = searchParams.get('q') ?? '';
|
||||||
const filtered = Boolean(status || q.trim());
|
const shortOnly = searchParams.get('maxMinutes') === String(SHORT_GAME_MINUTES);
|
||||||
|
const filtered = Boolean(status || q.trim() || shortOnly);
|
||||||
const librarySearch = searchParams.toString();
|
const librarySearch = searchParams.toString();
|
||||||
const [search, setSearch] = useState(q);
|
const [search, setSearch] = useState(q);
|
||||||
|
|
||||||
@@ -62,7 +65,7 @@ export default function GameLibrary() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?${libraryQuery({page, size, sort, status, q})}`;
|
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?${libraryQuery({page, size, sort, status, q, maxMinutes: shortOnly ? SHORT_GAME_MINUTES : null})}`;
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
'Authorization': `Bearer ${token}`,
|
||||||
@@ -88,7 +91,7 @@ export default function GameLibrary() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [navigate, expireSession, page, size, sort, status, q, t]);
|
}, [navigate, expireSession, page, size, sort, status, q, shortOnly, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
@@ -199,6 +202,15 @@ export default function GameLibrary() {
|
|||||||
{s ? t(`BACKLOG.STATUS.${s}`) : t('LIBRARY.FILTER_ALL')}
|
{s ? t(`BACKLOG.STATUS.${s}`) : t('LIBRARY.FILTER_ALL')}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={shortOnly ? 'default' : 'outline'}
|
||||||
|
aria-pressed={shortOnly}
|
||||||
|
className="h-7 rounded-full px-3 text-xs"
|
||||||
|
onClick={() => updateParams({maxMinutes: shortOnly ? null : SHORT_GAME_MINUTES, page: 0})}
|
||||||
|
>
|
||||||
|
<Timer className="size-3"/>{t('LIBRARY.FILTER_SHORT')}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative sm:w-64">
|
<div className="relative sm:w-64">
|
||||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
|
||||||
|
|||||||
@@ -102,3 +102,16 @@ describe('GameLibrary sort', () => {
|
|||||||
expect(new URL(fetch.mock.calls[0][0], 'http://x').searchParams.get('sort')).toBe(sort);
|
expect(new URL(fetch.mock.calls[0][0], 'http://x').searchParams.get('sort')).toBe(sort);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('GameLibrary short games', () => {
|
||||||
|
it('filters by time to beat and keeps the chip state in the URL', async () => {
|
||||||
|
const lib = renderLibrary();
|
||||||
|
await waitFor(() => expect(fetch).toHaveBeenCalled());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'LIBRARY.FILTER_SHORT'}));
|
||||||
|
|
||||||
|
await waitFor(() => expect(lib.params().get('maxMinutes')).toBe('600'));
|
||||||
|
await waitFor(() => expect(fetch.mock.calls.at(-1)[0]).toMatch(/[?&]maxMinutes=600(&|$)/));
|
||||||
|
expect(screen.getByRole('button', {name: 'LIBRARY.FILTER_SHORT'}).getAttribute('aria-pressed')).toBe('true');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,10 +25,11 @@ export const saveBacklogEntry = (appid, entry) =>
|
|||||||
body: JSON.stringify(entry),
|
body: JSON.stringify(entry),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const libraryQuery = ({page, size, sort, status, q}) => {
|
export const libraryQuery = ({page, size, sort, status, q, maxMinutes}) => {
|
||||||
const params = new URLSearchParams({page: String(page), size: String(size), sort});
|
const params = new URLSearchParams({page: String(page), size: String(size), sort});
|
||||||
if (status) params.set('status', status);
|
if (status) params.set('status', status);
|
||||||
if (q?.trim()) params.set('q', q.trim());
|
if (q?.trim()) params.set('q', q.trim());
|
||||||
|
if (maxMinutes) params.set('maxMinutes', String(maxMinutes));
|
||||||
return params.toString();
|
return params.toString();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ describe('libraryQuery', () => {
|
|||||||
.toBe('page=0&size=20&sort=playtimeMinutes%2Cdesc');
|
.toBe('page=0&size=20&sort=playtimeMinutes%2Cdesc');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('adds the time-to-beat limit', () => {
|
||||||
|
expect(libraryQuery({page: 0, size: 20, sort: 'appid,asc', status: null, q: '', maxMinutes: 600}))
|
||||||
|
.toBe('page=0&size=20&sort=appid%2Casc&maxMinutes=600');
|
||||||
|
});
|
||||||
|
|
||||||
it('adds status and the trimmed, encoded search', () => {
|
it('adds status and the trimmed, encoded search', () => {
|
||||||
expect(libraryQuery({page: 1, size: 40, sort: 'appid,asc', status: 'DROPPED', q: ' 100% & more '}))
|
expect(libraryQuery({page: 1, size: 40, sort: 'appid,asc', status: 'DROPPED', q: ' 100% & more '}))
|
||||||
.toBe('page=1&size=40&sort=appid%2Casc&status=DROPPED&q=100%25+%26+more');
|
.toBe('page=1&size=40&sort=appid%2Casc&status=DROPPED&q=100%25+%26+more');
|
||||||
|
|||||||
Reference in new issue
Block a user