From 9d1c7bb40d67bfb53dc2765fa50c4637af8d8f3c Mon Sep 17 00:00:00 2001 From: lobo Date: Sun, 27 Sep 2026 07:16:10 +0200 Subject: [PATCH] feat(stats): add stats page and pile trend Show the stat groups on /stats, the pile trend on the dashboard card, one switch per group in the settings, and the shared groups on the public profile. --- README.md | 7 +- public/i18n/de.json | 91 ++++++++++++++++++- public/i18n/en.json | 91 ++++++++++++++++++- src/App.jsx | 2 + src/components/ActivityCard.jsx | 5 +- src/components/AppHeader.jsx | 3 +- src/components/PileOfShameCard.jsx | 8 +- src/components/PileOfShameCard.test.jsx | 15 +++ src/components/stats/FinishedStats.jsx | 34 +++++++ src/components/stats/GameRow.jsx | 22 +++++ src/components/stats/HabitsStats.jsx | 31 +++++++ src/components/stats/OverviewStats.jsx | 30 ++++++ src/components/stats/PileStats.jsx | 44 +++++++++ src/components/stats/PileTrend.jsx | 38 ++++++++ src/components/stats/ProfileStatsSettings.jsx | 40 ++++++++ src/components/stats/StatTile.jsx | 9 ++ src/components/stats/StatsSection.jsx | 13 +++ src/components/stats/TasteStats.jsx | 55 +++++++++++ src/components/stats/TopGamesStats.jsx | 30 ++++++ src/components/stats/TrendStats.jsx | 14 +++ src/hooks/useUserStats.js | 39 ++++++++ src/pages/Dashboard.jsx | 9 +- src/pages/PublicProfilePage.jsx | 22 +++++ src/pages/PublicProfilePage.test.jsx | 12 +++ src/pages/SettingsPage.jsx | 31 +++++++ src/pages/SettingsPage.test.jsx | 51 +++++++++++ src/pages/StatsPage.jsx | 67 ++++++++++++++ src/pages/StatsPage.test.jsx | 87 ++++++++++++++++++ src/utils/games.js | 4 + src/utils/stats.js | 52 +++++++++++ src/utils/stats.test.js | 33 +++++++ 31 files changed, 978 insertions(+), 11 deletions(-) create mode 100644 src/components/stats/FinishedStats.jsx create mode 100644 src/components/stats/GameRow.jsx create mode 100644 src/components/stats/HabitsStats.jsx create mode 100644 src/components/stats/OverviewStats.jsx create mode 100644 src/components/stats/PileStats.jsx create mode 100644 src/components/stats/PileTrend.jsx create mode 100644 src/components/stats/ProfileStatsSettings.jsx create mode 100644 src/components/stats/StatTile.jsx create mode 100644 src/components/stats/StatsSection.jsx create mode 100644 src/components/stats/TasteStats.jsx create mode 100644 src/components/stats/TopGamesStats.jsx create mode 100644 src/components/stats/TrendStats.jsx create mode 100644 src/hooks/useUserStats.js create mode 100644 src/pages/StatsPage.jsx create mode 100644 src/pages/StatsPage.test.jsx create mode 100644 src/utils/stats.js create mode 100644 src/utils/stats.test.js diff --git a/README.md b/README.md index ca14e24..56bbd3a 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,16 @@ To Be Played is split into three repositories: ## Features -- **Dashboard**: most played games, games in progress, "Pile of Shame" with backlog hours and a random suggestion, +- **Dashboard**: most played games, games in progress, "Pile of Shame" with backlog hours, its trend of the last 90 + days ("this month: +N added, -N off the pile") and a random suggestion, Steam avatar and a banner from the most played game of the last two weeks, and an achievement card: unlocked total, perfect games, average completion and the 5 games closest to 100 %. - **What to play next** (`/play-next`): three backlog picks for a time budget (up to 5 / 15 / 40 h or any) and a mood (story, co-op, chill, action, scary from IGDB; speedrun from speedrun.com), high priority first, reroll without 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. - **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, @@ -43,6 +47,7 @@ To Be Played is split into three repositories: the proved Steam ID, never a typed-in one. A Steam ID typed in before shows as "not verified" with a button to verify it; unlinking is possible. - **Public profile** at `/u/`, opt-in in the settings, with perfect games and average completion. + The user picks in the settings which stat groups of "Your stats" it shows (none by default). - **Appearance**: accent color, corner style, wide layout, page background and banner from your own game art. Saved in Auth, so it follows you to every device. - **Broken pictures**: a Steam cover or achievement icon that does not load shows a placeholder and is reported diff --git a/public/i18n/de.json b/public/i18n/de.json index 4279c50..adbe848 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -34,6 +34,9 @@ "ERROR_TOO_MANY_ATTEMPTS": "Zu viele fehlgeschlagene Versuche. Bitte in {{minutes}} Minuten erneut versuchen." }, "SETTINGS": { + "PROFILE_STATS": "Statistik im öffentlichen Profil", + "PROFILE_STATS_HINT": "Wähle, was Besucher sehen. Deine Summen und Status sind immer da.", + "PROFILE_STATS_NEEDS_PUBLIC": "Mach dein Profil zuerst öffentlich.", "TITLE": "Einstellungen", "LOADING": "Einstellungen werden geladen …", "ACCOUNT": "Konto", @@ -257,7 +260,8 @@ "LANGUAGE": "Sprache", "DARK_MODE": "Dunkles Design", "LIGHT_MODE": "Helles Design", - "PLAY_NEXT": "Nächstes Spiel" + "PLAY_NEXT": "Nächstes Spiel", + "STATS": "Statistik" }, "BACKLOG": { "TITLE": "Mein Backlog", @@ -369,5 +373,90 @@ "REMOVE_NO": "Abbrechen", "REMOVED": "Spiel entfernt.", "NO_ACHIEVEMENTS": "Erfolge gibt es nur für Steam-Spiele." + }, + "STATS": { + "TITLE": "Deine Statistik", + "SUBTITLE": "Zahlen zu deiner Bibliothek. Ausgeblendete Spiele zählen nicht.", + "ERROR_LOAD": "Deine Statistik konnte nicht geladen werden.", + "OVERVIEW": { + "TITLE": "Überblick", + "GAMES": "Spiele", + "PLAYTIME": "Spielzeit gesamt", + "PLAYED": "Angefangen", + "NEVER_STARTED": "Nie gestartet" + }, + "PILE": { + "TITLE": "Pile of Shame", + "GAMES": "Spiele auf dem Stapel", + "NORMAL": "Zum Durchspielen", + "COMPLETIONIST": "Für 100 %", + "KNOWN_one": "IGDB kennt {{count}} davon", + "KNOWN_other": "IGDB kennt {{count}} davon", + "PACE": "In deinem Tempo ({{perWeek}} pro Woche in den letzten 90 Tagen) sind das {{weeks}} Wochen.", + "PACE_NONE": "Keine Spielzeit in den letzten 90 Tagen, daher noch kein Tempo.", + "WORK_YEARS": "Das sind mindestens {{years}} Jahre Vollzeitarbeit (40 Stunden pro Woche).", + "OLDEST": "Ältestes ungespieltes Spiel", + "SINCE": "seit {{date}}" + }, + "TREND": { + "TITLE": "Stapel-Verlauf", + "THIS_MONTH": "Diesen Monat: +{{added}} dazu, −{{done}} vom Stapel", + "STARTS": "Der Verlauf beginnt heute. Morgen gibt es die erste Linie." + }, + "TOP_GAMES": { + "TITLE": "Top-Spiele", + "EMPTY": "Noch keine Spielzeit.", + "LOYAL": "Treuestes Spiel", + "MONTHS_one": "in {{count}} Monat gespielt", + "MONTHS_other": "in {{count}} Monaten gespielt" + }, + "TASTE": { + "TITLE": "Dein Geschmack", + "GENRES": "Genres", + "MOODS": "Stimmungen", + "GAMES_one": "{{count}} Spiel", + "GAMES_other": "{{count}} Spiele", + "EMPTY": "Noch keine Genres. Sie kommen von IGDB und füllen sich in den nächsten Stunden." + }, + "HABITS": { + "TITLE": "Fun Facts", + "MOON": "Flüge zum Mond und zurück", + "MOON_HINT": "Apollo 11 brauchte 195 Stunden.", + "LONGEST_DAY": "Längster Spieltag", + "UNDER_ONE_HOUR": "Angefangen, aber unter 1 Stunde", + "UNDER_ONE_HOUR_HINT_one": "{{count}} Spiel", + "UNDER_ONE_HOUR_HINT_other": "{{count}} Spiele" + }, + "FINISHED": { + "TITLE": "Durchgespielt pro Jahr", + "EMPTY": "Seit Beginn des Verlaufs noch kein Spiel durchgespielt.", + "BEFORE_one": "{{count}} weiteres Spiel wurde vor Beginn des Verlaufs durchgespielt.", + "BEFORE_other": "{{count}} weitere Spiele wurden vor Beginn des Verlaufs durchgespielt." + }, + "GENRE": { + "ADVENTURE": "Adventure", + "ARCADE": "Arcade", + "CARD_BOARD_GAME": "Karten- & Brettspiel", + "FIGHTING": "Beat 'em up", + "HACK_AND_SLASH_BEAT_EM_UP": "Hack and Slash", + "INDIE": "Indie", + "MOBA": "MOBA", + "MUSIC": "Musik", + "PINBALL": "Flipper", + "PLATFORM": "Jump 'n' Run", + "POINT_AND_CLICK": "Point & Click", + "PUZZLE": "Rätsel", + "QUIZ_TRIVIA": "Quiz", + "RACING": "Rennspiel", + "REAL_TIME_STRATEGY_RTS": "Echtzeitstrategie", + "ROLE_PLAYING_RPG": "Rollenspiel", + "SHOOTER": "Shooter", + "SIMULATOR": "Simulation", + "SPORT": "Sport", + "STRATEGY": "Strategie", + "TACTICAL": "Taktik", + "TURN_BASED_STRATEGY_TBS": "Rundenstrategie", + "VISUAL_NOVEL": "Visual Novel" + } } } diff --git a/public/i18n/en.json b/public/i18n/en.json index 9c25ffe..30b34aa 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -34,6 +34,9 @@ "ERROR_TOO_MANY_ATTEMPTS": "Too many failed attempts. Please try again in {{minutes}} minutes." }, "SETTINGS": { + "PROFILE_STATS": "Stats on the public profile", + "PROFILE_STATS_HINT": "Pick what visitors see. Your totals and statuses are always there.", + "PROFILE_STATS_NEEDS_PUBLIC": "Make your profile public first.", "TITLE": "Settings", "LOADING": "Loading settings …", "ACCOUNT": "Account", @@ -257,7 +260,8 @@ "LANGUAGE": "Language", "DARK_MODE": "Dark mode", "LIGHT_MODE": "Light mode", - "PLAY_NEXT": "Play next" + "PLAY_NEXT": "Play next", + "STATS": "Stats" }, "BACKLOG": { "TITLE": "My backlog", @@ -369,5 +373,90 @@ "REMOVE_NO": "Cancel", "REMOVED": "Game removed.", "NO_ACHIEVEMENTS": "Achievements exist only for Steam games." + }, + "STATS": { + "TITLE": "Your stats", + "SUBTITLE": "Numbers about your library. Hidden games do not count.", + "ERROR_LOAD": "Could not load your stats.", + "OVERVIEW": { + "TITLE": "Overview", + "GAMES": "Games", + "PLAYTIME": "Total playtime", + "PLAYED": "Started", + "NEVER_STARTED": "Never started" + }, + "PILE": { + "TITLE": "Pile of Shame", + "GAMES": "Games on the pile", + "NORMAL": "To finish them", + "COMPLETIONIST": "To 100 % them", + "KNOWN_one": "IGDB knows {{count}} of them", + "KNOWN_other": "IGDB knows {{count}} of them", + "PACE": "At your pace ({{perWeek}} a week in the last 90 days) that is {{weeks}} weeks.", + "PACE_NONE": "No playtime in the last 90 days, so no pace yet.", + "WORK_YEARS": "That is at least {{years}} years of full-time work (40 hours a week).", + "OLDEST": "Oldest unplayed game", + "SINCE": "since {{date}}" + }, + "TREND": { + "TITLE": "Pile trend", + "THIS_MONTH": "This month: +{{added}} added, −{{done}} off the pile", + "STARTS": "The trend starts today. Come back tomorrow for the first line." + }, + "TOP_GAMES": { + "TITLE": "Top games", + "EMPTY": "No playtime yet.", + "LOYAL": "Most loyal game", + "MONTHS_one": "played in {{count}} month", + "MONTHS_other": "played in {{count}} months" + }, + "TASTE": { + "TITLE": "Your taste", + "GENRES": "Genres", + "MOODS": "Moods", + "GAMES_one": "{{count}} game", + "GAMES_other": "{{count}} games", + "EMPTY": "No genres yet. They come from IGDB and fill in over the next hours." + }, + "HABITS": { + "TITLE": "Fun facts", + "MOON": "Trips to the moon and back", + "MOON_HINT": "Apollo 11 took 195 hours.", + "LONGEST_DAY": "Longest gaming day", + "UNDER_ONE_HOUR": "Started, but under 1 hour", + "UNDER_ONE_HOUR_HINT_one": "{{count}} game", + "UNDER_ONE_HOUR_HINT_other": "{{count}} games" + }, + "FINISHED": { + "TITLE": "Finished per year", + "EMPTY": "No finished game since the history started.", + "BEFORE_one": "{{count}} more game was finished before the history started.", + "BEFORE_other": "{{count}} more games were finished before the history started." + }, + "GENRE": { + "ADVENTURE": "Adventure", + "ARCADE": "Arcade", + "CARD_BOARD_GAME": "Card & board game", + "FIGHTING": "Fighting", + "HACK_AND_SLASH_BEAT_EM_UP": "Hack and slash", + "INDIE": "Indie", + "MOBA": "MOBA", + "MUSIC": "Music", + "PINBALL": "Pinball", + "PLATFORM": "Platformer", + "POINT_AND_CLICK": "Point-and-click", + "PUZZLE": "Puzzle", + "QUIZ_TRIVIA": "Quiz", + "RACING": "Racing", + "REAL_TIME_STRATEGY_RTS": "Real-time strategy", + "ROLE_PLAYING_RPG": "Role-playing", + "SHOOTER": "Shooter", + "SIMULATOR": "Simulation", + "SPORT": "Sports", + "STRATEGY": "Strategy", + "TACTICAL": "Tactics", + "TURN_BASED_STRATEGY_TBS": "Turn-based strategy", + "VISUAL_NOVEL": "Visual novel" + } } } diff --git a/src/App.jsx b/src/App.jsx index 53a94eb..a015e00 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -12,6 +12,7 @@ import GameLibrary from './pages/GameLibrary.jsx'; import GameDetail from './pages/GameDetail.jsx'; import WishlistPage from './pages/WishlistPage.jsx'; import PlayNextPage from './pages/PlayNextPage.jsx'; +import StatsPage from './pages/StatsPage.jsx'; import PublicProfilePage from './pages/PublicProfilePage.jsx'; import AppHeader from './components/AppHeader.jsx'; import {Toaster} from '@/components/ui/sonner'; @@ -67,6 +68,7 @@ function App() { }/> }/> }/> + }/> }/> }/> }/> diff --git a/src/components/ActivityCard.jsx b/src/components/ActivityCard.jsx index d5c3125..f885b72 100644 --- a/src/components/ActivityCard.jsx +++ b/src/components/ActivityCard.jsx @@ -4,15 +4,12 @@ import {Activity} from 'lucide-react'; import {Card} from '@/components/ui/card'; import GameCover from './GameCover.jsx'; -import {formatPlaytime} from '../utils/games.js'; +import {formatDay, formatPlaytime} from '../utils/games.js'; const CHART_HEIGHT = 80; const BAR_WIDTH = 10; const BAR_GAP = 2; -const formatDay = (day, locale) => - new Date(`${day}T12:00:00`).toLocaleDateString(locale, {day: 'numeric', month: 'short'}); - /** * @param activity the answer of GET /api/library/activity */ diff --git a/src/components/AppHeader.jsx b/src/components/AppHeader.jsx index 1d51893..1c6e228 100644 --- a/src/components/AppHeader.jsx +++ b/src/components/AppHeader.jsx @@ -1,6 +1,6 @@ import {Link, NavLink, useLocation} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; -import {Dices, Languages, LayoutDashboard, Library, LogOut, Moon, Settings, Sun} from 'lucide-react'; +import {ChartColumn, Dices, Languages, LayoutDashboard, Library, LogOut, Moon, Settings, Sun} from 'lucide-react'; import {Button} from '@/components/ui/button'; import { @@ -22,6 +22,7 @@ const NAV_ITEMS = [ // The wishlist is a tab of the library. {to: '/library', labelKey: 'NAV.LIBRARY', Icon: Library, alsoActiveOn: ['/wishlist']}, {to: '/play-next', labelKey: 'NAV.PLAY_NEXT', Icon: Dices}, + {to: '/stats', labelKey: 'NAV.STATS', Icon: ChartColumn}, ]; const navLinkClass = (isActive) => cn( diff --git a/src/components/PileOfShameCard.jsx b/src/components/PileOfShameCard.jsx index 722bfca..ae36641 100644 --- a/src/components/PileOfShameCard.jsx +++ b/src/components/PileOfShameCard.jsx @@ -5,11 +5,15 @@ import {Dices, Inbox, Shuffle} from 'lucide-react'; import {Button} from '@/components/ui/button'; import {Card} from '@/components/ui/card'; import GameCover from './GameCover.jsx'; +import PileTrend from './stats/PileTrend.jsx'; import {STATUS_META} from './GameStatusBadge.jsx'; import {backlogShare, GAME_STATUSES} from '../utils/backlog.js'; import {formatPlaytime} from '../utils/games.js'; -export default function PileOfShameCard({summary, syncing, onShuffle, shuffling}) { +/** + * @param trend the TREND group of GET /api/library/stats; the card shows no trend while it is missing + */ +export default function PileOfShameCard({summary, trend, syncing, onShuffle, shuffling}) { const {t, i18n} = useTranslation(); const backlog = summary.byStatus?.BACKLOG ?? 0; const suggestion = summary.suggestion; @@ -44,6 +48,8 @@ export default function PileOfShameCard({summary, syncing, onShuffle, shuffling} )} + {trend && } + {summary.total > 0 && (
diff --git a/src/components/PileOfShameCard.test.jsx b/src/components/PileOfShameCard.test.jsx index 0c38979..ee8d1bf 100644 --- a/src/components/PileOfShameCard.test.jsx +++ b/src/components/PileOfShameCard.test.jsx @@ -42,6 +42,21 @@ describe('PileOfShameCard', () => { expect(onShuffle).toHaveBeenCalledOnce(); }); + it('shows the pile trend and the moves of this month once they are loaded', () => { + const trend = {days: [{day: '2026-09-26', backlogGames: 3}, {day: '2026-09-27', backlogGames: 2}], + thisMonth: {added: 1, done: 2}}; + renderCard({summary: summary(2), trend}); + + expect(screen.getByText('STATS.TREND.THIS_MONTH')).toBeTruthy(); + expect(screen.getByTestId('pile-trend')).toBeTruthy(); + }); + + it('shows no trend while it is not loaded', () => { + renderCard({summary: summary(2)}); + + expect(screen.queryByText('STATS.TREND.THIS_MONTH')).toBeNull(); + }); + it('says the backlog is empty when there is no suggestion', () => { renderCard({summary: summary(0)}); diff --git a/src/components/stats/FinishedStats.jsx b/src/components/stats/FinishedStats.jsx new file mode 100644 index 0000000..06dab23 --- /dev/null +++ b/src/components/stats/FinishedStats.jsx @@ -0,0 +1,34 @@ +import {useTranslation} from 'react-i18next'; +import {Flag} from 'lucide-react'; + +import StatsSection from './StatsSection.jsx'; + +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.perYear.map((y) => ( +
  • + {y.year} +
    +
    +
    + {y.games} +
  • + ))} +
+ )} + {finished.finishedBeforeTracking > 0 && ( +

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

+ )} +
+ ); +} diff --git a/src/components/stats/GameRow.jsx b/src/components/stats/GameRow.jsx new file mode 100644 index 0000000..0be9bbf --- /dev/null +++ b/src/components/stats/GameRow.jsx @@ -0,0 +1,22 @@ +import {Link} from 'react-router-dom'; +import GameCover from '../GameCover.jsx'; + +/** + * A game with its cover and a value on the right; links to the game page only in the user's own library. + */ +export default function GameRow({game, value, linked = true}) { + const content = ( + <> + + {game.name ?? `App ${game.appid}`} + {value} + + ); + return linked ? ( + + {content} + + ) : ( +
{content}
+ ); +} diff --git a/src/components/stats/HabitsStats.jsx b/src/components/stats/HabitsStats.jsx new file mode 100644 index 0000000..64d21e0 --- /dev/null +++ b/src/components/stats/HabitsStats.jsx @@ -0,0 +1,31 @@ +import {useTranslation} from 'react-i18next'; +import {Rocket} from 'lucide-react'; + +import StatsSection from './StatsSection.jsx'; +import StatTile from './StatTile.jsx'; +import {formatPlaytime} from '../../utils/games.js'; +import {formatDecimal, formatPercent} from '../../utils/stats.js'; + +export default function HabitsStats({habits}) { + const {t, i18n} = useTranslation(); + const day = habits.longestDay + ? new Date(`${habits.longestDay.day}T12:00:00`).toLocaleDateString(i18n.language, {dateStyle: 'medium'}) + : null; + return ( + +
+ + {habits.longestDay && ( + + )} + {habits.underOneHourShare != null && ( + + )} +
+
+ ); +} diff --git a/src/components/stats/OverviewStats.jsx b/src/components/stats/OverviewStats.jsx new file mode 100644 index 0000000..ad73173 --- /dev/null +++ b/src/components/stats/OverviewStats.jsx @@ -0,0 +1,30 @@ +import {useTranslation} from 'react-i18next'; +import {Gamepad2} from 'lucide-react'; + +import StatsSection from './StatsSection.jsx'; +import StatTile from './StatTile.jsx'; +import {GAME_STATUSES} from '../../utils/backlog.js'; +import {formatPlaytime} from '../../utils/games.js'; + +export default function OverviewStats({overview}) { + const {t, i18n} = useTranslation(); + const n = (value) => value.toLocaleString(i18n.language); + return ( + +
+ + + + +
+
    + {GAME_STATUSES.map((s) => ( +
  • + {t(`BACKLOG.STATUS.${s}`)}{' '} + {n(overview.byStatus[s] ?? 0)} +
  • + ))} +
+
+ ); +} diff --git a/src/components/stats/PileStats.jsx b/src/components/stats/PileStats.jsx new file mode 100644 index 0000000..f25e941 --- /dev/null +++ b/src/components/stats/PileStats.jsx @@ -0,0 +1,44 @@ +import {useTranslation} from 'react-i18next'; +import {Inbox} from 'lucide-react'; + +import StatsSection from './StatsSection.jsx'; +import StatTile from './StatTile.jsx'; +import GameRow from './GameRow.jsx'; +import {formatPlaytime} from '../../utils/games.js'; +import {formatDecimal} from '../../utils/stats.js'; + +export default function PileStats({pile, linked = true}) { + const {t, i18n} = useTranslation(); + const since = pile.oldestUnplayed + ? new Date(pile.oldestUnplayed.inLibrarySince).toLocaleDateString(i18n.language, {dateStyle: 'medium'}) + : null; + return ( + +
+ + + +
+ {pile.normalMinutes > 0 && ( +
    +
  • + {pile.paceWeeks == null + ? t('STATS.PILE.PACE_NONE') + : t('STATS.PILE.PACE', { + weeks: formatDecimal(pile.paceWeeks, i18n.language), + perWeek: formatPlaytime(pile.minutesPerWeek), + })} +
  • +
  • {t('STATS.PILE.WORK_YEARS', {years: formatDecimal(pile.workYears, i18n.language, 2)})}
  • +
+ )} + {pile.oldestUnplayed && ( +
+ {t('STATS.PILE.OLDEST')} + +
+ )} +
+ ); +} diff --git a/src/components/stats/PileTrend.jsx b/src/components/stats/PileTrend.jsx new file mode 100644 index 0000000..578444c --- /dev/null +++ b/src/components/stats/PileTrend.jsx @@ -0,0 +1,38 @@ +import {useTranslation} from 'react-i18next'; +import {formatDay} from '../../utils/games.js'; +import {trendPoints} from '../../utils/stats.js'; + +const WIDTH = 300; +const HEIGHT = 60; + +/** + * The pile size over the last 90 days and the moves of this month. + * + * @param trend the TREND group of GET /api/library/stats + */ +export default function PileTrend({trend}) { + const {t, i18n} = useTranslation(); + const {days, thisMonth} = trend; + return ( +
+

+ {t('STATS.TREND.THIS_MONTH', {added: thisMonth.added, 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} +
+
+ ) : ( +

{t('STATS.TREND.STARTS')}

+ )} +
+ ); +} diff --git a/src/components/stats/ProfileStatsSettings.jsx b/src/components/stats/ProfileStatsSettings.jsx new file mode 100644 index 0000000..1d5ffd4 --- /dev/null +++ b/src/components/stats/ProfileStatsSettings.jsx @@ -0,0 +1,40 @@ +import {useTranslation} from 'react-i18next'; + +import {Label} from '@/components/ui/label'; +import {Switch} from '@/components/ui/switch'; +import {PROFILE_STAT_GROUPS} from '../../utils/stats.js'; + +/** + * One switch per stat group the public profile can show. + * + * @param disabled the profile is private + * @param saving a save runs; the switches wait for it + * @param onChange gets the whole new list of groups + */ +export default function ProfileStatsSettings({selected, disabled, saving, onChange}) { + const {t} = useTranslation(); + const toggle = (group) => onChange(selected.includes(group) + ? selected.filter((g) => g !== group) + : PROFILE_STAT_GROUPS.filter((g) => g === group || selected.includes(g))); + + return ( +
+
+ {t('SETTINGS.PROFILE_STATS')} +

+ {disabled ? t('SETTINGS.PROFILE_STATS_NEEDS_PUBLIC') : t('SETTINGS.PROFILE_STATS_HINT')} +

+
+
    + {PROFILE_STAT_GROUPS.map((group) => ( +
  • + + toggle(group)}/> +
  • + ))} +
+
+ ); +} diff --git a/src/components/stats/StatTile.jsx b/src/components/stats/StatTile.jsx new file mode 100644 index 0000000..a7a9639 --- /dev/null +++ b/src/components/stats/StatTile.jsx @@ -0,0 +1,9 @@ +export default function StatTile({label, value, hint}) { + return ( +
+
{value}
+
{label}
+ {hint &&
{hint}
} +
+ ); +} diff --git a/src/components/stats/StatsSection.jsx b/src/components/stats/StatsSection.jsx new file mode 100644 index 0000000..eff92e4 --- /dev/null +++ b/src/components/stats/StatsSection.jsx @@ -0,0 +1,13 @@ +import {Card} from '@/components/ui/card'; + +export default function StatsSection({title, icon: Icon, children}) { + return ( + +

+ + {title} +

+ {children} +
+ ); +} diff --git a/src/components/stats/TasteStats.jsx b/src/components/stats/TasteStats.jsx new file mode 100644 index 0000000..0f9781e --- /dev/null +++ b/src/components/stats/TasteStats.jsx @@ -0,0 +1,55 @@ +import {useTranslation} from 'react-i18next'; +import {Palette} from 'lucide-react'; + +import StatsSection from './StatsSection.jsx'; +import {formatPlaytime} from '../../utils/games.js'; +import {genreKey} from '../../utils/stats.js'; + +function TagBars({tags, label}) { + 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})} + +
    +
    +
    +
    +
  • + ))} +
