diff --git a/README.md b/README.md index 275dce3..48b4d6d 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,10 @@ To Be Played is split into three repositories: - **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; games per platform. +- **Click a number** (stats page, dashboard, public profile, landing page, Pile Points): a number, bar, row or year + opens the games behind it in a dialog (a bottom sheet on phones), 40 per page with "Load more". In your own + views the status counts and the pile link to the library with the filter instead. Not clickable: sums (playtime), + averages, rates and the moon trips. The list always uses the same rules as the number. - **Pile Points** (`/points`): points for playing games off your pile (start, finish by time to beat, 100 %, month and streak bonuses; Core checks proof of play), spent in a small shop on an avatar border, a badge, a premium accent, a banner frame, an effect for 100 % games and an animated page or banner background (CSS and SVG in `effects.css`, two canvas engines in `EffectCanvas`; layers follow the mouse and the phone tilt, on iPhone after the switch in the settings), @@ -208,6 +212,19 @@ flowchart LR with `src/test/fetchRoutes.js`, which fails a test on any request without a route. - `src/utils/brokenImages.js` reports pictures that fail to load (`GameCover`, achievement icons on the game detail page) to Core. +- The games behind a number: pages wrap their content in `StatDrillProvider` (`source` own, profile, community or + points), which renders one `StatGamesDialog`. Numbers call `useStatDrill()` / `StatCount`; outside a provider they + are plain text. `src/utils/statGames.js` builds the four endpoint URLs. + +```mermaid +flowchart LR + Number["Number, bar or row"] --> Provider["StatDrillProvider"] + Provider --> Dialog["StatGamesDialog"] + Dialog -->|own| Own["Core /api/library/stats/games"] + Dialog -->|"profile + ticket"| Profile["Core /api/public/profiles/library/stats/games"] + Dialog -->|community| Community["Core /api/stats/public/games"] + Dialog -->|points| Points["Core /api/rewards/.../games"] +``` - `useLibrarySync` polls Core's `/api/library/sync-status` while a Steam sync runs, then loads the data again. - The bookmarklet hands its list over in the URL fragment (base64url JSON), which the browser sends to no server. The import page reads it once and removes it from the URL. `RequireSession` keeps the fragment on the way diff --git a/public/i18n/de.json b/public/i18n/de.json index c90c647..8880dee 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -496,7 +496,9 @@ }, "TREND": { "TITLE": "Stapel-Verlauf", - "THIS_MONTH": "Diesen Monat: +{{added}} dazu, −{{done}} vom Stapel", + "THIS_MONTH": "Diesen Monat:", + "THIS_MONTH_ADDED": "+{{added}} neu", + "THIS_MONTH_DONE": "−{{done}} vom Stapel", "STARTS": "Der Verlauf beginnt heute. Morgen gibt es die erste Linie." }, "TOP_GAMES": { @@ -557,6 +559,25 @@ "TACTICAL": "Taktik", "TURN_BASED_STRATEGY_TBS": "Rundenstrategie", "VISUAL_NOVEL": "Visual Novel" + }, + "DRILL": { + "SHOW_ALL": "Alle anzeigen", + "LOAD_MORE": "Mehr laden", + "EMPTY": "Hinter dieser Zahl stehen keine Spiele.", + "ERROR": "Die Spiele konnten nicht geladen werden.", + "NOT_SHARED": "Der Besitzer teilt diese Zahl nicht.", + "EXPIRED": "Diese Ansicht ist abgelaufen. Lade die Seite neu.", + "RELOAD": "Neu laden", + "IN_LIBRARIES_one": "in {{count}} Bibliothek", + "IN_LIBRARIES_other": "in {{count}} Bibliotheken", + "BEFORE_TRACKING": "Vor Beginn des Verlaufs durchgespielt", + "TREND_MOVES": "Stapel-Änderungen der letzten 90 Tage", + "TREND_ADDED": "Diesen Monat zum Stapel hinzugekommen", + "TREND_DONE": "Diesen Monat vom Stapel gegangen", + "NEW_IN_LIBRARY": "Neu", + "OPEN": "Im Steam-Shop öffnen", + "GAMES_one": "{{count}} Spiel", + "GAMES_other": "{{count}} Spiele" } }, "ROLES": { diff --git a/public/i18n/en.json b/public/i18n/en.json index 043bbd7..d9c0d99 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -496,7 +496,9 @@ }, "TREND": { "TITLE": "Pile trend", - "THIS_MONTH": "This month: +{{added}} added, −{{done}} off the pile", + "THIS_MONTH": "This month:", + "THIS_MONTH_ADDED": "+{{added}} added", + "THIS_MONTH_DONE": "−{{done}} off the pile", "STARTS": "The trend starts today. Come back tomorrow for the first line." }, "TOP_GAMES": { @@ -557,6 +559,25 @@ "TACTICAL": "Tactics", "TURN_BASED_STRATEGY_TBS": "Turn-based strategy", "VISUAL_NOVEL": "Visual novel" + }, + "DRILL": { + "SHOW_ALL": "Show all", + "LOAD_MORE": "Load more", + "EMPTY": "No games behind this number.", + "ERROR": "Could not load the games.", + "NOT_SHARED": "The owner does not share this number.", + "EXPIRED": "This view has expired. Reload the page.", + "RELOAD": "Reload", + "IN_LIBRARIES_one": "in {{count}} library", + "IN_LIBRARIES_other": "in {{count}} libraries", + "BEFORE_TRACKING": "Finished before the history started", + "TREND_MOVES": "Pile moves in the last 90 days", + "TREND_ADDED": "Added to the pile this month", + "TREND_DONE": "Off the pile this month", + "NEW_IN_LIBRARY": "New", + "OPEN": "Open in the Steam store", + "GAMES_one": "{{count}} game", + "GAMES_other": "{{count}} games" } }, "ROLES": { diff --git a/src/components/PileOfShameCard.jsx b/src/components/PileOfShameCard.jsx index 27820f5..3856fa1 100644 --- a/src/components/PileOfShameCard.jsx +++ b/src/components/PileOfShameCard.jsx @@ -6,10 +6,12 @@ import {Button} from '@/components/ui/button'; import {Card} from '@/components/ui/card'; import GameCover from './GameCover.jsx'; import PileTrend from './stats/PileTrend.jsx'; +import {StatCount} from './stats/StatDrill.jsx'; import {STATUS_META} from './GameStatusBadge.jsx'; import {backlogShare} from '../utils/backlog.js'; import {GAME_STATUSES} from '../utils/limits.js'; import {formatPlaytime} from '../utils/games.js'; +import {libraryLink} from '../utils/statGames.js'; /** * @param trend the TREND group of GET /api/library/stats; the card shows no trend while it is missing @@ -34,17 +36,25 @@ export default function PileOfShameCard({summary, trend, syncing, onShuffle, shu
- {backlog.toLocaleString(i18n.language)} + + {backlog.toLocaleString(i18n.language)} + {t('DASHBOARD.PILE_SHARE', {percent: backlogShare(summary)})}

- {t('DASHBOARD.NEVER_PLAYED', {count: summary.neverPlayed})} + 0 ? {key: 'NEVER_STARTED'} : null} + title={t('STATS.OVERVIEW.NEVER_STARTED')}> + {t('DASHBOARD.NEVER_PLAYED', {count: summary.neverPlayed})} +

