diff --git a/public/i18n/de.json b/public/i18n/de.json index 8f06761..2e3fbf4 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -504,6 +504,7 @@ }, "TOP_GAMES": { "TITLE": "Top-Spiele", + "SHARE": "Anteil an der Spielzeit dieser Spiele", "EMPTY": "Noch keine Spielzeit.", "LOYAL": "Treuestes Spiel", "MONTHS_one": "in {{count}} Monat gespielt", diff --git a/public/i18n/en.json b/public/i18n/en.json index 4e932dd..1d1bd8d 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -504,6 +504,7 @@ }, "TOP_GAMES": { "TITLE": "Top games", + "SHARE": "Share of the play time of these games", "EMPTY": "No playtime yet.", "LOYAL": "Most loyal game", "MONTHS_one": "played in {{count}} month", diff --git a/src/components/ActivityCard.jsx b/src/components/ActivityCard.jsx index 3e1d444..b32047b 100644 --- a/src/components/ActivityCard.jsx +++ b/src/components/ActivityCard.jsx @@ -5,8 +5,11 @@ import {Activity} from 'lucide-react'; import {Card} from '@/components/ui/card'; import GameCover from './GameCover.jsx'; import FitText from './FitText.jsx'; +import ChartPicker from './stats/ChartPicker.jsx'; +import GamePlayChart from './stats/GamePlayChart.jsx'; import {formatPlaytime} from '../utils/games.js'; import {formatDay} from '../utils/format.js'; +import {chartProps, linePoints, playChart} from '../utils/statCharts.js'; const CHART_HEIGHT = 80; const BAR_WIDTH = 10; @@ -14,42 +17,66 @@ const BAR_GAP = 2; /** * @param activity the answer of GET /api/library/activity + * @param charts the chart form per group of the user + * @param onChart `(group, form)`: shows the pickers */ -export default function ActivityCard({activity}) { +export default function ActivityCard({activity, charts = null, onChart = null}) { const {t, i18n} = useTranslation(); const {days, totalMinutes, recentGames} = activity; const max = Math.max(1, ...days.map((d) => d.minutes)); const width = days.length * (BAR_WIDTH + BAR_GAP); + const dayChart = chartProps(charts, onChart, 'ACTIVITY'); + const gameChart = chartProps(charts, onChart, 'ACTIVITY_GAMES'); + const line = dayChart.chart === 'LINE' && days.length > 1; + const points = line ? linePoints(days.map((d) => d.minutes), width, CHART_HEIGHT) : []; + const recent = recentGames.map((g) => ({appid: g.appid, name: g.name, minutes: g.playtime2Weeks})); + const form = playChart(gameChart.chart, recent); + const dayTitle = (d) => `${formatDay(d.day, i18n.language)}: ${formatPlaytime(d.minutes)}`; return ( -
+

{t('DASHBOARD.ACTIVITY')}

- - {t('DASHBOARD.ACTIVITY_TOTAL', {days: days.length, time: formatPlaytime(totalMinutes)})} - +
+ + {t('DASHBOARD.ACTIVITY_TOTAL', {days: days.length, time: formatPlaytime(totalMinutes)})} + + {dayChart.onChart && } +
{totalMinutes === 0 ? (

{t('DASHBOARD.ACTIVITY_EMPTY')}

) : (
- - {days.map((d, i) => { - const height = (d.minutes / max) * CHART_HEIGHT; - return ( - - {`${formatDay(d.day, i18n.language)}: ${formatPlaytime(d.minutes)}`} - - ); - })} - +
+ + {line ? ( + `${p.x},${p.y}`).join(' ')} fill="none" + className="stroke-primary" strokeWidth={2} strokeLinejoin="round" + vectorEffect="non-scaling-stroke"/> + ) : days.map((d, i) => { + const height = (d.minutes / max) * CHART_HEIGHT; + return ( + + {dayTitle(d)} + + ); + })} + + {/* Dots are HTML over the stretched svg, so they stay round and can show a tooltip. */} + {line && days.map((d, i) => ( + + ))} +
{formatDay(days[0].day, i18n.language)} {formatDay(days[days.length - 1].day, i18n.language)} @@ -59,7 +86,14 @@ export default function ActivityCard({activity}) { {recentGames.length > 0 && (
- {t('DASHBOARD.RECENTLY_PLAYED')} +
+ {t('DASHBOARD.RECENTLY_PLAYED')} + {gameChart.onChart && ( + + )} +
+ {form !== 'LIST' ? : (
    {recentGames.map((g) => (
  • @@ -73,6 +107,7 @@ export default function ActivityCard({activity}) {
  • ))}
+ )}
)} diff --git a/src/components/ActivityCard.test.jsx b/src/components/ActivityCard.test.jsx index afbd2c9..4f75e3c 100644 --- a/src/components/ActivityCard.test.jsx +++ b/src/components/ActivityCard.test.jsx @@ -1,13 +1,15 @@ // @vitest-environment jsdom import {afterEach, describe, expect, it, vi} from 'vitest'; -import {cleanup, render, screen} from '@testing-library/react'; +import {cleanup, fireEvent, render, screen, within} from '@testing-library/react'; import {MemoryRouter} from 'react-router-dom'; import ActivityCard from './ActivityCard.jsx'; vi.mock('react-i18next', () => import('../test/i18n-mock.js')); const days = (minutes) => minutes.map((m, i) => ({day: `2026-09-${String(10 + i).padStart(2, '0')}`, minutes: m})); -const renderCard = (activity) => render(); +const renderCard = (activity, props) => render(); +const recent = (n) => Array.from({length: n}, (_, i) => ({appid: 100 + i, name: `Game ${i}`, playtimeMinutes: 900, + playtime2Weeks: 600 - i * 10, status: 'PLAYING'})); describe('ActivityCard', () => { afterEach(cleanup); @@ -35,4 +37,81 @@ describe('ActivityCard', () => { expect(screen.getByText('DASHBOARD.ACTIVITY_EMPTY')).toBeTruthy(); }); + + it('drawsALineForTheStoredLineForm', () => { + const {container} = renderCard({days: days([0, 60, 120]), totalMinutes: 180, recentGames: []}, + {charts: {ACTIVITY: 'LINE'}}); + + expect(screen.queryAllByTestId('activity-bar')).toHaveLength(0); + const line = container.querySelector('polyline'); + expect(line.getAttribute('vector-effect')).toBe('non-scaling-stroke'); + const dots = screen.getAllByTestId('activity-dot'); + expect(dots).toHaveLength(3); + expect(dots[1].getAttribute('title')).toMatch(/: 1\.0h$/); + expect(container.querySelectorAll('svg[role="img"] polyline')).toHaveLength(1); + }); + + it('drawsBarsForALineWithOneDay', () => { + renderCard({days: days([60]), totalMinutes: 60, recentGames: []}, {charts: {ACTIVITY: 'LINE'}}); + + expect(screen.getAllByTestId('activity-bar')).toHaveLength(1); + }); + + it('showsNoPickerWithoutOnChart', () => { + renderCard({days: days([0, 60]), totalMinutes: 60, recentGames: recent(1)}); + + expect(screen.queryByRole('group')).toBeNull(); + }); + + it('showsTwoPickersWithDifferentNamesWithOnChart', () => { + const onChart = vi.fn(); + renderCard({days: days([0, 60]), totalMinutes: 60, recentGames: recent(2)}, {onChart}); + + const day = screen.getByRole('group', {name: 'STATS.CHART.LABEL'}); + expect(screen.getByRole('group', {name: 'STATS.CHART.LABEL: DASHBOARD.RECENTLY_PLAYED'})).toBeTruthy(); + fireEvent.click(within(day).getByRole('button', {name: 'STATS.CHART.LINE'})); + + expect(onChart).toHaveBeenCalledWith('ACTIVITY', 'LINE'); + }); + + it('recentGamesPieLinksTheGamesAndOtherIsPlain', () => { + const {container} = renderCard({days: days([0, 60]), totalMinutes: 60, recentGames: recent(7)}, + {charts: {ACTIVITY_GAMES: 'PIE'}}); + + expect(container.querySelectorAll('path.stroke-card')).toHaveLength(6); + const links = screen.getAllByRole('link'); + expect(links).toHaveLength(5); + expect(links[0].getAttribute('href')).toBe('/library/100'); + expect(screen.getByText('STATS.CHART.OTHER').closest('a,button')).toBeNull(); + expect(screen.getByText('9.8h')).toBeTruthy(); + expect(screen.getByText('STATS.TOP_GAMES.SHARE')).toBeTruthy(); + }); + + it('recentGamesPieWithOneGameDrawsTheList', () => { + const {container} = renderCard({days: days([0, 60]), totalMinutes: 60, recentGames: recent(1)}, + {charts: {ACTIVITY_GAMES: 'DONUT'}}); + + expect(container.querySelectorAll('path.stroke-card')).toHaveLength(0); + expect(screen.getByRole('link', {name: /Game 0/}).getAttribute('href')).toBe('/library/100'); + }); + + it('recentGamesBarsHaveNoCovers', () => { + const {container} = renderCard({days: days([0, 60]), totalMinutes: 60, recentGames: recent(2)}, + {charts: {ACTIVITY_GAMES: 'BARS'}}); + + expect(container.querySelectorAll('img')).toHaveLength(0); + expect(screen.getByRole('link', {name: /Game 1/}).getAttribute('href')).toBe('/library/101'); + }); + + it('twoGamesWithTheSameNameGetTheirOwnSlice', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const games = [{appid: 1, name: 'Prey', playtimeMinutes: 900, playtime2Weeks: 300, status: 'PLAYING'}, + {appid: 2, name: 'Prey', playtimeMinutes: 900, playtime2Weeks: 200, status: 'PLAYING'}]; + renderCard({days: days([0, 60]), totalMinutes: 60, recentGames: games}, {charts: {ACTIVITY_GAMES: 'PIE'}}); + + const sameKey = error.mock.calls.some((args) => args.join(' ').includes('same key')); + error.mockRestore(); + expect(sameKey).toBe(false); + expect(screen.getAllByRole('link', {name: /Prey/}).map((a) => a.getAttribute('href'))).toEqual(['/library/1', '/library/2']); + }); }); diff --git a/src/components/MostPlayedCard.jsx b/src/components/MostPlayedCard.jsx index cabadcf..42efb37 100644 --- a/src/components/MostPlayedCard.jsx +++ b/src/components/MostPlayedCard.jsx @@ -4,13 +4,22 @@ import {ArrowRight, Trophy} from 'lucide-react'; import {Button} from '@/components/ui/button'; import {Card} from '@/components/ui/card'; +import ChartPicker from './stats/ChartPicker.jsx'; +import GamePlayChart from './stats/GamePlayChart.jsx'; import GameCover from './GameCover.jsx'; import FitText from './FitText.jsx'; import {formatPlaytime} from '../utils/games.js'; import {coverEffect} from '../utils/rewards.js'; +import {playChart} from '../utils/statCharts.js'; -export default function MostPlayedCard({games, syncing, equipped}) { +/** + * @param chart LIST, BARS, PIE or DONUT + * @param onChart `(form)`: shows the picker + */ +export default function MostPlayedCard({games, syncing, equipped, chart = 'LIST', onChart}) { const {t} = useTranslation(); + const mapped = games.map((g) => ({appid: g.appid, name: g.name, minutes: g.playtimeMinutes})); + const form = playChart(chart, mapped); const maxMinutes = games[0]?.playtimeMinutes || 1; return ( @@ -19,15 +28,20 @@ export default function MostPlayedCard({games, syncing, equipped}) { {t('DASHBOARD.MOST_PLAYED')} - +
+ {onChart && } + +
{games.length === 0 ? (

{syncing ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')}

+ ) : form !== 'LIST' ? ( +
) : (
    {games.map((g, index) => ( diff --git a/src/components/stats/ChartPicker.jsx b/src/components/stats/ChartPicker.jsx index ed55bc4..08bb95a 100644 --- a/src/components/stats/ChartPicker.jsx +++ b/src/components/stats/ChartPicker.jsx @@ -8,11 +8,12 @@ import {STAT_CHARTS} from '../../utils/statCharts.js'; * * @param value the shown form * @param onChange `(form)` + * @param label the accessible name; the default fits a card with one picker */ -export default function ChartPicker({group, value, onChange}) { +export default function ChartPicker({group, value, onChange, label}) { const {t} = useTranslation(); return ( -
    +
    {STAT_CHARTS[group].map((form) => (
    diff --git a/src/components/stats/TopGamesStats.jsx b/src/components/stats/TopGamesStats.jsx index 4937ad3..bded35c 100644 --- a/src/components/stats/TopGamesStats.jsx +++ b/src/components/stats/TopGamesStats.jsx @@ -3,26 +3,35 @@ import {Trophy} from 'lucide-react'; import {Button} from '@/components/ui/button'; import StatsSection from './StatsSection.jsx'; +import ChartPicker from './ChartPicker.jsx'; +import GamePlayChart from './GamePlayChart.jsx'; import GameRow from './GameRow.jsx'; import {useStatDrill} from './StatDrill.jsx'; import {formatPlaytime} from '../../utils/games.js'; +import {playChart} from '../../utils/statCharts.js'; -export default function TopGamesStats({topGames, linked = true}) { +/** + * @param chart LIST, BARS, PIE or DONUT + * @param onChart `(form)`: shows the picker + */ +export default function TopGamesStats({topGames, linked = true, chart = 'LIST', onChart}) { const {t} = useTranslation(); const drill = useStatDrill(); const max = Math.max(1, ...topGames.mostPlayed.map((g) => g.minutes)); + const form = playChart(chart, topGames.mostPlayed); return ( - + }> {topGames.mostPlayed.length === 0 ? (

    {t('STATS.TOP_GAMES.EMPTY')}

    - ) : ( + ) : form === 'LIST' ? (
      {topGames.mostPlayed.map((g) => (
    1. ))}
    - )} + ) : } {drill && topGames.mostPlayed.length > 0 && ( ); - case 'ACTIVITY': return hasSteamId && activity && ; + case 'ACTIVITY': return hasSteamId && activity && ; case 'ACHIEVEMENTS': return hasSteamId && achievements && ; - default: return statGroupTile(id, stats.stats, true, userData?.settings?.statCharts); + default: return statGroupTile(id, stats.stats, true, charts, change); } }; diff --git a/src/pages/Dashboard.test.jsx b/src/pages/Dashboard.test.jsx index f8f93f8..709caca 100644 --- a/src/pages/Dashboard.test.jsx +++ b/src/pages/Dashboard.test.jsx @@ -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 () => { diff --git a/src/pages/PublicProfilePage.jsx b/src/pages/PublicProfilePage.jsx index aef8484..532c685 100644 --- a/src/pages/PublicProfilePage.jsx +++ b/src/pages/PublicProfilePage.jsx @@ -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() { && ; case 'FINISHED': return library.finished?.length > 0 && ; - 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. diff --git a/src/pages/PublicProfilePage.test.jsx b/src/pages/PublicProfilePage.test.jsx index b304aa7..a1cc354 100644 --- a/src/pages/PublicProfilePage.test.jsx +++ b/src/pages/PublicProfilePage.test.jsx @@ -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 { diff --git a/src/pages/StatsPage.jsx b/src/pages/StatsPage.jsx index 6608a2f..05a440f 100644 --- a/src/pages/StatsPage.jsx +++ b/src/pages/StatsPage.jsx @@ -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 ( @@ -73,12 +51,12 @@ export default function StatsPage() {
    - - - - + + + + - +
    )} diff --git a/src/pages/StatsPage.test.jsx b/src/pages/StatsPage.test.jsx index db0bf90..b326113 100644 --- a/src/pages/StatsPage.test.jsx +++ b/src/pages/StatsPage.test.jsx @@ -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(); diff --git a/src/utils/statCharts.js b/src/utils/statCharts.js index e9f6bb4..b80aca3 100644 --- a/src/utils/statCharts.js +++ b/src/utils/statCharts.js @@ -8,6 +8,9 @@ export const STAT_CHARTS = { TASTE: ['BARS', 'PIE', 'DONUT', 'LIST'], FINISHED: ['BARS', 'LINE', 'LIST'], TREND: ['LINE', 'BARS'], + TOP_GAMES: ['LIST', 'BARS', 'PIE', 'DONUT'], + ACTIVITY: ['BARS', 'LINE'], + ACTIVITY_GAMES: ['LIST', 'BARS', 'PIE', 'DONUT'], }; /** @@ -21,6 +24,24 @@ export const chartOf = (charts, group) => { return forms.includes(stored) ? stored : forms[0]; }; +/** + * The `chart` and `onChart` props of a stat view. + * + * @param onChart `(group, form)`, or null: the view shows no picker + */ +export const chartProps = (charts, onChart, group) => ({ + chart: chartOf(charts, group), + onChart: onChart ? (form) => onChart(group, form) : undefined, +}); + +/** + * The form to draw for play time per game: a pie or donut of fewer than two games with play time is a list. + * + * @param games `[{minutes}]` + */ +export const playChart = (chart, games) => + (chart === 'PIE' || chart === 'DONUT') && games.filter((g) => g.minutes > 0).length < 2 ? 'LIST' : chart; + /** * The slices of a pie: the items with a value, biggest first. More than `keep + 1` items: the `keep` biggest and * one "other" slice with the rest. diff --git a/src/utils/statCharts.test.js b/src/utils/statCharts.test.js index 072b134..22e4226 100644 --- a/src/utils/statCharts.test.js +++ b/src/utils/statCharts.test.js @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {chartOf, linePoints, sliceColors, slicePaths, statusSlices, topSlices, trendBars, yearSeries} from './statCharts.js'; +import {chartOf, chartProps, linePoints, playChart, sliceColors, slicePaths, statusSlices, topSlices, trendBars, yearSeries} from './statCharts.js'; import {GAME_STATUSES} from './limits.js'; describe('chartOf', () => { @@ -19,6 +19,48 @@ describe('chartOf', () => { }); }); +describe('chartOf for the play time groups', () => { + it('falls back to the first form', () => { + expect(chartOf(null, 'TOP_GAMES')).toBe('LIST'); + expect(chartOf({TOP_GAMES: 'RADAR'}, 'TOP_GAMES')).toBe('LIST'); + expect(chartOf({}, 'ACTIVITY')).toBe('BARS'); + expect(chartOf({ACTIVITY: 'LINE'}, 'ACTIVITY')).toBe('LINE'); + expect(chartOf({ACTIVITY_GAMES: 'PIE'}, 'ACTIVITY_GAMES')).toBe('PIE'); + expect(chartOf({ACTIVITY_GAMES: 'LINE'}, 'ACTIVITY_GAMES')).toBe('LIST'); + }); +}); + +describe('chartProps', () => { + it('has no onChart without a handler', () => { + expect(chartProps({TASTE: 'PIE'}, null, 'TASTE')).toEqual({chart: 'PIE', onChart: undefined}); + }); + + it('calls the handler with the group and the form', () => { + const calls = []; + const props = chartProps(null, (group, form) => calls.push([group, form]), 'TREND'); + + props.onChart('BARS'); + + expect(props.chart).toBe('LINE'); + expect(calls).toEqual([['TREND', 'BARS']]); + }); +}); + +describe('playChart', () => { + const game = (minutes) => ({appid: 1, name: 'G', minutes}); + + it('draws the list when a pie has fewer than two games with play time', () => { + expect(playChart('PIE', [game(10)])).toBe('LIST'); + expect(playChart('DONUT', [game(0), game(10)])).toBe('LIST'); + }); + + it('keeps the form otherwise', () => { + expect(playChart('PIE', [game(5), game(10)])).toBe('PIE'); + expect(playChart('BARS', [])).toBe('BARS'); + expect(playChart('LIST', [game(10)])).toBe('LIST'); + }); +}); + describe('topSlices', () => { const item = (name, games) => ({name, games}); const games = (i) => i.games;