feat(library): add stats page data and pile trend
Build, Test & Push Core Service / quality-check (push) Successful in 1m0s
Build, Test & Push Core Service / docker (push) Successful in 1m18s

Log every status change with a trigger, so no code path can miss it.
Store IGDB genres with the moods. Write a pile snapshot per user each
night. The sync moves an untouched pile game with new playtime to
PLAYING in one guarded update, so a status the user picks wins.
Old finished games get their year from the ending achievement.
GET /api/library/stats serves the groups; the public profile shows
only the groups in the profile ticket.
This commit is contained in:
lobo committed 2026-09-27 07:16:05 +02:00
1 parent b959542e23
commit 0fa2a37f47
48 files changed
+2069 -60

No files matched your search

@@ -0,0 +1,30 @@
-- Every status a library game ever had, for "finished per year" and the Pile of Shame trend. Written by a trigger,
-- so no code path (entity save, bulk update, native insert) can forget it. The past is not known: the log starts
-- on the day of this migration.
CREATE TABLE user_game_status_change (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
appid BIGINT NOT NULL,
-- NULL: the game came into the library with to_status.
from_status VARCHAR(16),
to_status VARCHAR(16) NOT NULL,
-- user_game.status_set_by_user after the change: false for the sync and for Core's own FINISHED.
by_user BOOLEAN NOT NULL,
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
FOREIGN KEY (user_id, appid) REFERENCES user_game (user_id, appid) ON DELETE CASCADE
);
CREATE INDEX idx_status_change_user_time ON user_game_status_change (user_id, changed_at);
CREATE FUNCTION log_user_game_status() RETURNS TRIGGER AS $$
BEGIN
INSERT INTO user_game_status_change (user_id, appid, from_status, to_status, by_user)
VALUES (NEW.user_id, NEW.appid, CASE WHEN TG_OP = 'UPDATE' THEN OLD.status END, NEW.status,
NEW.status_set_by_user);
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER user_game_status_insert AFTER INSERT ON user_game
FOR EACH ROW EXECUTE FUNCTION log_user_game_status();
CREATE TRIGGER user_game_status_update AFTER UPDATE OF status ON user_game
FOR EACH ROW WHEN (OLD.status IS DISTINCT FROM NEW.status) EXECUTE FUNCTION log_user_game_status();
@@ -0,0 +1,4 @@
-- IGDB genre names (e.g. "Role-playing (RPG)") per game, for the stats page. Filled with the moods; the next
-- library sync asks IGDB for every game again, so the column gets filled.
ALTER TABLE game_mood ADD COLUMN genres VARCHAR(64)[] NOT NULL DEFAULT '{}';
UPDATE game_mood SET checked_at = TIMESTAMPTZ 'epoch';
@@ -0,0 +1,25 @@
-- The size of each user's pile per day (Europe/Berlin), for the Pile of Shame trend. The nightly sync writes one row
-- per user with listed games; the past cannot be rebuilt, so the trend starts with the row of the migration day.
CREATE TABLE backlog_snapshot (
user_id BIGINT NOT NULL,
day DATE NOT NULL,
backlog_games INTEGER NOT NULL,
never_played INTEGER NOT NULL,
-- Normal time to beat of the backlog games IGDB knows; it also grows when IGDB learns new times.
backlog_minutes BIGINT NOT NULL,
listed_games INTEGER NOT NULL,
PRIMARY KEY (user_id, day)
);
INSERT INTO backlog_snapshot (user_id, day, backlog_games, never_played, backlog_minutes, listed_games)
SELECT ug.user_id, (NOW() AT TIME ZONE 'Europe/Berlin')::date,
COUNT(*) FILTER (WHERE ug.status = 'BACKLOG'), COUNT(*) FILTER (WHERE ug.playtime_minutes = 0),
COALESCE(SUM(t.normally_minutes) FILTER (WHERE ug.status = 'BACKLOG'), 0), COUNT(*)
FROM user_game ug LEFT JOIN game_time_to_beat t ON t.appid = ug.appid
WHERE ug.removed_at IS NULL AND ug.hidden_at IS NULL
GROUP BY ug.user_id;
-- When the current Steam account's first sync ran: the games of that sync are the import, no "added" in the trend.
-- The accounts of today count from the migration on, like the status history.
ALTER TABLE user_steam_sync ADD COLUMN history_since TIMESTAMPTZ;
UPDATE user_steam_sync SET history_since = NOW() WHERE history_steam_id IS NOT NULL;
@@ -0,0 +1,5 @@
-- For a FINISHED or COMPLETED game: when the user unlocked its ending achievement (the rule of EndingAchievement).
-- "Finished per year" counts the game in that year, before the status history (V14): a game finished by hand or by
-- Core long after its ending, e.g. when the library is first set up, still counts in the right year. Filled by the
-- nightly job; NULL when the game has no ending achievement, it is not unlocked, or Steam sent no unlock time.
ALTER TABLE user_game ADD COLUMN finished_at_estimate TIMESTAMPTZ;