Public Access
feat(stats): add stats page and pile trend
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:
1 parent
83bbc2730d
commit
9d1c7bb40d
31 files changed
+978
-11
No files matched your search
@@ -23,6 +23,7 @@ import PageContainer from '../components/PageContainer.jsx';
|
||||
import HeroArt from '../components/HeroArt.jsx';
|
||||
import SteamAvatar from '../components/SteamAvatar.jsx';
|
||||
import useSteamProfile from '../hooks/useSteamProfile.js';
|
||||
import useUserStats from '../hooks/useUserStats.js';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
import {apiFetch} from '../utils/api.js';
|
||||
@@ -35,6 +36,7 @@ const PLAYING_GAMES = 4;
|
||||
const PLAYING_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc&status=PLAYING';
|
||||
|
||||
const JSON_HEADERS = {'Content-Type': 'application/json'};
|
||||
const TREND_GROUPS = ['TREND'];
|
||||
|
||||
const Dashboard = () => {
|
||||
const {t} = useTranslation();
|
||||
@@ -51,6 +53,8 @@ const Dashboard = () => {
|
||||
const [shuffling, setShuffling] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const pileTrend = useUserStats(TREND_GROUPS, expireSession, hasSession());
|
||||
const reloadTrend = pileTrend.reload;
|
||||
|
||||
const fetchSteamCount = useCallback(async () => {
|
||||
try {
|
||||
@@ -168,7 +172,8 @@ const Dashboard = () => {
|
||||
fetchSummary();
|
||||
fetchActivity();
|
||||
fetchAchievements();
|
||||
}, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]);
|
||||
reloadTrend();
|
||||
}, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements, reloadTrend]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUserData = async () => {
|
||||
@@ -344,7 +349,7 @@ const Dashboard = () => {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<CurrentlyPlayingCard games={playing} syncing={sync.isRunning}/>
|
||||
{summary && (
|
||||
<PileOfShameCard summary={summary} syncing={sync.isRunning}
|
||||
<PileOfShameCard summary={summary} trend={pileTrend.stats?.trend} syncing={sync.isRunning}
|
||||
onShuffle={shuffleSuggestion} shuffling={shuffling}/>
|
||||
)}
|
||||
{!summary && summaryFailed && (
|
||||
|
||||
@@ -10,6 +10,12 @@ import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx';
|
||||
import {GAME_STATUSES} from '../utils/backlog.js';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import PileStats from '../components/stats/PileStats.jsx';
|
||||
import TrendStats from '../components/stats/TrendStats.jsx';
|
||||
import TopGamesStats from '../components/stats/TopGamesStats.jsx';
|
||||
import TasteStats from '../components/stats/TasteStats.jsx';
|
||||
import HabitsStats from '../components/stats/HabitsStats.jsx';
|
||||
import FinishedStats from '../components/stats/FinishedStats.jsx';
|
||||
|
||||
/**
|
||||
* Public, no login. Auth returns a short-lived ticket for a public profile; Core returns the data for it.
|
||||
@@ -181,6 +187,22 @@ export default function PublicProfilePage() {
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{library.stats && <SharedStats stats={library.stats}/>}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Only the groups the user shares come back; the game rows do not link, the library pages are private.
|
||||
function SharedStats({stats}) {
|
||||
return (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{stats.pile && <PileStats pile={stats.pile} linked={false}/>}
|
||||
{stats.trend && <TrendStats trend={stats.trend}/>}
|
||||
{stats.topGames && <TopGamesStats topGames={stats.topGames} linked={false}/>}
|
||||
{stats.taste && <TasteStats taste={stats.taste}/>}
|
||||
{stats.habits && <HabitsStats habits={stats.habits}/>}
|
||||
{stats.finished && <FinishedStats finished={stats.finished}/>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -62,6 +62,18 @@ describe('PublicProfilePage', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('shows only the stat groups the user shares, without links', async () => {
|
||||
library.stats = {pile: null, trend: null, topGames: null, taste: null, finished: null,
|
||||
habits: {moonTrips: 2.5, longestDay: null, underOneHourGames: 0, underOneHourShare: null}};
|
||||
mockBackends();
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText('STATS.HABITS.TITLE')).toBeTruthy();
|
||||
expect(screen.queryByText('STATS.PILE.TITLE')).toBeNull();
|
||||
expect(screen.queryByText('STATS.TOP_GAMES.TITLE')).toBeNull();
|
||||
delete library.stats;
|
||||
});
|
||||
|
||||
it('says the profile is unknown or private on a 404', async () => {
|
||||
mockBackends({profileStatus: 404});
|
||||
renderPage();
|
||||
|
||||
@@ -16,12 +16,14 @@ import {Skeleton} from '@/components/ui/skeleton';
|
||||
import {Switch} from '@/components/ui/switch';
|
||||
import FormField from '../components/FormField.jsx';
|
||||
import SteamAccountCard from '../components/SteamAccountCard.jsx';
|
||||
import ProfileStatsSettings from '../components/stats/ProfileStatsSettings.jsx';
|
||||
import {settingsSchema} from '../utils/validation.js';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import useSaveAppearance from '../hooks/useSaveAppearance.js';
|
||||
import {ACCENTS, CORNERS} from '../utils/appearance.js';
|
||||
import {coverUrl} from '../utils/games.js';
|
||||
import {saveProfileStats} from '../utils/stats.js';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {apiFetch, refreshSession} from '../utils/api.js';
|
||||
import {hasSession, markSession} from '../utils/auth.js';
|
||||
@@ -59,6 +61,7 @@ export default function SettingsPage() {
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState(null);
|
||||
const [savingProfileStats, setSavingProfileStats] = useState(false);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
// Read once: removing the parameter below must not load the profile again.
|
||||
const [steamOutcome] = useState(() => searchParams.get('steam'));
|
||||
@@ -197,6 +200,31 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Shown at once; the switches wait for the answer, so the saves reach Auth one after the other.
|
||||
const handleProfileStatsChange = async (groups) => {
|
||||
const previous = profile?.settings?.profileStats ?? [];
|
||||
const showProfileStats = (next) => setProfile((p) => ({...p, settings: {...p.settings, profileStats: next}}));
|
||||
showProfileStats(groups);
|
||||
setSavingProfileStats(true);
|
||||
try {
|
||||
const response = await saveProfileStats(groups);
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
showProfileStats(previous);
|
||||
toast.error(t('SETTINGS.ERROR_SAVE'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Profile stats save error:', err);
|
||||
showProfileStats(previous);
|
||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||
} finally {
|
||||
setSavingProfileStats(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyProfileLink = async (link) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
@@ -429,6 +457,9 @@ export default function SettingsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<ProfileStatsSettings selected={profile?.settings?.profileStats ?? []}
|
||||
disabled={!publicProfile} saving={savingProfileStats}
|
||||
onChange={handleProfileStatsChange}/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -55,6 +55,57 @@ describe('SettingsPage public profile', () => {
|
||||
await waitFor(() => expect(screen.queryByText(/\/u\/Lobo$/)).toBeNull());
|
||||
});
|
||||
|
||||
it('saves the stat groups the public profile shows, in a fixed order', async () => {
|
||||
renderPage(profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, profileStats: ['TREND']}}));
|
||||
|
||||
fireEvent.click(await screen.findByRole('switch', {name: 'STATS.PILE.TITLE'}));
|
||||
|
||||
await waitFor(() => expect(puts()).toHaveLength(1));
|
||||
expect(puts()[0]).toEqual({groups: ['PILE', 'TREND']});
|
||||
const [url] = fetch.mock.calls.find(([, init]) => init?.method === 'PUT');
|
||||
expect(url).toMatch(/\/api\/users\/settings\/profile-stats$/);
|
||||
await waitFor(() => expect(screen.getByRole('switch', {name: 'STATS.PILE.TITLE'}).getAttribute('aria-checked'))
|
||||
.toBe('true'));
|
||||
});
|
||||
|
||||
it('waits for a save before the next switch click', async () => {
|
||||
const answers = [];
|
||||
const me = profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, profileStats: []}});
|
||||
globalThis.fetch = vi.fn((url, init) => (init?.method === 'PUT'
|
||||
? new Promise((resolve) => answers.push(() => resolve({ok: true, status: 204, json: async () => ({})})))
|
||||
: Promise.resolve({ok: true, status: 200, json: async () => me})));
|
||||
render(<MemoryRouter><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
|
||||
|
||||
fireEvent.click(await screen.findByRole('switch', {name: 'STATS.PILE.TITLE'}));
|
||||
await waitFor(() => expect(screen.getByRole('switch', {name: 'STATS.TASTE.TITLE'}).disabled).toBe(true));
|
||||
answers.forEach((answer) => answer());
|
||||
await waitFor(() => expect(screen.getByRole('switch', {name: 'STATS.TASTE.TITLE'}).disabled).toBe(false));
|
||||
fireEvent.click(screen.getByRole('switch', {name: 'STATS.TASTE.TITLE'}));
|
||||
|
||||
await waitFor(() => expect(puts()).toHaveLength(2));
|
||||
expect(puts()[1]).toEqual({groups: ['PILE', 'TASTE']});
|
||||
});
|
||||
|
||||
it('switches the group back when the save fails', async () => {
|
||||
const me = profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, profileStats: []}});
|
||||
globalThis.fetch = vi.fn(async (url, init) => (init?.method === 'PUT'
|
||||
? {ok: false, status: 500, json: async () => ({})}
|
||||
: {ok: true, status: 200, json: async () => me}));
|
||||
render(<MemoryRouter><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
|
||||
|
||||
fireEvent.click(await screen.findByRole('switch', {name: 'STATS.PILE.TITLE'}));
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('switch', {name: 'STATS.PILE.TITLE'}).getAttribute('aria-checked'))
|
||||
.toBe('false'));
|
||||
});
|
||||
|
||||
it('offers the stat groups only while the profile is public', async () => {
|
||||
renderPage(profile({settings: {useDarkTheme: true, language: 'de', publicProfile: false}}));
|
||||
|
||||
expect((await screen.findByRole('switch', {name: 'STATS.PILE.TITLE'})).disabled).toBe(true);
|
||||
expect(screen.getByText('SETTINGS.PROFILE_STATS_NEEDS_PUBLIC')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('needs a display name first', async () => {
|
||||
renderPage(profile({displayName: null, settings: {useDarkTheme: true, language: 'de', publicProfile: false}}));
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import {useEffect} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {RefreshCw} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import OverviewStats from '../components/stats/OverviewStats.jsx';
|
||||
import PileStats from '../components/stats/PileStats.jsx';
|
||||
import TrendStats from '../components/stats/TrendStats.jsx';
|
||||
import TopGamesStats from '../components/stats/TopGamesStats.jsx';
|
||||
import TasteStats from '../components/stats/TasteStats.jsx';
|
||||
import HabitsStats from '../components/stats/HabitsStats.jsx';
|
||||
import FinishedStats from '../components/stats/FinishedStats.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import useUserStats from '../hooks/useUserStats.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
|
||||
const ALL_GROUPS = [];
|
||||
|
||||
export default function StatsPage() {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const loggedIn = hasSession();
|
||||
const {stats, failed, reload} = useUserStats(ALL_GROUPS, expireSession, loggedIn);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loggedIn) navigate('/login');
|
||||
}, [loggedIn, navigate]);
|
||||
|
||||
return (
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t('STATS.TITLE')}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('STATS.SUBTITLE')}</p>
|
||||
</div>
|
||||
|
||||
{failed && (
|
||||
<div className="flex flex-col items-center gap-3 py-12 text-sm text-muted-foreground">
|
||||
{t('STATS.ERROR_LOAD')}
|
||||
<Button variant="outline" size="sm" onClick={reload}><RefreshCw/>{t('DASHBOARD.RETRY')}</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!stats && !failed && (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Skeleton className="h-48 rounded-xl"/>
|
||||
<Skeleton className="h-48 rounded-xl"/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats && (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="md:col-span-2"><OverviewStats overview={stats.overview}/></div>
|
||||
<PileStats pile={stats.pile}/>
|
||||
<TrendStats trend={stats.trend}/>
|
||||
<TopGamesStats topGames={stats.topGames}/>
|
||||
<TasteStats taste={stats.taste}/>
|
||||
<HabitsStats habits={stats.habits}/>
|
||||
<FinishedStats finished={stats.finished}/>
|
||||
</div>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user