+ ); +} + +export default function TasteStats({taste}) { + const {t} = useTranslation(); + if (taste.genres.length === 0 && taste.moods.length === 0) { + return ( + +

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

+
+ ); + } + return ( + +
+
+ {t('STATS.TASTE.GENRES')} + t(`STATS.GENRE.${genreKey(name)}`, {defaultValue: name})}/> +
+
+ {t('STATS.TASTE.MOODS')} + t(`PLAY_NEXT.MOOD.${name}`, {defaultValue: name})}/> +
+
+
+ ); +} diff --git a/src/components/stats/TopGamesStats.jsx b/src/components/stats/TopGamesStats.jsx new file mode 100644 index 0000000..f029658 --- /dev/null +++ b/src/components/stats/TopGamesStats.jsx @@ -0,0 +1,30 @@ +import {useTranslation} from 'react-i18next'; +import {Trophy} from 'lucide-react'; + +import StatsSection from './StatsSection.jsx'; +import GameRow from './GameRow.jsx'; +import {formatPlaytime} from '../../utils/games.js'; + +export default function TopGamesStats({topGames, linked = true}) { + const {t} = useTranslation(); + return ( + + {topGames.mostPlayed.length === 0 ? ( +

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

+ ) : ( +
    + {topGames.mostPlayed.map((g) => ( +
  1. + ))} +
+ )} + {topGames.mostLoyal && ( +
+ {t('STATS.TOP_GAMES.LOYAL')} + +
+ )} +
+ ); +} diff --git a/src/components/stats/TrendStats.jsx b/src/components/stats/TrendStats.jsx new file mode 100644 index 0000000..1dfa954 --- /dev/null +++ b/src/components/stats/TrendStats.jsx @@ -0,0 +1,14 @@ +import {useTranslation} from 'react-i18next'; +import {TrendingDown} from 'lucide-react'; + +import StatsSection from './StatsSection.jsx'; +import PileTrend from './PileTrend.jsx'; + +export default function TrendStats({trend}) { + const {t} = useTranslation(); + return ( + + + + ); +} diff --git a/src/hooks/useUserStats.js b/src/hooks/useUserStats.js new file mode 100644 index 0000000..033dc9f --- /dev/null +++ b/src/hooks/useUserStats.js @@ -0,0 +1,39 @@ +import {useCallback, useEffect, useState} from 'react'; +import {apiFetch} from '../utils/api.js'; +import {statsUrl} from '../utils/stats.js'; + +/** + * GET /api/library/stats for the given groups (none = all). stats is null while loading and after an error. + * + * @param onUnauthorized called on a 401, e.g. useSessionExpired + */ +export default function useUserStats(groups, onUnauthorized, enabled = true) { + const [stats, setStats] = useState(null); + const [failed, setFailed] = useState(false); + const key = groups.join(','); + + const load = useCallback(async () => { + setFailed(false); + try { + const response = await apiFetch(statsUrl(key)); + if (response.status === 401) { + onUnauthorized(); + return; + } + if (!response.ok) { + setFailed(true); + return; + } + setStats(await response.json()); + } catch (err) { + console.error('Stats load error:', err); + setFailed(true); + } + }, [key, onUnauthorized]); + + useEffect(() => { + if (enabled) load(); + }, [enabled, load]); + + return {stats, failed, reload: load}; +} diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 4125ce4..bf98227 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -23,6 +23,7 @@ import PageContainer from '../components/PageContainer.jsx'; import HeroArt from '../components/HeroArt.jsx'; import SteamAvatar from '../components/SteamAvatar.jsx'; import useSteamProfile from '../hooks/useSteamProfile.js'; +import useUserStats from '../hooks/useUserStats.js'; import {cn} from '@/lib/utils'; import {usePreferences} from '../context/PreferencesContext.jsx'; import {apiFetch} from '../utils/api.js'; @@ -35,6 +36,7 @@ const PLAYING_GAMES = 4; const PLAYING_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc&status=PLAYING'; const JSON_HEADERS = {'Content-Type': 'application/json'}; +const TREND_GROUPS = ['TREND']; const Dashboard = () => { const {t} = useTranslation(); @@ -51,6 +53,8 @@ const Dashboard = () => { const [shuffling, setShuffling] = useState(false); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + const pileTrend = useUserStats(TREND_GROUPS, expireSession, hasSession()); + const reloadTrend = pileTrend.reload; const fetchSteamCount = useCallback(async () => { try { @@ -168,7 +172,8 @@ const Dashboard = () => { fetchSummary(); fetchActivity(); fetchAchievements(); - }, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]); + reloadTrend(); + }, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements, reloadTrend]); useEffect(() => { const fetchUserData = async () => { @@ -344,7 +349,7 @@ const Dashboard = () => {
{summary && ( - )} {!summary && summaryFailed && ( diff --git a/src/pages/PublicProfilePage.jsx b/src/pages/PublicProfilePage.jsx index 36c44e6..0cc1ebc 100644 --- a/src/pages/PublicProfilePage.jsx +++ b/src/pages/PublicProfilePage.jsx @@ -10,6 +10,12 @@ import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx'; import {GAME_STATUSES} from '../utils/backlog.js'; import {formatPlaytime} from '../utils/games.js'; import PageContainer from '../components/PageContainer.jsx'; +import PileStats from '../components/stats/PileStats.jsx'; +import TrendStats from '../components/stats/TrendStats.jsx'; +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'; /** * Public, no login. Auth returns a short-lived ticket for a public profile; Core returns the data for it. @@ -181,6 +187,22 @@ export default function PublicProfilePage() { )} + + {library.stats && } ); } + +// Only the groups the user shares come back; the game rows do not link, the library pages are private. +function SharedStats({stats}) { + return ( +
+ {stats.pile && } + {stats.trend && } + {stats.topGames && } + {stats.taste && } + {stats.habits && } + {stats.finished && } +
+ ); +} diff --git a/src/pages/PublicProfilePage.test.jsx b/src/pages/PublicProfilePage.test.jsx index b1ad687..0e1c0d5 100644 --- a/src/pages/PublicProfilePage.test.jsx +++ b/src/pages/PublicProfilePage.test.jsx @@ -62,6 +62,18 @@ describe('PublicProfilePage', () => { } }); + it('shows only the stat groups the user shares, without links', async () => { + library.stats = {pile: null, trend: null, topGames: null, taste: null, finished: null, + habits: {moonTrips: 2.5, longestDay: null, underOneHourGames: 0, underOneHourShare: null}}; + mockBackends(); + renderPage(); + + expect(await screen.findByText('STATS.HABITS.TITLE')).toBeTruthy(); + expect(screen.queryByText('STATS.PILE.TITLE')).toBeNull(); + expect(screen.queryByText('STATS.TOP_GAMES.TITLE')).toBeNull(); + delete library.stats; + }); + it('says the profile is unknown or private on a 404', async () => { mockBackends({profileStatus: 404}); renderPage(); diff --git a/src/pages/SettingsPage.jsx b/src/pages/SettingsPage.jsx index d95a4a0..dec56bf 100644 --- a/src/pages/SettingsPage.jsx +++ b/src/pages/SettingsPage.jsx @@ -16,12 +16,14 @@ import {Skeleton} from '@/components/ui/skeleton'; import {Switch} from '@/components/ui/switch'; import FormField from '../components/FormField.jsx'; import SteamAccountCard from '../components/SteamAccountCard.jsx'; +import ProfileStatsSettings from '../components/stats/ProfileStatsSettings.jsx'; import {settingsSchema} from '../utils/validation.js'; import {usePreferences} from '../context/PreferencesContext.jsx'; import useSessionExpired from '../hooks/useSessionExpired.js'; import useSaveAppearance from '../hooks/useSaveAppearance.js'; import {ACCENTS, CORNERS} from '../utils/appearance.js'; import {coverUrl} from '../utils/games.js'; +import {saveProfileStats} from '../utils/stats.js'; import {cn} from '@/lib/utils'; import {apiFetch, refreshSession} from '../utils/api.js'; import {hasSession, markSession} from '../utils/auth.js'; @@ -59,6 +61,7 @@ export default function SettingsPage() { const [profile, setProfile] = useState(null); const [isLoading, setIsLoading] = useState(true); const [loadError, setLoadError] = useState(null); + const [savingProfileStats, setSavingProfileStats] = useState(false); const [searchParams, setSearchParams] = useSearchParams(); // Read once: removing the parameter below must not load the profile again. const [steamOutcome] = useState(() => searchParams.get('steam')); @@ -197,6 +200,31 @@ export default function SettingsPage() { } }; + // Shown at once; the switches wait for the answer, so the saves reach Auth one after the other. + const handleProfileStatsChange = async (groups) => { + const previous = profile?.settings?.profileStats ?? []; + const showProfileStats = (next) => setProfile((p) => ({...p, settings: {...p.settings, profileStats: next}})); + showProfileStats(groups); + setSavingProfileStats(true); + try { + const response = await saveProfileStats(groups); + if (response.status === 401) { + expireSession(); + return; + } + if (!response.ok) { + showProfileStats(previous); + toast.error(t('SETTINGS.ERROR_SAVE')); + } + } catch (err) { + console.error('Profile stats save error:', err); + showProfileStats(previous); + toast.error(t('INFO.ERROR.NETWORK_FAILURE')); + } finally { + setSavingProfileStats(false); + } + }; + const copyProfileLink = async (link) => { try { await navigator.clipboard.writeText(link); @@ -429,6 +457,9 @@ export default function SettingsPage() {
)} +
diff --git a/src/pages/SettingsPage.test.jsx b/src/pages/SettingsPage.test.jsx index ac3fa60..52e8fee 100644 --- a/src/pages/SettingsPage.test.jsx +++ b/src/pages/SettingsPage.test.jsx @@ -55,6 +55,57 @@ describe('SettingsPage public profile', () => { await waitFor(() => expect(screen.queryByText(/\/u\/Lobo$/)).toBeNull()); }); + it('saves the stat groups the public profile shows, in a fixed order', async () => { + renderPage(profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, profileStats: ['TREND']}})); + + fireEvent.click(await screen.findByRole('switch', {name: 'STATS.PILE.TITLE'})); + + await waitFor(() => expect(puts()).toHaveLength(1)); + expect(puts()[0]).toEqual({groups: ['PILE', 'TREND']}); + const [url] = fetch.mock.calls.find(([, init]) => init?.method === 'PUT'); + expect(url).toMatch(/\/api\/users\/settings\/profile-stats$/); + await waitFor(() => expect(screen.getByRole('switch', {name: 'STATS.PILE.TITLE'}).getAttribute('aria-checked')) + .toBe('true')); + }); + + it('waits for a save before the next switch click', async () => { + const answers = []; + const me = profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, profileStats: []}}); + globalThis.fetch = vi.fn((url, init) => (init?.method === 'PUT' + ? new Promise((resolve) => answers.push(() => resolve({ok: true, status: 204, json: async () => ({})}))) + : Promise.resolve({ok: true, status: 200, json: async () => me}))); + render(); + + fireEvent.click(await screen.findByRole('switch', {name: 'STATS.PILE.TITLE'})); + await waitFor(() => expect(screen.getByRole('switch', {name: 'STATS.TASTE.TITLE'}).disabled).toBe(true)); + answers.forEach((answer) => answer()); + await waitFor(() => expect(screen.getByRole('switch', {name: 'STATS.TASTE.TITLE'}).disabled).toBe(false)); + fireEvent.click(screen.getByRole('switch', {name: 'STATS.TASTE.TITLE'})); + + await waitFor(() => expect(puts()).toHaveLength(2)); + expect(puts()[1]).toEqual({groups: ['PILE', 'TASTE']}); + }); + + it('switches the group back when the save fails', async () => { + const me = profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, profileStats: []}}); + globalThis.fetch = vi.fn(async (url, init) => (init?.method === 'PUT' + ? {ok: false, status: 500, json: async () => ({})} + : {ok: true, status: 200, json: async () => me})); + render(); + + fireEvent.click(await screen.findByRole('switch', {name: 'STATS.PILE.TITLE'})); + + await waitFor(() => expect(screen.getByRole('switch', {name: 'STATS.PILE.TITLE'}).getAttribute('aria-checked')) + .toBe('false')); + }); + + it('offers the stat groups only while the profile is public', async () => { + renderPage(profile({settings: {useDarkTheme: true, language: 'de', publicProfile: false}})); + + expect((await screen.findByRole('switch', {name: 'STATS.PILE.TITLE'})).disabled).toBe(true); + expect(screen.getByText('SETTINGS.PROFILE_STATS_NEEDS_PUBLIC')).toBeTruthy(); + }); + it('needs a display name first', async () => { renderPage(profile({displayName: null, settings: {useDarkTheme: true, language: 'de', publicProfile: false}})); diff --git a/src/pages/StatsPage.jsx b/src/pages/StatsPage.jsx new file mode 100644 index 0000000..6f71666 --- /dev/null +++ b/src/pages/StatsPage.jsx @@ -0,0 +1,67 @@ +import {useEffect} from 'react'; +import {useNavigate} from 'react-router-dom'; +import {useTranslation} from 'react-i18next'; +import {RefreshCw} from 'lucide-react'; + +import {Button} from '@/components/ui/button'; +import {Skeleton} from '@/components/ui/skeleton'; +import PageContainer from '../components/PageContainer.jsx'; +import OverviewStats from '../components/stats/OverviewStats.jsx'; +import PileStats from '../components/stats/PileStats.jsx'; +import TrendStats from '../components/stats/TrendStats.jsx'; +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 useSessionExpired from '../hooks/useSessionExpired.js'; +import useUserStats from '../hooks/useUserStats.js'; +import {hasSession} from '../utils/auth.js'; + +const ALL_GROUPS = []; + +export default function StatsPage() { + const {t} = useTranslation(); + const navigate = useNavigate(); + const expireSession = useSessionExpired(); + const loggedIn = hasSession(); + const {stats, failed, reload} = useUserStats(ALL_GROUPS, expireSession, loggedIn); + + useEffect(() => { + if (!loggedIn) navigate('/login'); + }, [loggedIn, navigate]); + + return ( + +
+

{t('STATS.TITLE')}

+

{t('STATS.SUBTITLE')}

+
+ + {failed && ( +
+ {t('STATS.ERROR_LOAD')} + +
+ )} + + {!stats && !failed && ( +
+ + +
+ )} + + {stats && ( +
+
+ + + + + + +
+ )} +
+ ); +} diff --git a/src/pages/StatsPage.test.jsx b/src/pages/StatsPage.test.jsx new file mode 100644 index 0000000..5477925 --- /dev/null +++ b/src/pages/StatsPage.test.jsx @@ -0,0 +1,87 @@ +// @vitest-environment jsdom +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; +import {MemoryRouter} from 'react-router-dom'; +import StatsPage from './StatsPage.jsx'; +import {PreferencesProvider} from '../context/PreferencesContext.jsx'; + +vi.mock('react-i18next', () => import('../test/i18n-mock.js')); + +const json = (body, status = 200) => ({ok: status < 400, status, json: async () => body}); + +const stats = (extra = {}) => ({ + overview: {totalGames: 12, totalPlaytimeMinutes: 6000, playedGames: 8, neverStarted: 4, + byStatus: {BACKLOG: 5, PLAYING: 3, FINISHED: 2, COMPLETED: 1, DROPPED: 1}}, + pile: {backlogGames: 5, normalMinutes: 4200, completionistMinutes: 9000, gamesWithTimeToBeat: 4, + minutesPerWeek: 420, paceWeeks: 10, workYears: 0.03, + oldestUnplayed: {appid: 220, name: 'Half-Life 2', imageUrl: null, inLibrarySince: '2026-01-01T00:00:00Z'}}, + topGames: {mostPlayed: [{appid: 620, name: 'Portal 2', imageUrl: null, minutes: 900}], + mostLoyal: {appid: 620, name: 'Portal 2', imageUrl: null, months: 3, minutes: 900}}, + 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}, + 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, +}); + +const renderPage = () => render(); + +describe('StatsPage', () => { + beforeEach(() => { + localStorage.setItem('tbp_session', '1'); + }); + + afterEach(() => { + cleanup(); + localStorage.clear(); + }); + + it('asks for every group once and shows each of them', async () => { + globalThis.fetch = vi.fn(async () => json(stats())); + renderPage(); + + 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']) { + expect(screen.getByText(`STATS.${title}.TITLE`)).toBeTruthy(); + } + expect(screen.getAllByText('Portal 2')).toHaveLength(2); + expect(screen.getByText('Half-Life 2')).toBeTruthy(); + expect(screen.getByText('STATS.PILE.PACE')).toBeTruthy(); + expect(screen.getByText('STATS.FINISHED.BEFORE:1')).toBeTruthy(); + expect(screen.getByTestId('pile-trend')).toBeTruthy(); + }); + + it('shows the empty texts of a new library', async () => { + const empty = stats(); + globalThis.fetch = vi.fn(async () => json({ + ...empty, + pile: {...empty.pile, paceWeeks: null, oldestUnplayed: null}, + topGames: {mostPlayed: [], mostLoyal: null}, + taste: {genres: [], moods: []}, + habits: {moonTrips: 0, longestDay: null, underOneHourGames: 0, underOneHourShare: null}, + finished: {perYear: [], finishedBeforeTracking: 0}, + trend: {days: [empty.trend.days[1]], thisMonth: {added: 0, done: 0}}, + })); + renderPage(); + + expect(await screen.findByText('STATS.PILE.PACE_NONE')).toBeTruthy(); + 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.TREND.STARTS')).toBeTruthy(); + expect(screen.queryByText('STATS.HABITS.LONGEST_DAY')).toBeNull(); + expect(screen.queryByTestId('pile-trend')).toBeNull(); + }); + + it('offers a retry when the stats could not be loaded', async () => { + globalThis.fetch = vi.fn(async () => json({}, 500)); + renderPage(); + + fireEvent.click(await screen.findByRole('button', {name: /DASHBOARD.RETRY/})); + + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/src/utils/games.js b/src/utils/games.js index c43a6f6..92845a6 100644 --- a/src/utils/games.js +++ b/src/utils/games.js @@ -10,6 +10,10 @@ export const formatPlaytime = (minutes) => { return hours >= 10 ? `${Math.round(hours)}h` : `${hours.toFixed(1)}h`; }; +// A LocalDate from Core ("2026-09-27") as a short day; noon, so no time zone moves it to the day before. +export const formatDay = (day, locale) => + new Date(`${day}T12:00:00`).toLocaleDateString(locale, {day: 'numeric', month: 'short'}); + // 460x215 store header. Not every app has one; callers need an onError fallback. export const coverUrl = (appid) => `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/header.jpg`; diff --git a/src/utils/stats.js b/src/utils/stats.js new file mode 100644 index 0000000..5c65908 --- /dev/null +++ b/src/utils/stats.js @@ -0,0 +1,52 @@ +import {apiFetch} from './api.js'; + +// The groups a user can show on the public profile; the overview numbers are always there. +export const PROFILE_STAT_GROUPS = ['PILE', 'TREND', 'TOP_GAMES', 'TASTE', 'HABITS', 'FINISHED']; + +/** + * PUT /api/users/settings/profile-stats on Auth. The caller handles the response status. + */ +export const saveProfileStats = (groups) => + apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings/profile-stats`, { + method: 'PUT', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({groups}), + }); + +/** + * @param groups comma separated stat groups of Core (StatGroup); empty for all + */ +export const statsUrl = (groups) => { + const query = groups ? `?groups=${groups}` : ''; + return `${import.meta.env.VITE_CORE_API_URL}/api/library/stats${query}`; +}; + +/** + * The i18n key of an IGDB genre name, e.g. "Role-playing (RPG)" -> "ROLE_PLAYING_RPG". Callers fall back to the + * name, so a genre IGDB adds later still shows. + */ +export const genreKey = (name) => name.toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_|_$/g, ''); + +export const formatDecimal = (value, locale, digits = 1) => + value.toLocaleString(locale, {minimumFractionDigits: 0, maximumFractionDigits: digits}); + +export const formatPercent = (share, locale) => + share.toLocaleString(locale, {style: 'percent', maximumFractionDigits: 0}); + +const DAY_MS = 24 * 60 * 60 * 1000; +const dayNumber = (day) => Math.round(Date.parse(`${day}T00:00:00Z`) / DAY_MS); + +/** + * Points of an SVG polyline for the pile per day, oldest left. x follows the date, so days without a snapshot + * keep their width. The y axis starts at 0, so a small change looks small. One day gives a flat line over the + * whole width. + */ +export const trendPoints = (days, width, height) => { + if (days.length === 0) return ''; + const max = Math.max(1, ...days.map((d) => d.backlogGames)); + const y = (d) => height - (d.backlogGames / max) * height; + if (days.length === 1) return `0,${y(days[0])} ${width},${y(days[0])}`; + const first = dayNumber(days[0].day); + const span = dayNumber(days[days.length - 1].day) - first; + return days.map((d) => `${((dayNumber(d.day) - first) / span) * width},${y(d)}`).join(' '); +}; diff --git a/src/utils/stats.test.js b/src/utils/stats.test.js new file mode 100644 index 0000000..6c50139 --- /dev/null +++ b/src/utils/stats.test.js @@ -0,0 +1,33 @@ +import {describe, expect, it} from 'vitest'; +import {genreKey, trendPoints} from './stats.js'; + +describe('genreKey', () => { + it('turns an IGDB genre name into an i18n key', () => { + expect(genreKey('Role-playing (RPG)')).toBe('ROLE_PLAYING_RPG'); + expect(genreKey("Hack and slash/Beat 'em up")).toBe('HACK_AND_SLASH_BEAT_EM_UP'); + expect(genreKey('Card & Board Game')).toBe('CARD_BOARD_GAME'); + }); +}); + +describe('trendPoints', () => { + const day = (date, backlogGames) => ({day: date, backlogGames}); + + it('spreads the days over the width, the biggest pile at the top', () => { + expect(trendPoints([day('2026-09-01', 10), day('2026-09-02', 5), day('2026-09-03', 0)], 100, 40)) + .toBe('0,0 50,20 100,40'); + }); + + it('keeps the width of days without a snapshot', () => { + expect(trendPoints([day('2026-09-01', 4), day('2026-09-04', 4), day('2026-09-05', 4)], 100, 40)) + .toBe('0,0 75,0 100,0'); + }); + + it('draws one day as a flat line', () => { + expect(trendPoints([day('2026-09-01', 3)], 100, 40)).toBe('0,0 100,0'); + }); + + it('draws an empty pile at the bottom and nothing without days', () => { + expect(trendPoints([day('2026-09-01', 0), day('2026-09-02', 0)], 100, 40)).toBe('0,40 100,40'); + expect(trendPoints([], 100, 40)).toBe(''); + }); +});