Public Access
feat(stats): add chart form pickers to dashboard and profile tiles
This commit is contained in:
1 parent
13b0d5e7e9
commit
de199a20b7
19 files changed
+507
-98
No files matched your search
+10
-6
@@ -23,6 +23,8 @@ import PageContainer from '../components/PageContainer.jsx';
|
||||
import useSteamProfile from '../hooks/useSteamProfile.js';
|
||||
import useUserStats from '../hooks/useUserStats.js';
|
||||
import useSaveResponse from '../hooks/useSaveResponse.js';
|
||||
import useStatCharts from '../hooks/useStatCharts.js';
|
||||
import {chartProps} from '../utils/statCharts.js';
|
||||
import TileGrid from '../components/TileGrid.jsx';
|
||||
import {StatDrillProvider} from '../components/stats/StatDrill.jsx';
|
||||
import statGroupTile from '../components/stats/statGroupTile.jsx';
|
||||
@@ -42,6 +44,7 @@ const Dashboard = () => {
|
||||
const [shuffling, setShuffling] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const runSave = useSaveResponse();
|
||||
const {charts, change} = useStatCharts();
|
||||
const layout = normalizeLayout(userData?.settings?.dashboardTiles, DASHBOARD_TILES, DEFAULT_DASHBOARD);
|
||||
// The stat groups of the shown stat tiles, and TREND for the pile card and the "pile grew" hint. Waits for the
|
||||
// saved layout in /me.
|
||||
@@ -71,10 +74,11 @@ const Dashboard = () => {
|
||||
stats.reload();
|
||||
},
|
||||
});
|
||||
const forceSync = useForceSync(sync.watch);
|
||||
const {watch} = sync;
|
||||
const forceSync = useForceSync(watch);
|
||||
useEffect(() => {
|
||||
if (steamData.syncStarted) sync.watch();
|
||||
}, [steamData, sync.watch]);
|
||||
if (steamData.syncStarted) watch();
|
||||
}, [steamData, watch]);
|
||||
|
||||
if (isLoading) {
|
||||
return <DashboardSkeleton/>;
|
||||
@@ -101,7 +105,7 @@ const Dashboard = () => {
|
||||
case 'GAME_COUNT': return showLibrary && <SteamGameCount targetCount={steamData.count}/>;
|
||||
case 'MOST_PLAYED':
|
||||
return showLibrary && <MostPlayedCard games={topGames.slice(0, TOP_GAMES[size])} syncing={sync.isRunning}
|
||||
equipped={equipped}/>;
|
||||
equipped={equipped} {...chartProps(charts, change, 'TOP_GAMES')}/>;
|
||||
case 'PLAYING':
|
||||
return showLibrary && (
|
||||
<CurrentlyPlayingCard games={playing.slice(0, PLAYING_GAMES[size])} syncing={sync.isRunning} wide={size === 'L'}/>
|
||||
@@ -120,9 +124,9 @@ const Dashboard = () => {
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
case 'ACTIVITY': return hasSteamId && activity && <ActivityCard activity={activity}/>;
|
||||
case 'ACTIVITY': return hasSteamId && activity && <ActivityCard activity={activity} charts={charts} onChart={change}/>;
|
||||
case 'ACHIEVEMENTS': return hasSteamId && achievements && <AchievementsCard summary={achievements}/>;
|
||||
default: return statGroupTile(id, stats.stats, true, userData?.settings?.statCharts);
|
||||
default: return statGroupTile(id, stats.stats, true, charts, change);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
|
||||
import {cleanup, fireEvent, screen, waitFor, within} from '@testing-library/react';
|
||||
import Dashboard from './Dashboard.jsx';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
||||
@@ -113,7 +113,47 @@ describe('Dashboard', () => {
|
||||
expect(screen.getByRole('button', {name: /2024/})).toBeTruthy();
|
||||
expect(screen.getByText('2023: 0')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', {name: /2023/})).toBeNull();
|
||||
expect(screen.queryByRole('group', {name: 'STATS.CHART.LABEL'})).toBeNull();
|
||||
expect(screen.getByRole('group', {name: 'STATS.CHART.LABEL'})).toBeTruthy();
|
||||
});
|
||||
|
||||
const putBodies = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([, init]) => JSON.parse(init.body));
|
||||
|
||||
it('aTasteTilePickerSavesAndRedraws', async () => {
|
||||
fetchRoutes({
|
||||
...dashboardRoutes({dashboardTiles: [{id: 'STATS_TASTE', size: 'M'}]}),
|
||||
'GET /api/library/stats': json(200, {trend: null, taste: {genres: [{name: 'Shooter', games: 2, minutes: 1200},
|
||||
{name: 'Puzzle', games: 1, minutes: 300}], moods: []}}),
|
||||
'PUT /api/users/settings/stat-chart': json(204, {}),
|
||||
});
|
||||
const {container} = renderDashboard();
|
||||
await screen.findByText('STATS.TASTE.TITLE');
|
||||
|
||||
fireEvent.click(within(screen.getByRole('group', {name: 'STATS.CHART.LABEL'})).getByRole('button', {name: 'STATS.CHART.PIE'}));
|
||||
|
||||
await waitFor(() => expect(container.querySelectorAll('path.stroke-card').length).toBeGreaterThan(0));
|
||||
expect(putBodies()).toEqual([{group: 'TASTE', chart: 'PIE'}]);
|
||||
});
|
||||
|
||||
it('theMostPlayedCardShowsTheTopGamesForm', async () => {
|
||||
const container = mockDashboard({dashboardTiles: [{id: 'MOST_PLAYED', size: 'L'}], statCharts: {TOP_GAMES: 'DONUT'}});
|
||||
|
||||
await screen.findByRole('group', {name: 'STATS.CHART.LABEL'});
|
||||
expect(container.querySelectorAll('path.stroke-card')).toHaveLength(6);
|
||||
const card = container.querySelector('[data-tile="MOST_PLAYED"]');
|
||||
expect(within(card).getAllByRole('link', {name: /Game/})).toHaveLength(5);
|
||||
expect(within(card).getByText('STATS.CHART.OTHER').closest('a,button')).toBeNull();
|
||||
});
|
||||
|
||||
it('theActivityPickerSavesTheForm', async () => {
|
||||
fetchRoutes({...dashboardRoutes(), 'PUT /api/users/settings/stat-chart': json(204, {})});
|
||||
const {container} = renderDashboard();
|
||||
await screen.findByText('DASHBOARD.ACTIVITY');
|
||||
|
||||
const card = container.querySelector('[data-tile="ACTIVITY"]');
|
||||
fireEvent.click(within(card).getByRole('button', {name: 'STATS.CHART.LINE'}));
|
||||
|
||||
await waitFor(() => expect(container.querySelector('polyline')).not.toBeNull());
|
||||
expect(putBodies()).toEqual([{group: 'ACTIVITY', chart: 'LINE'}]);
|
||||
});
|
||||
|
||||
it('leaves out a stat tile whose data did not come', async () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {normalizeProfileStyle} from '../utils/profileStyle.js';
|
||||
import {DEFAULT_PROFILE, PROFILE_TILES, PROFILE_TITLES, normalizeLayout, saveTiles} from '../utils/tiles.js';
|
||||
import useCurrentUser from '../hooks/useCurrentUser.js';
|
||||
import useSaveResponse from '../hooks/useSaveResponse.js';
|
||||
import useStatCharts from '../hooks/useStatCharts.js';
|
||||
import HeroBanner from '../components/HeroBanner.jsx';
|
||||
import PageBackground from '../components/PageBackground.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
@@ -46,6 +47,7 @@ export default function PublicProfilePage() {
|
||||
const [reloads, setReloads] = useState(0);
|
||||
const viewer = useCurrentUser(useSession().active);
|
||||
const runSave = useSaveResponse();
|
||||
const own = useStatCharts();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -144,7 +146,8 @@ export default function PublicProfilePage() {
|
||||
&& <Playing games={library.playing.slice(0, PLAYING_GAMES[size])} compact={size === 'S'}/>;
|
||||
case 'FINISHED':
|
||||
return library.finished?.length > 0 && <Finished games={library.finished.slice(0, FINISHED_GAMES[size])} equipped={equipped}/>;
|
||||
default: return statGroupTile(id, library.stats, owner, profile.style?.statCharts);
|
||||
default: return statGroupTile(id, library.stats, owner, (owner ? own.charts : null) ?? profile.style?.statCharts,
|
||||
owner ? own.change : null);
|
||||
}
|
||||
};
|
||||
// Core sends only the data of the shown tiles (the ticket says which), so a new tile needs a new load.
|
||||
|
||||
@@ -408,6 +408,54 @@ describe('PublicProfilePage', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const tasteStats = {pile: null, trend: null, finished: null, habits: null, topGames: null, platforms: null,
|
||||
taste: {genres: [{name: 'Shooter', games: 2, minutes: 1200}, {name: 'Puzzle', games: 1, minutes: 300}], moods: []}};
|
||||
|
||||
it('theOwnerGetsThePickerOnATasteTile', async () => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
library.stats = tasteStats;
|
||||
try {
|
||||
mockBackends({me: {displayName: 'lobo', role: 'USER', settings: {}}, style: {tiles: [{id: 'STATS_TASTE', size: 'M'}]},
|
||||
extra: {'PUT /api/users/settings/stat-chart': json(204, {})}});
|
||||
const {container} = renderPage();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'STATS.CHART.DONUT'}));
|
||||
|
||||
await waitFor(() => expect(container.querySelectorAll('path.stroke-card').length).toBeGreaterThan(0));
|
||||
const bodies = fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([, init]) => JSON.parse(init.body));
|
||||
expect(bodies).toEqual([{group: 'TASTE', chart: 'DONUT'}]);
|
||||
} finally {
|
||||
delete library.stats;
|
||||
localStorage.clear();
|
||||
}
|
||||
});
|
||||
|
||||
const twoGameStats = {...topGamesStats, topGames: {mostLoyal: null, mostPlayed: [
|
||||
{appid: 620, name: 'Portal 2', imageUrl: null, minutes: 900}, {appid: 220, name: 'Half-Life 2', imageUrl: null, minutes: 300}]}};
|
||||
|
||||
it('topGamesPieLinksForTheOwnerAndIsPlainForAVisitor', async () => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
library.stats = twoGameStats;
|
||||
try {
|
||||
const style = {tiles: [{id: 'STATS_TOP_GAMES', size: 'M'}], statCharts: {TOP_GAMES: 'PIE'}};
|
||||
mockBackends({me: {displayName: 'lobo', role: 'USER', settings: {statCharts: {TOP_GAMES: 'PIE'}}}, style});
|
||||
const {unmount} = renderPage();
|
||||
expect((await screen.findByRole('link', {name: /Portal 2/})).getAttribute('href')).toBe('/library/620');
|
||||
unmount();
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
|
||||
mockBackends({style});
|
||||
renderPage();
|
||||
await screen.findByText('Portal 2');
|
||||
expect(screen.queryByRole('link', {name: /Portal 2/})).toBeNull();
|
||||
expect(screen.queryByRole('button', {name: /Portal 2/})).toBeNull();
|
||||
} finally {
|
||||
delete library.stats;
|
||||
localStorage.clear();
|
||||
}
|
||||
});
|
||||
|
||||
it('showsBarsForAFormTheProfileDoesNotKnow', async () => {
|
||||
library.stats = platformStats;
|
||||
try {
|
||||
|
||||
+9
-31
@@ -1,4 +1,3 @@
|
||||
import {useRef} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {RefreshCw} from 'lucide-react';
|
||||
|
||||
@@ -15,37 +14,16 @@ import FinishedStats from '../components/stats/FinishedStats.jsx';
|
||||
import PlatformStats from '../components/stats/PlatformStats.jsx';
|
||||
import {StatDrillProvider} from '../components/stats/StatDrill.jsx';
|
||||
import useUserStats from '../hooks/useUserStats.js';
|
||||
import useSaveResponse from '../hooks/useSaveResponse.js';
|
||||
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||
import {chartOf, saveStatChart, STAT_CHARTS} from '../utils/statCharts.js';
|
||||
import useStatCharts from '../hooks/useStatCharts.js';
|
||||
import {chartProps} from '../utils/statCharts.js';
|
||||
|
||||
const ALL_GROUPS = [];
|
||||
|
||||
export default function StatsPage() {
|
||||
const {t} = useTranslation();
|
||||
const {stats, failed, reload} = useUserStats(ALL_GROUPS);
|
||||
const {user, updateUser} = useCurrentUserContext();
|
||||
const runSave = useSaveResponse();
|
||||
const charts = user?.settings?.statCharts;
|
||||
|
||||
// The new form shows at once; a failed save puts only this group back.
|
||||
const setChart = (group, form) => updateUser((u) => {
|
||||
const next = {...u.settings?.statCharts};
|
||||
if (form) next[group] = form;
|
||||
else delete next[group];
|
||||
return {...u, settings: {...u.settings, statCharts: next}};
|
||||
});
|
||||
const latest = useRef({});
|
||||
const change = async (group, form) => {
|
||||
const before = charts?.[group] ?? null;
|
||||
const sent = form === STAT_CHARTS[group][0] ? null : form;
|
||||
const id = (latest.current[group] ?? 0) + 1;
|
||||
latest.current[group] = id;
|
||||
setChart(group, sent);
|
||||
// A failed older save must not undo a newer choice.
|
||||
if (!await runSave(() => saveStatChart(group, sent)) && latest.current[group] === id) setChart(group, before);
|
||||
};
|
||||
const chartProps = (group) => ({chart: chartOf(charts, group), onChart: (form) => change(group, form)});
|
||||
const {charts, change} = useStatCharts();
|
||||
const props = (group) => chartProps(charts, change, group);
|
||||
|
||||
return (
|
||||
<StatDrillProvider source="own">
|
||||
@@ -73,12 +51,12 @@ export default function StatsPage() {
|
||||
<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} {...chartProps('TREND')}/>
|
||||
<TopGamesStats topGames={stats.topGames}/>
|
||||
<TasteStats taste={stats.taste} {...chartProps('TASTE')}/>
|
||||
<PlatformStats platforms={stats.platforms} {...chartProps('PLATFORMS')}/>
|
||||
<TrendStats trend={stats.trend} {...props('TREND')}/>
|
||||
<TopGamesStats topGames={stats.topGames} {...props('TOP_GAMES')}/>
|
||||
<TasteStats taste={stats.taste} {...props('TASTE')}/>
|
||||
<PlatformStats platforms={stats.platforms} {...props('PLATFORMS')}/>
|
||||
<HabitsStats habits={stats.habits}/>
|
||||
<FinishedStats finished={stats.finished} {...chartProps('FINISHED')}/>
|
||||
<FinishedStats finished={stats.finished} {...props('FINISHED')}/>
|
||||
</div>
|
||||
)}
|
||||
</PageContainer>
|
||||
|
||||
@@ -292,10 +292,10 @@ describe('StatsPage', () => {
|
||||
await screen.findByText('STATS.PLATFORMS.TITLE');
|
||||
|
||||
expect(slices('STATS.PLATFORMS.TITLE')).toHaveLength(0);
|
||||
for (const title of ['PLATFORMS', 'TASTE', 'FINISHED', 'TREND']) {
|
||||
for (const title of ['PLATFORMS', 'TASTE', 'FINISHED', 'TREND', 'TOP_GAMES']) {
|
||||
expect(within(picker(`STATS.${title}.TITLE`)).getAllByRole('button').length).toBeGreaterThan(1);
|
||||
}
|
||||
expect(within(section('STATS.TOP_GAMES.TITLE')).queryByRole('group', {name: 'STATS.CHART.LABEL'})).toBeNull();
|
||||
expect(within(picker('STATS.TOP_GAMES.TITLE')).getByRole('button', {name: 'STATS.CHART.LIST'}).getAttribute('aria-pressed')).toBe('true');
|
||||
expect(within(picker('STATS.PLATFORMS.TITLE')).getByRole('button', {name: 'STATS.CHART.BARS'}).getAttribute('aria-pressed')).toBe('true');
|
||||
});
|
||||
|
||||
@@ -315,6 +315,48 @@ describe('StatsPage', () => {
|
||||
expect(putBodies().at(-1)).toEqual({group: 'PLATFORMS', chart: null});
|
||||
});
|
||||
|
||||
const twoGames = () => stats({topGames: {
|
||||
mostPlayed: [{appid: 620, name: 'Portal 2', imageUrl: null, minutes: 900},
|
||||
{appid: 220, name: 'Half-Life 2', imageUrl: null, minutes: 300}],
|
||||
mostLoyal: {appid: 620, name: 'Portal 2', imageUrl: null, months: 3, minutes: 900}}});
|
||||
|
||||
it('topGamesPieLinksEachGameAndShowsTheShareNote', async () => {
|
||||
answerStats(json(200, twoGames()));
|
||||
renderPage();
|
||||
await screen.findByText('STATS.TOP_GAMES.TITLE');
|
||||
|
||||
fireEvent.click(within(picker('STATS.TOP_GAMES.TITLE')).getByRole('button', {name: 'STATS.CHART.PIE'}));
|
||||
|
||||
await waitFor(() => expect(slices('STATS.TOP_GAMES.TITLE')).toHaveLength(2));
|
||||
expect(putBodies()).toEqual([{group: 'TOP_GAMES', chart: 'PIE'}]);
|
||||
const card = section('STATS.TOP_GAMES.TITLE');
|
||||
expect(within(card).getByRole('link', {name: /Half-Life 2/}).getAttribute('href')).toBe('/library/220');
|
||||
expect(within(card).getByRole('link', {name: /Half-Life 2/}).textContent).toContain('5.0h');
|
||||
expect(within(card).getByText('STATS.TOP_GAMES.SHARE')).toBeTruthy();
|
||||
expect(within(card).getByText('STATS.TOP_GAMES.LOYAL')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('topGamesPieWithOneGameDrawsTheList', async () => {
|
||||
answerStats(json(200, stats()), {TOP_GAMES: 'PIE'});
|
||||
renderPage();
|
||||
await screen.findByText('STATS.TOP_GAMES.TITLE');
|
||||
|
||||
expect(slices('STATS.TOP_GAMES.TITLE')).toHaveLength(0);
|
||||
expect(section('STATS.TOP_GAMES.TITLE').querySelectorAll('img').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('topGamesBarsHaveNoCoversAndLinkTheGame', async () => {
|
||||
answerStats(json(200, twoGames()), {TOP_GAMES: 'BARS'});
|
||||
renderPage();
|
||||
await screen.findByText('STATS.TOP_GAMES.TITLE');
|
||||
|
||||
const card = section('STATS.TOP_GAMES.TITLE');
|
||||
const bars = within(card).getAllByRole('link', {name: /Half-Life 2/});
|
||||
expect(bars[0].getAttribute('href')).toBe('/library/220');
|
||||
expect(bars[0].querySelector('img')).toBeNull();
|
||||
expect(bars[0].querySelectorAll('.bg-primary')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('showsTheStoredFormOfTheUser', async () => {
|
||||
answerStats(json(200, stats()), {PLATFORMS: 'DONUT', TREND: 'BARS'});
|
||||
renderPage();
|
||||
|
||||
Reference in new issue
Block a user