diff --git a/README.md b/README.md index b6a44a2..56f9ca8 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,9 @@ To Be Played is split into three repositories: repeats, and "Start playing" in one click. - **Your stats** (`/stats`): totals and statuses; the pile in hours, in weeks at your pace of the last 90 days and in years of full-time work, and its oldest unplayed game; most played and most loyal games; top genres and moods; - trips to the moon, the longest gaming day, games started but under one hour; finished games per year. + trips to the moon, the longest gaming day, games started but under one hour; finished games per year; games per platform. +- **Landing page**: next to the community numbers and the top games, three blocks: games per platform, top genres and + finished games per year of all users. - **Library**: status filters, "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest in library, rating or priority, paging. Each row shows the unlocked achievements. - **Add a game by name** for games outside Steam (PlayStation, Switch, Xbox, physical): search IGDB, pick a hit, diff --git a/public/i18n/de.json b/public/i18n/de.json index e763ced..3fd0368 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -271,7 +271,10 @@ "BIGGEST_LIBRARY": "Größte Bibliothek", "BIGGEST_BACKLOG": "Größter Backlog", "TOP_PLAYED": "Meistgespielte Spiele", - "TOP_OWNED": "Meistbesessene Spiele" + "TOP_OWNED": "Meistbesessene Spiele", + "PLATFORMS": "Spiele pro Plattform", + "TOP_GENRES": "Top-Genres", + "FINISHED_PER_YEAR": "Abgeschlossen pro Jahr" }, "MAIN": { "WELCOME": "Dein Game-Backlog unter Kontrolle", @@ -476,6 +479,10 @@ "BEFORE_one": "{{count}} weiteres Spiel wurde vor Beginn des Verlaufs durchgespielt.", "BEFORE_other": "{{count}} weitere Spiele wurden vor Beginn des Verlaufs durchgespielt." }, + "PLATFORMS": { + "TITLE": "Spiele pro Plattform", + "EMPTY": "Noch keine Spiele." + }, "GENRE": { "ADVENTURE": "Adventure", "ARCADE": "Arcade", diff --git a/public/i18n/en.json b/public/i18n/en.json index 09f4d13..d3d9083 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -271,7 +271,10 @@ "BIGGEST_LIBRARY": "Biggest library", "BIGGEST_BACKLOG": "Biggest backlog", "TOP_PLAYED": "Most played games", - "TOP_OWNED": "Most owned games" + "TOP_OWNED": "Most owned games", + "PLATFORMS": "Games per platform", + "TOP_GENRES": "Top genres", + "FINISHED_PER_YEAR": "Finished per year" }, "MAIN": { "WELCOME": "Control your game backlog", @@ -476,6 +479,10 @@ "BEFORE_one": "{{count}} more game was finished before the history started.", "BEFORE_other": "{{count}} more games were finished before the history started." }, + "PLATFORMS": { + "TITLE": "Games per platform", + "EMPTY": "No games yet." + }, "GENRE": { "ADVENTURE": "Adventure", "ARCADE": "Arcade", diff --git a/src/components/stats/FinishedStats.jsx b/src/components/stats/FinishedStats.jsx index 06dab23..d19efcd 100644 --- a/src/components/stats/FinishedStats.jsx +++ b/src/components/stats/FinishedStats.jsx @@ -3,26 +3,32 @@ import {Flag} from 'lucide-react'; import StatsSection from './StatsSection.jsx'; +export function YearBars({perYear}) { + const max = Math.max(1, ...perYear.map((y) => y.games)); + return ( + + ); +} + export default function FinishedStats({finished}) { const {t} = useTranslation(); - const max = Math.max(1, ...finished.perYear.map((y) => y.games)); return ( {finished.perYear.length === 0 ? (

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

) : ( - + )} {finished.finishedBeforeTracking > 0 && (

diff --git a/src/components/stats/PlatformStats.jsx b/src/components/stats/PlatformStats.jsx new file mode 100644 index 0000000..93e12f7 --- /dev/null +++ b/src/components/stats/PlatformStats.jsx @@ -0,0 +1,18 @@ +import {useTranslation} from 'react-i18next'; +import {MonitorSmartphone} from 'lucide-react'; + +import StatsSection from './StatsSection.jsx'; +import {TagBars} from './TasteStats.jsx'; + +export default function PlatformStats({platforms}) { + const {t} = useTranslation(); + return ( + + {platforms.perPlatform.length === 0 ? ( +

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

+ ) : ( + + )} +
+ ); +} diff --git a/src/components/stats/TasteStats.jsx b/src/components/stats/TasteStats.jsx index 0f9781e..7d8a0e3 100644 --- a/src/components/stats/TasteStats.jsx +++ b/src/components/stats/TasteStats.jsx @@ -5,7 +5,7 @@ import StatsSection from './StatsSection.jsx'; import {formatPlaytime} from '../../utils/games.js'; import {genreKey} from '../../utils/stats.js'; -function TagBars({tags, label}) { +export function TagBars({tags, label = (name) => name}) { const {t} = useTranslation(); const max = Math.max(1, ...tags.map((tag) => tag.minutes)); return ( diff --git a/src/components/stats/statGroupTile.jsx b/src/components/stats/statGroupTile.jsx index 08a52f6..02f857f 100644 --- a/src/components/stats/statGroupTile.jsx +++ b/src/components/stats/statGroupTile.jsx @@ -5,6 +5,7 @@ import TopGamesStats from './TopGamesStats.jsx'; import TasteStats from './TasteStats.jsx'; import HabitsStats from './HabitsStats.jsx'; import FinishedStats from './FinishedStats.jsx'; +import PlatformStats from './PlatformStats.jsx'; /** * One stat group as a tile; null while its data is not there. A function, not a component: a tile grid leaves out a @@ -22,6 +23,7 @@ export default function statGroupTile(id, stats, linked = true) { case 'STATS_TASTE': return stats?.taste ? : null; case 'STATS_HABITS': return stats?.habits ? : null; case 'STATS_FINISHED': return stats?.finished ? : null; + case 'STATS_PLATFORMS': return stats?.platforms ? : null; default: return null; } } diff --git a/src/pages/LandingPage.jsx b/src/pages/LandingPage.jsx index 78ae349..c086ace 100644 --- a/src/pages/LandingPage.jsx +++ b/src/pages/LandingPage.jsx @@ -11,7 +11,10 @@ import {Skeleton} from '@/components/ui/skeleton'; import GameCover from '../components/GameCover.jsx'; import Logo from '../components/Logo.jsx'; import StatCard from '../components/StatCard.jsx'; +import {YearBars} from '../components/stats/FinishedStats.jsx'; +import {TagBars} from '../components/stats/TasteStats.jsx'; import useSession from '../hooks/useSession.js'; +import {genreKey} from '../utils/stats.js'; import usePublicStats from '../hooks/usePublicStats.js'; @@ -36,6 +39,15 @@ function RankedGames({title, games, formatValue}) { ); } +function BarsCard({title, children}) { + return ( + +

{title}

+
{children}
+
+ ); +} + export default function LandingPage() { const {t, i18n} = useTranslation(); const {data, isLoading} = usePublicStats(); @@ -102,6 +114,25 @@ export default function LandingPage() { /> )} + +
+ {core?.gamesPerPlatform?.length > 0 && ( + + + + )} + {core?.topGenres?.length > 0 && ( + + t(`STATS.GENRE.${genreKey(name)}`, {defaultValue: name})}/> + + )} + {core?.finishedPerYear?.length > 0 && ( + + + + )} +
); diff --git a/src/pages/LandingPage.test.jsx b/src/pages/LandingPage.test.jsx new file mode 100644 index 0000000..2c48c1f --- /dev/null +++ b/src/pages/LandingPage.test.jsx @@ -0,0 +1,45 @@ +// @vitest-environment jsdom +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, render, screen} from '@testing-library/react'; +import {MemoryRouter} from 'react-router-dom'; +import LandingPage from './LandingPage.jsx'; +import {fetchRoutes, json} from '../test/fetchRoutes.js'; + +vi.mock('react-i18next', () => import('../test/i18n-mock.js')); +vi.mock('../hooks/useSession.js', () => ({default: () => ({active: false})})); + +// Auth and Core answer the same path; one object serves both shapes. +const answer = (core) => fetchRoutes({'GET /api/stats/public': json(200, {topPlayedGames: [], topOwnedGames: [], ...core})}); + +const renderPage = () => render(); + +describe('LandingPage', () => { + afterEach(cleanup); + + it('shows games per platform, top genres and finished per year', async () => { + answer({ + gamesPerPlatform: [{name: 'PS5', games: 3, minutes: 600}], + topGenres: [{name: 'Shooter', games: 2, minutes: 1200}], + finishedPerYear: [{year: 2025, games: 4}], + }); + renderPage(); + + expect(await screen.findByText('LANDING.PLATFORMS')).toBeTruthy(); + expect(screen.getByText('PS5')).toBeTruthy(); + expect(screen.getByText('LANDING.TOP_GENRES')).toBeTruthy(); + expect(screen.getByText('STATS.GENRE.SHOOTER')).toBeTruthy(); + expect(screen.getByText('LANDING.FINISHED_PER_YEAR')).toBeTruthy(); + expect(screen.getByText('2025')).toBeTruthy(); + }); + + it('hides the three blocks while their lists are empty', async () => { + answer({gamesPerPlatform: [], topGenres: [], finishedPerYear: []}); + renderPage(); + + await screen.findByText('LANDING.STATS_TITLE'); + await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); + for (const title of ['PLATFORMS', 'TOP_GENRES', 'FINISHED_PER_YEAR']) { + expect(screen.queryByText(`LANDING.${title}`)).toBeNull(); + } + }); +}); diff --git a/src/pages/StatsPage.jsx b/src/pages/StatsPage.jsx index 240a79b..7056caa 100644 --- a/src/pages/StatsPage.jsx +++ b/src/pages/StatsPage.jsx @@ -11,6 +11,7 @@ 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 PlatformStats from '../components/stats/PlatformStats.jsx'; import useUserStats from '../hooks/useUserStats.js'; const ALL_GROUPS = []; @@ -47,6 +48,7 @@ export default function StatsPage() { + diff --git a/src/pages/StatsPage.test.jsx b/src/pages/StatsPage.test.jsx index aaff1b6..d6c2232 100644 --- a/src/pages/StatsPage.test.jsx +++ b/src/pages/StatsPage.test.jsx @@ -21,6 +21,7 @@ const stats = (extra = {}) => ({ 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}, + platforms: {perPlatform: [{name: 'Steam', games: 10, minutes: 5000}, {name: 'PS5', games: 2, minutes: 600}]}, 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, @@ -45,11 +46,12 @@ describe('StatsPage', () => { 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']) { + for (const title of ['PILE', 'TREND', 'TOP_GAMES', 'TASTE', 'HABITS', 'FINISHED', 'PLATFORMS']) { expect(screen.getByText(`STATS.${title}.TITLE`)).toBeTruthy(); } expect(screen.getAllByText('Portal 2')).toHaveLength(2); expect(screen.getByText('Half-Life 2')).toBeTruthy(); + expect(screen.getByText('PS5')).toBeTruthy(); expect(screen.getByText('STATS.PILE.PACE')).toBeTruthy(); expect(screen.getByText('STATS.FINISHED.BEFORE:1')).toBeTruthy(); expect(screen.getByTestId('pile-trend')).toBeTruthy(); @@ -64,6 +66,7 @@ describe('StatsPage', () => { taste: {genres: [], moods: []}, habits: {moonTrips: 0, longestDay: null, underOneHourGames: 0, underOneHourShare: null}, finished: {perYear: [], finishedBeforeTracking: 0}, + platforms: {perPlatform: []}, trend: {days: [empty.trend.days[1]], thisMonth: {added: 0, done: 0}}, })); renderPage(); @@ -72,6 +75,7 @@ describe('StatsPage', () => { 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.PLATFORMS.EMPTY')).toBeTruthy(); expect(screen.getByText('STATS.TREND.STARTS')).toBeTruthy(); expect(screen.queryByText('STATS.HABITS.LONGEST_DAY')).toBeNull(); expect(screen.queryByTestId('pile-trend')).toBeNull(); diff --git a/src/utils/tiles.js b/src/utils/tiles.js index e800c75..a9fbf97 100644 --- a/src/utils/tiles.js +++ b/src/utils/tiles.js @@ -16,6 +16,7 @@ const STAT_TILES = { STATS_TASTE: {M: 6, L: 12}, STATS_HABITS: {M: 6, L: 12}, STATS_FINISHED: {M: 6, L: 12}, + STATS_PLATFORMS: {M: 6, L: 12}, }; export const DASHBOARD_TILES = {