Public Access
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.
This commit is contained in:
1 parent
83bbc2730d
commit
9d1c7bb40d
31 files changed
+978
-11
No files matched your search
@@ -19,12 +19,16 @@ To Be Played is split into three repositories:
|
|||||||
|
|
||||||
## Features
|
## 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
|
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 %.
|
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
|
- **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
|
(story, co-op, chill, action, scary from IGDB; speedrun from speedrun.com), high priority first, reroll without
|
||||||
repeats, and "Start playing" in one click.
|
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
|
- **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.
|
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,
|
- **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
|
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.
|
verify it; unlinking is possible.
|
||||||
- **Public profile** at `/u/<display name>`, opt-in in the settings, with perfect games and average completion.
|
- **Public profile** at `/u/<display name>`, 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.
|
- **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.
|
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
|
- **Broken pictures**: a Steam cover or achievement icon that does not load shows a placeholder and is reported
|
||||||
|
|||||||
+90
-1
@@ -34,6 +34,9 @@
|
|||||||
"ERROR_TOO_MANY_ATTEMPTS": "Zu viele fehlgeschlagene Versuche. Bitte in {{minutes}} Minuten erneut versuchen."
|
"ERROR_TOO_MANY_ATTEMPTS": "Zu viele fehlgeschlagene Versuche. Bitte in {{minutes}} Minuten erneut versuchen."
|
||||||
},
|
},
|
||||||
"SETTINGS": {
|
"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",
|
"TITLE": "Einstellungen",
|
||||||
"LOADING": "Einstellungen werden geladen …",
|
"LOADING": "Einstellungen werden geladen …",
|
||||||
"ACCOUNT": "Konto",
|
"ACCOUNT": "Konto",
|
||||||
@@ -257,7 +260,8 @@
|
|||||||
"LANGUAGE": "Sprache",
|
"LANGUAGE": "Sprache",
|
||||||
"DARK_MODE": "Dunkles Design",
|
"DARK_MODE": "Dunkles Design",
|
||||||
"LIGHT_MODE": "Helles Design",
|
"LIGHT_MODE": "Helles Design",
|
||||||
"PLAY_NEXT": "Nächstes Spiel"
|
"PLAY_NEXT": "Nächstes Spiel",
|
||||||
|
"STATS": "Statistik"
|
||||||
},
|
},
|
||||||
"BACKLOG": {
|
"BACKLOG": {
|
||||||
"TITLE": "Mein Backlog",
|
"TITLE": "Mein Backlog",
|
||||||
@@ -369,5 +373,90 @@
|
|||||||
"REMOVE_NO": "Abbrechen",
|
"REMOVE_NO": "Abbrechen",
|
||||||
"REMOVED": "Spiel entfernt.",
|
"REMOVED": "Spiel entfernt.",
|
||||||
"NO_ACHIEVEMENTS": "Erfolge gibt es nur für Steam-Spiele."
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+90
-1
@@ -34,6 +34,9 @@
|
|||||||
"ERROR_TOO_MANY_ATTEMPTS": "Too many failed attempts. Please try again in {{minutes}} minutes."
|
"ERROR_TOO_MANY_ATTEMPTS": "Too many failed attempts. Please try again in {{minutes}} minutes."
|
||||||
},
|
},
|
||||||
"SETTINGS": {
|
"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",
|
"TITLE": "Settings",
|
||||||
"LOADING": "Loading settings …",
|
"LOADING": "Loading settings …",
|
||||||
"ACCOUNT": "Account",
|
"ACCOUNT": "Account",
|
||||||
@@ -257,7 +260,8 @@
|
|||||||
"LANGUAGE": "Language",
|
"LANGUAGE": "Language",
|
||||||
"DARK_MODE": "Dark mode",
|
"DARK_MODE": "Dark mode",
|
||||||
"LIGHT_MODE": "Light mode",
|
"LIGHT_MODE": "Light mode",
|
||||||
"PLAY_NEXT": "Play next"
|
"PLAY_NEXT": "Play next",
|
||||||
|
"STATS": "Stats"
|
||||||
},
|
},
|
||||||
"BACKLOG": {
|
"BACKLOG": {
|
||||||
"TITLE": "My backlog",
|
"TITLE": "My backlog",
|
||||||
@@ -369,5 +373,90 @@
|
|||||||
"REMOVE_NO": "Cancel",
|
"REMOVE_NO": "Cancel",
|
||||||
"REMOVED": "Game removed.",
|
"REMOVED": "Game removed.",
|
||||||
"NO_ACHIEVEMENTS": "Achievements exist only for Steam games."
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@ import GameLibrary from './pages/GameLibrary.jsx';
|
|||||||
import GameDetail from './pages/GameDetail.jsx';
|
import GameDetail from './pages/GameDetail.jsx';
|
||||||
import WishlistPage from './pages/WishlistPage.jsx';
|
import WishlistPage from './pages/WishlistPage.jsx';
|
||||||
import PlayNextPage from './pages/PlayNextPage.jsx';
|
import PlayNextPage from './pages/PlayNextPage.jsx';
|
||||||
|
import StatsPage from './pages/StatsPage.jsx';
|
||||||
import PublicProfilePage from './pages/PublicProfilePage.jsx';
|
import PublicProfilePage from './pages/PublicProfilePage.jsx';
|
||||||
import AppHeader from './components/AppHeader.jsx';
|
import AppHeader from './components/AppHeader.jsx';
|
||||||
import {Toaster} from '@/components/ui/sonner';
|
import {Toaster} from '@/components/ui/sonner';
|
||||||
@@ -67,6 +68,7 @@ function App() {
|
|||||||
<Route path="/library/:appid" element={<GameDetail/>}/>
|
<Route path="/library/:appid" element={<GameDetail/>}/>
|
||||||
<Route path="/wishlist" element={<WishlistPage/>}/>
|
<Route path="/wishlist" element={<WishlistPage/>}/>
|
||||||
<Route path="/play-next" element={<PlayNextPage/>}/>
|
<Route path="/play-next" element={<PlayNextPage/>}/>
|
||||||
|
<Route path="/stats" element={<StatsPage/>}/>
|
||||||
<Route path="/u/:name" element={<PublicProfilePage/>}/>
|
<Route path="/u/:name" element={<PublicProfilePage/>}/>
|
||||||
<Route path="/settings" element={<SettingsPage/>}/>
|
<Route path="/settings" element={<SettingsPage/>}/>
|
||||||
<Route path="/impressum" element={<ImprintPage/>}/>
|
<Route path="/impressum" element={<ImprintPage/>}/>
|
||||||
|
|||||||
@@ -4,15 +4,12 @@ import {Activity} from 'lucide-react';
|
|||||||
|
|
||||||
import {Card} from '@/components/ui/card';
|
import {Card} from '@/components/ui/card';
|
||||||
import GameCover from './GameCover.jsx';
|
import GameCover from './GameCover.jsx';
|
||||||
import {formatPlaytime} from '../utils/games.js';
|
import {formatDay, formatPlaytime} from '../utils/games.js';
|
||||||
|
|
||||||
const CHART_HEIGHT = 80;
|
const CHART_HEIGHT = 80;
|
||||||
const BAR_WIDTH = 10;
|
const BAR_WIDTH = 10;
|
||||||
const BAR_GAP = 2;
|
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
|
* @param activity the answer of GET /api/library/activity
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {Link, NavLink, useLocation} from 'react-router-dom';
|
import {Link, NavLink, useLocation} from 'react-router-dom';
|
||||||
import {useTranslation} from 'react-i18next';
|
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 {Button} from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -22,6 +22,7 @@ const NAV_ITEMS = [
|
|||||||
// The wishlist is a tab of the library.
|
// The wishlist is a tab of the library.
|
||||||
{to: '/library', labelKey: 'NAV.LIBRARY', Icon: Library, alsoActiveOn: ['/wishlist']},
|
{to: '/library', labelKey: 'NAV.LIBRARY', Icon: Library, alsoActiveOn: ['/wishlist']},
|
||||||
{to: '/play-next', labelKey: 'NAV.PLAY_NEXT', Icon: Dices},
|
{to: '/play-next', labelKey: 'NAV.PLAY_NEXT', Icon: Dices},
|
||||||
|
{to: '/stats', labelKey: 'NAV.STATS', Icon: ChartColumn},
|
||||||
];
|
];
|
||||||
|
|
||||||
const navLinkClass = (isActive) => cn(
|
const navLinkClass = (isActive) => cn(
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ import {Dices, Inbox, Shuffle} from 'lucide-react';
|
|||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
import {Card} from '@/components/ui/card';
|
import {Card} from '@/components/ui/card';
|
||||||
import GameCover from './GameCover.jsx';
|
import GameCover from './GameCover.jsx';
|
||||||
|
import PileTrend from './stats/PileTrend.jsx';
|
||||||
import {STATUS_META} from './GameStatusBadge.jsx';
|
import {STATUS_META} from './GameStatusBadge.jsx';
|
||||||
import {backlogShare, GAME_STATUSES} from '../utils/backlog.js';
|
import {backlogShare, GAME_STATUSES} from '../utils/backlog.js';
|
||||||
import {formatPlaytime} from '../utils/games.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 {t, i18n} = useTranslation();
|
||||||
const backlog = summary.byStatus?.BACKLOG ?? 0;
|
const backlog = summary.byStatus?.BACKLOG ?? 0;
|
||||||
const suggestion = summary.suggestion;
|
const suggestion = summary.suggestion;
|
||||||
@@ -44,6 +48,8 @@ export default function PileOfShameCard({summary, syncing, onShuffle, shuffling}
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{trend && <PileTrend trend={trend}/>}
|
||||||
|
|
||||||
{summary.total > 0 && (
|
{summary.total > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex h-2.5 overflow-hidden rounded-full bg-muted">
|
<div className="flex h-2.5 overflow-hidden rounded-full bg-muted">
|
||||||
|
|||||||
@@ -42,6 +42,21 @@ describe('PileOfShameCard', () => {
|
|||||||
expect(onShuffle).toHaveBeenCalledOnce();
|
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', () => {
|
it('says the backlog is empty when there is no suggestion', () => {
|
||||||
renderCard({summary: summary(0)});
|
renderCard({summary: summary(0)});
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<StatsSection title={t('STATS.FINISHED.TITLE')} icon={Flag}>
|
||||||
|
{finished.perYear.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t('STATS.FINISHED.EMPTY')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{finished.perYear.map((y) => (
|
||||||
|
<li key={y.year} className="flex items-center gap-3 text-sm">
|
||||||
|
<span className="w-12 tabular-nums">{y.year}</span>
|
||||||
|
<div className="h-2 flex-1 rounded-full bg-muted">
|
||||||
|
<div className="h-full rounded-full bg-primary"
|
||||||
|
style={{width: `${(y.games / max) * 100}%`}}/>
|
||||||
|
</div>
|
||||||
|
<span className="w-8 text-right tabular-nums">{y.games}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{finished.finishedBeforeTracking > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('STATS.FINISHED.BEFORE', {count: finished.finishedBeforeTracking})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</StatsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 = (
|
||||||
|
<>
|
||||||
|
<GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-16 shrink-0"/>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm font-medium">{game.name ?? `App ${game.appid}`}</span>
|
||||||
|
<span className="text-sm text-muted-foreground tabular-nums">{value}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
return linked ? (
|
||||||
|
<Link to={`/library/${game.appid}`} className="flex items-center gap-3 hover:text-primary transition-colors">
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-3">{content}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<StatsSection title={t('STATS.HABITS.TITLE')} icon={Rocket}>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
|
<StatTile label={t('STATS.HABITS.MOON')} value={formatDecimal(habits.moonTrips, i18n.language)}
|
||||||
|
hint={t('STATS.HABITS.MOON_HINT')}/>
|
||||||
|
{habits.longestDay && (
|
||||||
|
<StatTile label={t('STATS.HABITS.LONGEST_DAY')} value={formatPlaytime(habits.longestDay.minutes)}
|
||||||
|
hint={day}/>
|
||||||
|
)}
|
||||||
|
{habits.underOneHourShare != null && (
|
||||||
|
<StatTile label={t('STATS.HABITS.UNDER_ONE_HOUR')}
|
||||||
|
value={formatPercent(habits.underOneHourShare, i18n.language)}
|
||||||
|
hint={t('STATS.HABITS.UNDER_ONE_HOUR_HINT', {count: habits.underOneHourGames})}/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</StatsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<StatsSection title={t('STATS.OVERVIEW.TITLE')} icon={Gamepad2}>
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<StatTile label={t('STATS.OVERVIEW.GAMES')} value={n(overview.totalGames)}/>
|
||||||
|
<StatTile label={t('STATS.OVERVIEW.PLAYTIME')} value={formatPlaytime(overview.totalPlaytimeMinutes)}/>
|
||||||
|
<StatTile label={t('STATS.OVERVIEW.PLAYED')} value={n(overview.playedGames)}/>
|
||||||
|
<StatTile label={t('STATS.OVERVIEW.NEVER_STARTED')} value={n(overview.neverStarted)}/>
|
||||||
|
</div>
|
||||||
|
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground">
|
||||||
|
{GAME_STATUSES.map((s) => (
|
||||||
|
<li key={s}>
|
||||||
|
{t(`BACKLOG.STATUS.${s}`)}{' '}
|
||||||
|
<span className="tabular-nums text-foreground">{n(overview.byStatus[s] ?? 0)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</StatsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<StatsSection title={t('STATS.PILE.TITLE')} icon={Inbox}>
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
|
<StatTile label={t('STATS.PILE.GAMES')} value={pile.backlogGames.toLocaleString(i18n.language)}/>
|
||||||
|
<StatTile label={t('STATS.PILE.NORMAL')} value={formatPlaytime(pile.normalMinutes)}
|
||||||
|
hint={t('STATS.PILE.KNOWN', {count: pile.gamesWithTimeToBeat})}/>
|
||||||
|
<StatTile label={t('STATS.PILE.COMPLETIONIST')} value={formatPlaytime(pile.completionistMinutes)}/>
|
||||||
|
</div>
|
||||||
|
{pile.normalMinutes > 0 && (
|
||||||
|
<ul className="space-y-1 text-sm">
|
||||||
|
<li>
|
||||||
|
{pile.paceWeeks == null
|
||||||
|
? t('STATS.PILE.PACE_NONE')
|
||||||
|
: t('STATS.PILE.PACE', {
|
||||||
|
weeks: formatDecimal(pile.paceWeeks, i18n.language),
|
||||||
|
perWeek: formatPlaytime(pile.minutesPerWeek),
|
||||||
|
})}
|
||||||
|
</li>
|
||||||
|
<li>{t('STATS.PILE.WORK_YEARS', {years: formatDecimal(pile.workYears, i18n.language, 2)})}</li>
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{pile.oldestUnplayed && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<span className="text-sm font-medium">{t('STATS.PILE.OLDEST')}</span>
|
||||||
|
<GameRow game={pile.oldestUnplayed} value={t('STATS.PILE.SINCE', {date: since})} linked={linked}/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</StatsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm">
|
||||||
|
{t('STATS.TREND.THIS_MONTH', {added: thisMonth.added, done: thisMonth.done})}
|
||||||
|
</p>
|
||||||
|
{days.length > 1 ? (
|
||||||
|
<div>
|
||||||
|
<svg viewBox={`0 0 ${WIDTH} ${HEIGHT}`} preserveAspectRatio="none" className="h-16 w-full"
|
||||||
|
role="img" aria-label={t('STATS.TREND.TITLE')}>
|
||||||
|
<polyline data-testid="pile-trend" points={trendPoints(days, WIDTH, HEIGHT)} fill="none"
|
||||||
|
strokeWidth={2} vectorEffect="non-scaling-stroke" className="stroke-rose-500"/>
|
||||||
|
</svg>
|
||||||
|
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>{formatDay(days[0].day, i18n.language)}: {days[0].backlogGames}</span>
|
||||||
|
<span>{formatDay(days[days.length - 1].day, i18n.language)}: {days[days.length - 1].backlogGames}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">{t('STATS.TREND.STARTS')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-sm font-medium">{t('SETTINGS.PROFILE_STATS')}</span>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{disabled ? t('SETTINGS.PROFILE_STATS_NEEDS_PUBLIC') : t('SETTINGS.PROFILE_STATS_HINT')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{PROFILE_STAT_GROUPS.map((group) => (
|
||||||
|
<li key={group} className="flex items-center justify-between gap-4">
|
||||||
|
<Label htmlFor={`profileStats-${group}`}>{t(`STATS.${group}.TITLE`)}</Label>
|
||||||
|
<Switch id={`profileStats-${group}`} checked={selected.includes(group)}
|
||||||
|
disabled={disabled || saving}
|
||||||
|
onCheckedChange={() => toggle(group)}/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export default function StatTile({label, value, hint}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<div className="text-2xl font-bold tracking-tight tabular-nums">{value}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{label}</div>
|
||||||
|
{hint && <div className="mt-1 text-xs text-muted-foreground">{hint}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import {Card} from '@/components/ui/card';
|
||||||
|
|
||||||
|
export default function StatsSection({title, icon: Icon, children}) {
|
||||||
|
return (
|
||||||
|
<Card className="gap-4 p-5">
|
||||||
|
<h2 className="flex items-center gap-2 font-semibold">
|
||||||
|
<Icon className="size-4 text-primary"/>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{children}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<li key={tag.name} className="text-sm">
|
||||||
|
<div className="flex justify-between gap-2">
|
||||||
|
<span className="truncate">{label(tag.name)}</span>
|
||||||
|
<span className="text-muted-foreground tabular-nums">
|
||||||
|
{formatPlaytime(tag.minutes)} · {t('STATS.TASTE.GAMES', {count: tag.games})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 h-1.5 rounded-full bg-muted">
|
||||||
|
<div className="h-full rounded-full bg-primary"
|
||||||
|
style={{width: `${Math.max(2, (tag.minutes / max) * 100)}%`}}/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TasteStats({taste}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
if (taste.genres.length === 0 && taste.moods.length === 0) {
|
||||||
|
return (
|
||||||
|
<StatsSection title={t('STATS.TASTE.TITLE')} icon={Palette}>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('STATS.TASTE.EMPTY')}</p>
|
||||||
|
</StatsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<StatsSection title={t('STATS.TASTE.TITLE')} icon={Palette}>
|
||||||
|
<div className="grid gap-6 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<span className="text-sm font-medium">{t('STATS.TASTE.GENRES')}</span>
|
||||||
|
<TagBars tags={taste.genres}
|
||||||
|
label={(name) => t(`STATS.GENRE.${genreKey(name)}`, {defaultValue: name})}/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<span className="text-sm font-medium">{t('STATS.TASTE.MOODS')}</span>
|
||||||
|
<TagBars tags={taste.moods} label={(name) => t(`PLAY_NEXT.MOOD.${name}`, {defaultValue: name})}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</StatsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<StatsSection title={t('STATS.TOP_GAMES.TITLE')} icon={Trophy}>
|
||||||
|
{topGames.mostPlayed.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t('STATS.TOP_GAMES.EMPTY')}</p>
|
||||||
|
) : (
|
||||||
|
<ol className="space-y-2">
|
||||||
|
{topGames.mostPlayed.map((g) => (
|
||||||
|
<li key={g.appid}><GameRow game={g} value={formatPlaytime(g.minutes)} linked={linked}/></li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
{topGames.mostLoyal && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<span className="text-sm font-medium">{t('STATS.TOP_GAMES.LOYAL')}</span>
|
||||||
|
<GameRow game={topGames.mostLoyal} linked={linked}
|
||||||
|
value={t('STATS.TOP_GAMES.MONTHS', {count: topGames.mostLoyal.months})}/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</StatsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<StatsSection title={t('STATS.TREND.TITLE')} icon={TrendingDown}>
|
||||||
|
<PileTrend trend={trend}/>
|
||||||
|
</StatsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import PageContainer from '../components/PageContainer.jsx';
|
|||||||
import HeroArt from '../components/HeroArt.jsx';
|
import HeroArt from '../components/HeroArt.jsx';
|
||||||
import SteamAvatar from '../components/SteamAvatar.jsx';
|
import SteamAvatar from '../components/SteamAvatar.jsx';
|
||||||
import useSteamProfile from '../hooks/useSteamProfile.js';
|
import useSteamProfile from '../hooks/useSteamProfile.js';
|
||||||
|
import useUserStats from '../hooks/useUserStats.js';
|
||||||
import {cn} from '@/lib/utils';
|
import {cn} from '@/lib/utils';
|
||||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||||
import {apiFetch} from '../utils/api.js';
|
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 PLAYING_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc&status=PLAYING';
|
||||||
|
|
||||||
const JSON_HEADERS = {'Content-Type': 'application/json'};
|
const JSON_HEADERS = {'Content-Type': 'application/json'};
|
||||||
|
const TREND_GROUPS = ['TREND'];
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
@@ -51,6 +53,8 @@ const Dashboard = () => {
|
|||||||
const [shuffling, setShuffling] = useState(false);
|
const [shuffling, setShuffling] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
const pileTrend = useUserStats(TREND_GROUPS, expireSession, hasSession());
|
||||||
|
const reloadTrend = pileTrend.reload;
|
||||||
|
|
||||||
const fetchSteamCount = useCallback(async () => {
|
const fetchSteamCount = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -168,7 +172,8 @@ const Dashboard = () => {
|
|||||||
fetchSummary();
|
fetchSummary();
|
||||||
fetchActivity();
|
fetchActivity();
|
||||||
fetchAchievements();
|
fetchAchievements();
|
||||||
}, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]);
|
reloadTrend();
|
||||||
|
}, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements, reloadTrend]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchUserData = async () => {
|
const fetchUserData = async () => {
|
||||||
@@ -344,7 +349,7 @@ const Dashboard = () => {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<CurrentlyPlayingCard games={playing} syncing={sync.isRunning}/>
|
<CurrentlyPlayingCard games={playing} syncing={sync.isRunning}/>
|
||||||
{summary && (
|
{summary && (
|
||||||
<PileOfShameCard summary={summary} syncing={sync.isRunning}
|
<PileOfShameCard summary={summary} trend={pileTrend.stats?.trend} syncing={sync.isRunning}
|
||||||
onShuffle={shuffleSuggestion} shuffling={shuffling}/>
|
onShuffle={shuffleSuggestion} shuffling={shuffling}/>
|
||||||
)}
|
)}
|
||||||
{!summary && summaryFailed && (
|
{!summary && summaryFailed && (
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx';
|
|||||||
import {GAME_STATUSES} from '../utils/backlog.js';
|
import {GAME_STATUSES} from '../utils/backlog.js';
|
||||||
import {formatPlaytime} from '../utils/games.js';
|
import {formatPlaytime} from '../utils/games.js';
|
||||||
import PageContainer from '../components/PageContainer.jsx';
|
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.
|
* 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() {
|
|||||||
</ul>
|
</ul>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{library.stats && <SharedStats stats={library.stats}/>}
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only the groups the user shares come back; the game rows do not link, the library pages are private.
|
||||||
|
function SharedStats({stats}) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
{stats.pile && <PileStats pile={stats.pile} linked={false}/>}
|
||||||
|
{stats.trend && <TrendStats trend={stats.trend}/>}
|
||||||
|
{stats.topGames && <TopGamesStats topGames={stats.topGames} linked={false}/>}
|
||||||
|
{stats.taste && <TasteStats taste={stats.taste}/>}
|
||||||
|
{stats.habits && <HabitsStats habits={stats.habits}/>}
|
||||||
|
{stats.finished && <FinishedStats finished={stats.finished}/>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 () => {
|
it('says the profile is unknown or private on a 404', async () => {
|
||||||
mockBackends({profileStatus: 404});
|
mockBackends({profileStatus: 404});
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|||||||
@@ -16,12 +16,14 @@ import {Skeleton} from '@/components/ui/skeleton';
|
|||||||
import {Switch} from '@/components/ui/switch';
|
import {Switch} from '@/components/ui/switch';
|
||||||
import FormField from '../components/FormField.jsx';
|
import FormField from '../components/FormField.jsx';
|
||||||
import SteamAccountCard from '../components/SteamAccountCard.jsx';
|
import SteamAccountCard from '../components/SteamAccountCard.jsx';
|
||||||
|
import ProfileStatsSettings from '../components/stats/ProfileStatsSettings.jsx';
|
||||||
import {settingsSchema} from '../utils/validation.js';
|
import {settingsSchema} from '../utils/validation.js';
|
||||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||||
import useSaveAppearance from '../hooks/useSaveAppearance.js';
|
import useSaveAppearance from '../hooks/useSaveAppearance.js';
|
||||||
import {ACCENTS, CORNERS} from '../utils/appearance.js';
|
import {ACCENTS, CORNERS} from '../utils/appearance.js';
|
||||||
import {coverUrl} from '../utils/games.js';
|
import {coverUrl} from '../utils/games.js';
|
||||||
|
import {saveProfileStats} from '../utils/stats.js';
|
||||||
import {cn} from '@/lib/utils';
|
import {cn} from '@/lib/utils';
|
||||||
import {apiFetch, refreshSession} from '../utils/api.js';
|
import {apiFetch, refreshSession} from '../utils/api.js';
|
||||||
import {hasSession, markSession} from '../utils/auth.js';
|
import {hasSession, markSession} from '../utils/auth.js';
|
||||||
@@ -59,6 +61,7 @@ export default function SettingsPage() {
|
|||||||
const [profile, setProfile] = useState(null);
|
const [profile, setProfile] = useState(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [loadError, setLoadError] = useState(null);
|
const [loadError, setLoadError] = useState(null);
|
||||||
|
const [savingProfileStats, setSavingProfileStats] = useState(false);
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
// Read once: removing the parameter below must not load the profile again.
|
// Read once: removing the parameter below must not load the profile again.
|
||||||
const [steamOutcome] = useState(() => searchParams.get('steam'));
|
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) => {
|
const copyProfileLink = async (link) => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(link);
|
await navigator.clipboard.writeText(link);
|
||||||
@@ -429,6 +457,9 @@ export default function SettingsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<ProfileStatsSettings selected={profile?.settings?.profileStats ?? []}
|
||||||
|
disabled={!publicProfile} saving={savingProfileStats}
|
||||||
|
onChange={handleProfileStatsChange}/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -55,6 +55,57 @@ describe('SettingsPage public profile', () => {
|
|||||||
await waitFor(() => expect(screen.queryByText(/\/u\/Lobo$/)).toBeNull());
|
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(<MemoryRouter><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
|
||||||
|
|
||||||
|
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(<MemoryRouter><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
|
||||||
|
|
||||||
|
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 () => {
|
it('needs a display name first', async () => {
|
||||||
renderPage(profile({displayName: null, settings: {useDarkTheme: true, language: 'de', publicProfile: false}}));
|
renderPage(profile({displayName: null, settings: {useDarkTheme: true, language: 'de', publicProfile: false}}));
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">{t('STATS.TITLE')}</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{t('STATS.SUBTITLE')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{failed && (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-12 text-sm text-muted-foreground">
|
||||||
|
{t('STATS.ERROR_LOAD')}
|
||||||
|
<Button variant="outline" size="sm" onClick={reload}><RefreshCw/>{t('DASHBOARD.RETRY')}</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!stats && !failed && (
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<Skeleton className="h-48 rounded-xl"/>
|
||||||
|
<Skeleton className="h-48 rounded-xl"/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stats && (
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<div className="md:col-span-2"><OverviewStats overview={stats.overview}/></div>
|
||||||
|
<PileStats pile={stats.pile}/>
|
||||||
|
<TrendStats trend={stats.trend}/>
|
||||||
|
<TopGamesStats topGames={stats.topGames}/>
|
||||||
|
<TasteStats taste={stats.taste}/>
|
||||||
|
<HabitsStats habits={stats.habits}/>
|
||||||
|
<FinishedStats finished={stats.finished}/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(<MemoryRouter><PreferencesProvider><StatsPage/></PreferencesProvider></MemoryRouter>);
|
||||||
|
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,10 @@ export const formatPlaytime = (minutes) => {
|
|||||||
return hours >= 10 ? `${Math.round(hours)}h` : `${hours.toFixed(1)}h`;
|
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.
|
// 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`;
|
export const coverUrl = (appid) => `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/header.jpg`;
|
||||||
|
|
||||||
|
|||||||
@@ -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(' ');
|
||||||
|
};
|
||||||
@@ -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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user