{summary.backlogMinutes > 0 && (

- {t('DASHBOARD.BACKLOG_HOURS', {time: formatPlaytime(summary.backlogMinutes)})} + + {t('DASHBOARD.BACKLOG_HOURS', {time: formatPlaytime(summary.backlogMinutes)})} +

)}
@@ -67,7 +77,9 @@ export default function PileOfShameCard({summary, trend, syncing, onShuffle, shu
  • {t(`BACKLOG.STATUS.${s}`)} - {summary.byStatus[s]} + + {summary.byStatus[s]} +
  • ))} diff --git a/src/components/PileOfShameCard.test.jsx b/src/components/PileOfShameCard.test.jsx index ee8d1bf..0491577 100644 --- a/src/components/PileOfShameCard.test.jsx +++ b/src/components/PileOfShameCard.test.jsx @@ -1,8 +1,10 @@ // @vitest-environment jsdom import {afterEach, describe, expect, it, vi} from 'vitest'; -import {cleanup, fireEvent, render, screen} from '@testing-library/react'; +import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {MemoryRouter} from 'react-router-dom'; import PileOfShameCard from './PileOfShameCard.jsx'; +import {StatDrillProvider} from './stats/StatDrill.jsx'; +import {fetchRoutes, json} from '../test/fetchRoutes.js'; vi.mock('react-i18next', () => import('../test/i18n-mock.js')); @@ -74,4 +76,17 @@ describe('PileOfShameCard', () => { expect(screen.queryByText('DASHBOARD.BACKLOG_HOURS')).toBeNull(); }); + + it('linksTheBacklogNumberToTheLibraryAndOpensTheNeverPlayedGames', async () => { + fetchRoutes({'GET /api/library/stats/games': json(200, {content: [{appid: 7, name: 'Untouched'}], + page: 0, size: 40, totalElements: 1, totalPages: 1})}); + render( {}}/>); + + expect(screen.getAllByRole('link', {name: 'BACKLOG.STATUS.BACKLOG: 2'})[0].getAttribute('href')) + .toBe('/library?hideDuplicates=true&status=BACKLOG'); + fireEvent.click(screen.getByRole('button', {name: 'DASHBOARD.NEVER_PLAYED:1'})); + + expect(await screen.findByText('Untouched')).toBeTruthy(); + await waitFor(() => expect(fetch.mock.calls[0][0]).toContain('key=NEVER_STARTED')); + }); }); diff --git a/src/components/StatCard.jsx b/src/components/StatCard.jsx index 9599219..937025d 100644 --- a/src/components/StatCard.jsx +++ b/src/components/StatCard.jsx @@ -2,6 +2,7 @@ import {useEffect, useState} from 'react'; import {useTranslation} from 'react-i18next'; import {Card} from '@/components/ui/card'; +import {useDrillTarget} from './stats/StatDrill.jsx'; function useCountUp(target, durationMs = 1600) { const [value, setValue] = useState(0); @@ -25,10 +26,14 @@ function useCountUp(target, durationMs = 1600) { return value; } -export default function StatCard({label, value, icon: Icon, suffix}) { +/** + * @param drill `{params}` of StatCount: the card is a button that opens the games behind the number + */ +export default function StatCard({label, value, icon: Icon, suffix, drill}) { const {i18n} = useTranslation(); const animated = useCountUp(value); - return ( + const target = useDrillTarget({title: label, ...drill}); + const card = ( {Icon && ( @@ -41,4 +46,10 @@ export default function StatCard({label, value, icon: Icon, suffix}) {
    {label}
    ); + return target ? ( + + ) : card; } diff --git a/src/components/SteamGameCount.jsx b/src/components/SteamGameCount.jsx index dbee51d..6229c3b 100644 --- a/src/components/SteamGameCount.jsx +++ b/src/components/SteamGameCount.jsx @@ -33,7 +33,7 @@ const SteamGameCount = ({targetCount}) => {
    -
    {count}
    + {count}
    {t('DASHBOARD.STEAM_GAMES')}
    diff --git a/src/components/stats/FinishedStats.jsx b/src/components/stats/FinishedStats.jsx index d19efcd..f75e11b 100644 --- a/src/components/stats/FinishedStats.jsx +++ b/src/components/stats/FinishedStats.jsx @@ -2,19 +2,25 @@ import {useTranslation} from 'react-i18next'; import {Flag} from 'lucide-react'; import StatsSection from './StatsSection.jsx'; +import {DrillRow, StatCount, useStatDrill} from './StatDrill.jsx'; -export function YearBars({perYear}) { +/** + * @param onSelect `(year)`: the rows are buttons + */ +export function YearBars({perYear, onSelect}) { const max = Math.max(1, ...perYear.map((y) => y.games)); return ( @@ -23,16 +29,20 @@ export function YearBars({perYear}) { export default function FinishedStats({finished}) { const {t} = useTranslation(); + const drill = useStatDrill(); return ( {finished.perYear.length === 0 ? (

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

    ) : ( - + drill.open(`${t('STATS.FINISHED.TITLE')}: ${year}`, {key: 'FINISHED_YEAR', arg: year}))}/> )} {finished.finishedBeforeTracking > 0 && (

    - {t('STATS.FINISHED.BEFORE', {count: finished.finishedBeforeTracking})} + + {t('STATS.FINISHED.BEFORE', {count: finished.finishedBeforeTracking})} +

    )}
    diff --git a/src/components/stats/GameRow.jsx b/src/components/stats/GameRow.jsx index f86c60b..a6b0246 100644 --- a/src/components/stats/GameRow.jsx +++ b/src/components/stats/GameRow.jsx @@ -1,23 +1,31 @@ import {Link} from 'react-router-dom'; +import {useTranslation} from 'react-i18next'; import GameCover from '../GameCover.jsx'; import FitText from '../FitText.jsx'; +import {gameHref} from '../../utils/statGames.js'; /** - * A game with its cover and a value on the right; links to the game page only in the user's own library. + * A game with its cover and a value on the right; links to the game page in the user's own library, to the Steam + * store for a visitor. */ export default function GameRow({game, value, linked = true}) { + const {t} = useTranslation(); const content = ( <> - {value} + {value} ); - return linked ? ( - - {content} - - ) : ( -
    {content}
    - ); + const className = 'flex items-center gap-3 hover:text-primary transition-colors'; + if (linked) return {content}; + const href = gameHref(game.appid, false); + if (href) { + return ( + + {content} + + ); + } + return
    {content}
    ; } diff --git a/src/components/stats/HabitsStats.jsx b/src/components/stats/HabitsStats.jsx index a7c40cc..2ab4d74 100644 --- a/src/components/stats/HabitsStats.jsx +++ b/src/components/stats/HabitsStats.jsx @@ -19,12 +19,13 @@ export default function HabitsStats({habits}) { hint={t('STATS.HABITS.MOON_HINT')}/> {habits.longestDay && ( + hint={day} drill={{params: {key: 'LONGEST_DAY'}}}/> )} {habits.underOneHourShare != null && ( + hint={t('STATS.HABITS.UNDER_ONE_HOUR_HINT', {count: habits.underOneHourGames})} + drill={{params: {key: 'UNDER_ONE_HOUR'}}}/> )} diff --git a/src/components/stats/OverviewStats.jsx b/src/components/stats/OverviewStats.jsx index e72bd0f..cebf363 100644 --- a/src/components/stats/OverviewStats.jsx +++ b/src/components/stats/OverviewStats.jsx @@ -4,7 +4,9 @@ import {Gamepad2} from 'lucide-react'; import StatsSection from './StatsSection.jsx'; import StatTile from './StatTile.jsx'; import {GAME_STATUSES} from '../../utils/limits.js'; +import {StatCount} from './StatDrill.jsx'; import {formatPlaytime} from '../../utils/games.js'; +import {libraryLink} from '../../utils/statGames.js'; export default function OverviewStats({overview}) { const {t, i18n} = useTranslation(); @@ -12,16 +14,22 @@ export default function OverviewStats({overview}) { return (
    - + - - + +
      {GAME_STATUSES.map((s) => (
    • {t(`BACKLOG.STATUS.${s}`)}{' '} - {n(overview.byStatus[s] ?? 0)} + + {n(overview.byStatus[s] ?? 0)} +
    • ))}
    diff --git a/src/components/stats/PileStats.jsx b/src/components/stats/PileStats.jsx index 71dfa78..b60df7b 100644 --- a/src/components/stats/PileStats.jsx +++ b/src/components/stats/PileStats.jsx @@ -5,6 +5,7 @@ import StatsSection from './StatsSection.jsx'; import StatTile from './StatTile.jsx'; import GameRow from './GameRow.jsx'; import {formatPlaytime} from '../../utils/games.js'; +import {libraryLink} from '../../utils/statGames.js'; import {formatDecimal} from '../../utils/stats.js'; import {formatDate} from '../../utils/format.js'; @@ -16,9 +17,11 @@ export default function PileStats({pile, linked = true}) { return (
    - + + hint={t('STATS.PILE.KNOWN', {count: pile.gamesWithTimeToBeat})} + drill={{params: {key: 'PILE_TIME_TO_BEAT'}}}/>
    {pile.normalMinutes > 0 && ( diff --git a/src/components/stats/PileTrend.jsx b/src/components/stats/PileTrend.jsx index 5eabd7b..26a2bb4 100644 --- a/src/components/stats/PileTrend.jsx +++ b/src/components/stats/PileTrend.jsx @@ -1,4 +1,5 @@ import {useTranslation} from 'react-i18next'; +import {StatCount} from './StatDrill.jsx'; import {formatDay} from '../../utils/format.js'; import {trendPoints} from '../../utils/stats.js'; @@ -16,15 +17,25 @@ export default function PileTrend({trend}) { return (

    - {t('STATS.TREND.THIS_MONTH', {added: thisMonth.added, done: thisMonth.done})} + {t('STATS.TREND.THIS_MONTH')}{' '} + 0 ? {key: 'TREND_ADDED'} : null} title={t('STATS.DRILL.TREND_ADDED')}> + {t('STATS.TREND.THIS_MONTH_ADDED', {added: thisMonth.added})} + + {', '} + 0 ? {key: 'TREND_DONE'} : null} title={t('STATS.DRILL.TREND_DONE')}> + {t('STATS.TREND.THIS_MONTH_DONE', {done: thisMonth.done})} +

    {days.length > 1 ? (
    - - - + + + + +
    {formatDay(days[0].day, i18n.language)}: {days[0].backlogGames} {formatDay(days[days.length - 1].day, i18n.language)}: {days[days.length - 1].backlogGames} diff --git a/src/components/stats/PlatformStats.jsx b/src/components/stats/PlatformStats.jsx index 93e12f7..a73f8f2 100644 --- a/src/components/stats/PlatformStats.jsx +++ b/src/components/stats/PlatformStats.jsx @@ -3,15 +3,17 @@ import {MonitorSmartphone} from 'lucide-react'; import StatsSection from './StatsSection.jsx'; import {TagBars} from './TasteStats.jsx'; +import {useTagDrill} from './StatDrill.jsx'; export default function PlatformStats({platforms}) { const {t} = useTranslation(); + const select = useTagDrill(); return ( {platforms.perPlatform.length === 0 ? (

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

    ) : ( - + )}
    ); diff --git a/src/components/stats/StatDrill.jsx b/src/components/stats/StatDrill.jsx new file mode 100644 index 0000000..21842b7 --- /dev/null +++ b/src/components/stats/StatDrill.jsx @@ -0,0 +1,90 @@ +import {createContext, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import {Link} from 'react-router-dom'; + +import {cn} from '@/lib/utils'; +import StatGamesDialog from './StatGamesDialog.jsx'; + +const StatDrillContext = createContext(null); + +// The dotted underline says "there is more behind this number". +const DRILL_CLASS = 'inline-flex items-center justify-center max-sm:min-h-11 max-sm:min-w-11 underline decoration-dotted ' + + 'underline-offset-4 hover:text-primary focus-visible:ring-2 focus-visible:ring-ring rounded-sm cursor-pointer'; + +/** + * Holds the dialog of the games behind a number, once for a page. + * + * @param source own, profile, community or points + * @param ticket profile: the ticket for the visitor's requests + */ +export function StatDrillProvider({source, ticket, children}) { + const [request, setRequest] = useState(null); + const opener = useRef(null); + useEffect(() => { + if (!request) opener.current?.focus?.(); + }, [request]); + const value = useMemo(() => ({ + source, + open: (title, params) => { + opener.current = document.activeElement; + setRequest({title, source, ticket, params}); + }, + }), [source, ticket]); + return ( + + {children} + setRequest(null)} + onCloseAutoFocus={(event) => event.preventDefault()}/> + + ); +} + +/** `{source, open(title, params)}`, or null outside a provider: the numbers are not clickable there. */ +export const useStatDrill = () => useContext(StatDrillContext); + +/** + * What a number does on click: a library link (own library only, `to`), the dialog (`params`), or nothing (null). + */ +export function useDrillTarget({to, params, title}) { + const drill = useStatDrill(); + if (to && drill?.source === 'own') return {to}; + if (drill && params) return {onClick: () => drill.open(title, params)}; + return null; +} + +/** `(key) => onSelect` of TagBars for a key with the tag name as its argument; null outside a provider. */ +export function useTagDrill() { + const drill = useStatDrill(); + return (key) => drill && ((tag, text) => drill.open(text, {key, arg: tag.name})); +} + +/** A bar row: a button when it has `onSelect`. */ +export function DrillRow({onSelect, className, children}) { + return onSelect ? ( + + ) :
    {children}
    ; +} + +export function DrillButton({onClick, label, className, children}) { + return ( + + ); +} + +/** + * A number (or any inline content) that opens the games behind it. Plain content when it has no target. + * + * @param to library URL, used instead of the dialog in the user's own views + * @param params `{key, arg}` or `{track}` / `{kind, ref}` of the games request + */ +export function StatCount({to, params, title, label, className, children}) { + const target = useDrillTarget({to, params, title}); + if (!target) return children; + return target.to + ? {children} + : {children}; +} diff --git a/src/components/stats/StatGamesDialog.jsx b/src/components/stats/StatGamesDialog.jsx new file mode 100644 index 0000000..21588c6 --- /dev/null +++ b/src/components/stats/StatGamesDialog.jsx @@ -0,0 +1,111 @@ +import {useCallback, useEffect, useRef, useState} from 'react'; +import {useTranslation} from 'react-i18next'; + +import {Button} from '@/components/ui/button'; +import {Dialog, DialogContent, DialogDescription, DialogTitle} from '@/components/ui/dialog'; +import {Skeleton} from '@/components/ui/skeleton'; +import GameRow from './GameRow.jsx'; +import GameStatusBadge from '../GameStatusBadge.jsx'; +import {formatDate} from '../../utils/format.js'; +import {formatPlaytime} from '../../utils/games.js'; +import {fetchStatGames} from '../../utils/statGames.js'; + +const ERRORS = {403: 'STATS.DRILL.NOT_SHARED', 401: 'STATS.DRILL.EXPIRED'}; + +/** + * The games behind one number, 40 per page. + * + * @param request `{title, source, ticket, params}` or null (closed) + */ +export default function StatGamesDialog({request, onClose, onCloseAutoFocus}) { + const {t, i18n} = useTranslation(); + const [state, setState] = useState({games: [], page: 0, totalPages: 0, total: 0, untracked: 0}); + const [status, setStatus] = useState('loading'); + const openRequest = useRef(null); + + const load = useCallback(async (page, current) => { + setStatus('loading'); + const {ok, status: code, data} = await fetchStatGames(request.source, {...request.params, page}, request.ticket); + if (openRequest.current !== request) return; + if (!ok) { + setStatus(code); + return; + } + setState({ + games: [...current, ...data.content], page, totalPages: data.totalPages, total: data.totalElements, + untracked: data.untracked ?? 0, + }); + setStatus('ok'); + }, [request]); + + useEffect(() => { + openRequest.current = request; + if (!request) return; + setState({games: [], page: 0, totalPages: 0, total: 0, untracked: 0}); + load(0, []); + }, [request, load]); + + const value = (game) => { + if (request.source === 'community') { + return `${t('STATS.DRILL.IN_LIBRARIES', {count: game.libraries})} · ${formatPlaytime(game.minutes)}`; + } + const moved = game.fromStatus || game.toStatus; + return ( + <> + {!moved && game.status && } + {moved && ( + {game.fromStatus ? t(`BACKLOG.STATUS.${game.fromStatus}`) : t('STATS.DRILL.NEW_IN_LIBRARY')} + {' → '}{game.toStatus ? t(`BACKLOG.STATUS.${game.toStatus}`) : '–'} + )} + {game.at + ? formatDate(game.at, i18n.language, {dateStyle: 'medium'}) + : formatPlaytime(game.minutes ?? game.playtimeMinutes ?? 0)} + + ); + }; + + const failed = typeof status === 'number'; + return ( + !open && onClose()}> + + {request?.title} + {status === 'ok' ? t('STATS.DRILL.GAMES', {count: state.total}) : ' '} + {request && ( +
      + {state.games.map((game, index) => ( +
    • + +
    • + ))} +
    + )} + {status === 'loading' && } + {status === 'ok' && state.games.length === 0 && ( +

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

    + )} + {status === 'ok' && state.untracked > 0 && ( +

    {t('STATS.FINISHED.BEFORE', {count: state.untracked})}

    + )} + {failed && ( +
    +

    {t(ERRORS[status] ?? 'STATS.DRILL.ERROR')}

    + {status === 401 + ? + : status !== 403 && ( + + )} +
    + )} + {status === 'ok' && state.page + 1 < state.totalPages && ( + + )} +
    +
    + ); +} diff --git a/src/components/stats/StatGamesDialog.test.jsx b/src/components/stats/StatGamesDialog.test.jsx new file mode 100644 index 0000000..88d1aa2 --- /dev/null +++ b/src/components/stats/StatGamesDialog.test.jsx @@ -0,0 +1,131 @@ +// @vitest-environment jsdom +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {act, cleanup, fireEvent, render, screen} from '@testing-library/react'; +import {MemoryRouter} from 'react-router-dom'; +import StatGamesDialog from './StatGamesDialog.jsx'; +import {StatCount, StatDrillProvider} from './StatDrill.jsx'; +import {fetchRoutes, json} from '../../test/fetchRoutes.js'; + +vi.mock('react-i18next', () => import('../../test/i18n-mock.js')); + +const GAMES = '/api/library/stats/games'; +const rows = (from, count, extra = {}) => Array.from({length: count}, (_, i) => ( + {appid: from + i, name: `Game ${from + i}`, status: 'BACKLOG', playtimeMinutes: 60, ...extra})); +const page = (content, index, totalPages, totalElements) => + json(200, {content, page: index, size: 40, totalElements, totalPages}); + +const renderDialog = (request, onClose = () => {}) => render( + ); + +describe('StatGamesDialog', () => { + afterEach(cleanup); + + it('loadsPageZeroAndAppendsTheNextOne', async () => { + fetchRoutes({[`GET ${GAMES}`]: (url) => url.includes('page=1') + ? page(rows(41, 5), 1, 2, 45) + : page(rows(1, 40), 0, 2, 45)}); + renderDialog(); + + expect(await screen.findByText('Game 40')).toBeTruthy(); + expect(screen.getAllByRole('listitem')).toHaveLength(40); + expect(fetch.mock.calls[0][0]).toContain('key=GENRE&arg=Shooter&page=0'); + + fireEvent.click(screen.getByRole('button', {name: 'STATS.DRILL.LOAD_MORE'})); + + expect(await screen.findByText('Game 45')).toBeTruthy(); + expect(screen.getAllByRole('listitem')).toHaveLength(45); + expect(screen.queryByRole('button', {name: 'STATS.DRILL.LOAD_MORE'})).toBeNull(); + }); + + it('closesOnEscape', async () => { + fetchRoutes({[`GET ${GAMES}`]: page(rows(1, 1), 0, 1, 1)}); + const onClose = vi.fn(); + renderDialog({}, onClose); + await screen.findByText('Game 1'); + + fireEvent.keyDown(screen.getByRole('dialog'), {key: 'Escape'}); + + expect(onClose).toHaveBeenCalled(); + }); + + it('offersARetryAfterAnError', async () => { + let fail = true; + fetchRoutes({[`GET ${GAMES}`]: () => (fail ? json(500, {}) : page(rows(1, 1), 0, 1, 1))}); + renderDialog(); + + expect(await screen.findByText('STATS.DRILL.ERROR')).toBeTruthy(); + fail = false; + fireEvent.click(screen.getByRole('button', {name: 'DASHBOARD.RETRY'})); + + expect(await screen.findByText('Game 1')).toBeTruthy(); + }); + + it('saysSoWhenTheOwnerDoesNotShareTheNumber', async () => { + fetchRoutes({'GET /api/public/profiles/library/stats/games': json(403, {code: 'NOT_SHARED'})}); + renderDialog({source: 'profile', ticket: 't'}); + + expect(await screen.findByText('STATS.DRILL.NOT_SHARED')).toBeTruthy(); + expect(screen.queryByRole('button', {name: 'DASHBOARD.RETRY'})).toBeNull(); + }); + + it('asksForAReloadWhenTheTicketExpired', async () => { + const mock = fetchRoutes({'GET /api/public/profiles/library/stats/games': json(401, {code: 'INVALID_TICKET'})}); + renderDialog({source: 'profile', ticket: 'old'}); + + expect(await screen.findByText('STATS.DRILL.EXPIRED')).toBeTruthy(); + expect(screen.getByRole('button', {name: 'STATS.DRILL.RELOAD'})).toBeTruthy(); + expect(mock.mock.calls[0][1].headers['X-Profile-Ticket']).toBe('old'); + }); + + it('showsTheStatusMoveOfATrendRow', async () => { + fetchRoutes({[`GET ${GAMES}`]: page( + [{appid: 5, name: 'Moved', fromStatus: 'BACKLOG', toStatus: 'PLAYING', at: '2026-09-20T10:00:00Z'}], 0, 1, 1)}); + renderDialog({params: {key: 'TREND_MOVES'}}); + + await screen.findByText('Moved'); + + expect(screen.getByText(/BACKLOG\.STATUS\.BACKLOG → BACKLOG\.STATUS\.PLAYING/)).toBeTruthy(); + }); + + it('showsTheLibraryCountOfACommunityRow', async () => { + fetchRoutes({'GET /api/stats/public/games': page([{appid: 7, name: 'Shared', libraries: 3, minutes: 120}], 0, 1, 1)}); + renderDialog({source: 'community', params: {key: 'OWNED'}}); + + await screen.findByText('Shared'); + + expect(screen.getByText('STATS.DRILL.IN_LIBRARIES:3 · 2.0h')).toBeTruthy(); + }); + + it('focusReturnsToTheClickedNumber', async () => { + fetchRoutes({[`GET ${GAMES}`]: page(rows(1, 1), 0, 1, 1)}); + render( + 1 + ); + const number = screen.getByRole('button', {name: 'Played: 1'}); + number.focus(); + fireEvent.click(number); + await screen.findByText('Game 1'); + + fireEvent.keyDown(screen.getByRole('dialog'), {key: 'Escape'}); + await act(async () => {}); + + expect(document.activeElement).toBe(number); + }); + + it('aLateAnswerOfAClosedListDoesNotFillTheOpenOne', async () => { + let answerOld; + fetchRoutes({[`GET ${GAMES}`]: (url) => (url.includes('arg=Old') + ? new Promise((resolve) => { answerOld = resolve; }) + : page([{appid: 2, name: 'New game', playtimeMinutes: 1}], 0, 1, 1))}); + const request = (arg) => ({title: arg, source: 'own', params: {key: 'GENRE', arg}}); + const view = render( {}}/>); + view.rerender( {}}/>); + await screen.findByText('New game'); + + await act(async () => answerOld(page([{appid: 1, name: 'Old game', playtimeMinutes: 1}], 0, 1, 1))); + + expect(screen.queryByText('Old game')).toBeNull(); + expect(screen.getByText('New game')).toBeTruthy(); + }); +}); diff --git a/src/components/stats/StatTile.jsx b/src/components/stats/StatTile.jsx index a7a9639..ccef8d6 100644 --- a/src/components/stats/StatTile.jsx +++ b/src/components/stats/StatTile.jsx @@ -1,9 +1,24 @@ -export default function StatTile({label, value, hint}) { - return ( -
    +import {Link} from 'react-router-dom'; + +import {cn} from '@/lib/utils'; +import {useDrillTarget} from './StatDrill.jsx'; + +const CLICKABLE = 'block w-full text-left transition-colors hover:border-primary focus-visible:ring-2 focus-visible:ring-ring'; + +/** + * @param drill `{to, params, title}` of StatCount: the tile is a link or button when it has a target here + */ +export default function StatTile({label, value, hint, drill}) { + const target = useDrillTarget({title: label, ...drill}); + const body = ( + <>
    {value}
    {label}
    {hint &&
    {hint}
    } -
    + ); + const className = 'rounded-lg border p-4'; + if (target?.to) return {body}; + if (target) return ; + return
    {body}
    ; } diff --git a/src/components/stats/TasteStats.jsx b/src/components/stats/TasteStats.jsx index 7d8a0e3..2d289ce 100644 --- a/src/components/stats/TasteStats.jsx +++ b/src/components/stats/TasteStats.jsx @@ -2,26 +2,32 @@ import {useTranslation} from 'react-i18next'; import {Palette} from 'lucide-react'; import StatsSection from './StatsSection.jsx'; +import {DrillRow, useTagDrill} from './StatDrill.jsx'; import {formatPlaytime} from '../../utils/games.js'; import {genreKey} from '../../utils/stats.js'; -export function TagBars({tags, label = (name) => name}) { +/** + * @param onSelect `(tag, shownLabel)`: the rows are buttons + */ +export function TagBars({tags, label = (name) => name, onSelect}) { const {t} = useTranslation(); const max = Math.max(1, ...tags.map((tag) => tag.minutes)); return (
      {tags.map((tag) => (
    • -
      - {label(tag.name)} - - {formatPlaytime(tag.minutes)} · {t('STATS.TASTE.GAMES', {count: tag.games})} - -
      -
      -
      -
      + onSelect(tag, label(tag.name)))}> +
      + {label(tag.name)} + + {formatPlaytime(tag.minutes)} · {t('STATS.TASTE.GAMES', {count: tag.games})} + +
      +
      +
      +
      +
    • ))}
    @@ -30,6 +36,7 @@ export function TagBars({tags, label = (name) => name}) { export default function TasteStats({taste}) { const {t} = useTranslation(); + const select = useTagDrill(); if (taste.genres.length === 0 && taste.moods.length === 0) { return ( @@ -42,12 +49,12 @@ export default function TasteStats({taste}) {
    {t('STATS.TASTE.GENRES')} - t(`STATS.GENRE.${genreKey(name)}`, {defaultValue: name})}/>
    {t('STATS.TASTE.MOODS')} - t(`PLAY_NEXT.MOOD.${name}`, {defaultValue: name})}/> + t(`PLAY_NEXT.MOOD.${name}`, {defaultValue: name})}/>
    diff --git a/src/components/stats/TopGamesStats.jsx b/src/components/stats/TopGamesStats.jsx index f029658..ac8f6b4 100644 --- a/src/components/stats/TopGamesStats.jsx +++ b/src/components/stats/TopGamesStats.jsx @@ -1,12 +1,15 @@ import {useTranslation} from 'react-i18next'; import {Trophy} from 'lucide-react'; +import {Button} from '@/components/ui/button'; import StatsSection from './StatsSection.jsx'; import GameRow from './GameRow.jsx'; +import {useStatDrill} from './StatDrill.jsx'; import {formatPlaytime} from '../../utils/games.js'; export default function TopGamesStats({topGames, linked = true}) { const {t} = useTranslation(); + const drill = useStatDrill(); return ( {topGames.mostPlayed.length === 0 ? ( @@ -18,6 +21,12 @@ export default function TopGamesStats({topGames, linked = true}) { ))} )} + {drill && topGames.mostPlayed.length > 0 && ( + + )} {topGames.mostLoyal && (
    {t('STATS.TOP_GAMES.LOYAL')} diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 254e0e0..382b545 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -23,6 +23,7 @@ import useSteamProfile from '../hooks/useSteamProfile.js'; import useUserStats from '../hooks/useUserStats.js'; import useSaveResponse from '../hooks/useSaveResponse.js'; import TileGrid from '../components/TileGrid.jsx'; +import {StatDrillProvider} from '../components/stats/StatDrill.jsx'; import statGroupTile from '../components/stats/statGroupTile.jsx'; import { DASHBOARD_TILES, DASHBOARD_TITLES, DEFAULT_DASHBOARD, normalizeLayout, PLAYING_GAMES, saveTiles, statGroup, TOP_GAMES, @@ -122,18 +123,20 @@ const Dashboard = () => { }; return ( - - setEditing(true)}/> + + + setEditing(true)}/> - setEditing(false)}/> - - {/* Last child: space-y gives every other child a bottom margin, which would move the fixed card. */} - - + setEditing(false)}/> + + {/* Last child: space-y gives every other child a bottom margin, which would move the fixed card. */} + + + ); }; diff --git a/src/pages/LandingPage.jsx b/src/pages/LandingPage.jsx index 67accaf..2af4486 100644 --- a/src/pages/LandingPage.jsx +++ b/src/pages/LandingPage.jsx @@ -14,12 +14,16 @@ 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 {StatDrillProvider, useStatDrill, useTagDrill} from '../components/stats/StatDrill.jsx'; import useSession from '../hooks/useSession.js'; import {genreKey} from '../utils/stats.js'; +import {gameHref} from '../utils/statGames.js'; import usePublicStats from '../hooks/usePublicStats.js'; -function RankedGames({title, games, formatValue}) { +function RankedGames({title, games, formatValue, drillKey}) { + const {t} = useTranslation(); + const drill = useStatDrill(); return (

    @@ -31,15 +35,58 @@ function RankedGames({title, games, formatValue}) {
  • {idx + 1} - + {formatValue(g.value ?? 0)}
  • ))} + {drill && ( + + )} ); } +function GameName({game}) { + const href = gameHref(game.appid, false); + const name = ; + return ( + + {href ? {name} : name} + + ); +} + +function CommunityBars({core}) { + const {t} = useTranslation(); + const drill = useStatDrill(); + const select = useTagDrill(); + const genreLabel = (name) => t(`STATS.GENRE.${genreKey(name)}`, {defaultValue: name}); + return ( +
    + {core?.gamesPerPlatform?.length > 0 && ( + + + + )} + {core?.topGenres?.length > 0 && ( + + + + )} + {core?.finishedPerYear?.length > 0 && ( + + drill.open(`${t('LANDING.FINISHED_PER_YEAR')}: ${year}`, {key: 'FINISHED_YEAR', arg: year}))}/> + + )} +
    + ); +} + function BarsCard({title, children}) { return ( @@ -60,81 +107,69 @@ export default function LandingPage() { const stats = [ [t('LANDING.TOTAL_USERS'), auth?.totalUsers, Users], [t('LANDING.STEAM_LINKED'), auth?.usersWithSteamLinked, Link2], - [t('LANDING.TOTAL_GAMES'), core?.totalLibraryEntries, Gamepad2], - [t('LANDING.GAMES_PLAYED'), core?.totalGamesPlayed, Sparkles], - [t('LANDING.DISTINCT_GAMES'), core?.totalDistinctGames, Blocks], + [t('LANDING.TOTAL_GAMES'), core?.totalLibraryEntries, Gamepad2, undefined, 'ENTRIES'], + [t('LANDING.GAMES_PLAYED'), core?.totalGamesPlayed, Sparkles, undefined, 'PLAYED'], + [t('LANDING.DISTINCT_GAMES'), core?.totalDistinctGames, Blocks, undefined, 'OWNED'], [t('LANDING.TOTAL_PLAYTIME_DAYS'), core?.totalPlaytimeDays, Clock, ` ${t('LANDING.DAYS_SUFFIX')}`], [t('LANDING.BIGGEST_LIBRARY'), core?.biggestLibrary, Library], [t('LANDING.BIGGEST_BACKLOG'), core?.biggestBacklog, Archive], ]; return ( -
    -
    -
    -
    - - ToBePlayed - -

    {t('MAIN.WELCOME')}

    -

    {t('MAIN.DESCRIPTION')}

    - +
    +
    + +
    +

    {t('LANDING.STATS_TITLE')}

    + +
    + {isLoading + ? stats.map(([label]) => ) + : stats.map(([label, value, icon, suffix, key]) => ( + + ))} +
    + +
    + {core?.topPlayedGames?.length > 0 && ( + `${Math.round(minutes / 60).toLocaleString(i18n.language)}h`} + drillKey="MOST_PLAYED" + /> )} - -
    -
    + {core?.topOwnedGames?.length > 0 && ( + count.toLocaleString(i18n.language)} + drillKey="OWNED" + /> + )} +
    -
    -

    {t('LANDING.STATS_TITLE')}

    - -
    - {isLoading - ? stats.map(([label]) => ) - : stats.map(([label, value, icon, suffix]) => ( - - ))} -
    - -
    - {core?.topPlayedGames?.length > 0 && ( - `${Math.round(minutes / 60).toLocaleString(i18n.language)}h`} - /> - )} - {core?.topOwnedGames?.length > 0 && ( - count.toLocaleString(i18n.language)} - /> - )} -
    - -
    - {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 index 2c48c1f..55a926e 100644 --- a/src/pages/LandingPage.test.jsx +++ b/src/pages/LandingPage.test.jsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import {afterEach, describe, expect, it, vi} from 'vitest'; -import {cleanup, render, screen} from '@testing-library/react'; +import {cleanup, fireEvent, render, screen} from '@testing-library/react'; import {MemoryRouter} from 'react-router-dom'; import LandingPage from './LandingPage.jsx'; import {fetchRoutes, json} from '../test/fetchRoutes.js'; @@ -42,4 +42,20 @@ describe('LandingPage', () => { expect(screen.queryByText(`LANDING.${title}`)).toBeNull(); } }); + + it('opensTheGamesBehindTheTotalButNotBehindOneUsersLibrary', async () => { + fetchRoutes({ + 'GET /api/stats/public': json(200, {topPlayedGames: [], topOwnedGames: [], totalLibraryEntries: 9, biggestLibrary: 4}), + 'GET /api/stats/public/games': json(200, {content: [{appid: 3, name: 'Common', libraries: 2, minutes: 60}], + page: 0, size: 40, totalElements: 1, totalPages: 1}), + }); + renderPage(); + + fireEvent.click(await screen.findByRole('button', {name: /LANDING\.TOTAL_GAMES/})); + + expect(await screen.findByText('Common')).toBeTruthy(); + expect(fetch.mock.calls.at(-1)[0]).toContain('/api/stats/public/games?key=ENTRIES&page=0'); + expect(screen.queryByRole('button', {name: /LANDING\.BIGGEST_LIBRARY/})).toBeNull(); + expect(screen.queryByRole('button', {name: /LANDING\.TOTAL_USERS/})).toBeNull(); + }); }); diff --git a/src/pages/PointsPage.jsx b/src/pages/PointsPage.jsx index 9f0353c..a7fb069 100644 --- a/src/pages/PointsPage.jsx +++ b/src/pages/PointsPage.jsx @@ -12,6 +12,7 @@ import {cn} from '@/lib/utils'; import EffectBackground from '../components/EffectBackground.jsx'; import FitText from '../components/FitText.jsx'; import PageContainer from '../components/PageContainer.jsx'; +import {StatCount, StatDrillProvider, useDrillTarget} from '../components/stats/StatDrill.jsx'; import {usePreferences} from '../context/PreferencesContext.jsx'; import useInView from '../hooks/useInView.js'; import {formatDate} from '../utils/format.js'; @@ -60,95 +61,103 @@ export default function PointsPage() { ); } if (['BUY', 'UNLOCK'].includes(entry.kind)) return t(`POINTS.ITEMS.${entry.ref}`); - if (['MONTH', 'STREAK'].includes(entry.kind)) return entry.ref; + if (['MONTH', 'STREAK'].includes(entry.kind)) { + return ( + + {entry.ref} + + ); + } return null; }; return ( - -
    -

    {t('POINTS.TITLE')}

    -

    - - {t('POINTS.BALANCE')}: {wallet.balance} -

    -
    + + +
    +

    {t('POINTS.TITLE')}

    +

    + + {t('POINTS.BALANCE')}: {wallet.balance} +

    +
    - -

    {t('POINTS.HOW_TO')}

    -
      - {HOW_TO.map((kind) =>
    • {t(`POINTS.RULES.${kind}`)}
    • )} -
    -
    + +

    {t('POINTS.HOW_TO')}

    +
      + {HOW_TO.map((kind) =>
    • {t(`POINTS.RULES.${kind}`)}
    • )} +
    +
    -
    -

    {t('POINTS.SHOP')}

    - {SLOTS.map((slot) => ( - -

    {t(`POINTS.SLOTS.${slot}`)}

    -
      - {wallet.catalog.filter((item) => item.slot === slot).map((item) => { - const owned = wallet.owned.includes(item.code); - const worn = wallet.equipped[slot] === item.code; - const locked = !owned && item.track; - return ( -
    • - - {t(`POINTS.ITEMS.${item.code}`)} - {worn && {t('POINTS.EQUIPPED')}} - {owned ? ( - - ) : locked ? ( - - ) : ( - <> - {item.price} - - + ) : locked ? ( + + ) : ( + <> + {item.price} + + + )} +
    • + ); + })} +
    +
    + ))} +
    + + +

    {t('POINTS.HISTORY')}

    + {wallet.history.length === 0 ? ( +

    {t('POINTS.EMPTY_HISTORY')}

    + ) : ( +
      + {wallet.history.map((entry) => { + const label = entryLabel(entry); + return ( +
    • + + {formatDate(entry.eventAt, i18n.language)} + + + {t(`POINTS.KINDS.${entry.kind}`)} + {label && {label}} + + {entry.points !== 0 && ( + + {entry.points > 0 ? `+${entry.points}` : entry.points} + )}
    • ); })}
    -
    - ))} - - - -

    {t('POINTS.HISTORY')}

    - {wallet.history.length === 0 ? ( -

    {t('POINTS.EMPTY_HISTORY')}

    - ) : ( -
      - {wallet.history.map((entry) => { - const label = entryLabel(entry); - return ( -
    • - - {formatDate(entry.eventAt, i18n.language)} - - - {t(`POINTS.KINDS.${entry.kind}`)} - {label && {label}} - - {entry.points !== 0 && ( - - {entry.points > 0 ? `+${entry.points}` : entry.points} - - )} -
    • - ); - })} -
    - )} -
    -
    + )} + +
    + ); } @@ -192,8 +201,15 @@ function LockedProgress({item, progress}) { const {t} = useTranslation(); const count = Math.min(progress, item.target); const text = t(`POINTS.PROGRESS.${item.track}`, {count, target: item.target}); + // Playtime months compare two numbers: no list of games shows them. + const target = useDrillTarget({ + params: item.track === 'PLAY_MORE_MONTHS' ? null : {track: item.track}, title: t(`POINTS.ITEMS.${item.code}`), + }); + const className = 'flex w-40 shrink-0 flex-col gap-1 text-xs text-muted-foreground'; + const Wrapper = target ? 'button' : 'div'; return ( -
    + {text} @@ -202,6 +218,6 @@ function LockedProgress({item, progress}) { aria-valuemax={item.target} className="h-1.5 overflow-hidden rounded-full bg-muted">
    -
    + ); } diff --git a/src/pages/PointsPage.test.jsx b/src/pages/PointsPage.test.jsx index 062f1fa..a4d152c 100644 --- a/src/pages/PointsPage.test.jsx +++ b/src/pages/PointsPage.test.jsx @@ -90,6 +90,34 @@ describe('PointsPage', () => { const bar = within(item).getByRole('progressbar'); expect(bar.getAttribute('aria-valuenow')).toBe('7'); expect(bar.getAttribute('aria-valuemax')).toBe('10'); + expect(within(item).queryByRole('button', {name: 'POINTS.BUY'})).toBeNull(); + }); + + it('opens the games behind a locked bar, and the history of a month', async () => { + const page = (appid) => json(200, {content: [{appid, name: `G${appid}`, status: 'FINISHED', at: '2026-09-01T10:00:00Z'}], + page: 0, size: 40, totalElements: 1, totalPages: 1, untracked: 3}); + await renderPage({ + 'GET /api/rewards/progress/GAMES_FINISHED/games': page(1), + 'GET /api/rewards/history/MONTH/2026-09/games': page(2), + }); + + const bar = screen.getByText('POINTS.ITEMS.BADGE_FINISH_10').closest('li'); + fireEvent.click(within(bar).getByRole('button')); + expect(await screen.findByText('G1')).toBeTruthy(); + expect(screen.getByText('STATS.FINISHED.BEFORE:3')).toBeTruthy(); + fireEvent.keyDown(document.activeElement, {key: 'Escape'}); + await waitFor(() => expect(screen.queryByText('G1')).toBeNull()); + + fireEvent.click(screen.getByRole('button', {name: '2026-09'})); + expect(await screen.findByText('G2')).toBeTruthy(); + }); + + it('shows no button on a playtime-month bar', async () => { + await renderPage({'GET /api/rewards/wallet': json(200, wallet({ + catalog: [{code: 'BADGE_MONTHS', slot: 'BADGE', price: 0, track: 'PLAY_MORE_MONTHS', target: 3}], + }))}); + + const item = screen.getByText('POINTS.ITEMS.BADGE_MONTHS').closest('li'); expect(within(item).queryByRole('button')).toBeNull(); }); diff --git a/src/pages/PublicProfilePage.jsx b/src/pages/PublicProfilePage.jsx index 5f3b647..9267167 100644 --- a/src/pages/PublicProfilePage.jsx +++ b/src/pages/PublicProfilePage.jsx @@ -24,6 +24,7 @@ import HeroBanner from '../components/HeroBanner.jsx'; import PageBackground from '../components/PageBackground.jsx'; import PageContainer from '../components/PageContainer.jsx'; import TileGrid from '../components/TileGrid.jsx'; +import {StatCount, StatDrillProvider} from '../components/stats/StatDrill.jsx'; import statGroupTile from '../components/stats/statGroupTile.jsx'; // Games per tile size; Core sends at most 8 playing and 12 finished games. @@ -177,8 +178,11 @@ export default function PublicProfilePage() { )} - setEditing(false)}/> + + setEditing(false)}/> +
    ); @@ -187,22 +191,25 @@ export default function PublicProfilePage() { function Summary({library, compact}) { const {t, i18n} = useTranslation(); const stats = [ - [Library, library.totalGames.toLocaleString(i18n.language), t('PROFILE.GAMES')], + [Library, library.totalGames.toLocaleString(i18n.language), t('PROFILE.GAMES'), 'TOTAL', library.totalGames], [Clock, formatPlaytime(library.totalPlaytimeMinutes), t('PROFILE.PLAYTIME')], - [CircleCheck, (library.finishedGames ?? 0).toLocaleString(i18n.language), t('PROFILE.FINISHED')], + [CircleCheck, (library.finishedGames ?? 0).toLocaleString(i18n.language), t('PROFILE.FINISHED'), 'FINISHED_ALL', library.finishedGames], ]; if (library.averageCompletion != null) { stats.push( - [Trophy, (library.perfectGames ?? 0).toLocaleString(i18n.language), t('PROFILE.PERFECT_GAMES')], + [Trophy, (library.perfectGames ?? 0).toLocaleString(i18n.language), t('PROFILE.PERFECT_GAMES'), 'PERFECT', library.perfectGames], [Percent, `${library.averageCompletion.toLocaleString(i18n.language)} %`, t('PROFILE.AVERAGE_COMPLETION')], ); } return ( 3 && !compact ? 'grid-cols-2 sm:grid-cols-5' : 'grid-cols-3')}> - {stats.map(([Icon, value, label]) => ( + {stats.map(([Icon, value, label, key, count]) => (
    -
    {value}
    +
    + 0 ? {key} : null} title={label} + label={`${label}: ${value}`}>{value} +
    {label}
    ))} @@ -225,7 +232,10 @@ function StatusBar({byStatus}) {
  • {t(`BACKLOG.STATUS.${s}`)} - {byStatus[s] ?? 0} + 0 ? {key: 'STATUS', arg: s} : null} title={t(`BACKLOG.STATUS.${s}`)} + label={`${t(`BACKLOG.STATUS.${s}`)}: ${byStatus[s] ?? 0}`}> + {byStatus[s] ?? 0} +
  • ))} diff --git a/src/pages/PublicProfilePage.test.jsx b/src/pages/PublicProfilePage.test.jsx index 2c6adde..e89c2d2 100644 --- a/src/pages/PublicProfilePage.test.jsx +++ b/src/pages/PublicProfilePage.test.jsx @@ -15,12 +15,13 @@ const library = { }; // `me` only for a test with a session: without one the page asks nothing about the viewer. -function mockBackends({name = 'Lobo', profileStatus = 200, libraryStatus = 200, style, answer = library, me, equipped = {}, rewardsStatus = 200} = {}) { +function mockBackends({name = 'Lobo', profileStatus = 200, libraryStatus = 200, style, answer = library, me, equipped = {}, rewardsStatus = 200, extra = {}} = {}) { fetchRoutes({ [`GET /api/public/profiles/${encodeURIComponent(name)}`]: json(profileStatus, {displayName: 'Lobo', memberSince: '2026-03-02', ticket: 'the-ticket', style}), 'GET /api/public/profiles/library': json(libraryStatus, answer), 'GET /api/public/profiles/rewards': json(rewardsStatus, {equipped}), + ...extra, ...(me && { 'GET /api/users/me': json(200, me), 'PUT /api/users/settings/profile-tiles': json(204, {}), @@ -260,4 +261,33 @@ describe('PublicProfilePage', () => { await waitFor(() => expect(screen.getByText('PROFILE.ERROR_LOAD')).toBeTruthy()); }); + + it('opensTheGamesBehindAStatusCountWithTheTicket', async () => { + mockBackends({extra: {'GET /api/public/profiles/library/stats/games': json(200, { + content: [{appid: 1, name: 'Backlogged', status: 'BACKLOG', playtimeMinutes: 0}], page: 0, size: 40, totalElements: 1, totalPages: 1})}}); + const {container} = renderPage(); + + fireEvent.click(await screen.findByRole('button', {name: 'BACKLOG.STATUS.BACKLOG: 142'})); + + expect(await screen.findByText('Backlogged')).toBeTruthy(); + const call = fetch.mock.calls.find(([url]) => url.includes('/stats/games')); + expect(call[0]).toContain('key=STATUS&arg=BACKLOG'); + expect(call[1].headers['X-Profile-Ticket']).toBe('the-ticket'); + expect(container.querySelector('a[href^="/library"]')).toBeNull(); + }); + + it('rendersNoButtonForAGroupTheOwnerDoesNotShare', async () => { + library.stats = {pile: null, trend: null, topGames: null, taste: null, finished: null, + habits: {moonTrips: 2.5, longestDay: null, underOneHourGames: 0, underOneHourShare: null}}; + try { + mockBackends({style: {tiles: [{id: 'STATS_HABITS', size: 'M'}, {id: 'STATS_PILE', size: 'M'}]}}); + renderPage(); + + await screen.findByText('STATS.HABITS.TITLE'); + + expect(screen.queryByRole('button', {name: /STATS\.PILE/})).toBeNull(); + } finally { + delete library.stats; + } + }); }); diff --git a/src/pages/StatsPage.jsx b/src/pages/StatsPage.jsx index 7056caa..0fc3877 100644 --- a/src/pages/StatsPage.jsx +++ b/src/pages/StatsPage.jsx @@ -12,6 +12,7 @@ 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 {StatDrillProvider} from '../components/stats/StatDrill.jsx'; import useUserStats from '../hooks/useUserStats.js'; const ALL_GROUPS = []; @@ -21,38 +22,40 @@ export default function StatsPage() { const {stats, failed, reload} = useUserStats(ALL_GROUPS); return ( - -
    -

    {t('STATS.TITLE')}

    -

    {t('STATS.SUBTITLE')}

    -
    - - {failed && ( -
    - {t('STATS.ERROR_LOAD')} - + + +
    +

    {t('STATS.TITLE')}

    +

    {t('STATS.SUBTITLE')}

    - )} - {!stats && !failed && ( -
    - - -
    - )} + {failed && ( +
    + {t('STATS.ERROR_LOAD')} + +
    + )} - {stats && ( -
    -
    - - - - - - - -
    - )} -
    + {!stats && !failed && ( +
    + + +
    + )} + + {stats && ( +
    +
    + + + + + + + +
    + )} + +
    ); } diff --git a/src/pages/StatsPage.test.jsx b/src/pages/StatsPage.test.jsx index d6c2232..53fbcae 100644 --- a/src/pages/StatsPage.test.jsx +++ b/src/pages/StatsPage.test.jsx @@ -89,4 +89,36 @@ describe('StatsPage', () => { await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); }); + + it('opensTheGamesBehindARowWithTheRawName', async () => { + fetchRoutes({ + 'GET /api/library/stats': json(200, stats()), + 'GET /api/library/stats/games': json(200, {content: [{appid: 620, name: 'Portal 2', status: 'BACKLOG', playtimeMinutes: 60}], + page: 0, size: 40, totalElements: 1, totalPages: 1}), + }); + renderPage(); + + fireEvent.click(await screen.findByRole('button', {name: /STATS\.GENRE/})); + + expect(await screen.findByRole('dialog')).toBeTruthy(); + await waitFor(() => expect(fetch.mock.calls.at(-1)[0]).toMatch(/stats\/games\?key=GENRE&arg=Shooter&page=0$/)); + }); + + it('linksAStatusCountToTheFilteredLibrary', async () => { + answerStats(json(200, stats())); + renderPage(); + + const link = await screen.findByRole('link', {name: 'BACKLOG.STATUS.BACKLOG: 5'}); + + expect(link.getAttribute('href')).toBe('/library?hideDuplicates=true&status=BACKLOG'); + }); + + it('leavesTheMoonTripsAlone', async () => { + answerStats(json(200, stats())); + renderPage(); + + await screen.findByText('STATS.HABITS.MOON'); + + expect(screen.queryByRole('button', {name: /STATS.HABITS.MOON/})).toBeNull(); + }); }); diff --git a/src/utils/statGames.js b/src/utils/statGames.js new file mode 100644 index 0000000..c777ae9 --- /dev/null +++ b/src/utils/statGames.js @@ -0,0 +1,54 @@ +import {request} from './api.js'; + +const CORE = () => import.meta.env.VITE_CORE_API_URL; + +const query = (params) => params + .filter(([, value]) => value != null && value !== '') + .map(([name, value]) => `${name}=${encodeURIComponent(value)}`) + .join('&'); + +/** + * The URL of one page of the games behind a number. + * + * @param source own, profile, community or points + * @param track points: the unlock track of a locked bar + * @param kind points: the history kind (MONTH or STREAK) with its `ref` + */ +export function statGamesUrl({source, key, arg, page = 0, track, kind, ref}) { + const base = `${CORE()}/api`; + if (source === 'points') { + const path = track + ? `progress/${encodeURIComponent(track)}` + : `history/${encodeURIComponent(kind)}/${encodeURIComponent(ref)}`; + return `${base}/rewards/${path}/games?${query([['page', page]])}`; + } + const path = { + own: 'library/stats/games', + profile: 'public/profiles/library/stats/games', + community: 'stats/public/games', + }[source]; + return `${base}/${path}?${query([['key', key], ['arg', arg], ['page', page]])}`; +} + +/** + * `{ok, status, data}` like `request`. The profile and the community list are public: no login, no refresh. + */ +export async function fetchStatGames(source, params, ticket) { + const url = statGamesUrl({source, ...params}); + if (source !== 'profile' && source !== 'community') return request(url); + try { + const response = await fetch(url, source === 'profile' ? {headers: {'X-Profile-Ticket': ticket}} : undefined); + return {ok: response.ok, status: response.status, data: response.ok ? await response.json() : null}; + } catch (err) { + console.error(`Request to ${url} failed:`, err); + return {ok: false, status: 0, data: null}; + } +} + +export const libraryLink = (status) => `/library?hideDuplicates=true${status ? `&status=${status}` : ''}`; + +// A visitor has no game page of the owner; the Steam store is the only public one. Manual games have no appid there. +export const gameHref = (appid, own) => { + if (own) return `/library/${appid}`; + return appid > 0 ? `https://store.steampowered.com/app/${appid}` : null; +}; diff --git a/src/utils/statGames.test.js b/src/utils/statGames.test.js new file mode 100644 index 0000000..fa8941a --- /dev/null +++ b/src/utils/statGames.test.js @@ -0,0 +1,35 @@ +import {describe, expect, it} from 'vitest'; +import {gameHref, libraryLink, statGamesUrl} from './statGames.js'; + +describe('statGamesUrl', () => { + it('own_EncodesTheArgAndPage', () => { + expect(statGamesUrl({source: 'own', key: 'GENRE', arg: 'Role-Playing & Co', page: 2})) + .toMatch(/\/api\/library\/stats\/games\?key=GENRE&arg=Role-Playing%20%26%20Co&page=2$/); + }); + + it('profile_And_Community_UseTheirPaths', () => { + expect(statGamesUrl({source: 'profile', key: 'PILE'})).toMatch(/\/api\/public\/profiles\/library\/stats\/games\?key=PILE&page=0$/); + expect(statGamesUrl({source: 'community', key: 'OWNED'})).toMatch(/\/api\/stats\/public\/games\?key=OWNED&page=0$/); + }); + + it('points_TrackAndHistory', () => { + expect(statGamesUrl({source: 'points', track: 'GAMES_FINISHED'})).toMatch(/\/api\/rewards\/progress\/GAMES_FINISHED\/games\?page=0$/); + expect(statGamesUrl({source: 'points', kind: 'MONTH', ref: '2026-09', page: 1})) + .toMatch(/\/api\/rewards\/history\/MONTH\/2026-09\/games\?page=1$/); + }); +}); + +describe('libraryLink', () => { + it('addsTheStatusOnlyWhenGiven', () => { + expect(libraryLink()).toBe('/library?hideDuplicates=true'); + expect(libraryLink('BACKLOG')).toBe('/library?hideDuplicates=true&status=BACKLOG'); + }); +}); + +describe('gameHref', () => { + it('own_LinksTheLibraryPage', () => expect(gameHref(5, true)).toBe('/library/5')); + it('visitor_LinksTheStoreOnlyForSteamApps', () => { + expect(gameHref(5, false)).toBe('https://store.steampowered.com/app/5'); + expect(gameHref(-3, false)).toBeNull(); + }); +});