feat(stats): add stats page and pile trend
Build, Test & Push Frontend / quality-check (push) Successful in 15s
Build, Test & Push Frontend / docker (push) Successful in 22s

Show the stat groups on /stats, the pile trend on the dashboard card,
one switch per group in the settings, and the shared groups on the
public profile.
This commit is contained in:
lobo committed 2026-09-27 07:16:10 +02:00
1 parent 83bbc2730d
commit 9d1c7bb40d
31 files changed
+978 -11

No files matched your search

+87
View File
@@ -0,0 +1,87 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import StatsPage from './StatsPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
const json = (body, status = 200) => ({ok: status < 400, status, json: async () => body});
const stats = (extra = {}) => ({
overview: {totalGames: 12, totalPlaytimeMinutes: 6000, playedGames: 8, neverStarted: 4,
byStatus: {BACKLOG: 5, PLAYING: 3, FINISHED: 2, COMPLETED: 1, DROPPED: 1}},
pile: {backlogGames: 5, normalMinutes: 4200, completionistMinutes: 9000, gamesWithTimeToBeat: 4,
minutesPerWeek: 420, paceWeeks: 10, workYears: 0.03,
oldestUnplayed: {appid: 220, name: 'Half-Life 2', imageUrl: null, inLibrarySince: '2026-01-01T00:00:00Z'}},
topGames: {mostPlayed: [{appid: 620, name: 'Portal 2', imageUrl: null, minutes: 900}],
mostLoyal: {appid: 620, name: 'Portal 2', imageUrl: null, months: 3, minutes: 900}},
taste: {genres: [{name: 'Shooter', games: 2, minutes: 1200}], moods: [{name: 'CHILL', games: 1, minutes: 900}]},
habits: {moonTrips: 0.5, longestDay: {day: '2026-07-01', minutes: 200}, underOneHourGames: 1, underOneHourShare: 0.125},
finished: {perYear: [{year: 2026, games: 2}], finishedBeforeTracking: 1},
trend: {days: [{day: '2026-09-26', backlogGames: 6, neverPlayed: 4, backlogMinutes: 5000},
{day: '2026-09-27', backlogGames: 5, neverPlayed: 4, backlogMinutes: 4200}], thisMonth: {added: 1, done: 2}},
...extra,
});
const renderPage = () => render(<MemoryRouter><PreferencesProvider><StatsPage/></PreferencesProvider></MemoryRouter>);
describe('StatsPage', () => {
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
localStorage.clear();
});
it('asks for every group once and shows each of them', async () => {
globalThis.fetch = vi.fn(async () => json(stats()));
renderPage();
expect(await screen.findByText('STATS.OVERVIEW.TITLE')).toBeTruthy();
expect(fetch).toHaveBeenCalledOnce();
expect(fetch.mock.calls[0][0]).toMatch(/\/api\/library\/stats$/);
for (const title of ['PILE', 'TREND', 'TOP_GAMES', 'TASTE', 'HABITS', 'FINISHED']) {
expect(screen.getByText(`STATS.${title}.TITLE`)).toBeTruthy();
}
expect(screen.getAllByText('Portal 2')).toHaveLength(2);
expect(screen.getByText('Half-Life 2')).toBeTruthy();
expect(screen.getByText('STATS.PILE.PACE')).toBeTruthy();
expect(screen.getByText('STATS.FINISHED.BEFORE:1')).toBeTruthy();
expect(screen.getByTestId('pile-trend')).toBeTruthy();
});
it('shows the empty texts of a new library', async () => {
const empty = stats();
globalThis.fetch = vi.fn(async () => json({
...empty,
pile: {...empty.pile, paceWeeks: null, oldestUnplayed: null},
topGames: {mostPlayed: [], mostLoyal: null},
taste: {genres: [], moods: []},
habits: {moonTrips: 0, longestDay: null, underOneHourGames: 0, underOneHourShare: null},
finished: {perYear: [], finishedBeforeTracking: 0},
trend: {days: [empty.trend.days[1]], thisMonth: {added: 0, done: 0}},
}));
renderPage();
expect(await screen.findByText('STATS.PILE.PACE_NONE')).toBeTruthy();
expect(screen.getByText('STATS.TOP_GAMES.EMPTY')).toBeTruthy();
expect(screen.getByText('STATS.TASTE.EMPTY')).toBeTruthy();
expect(screen.getByText('STATS.FINISHED.EMPTY')).toBeTruthy();
expect(screen.getByText('STATS.TREND.STARTS')).toBeTruthy();
expect(screen.queryByText('STATS.HABITS.LONGEST_DAY')).toBeNull();
expect(screen.queryByTestId('pile-trend')).toBeNull();
});
it('offers a retry when the stats could not be loaded', async () => {
globalThis.fetch = vi.fn(async () => json({}, 500));
renderPage();
fireEvent.click(await screen.findByRole('button', {name: /DASHBOARD.RETRY/}));
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
});
});