)}
{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 = {