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
@@ -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`;
|
||||
|
||||
|
||||
@@ -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