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

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