feat(stats): add stats page and pile trend
Build, Test & Push Frontend / quality-check (push) Successful in 15s
Build, Test & Push Frontend / docker (push) Successful in 22s

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:
lobo committed 2026-09-27 07:16:10 +02:00
1 parent 83bbc2730d
commit 9d1c7bb40d
31 files changed
+978 -11

No files matched your search

+52
View File
@@ -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(' ');
};