From 8f884040d80b196d3f3b6da06097dfbcdc478498 Mon Sep 17 00:00:00 2001 From: lobo Date: Sun, 11 Oct 2026 11:52:45 +0200 Subject: [PATCH] fix(stats): count each set once by its set playtime Played, never started, under one hour and oldest unplayed used the counted edition's own minutes, so a set played only on PS5 showed as never started. They and their drill-down lists now use the set total (user_game_entry_playtime). Refs #38. Nightly snapshot (#43) unchanged. --- readme.md | 6 +- .../modules/library/api/dto/UserStatsDto.java | 3 + .../library/repo/UserGameRepository.java | 3 +- .../library/repo/UserStatsRepository.java | 20 +++-- .../api/PublicProfileControllerTest.java | 16 ++++ .../library/api/StatGamesFlowTest.java | 17 ++++ .../library/repo/UserGameRepositoryTest.java | 8 ++ .../library/repo/UserStatsRepositoryTest.java | 81 ++++++++++++++++++- 8 files changed, 142 insertions(+), 12 deletions(-) diff --git a/readme.md b/readme.md index a076b2a..118d789 100644 --- a/readme.md +++ b/readme.md @@ -122,8 +122,10 @@ flowchart LR genres, moods, perfect games and the longest day list one entry per set, with the playtime of all its editions (`user_game_entry_member`, V43; its list shows that sum as `minutes`). Every playtime number (most played, genres, moods, the total playtime and moon trips, the drill-down lists) uses the set total, the view - `user_game_entry_playtime` (V45), the same as the library card, sort and public profile. Platforms stay per edition: - a Steam + PlayStation set counts on both platforms. + `user_game_entry_playtime` (V45), the same as the library card, sort and public profile. So do "played", "never + started" and "under one hour": a set counts once, judged by its set total (also the dashboard "never played" and the + oldest unplayed game). Only the nightly trend snapshot `never_played` still uses the counted edition's own minutes. + Platforms stay per edition: a Steam + PlayStation set counts on both platforms. - **Drill-down**: every countable number opens the games behind it. A `StatKey` (library), `CommunityKey` (landing page) or a Pile Points track/bonus row names a number; its SQL "game set" is the same text the number counts, so the list and the number cannot drift. 40 games per page (at most page 250), a fixed order per key. A profile visitor diff --git a/src/main/java/core_service/modules/library/api/dto/UserStatsDto.java b/src/main/java/core_service/modules/library/api/dto/UserStatsDto.java index 17cc9e9..d8f8c5c 100644 --- a/src/main/java/core_service/modules/library/api/dto/UserStatsDto.java +++ b/src/main/java/core_service/modules/library/api/dto/UserStatsDto.java @@ -24,6 +24,8 @@ public record UserStatsDto( ) { /** + * @param playedGames games with a set total above 0 + * @param neverStarted games with a set total of 0 * @param byStatus has every status, also those with 0 games */ public record Overview(long totalGames, long totalPlaytimeMinutes, long playedGames, long neverStarted, @@ -56,6 +58,7 @@ public record UserStatsDto( /** * @param moonTrips the total playtime as flights to the moon and back (Apollo 11, 195.3 hours) * @param longestDay the day with the most playtime; null without history + * @param underOneHourGames games whose set total is 1 to 59 minutes * @param underOneHourShare share of the started games with less than one hour; null when none is started */ public record Habits(double moonTrips, DayMinutes longestDay, long underOneHourGames, Double underOneHourShare) {} diff --git a/src/main/java/core_service/modules/library/repo/UserGameRepository.java b/src/main/java/core_service/modules/library/repo/UserGameRepository.java index 745cccf..fd8abcb 100644 --- a/src/main/java/core_service/modules/library/repo/UserGameRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserGameRepository.java @@ -123,7 +123,8 @@ public interface UserGameRepository extends JpaRepository nativeQuery = true) List findEntriesPlayedInTwoWeeks(@Param("userId") Long userId, @Param("limit") int limit); - @Query("select count(c) from CountedUserGame c where c.userId = :userId and c.playtimeMinutes = 0") + /** The entries whose set total (V45) is 0. */ + @Query("select count(ep) from UserGameEntryPlaytime ep where ep.userId = :userId and ep.playtimeMinutes = 0") long countListedNeverPlayed(@Param("userId") Long userId); @Query("select new core_service.modules.library.domain.StatusCount(c.status, count(c)) from CountedUserGame c " + diff --git a/src/main/java/core_service/modules/library/repo/UserStatsRepository.java b/src/main/java/core_service/modules/library/repo/UserStatsRepository.java index 1365e3c..207cb5e 100644 --- a/src/main/java/core_service/modules/library/repo/UserStatsRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserStatsRepository.java @@ -114,11 +114,11 @@ public class UserStatsRepository { private static final Map SETS = Map.ofEntries( Map.entry(StatKey.TOTAL, counted("true")), Map.entry(StatKey.STATUS, counted("ug.status = :arg")), - Map.entry(StatKey.PLAYED, counted("ug.playtime_minutes > 0")), - Map.entry(StatKey.NEVER_STARTED, counted("ug.playtime_minutes = 0")), + Map.entry(StatKey.PLAYED, entries("ug.playtime_minutes > 0")), + Map.entry(StatKey.NEVER_STARTED, entries("ug.playtime_minutes = 0")), Map.entry(StatKey.PILE, counted("ug.status = 'BACKLOG' and " + UserGameRepository.NOT_COMING)), Map.entry(StatKey.FINISHED_ALL, counted("ug.status in " + FINISHED)), - Map.entry(StatKey.UNDER_ONE_HOUR, counted(UNDER_ONE_HOUR)), + Map.entry(StatKey.UNDER_ONE_HOUR, entries(UNDER_ONE_HOUR)), Map.entry(StatKey.FINISHED_UNTRACKED, counted(UNTRACKED)), Map.entry(StatKey.PILE_TIME_TO_BEAT, PILE_TTB_SET), Map.entry(StatKey.MOST_PLAYED, "select ug.appid, ug.playtime_minutes as minutes, " + NO_EVENT + ", " + NO_MOVE @@ -168,9 +168,10 @@ public class UserStatsRepository { return jdbc.query(""" select ug.appid, sg.name, sg.image_url, ug.first_seen_at from counted_user_game ug join steam_game sg on sg.appid = ug.appid - where %s and ug.status = 'BACKLOG' and ug.playtime_minutes = 0 and %s + join %s ep on ep.appid = ug.appid + where %s and ug.status = 'BACKLOG' and ep.playtime_minutes = 0 and %s order by ug.first_seen_at, ug.appid limit 1 - """.formatted(OF_USER, UserGameRepository.NOT_COMING), user(userId), + """.formatted(ENTRY_PLAYTIME, OF_USER, UserGameRepository.NOT_COMING), user(userId), (rs, i) -> new UserStats.OldestGame(rs.getLong("appid"), rs.getString("name"), rs.getString("image_url"), rs.getTimestamp("first_seen_at").toInstant())) .stream().findFirst(); @@ -251,8 +252,8 @@ public class UserStatsRepository { return jdbc.queryForObject(""" select count(*) filter (where ug.playtime_minutes > 0) as started, count(*) filter (where %s) as under_one_hour - from counted_user_game ug where %s - """.formatted(UNDER_ONE_HOUR, OF_USER), user(userId), + from %s ug + """.formatted(UNDER_ONE_HOUR, ENTRY_PLAYTIME), user(userId), (rs, i) -> new UserStats.PlayedCounts(rs.getLong("started"), rs.getLong("under_one_hour"))); } @@ -321,6 +322,11 @@ public class UserStatsRepository { return games != null ? games : 0L; } + /** The entries whose set total (V45) matches {@code where}; alias ug. */ + private static String entries(String where) { + return "select ug.appid, %s from %s ug where %s".formatted(NO_STAT, ENTRY_PLAYTIME, where); + } + private static String counted(String where) { return "select ug.appid, %s from counted_user_game ug where %s and %s".formatted(NO_STAT, OF_USER, where); } diff --git a/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java b/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java index 337e7d2..4155230 100644 --- a/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java +++ b/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java @@ -101,6 +101,7 @@ class PublicProfileControllerTest extends AbstractIntegrationTest { @BeforeEach void seed() { jdbc.update("delete from game_link where set_id = 980002"); + jdbc.update("delete from user_psn_title where user_id = ?", USER_ID); gameReleaseRepository.deleteAll(); userGameRepository.deleteAll(); schemaMetaRepository.deleteAll(); @@ -237,6 +238,21 @@ class PublicProfileControllerTest extends AbstractIntegrationTest { .andExpect(jsonPath("$.stats.topGames.mostPlayed[0].minutes").value(1000)); } + @Test + void publicProfile_HabitsCountsEqualTheOwnersStats() throws Exception { + jdbc.update("update user_game set playtime_minutes = 0 where user_id = ? and appid = 220", USER_ID); + jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes) " + + "values (?, 'c1', 'STEAM_OWNED', 220, 'PS5', 40)", USER_ID); + var own = userStatsService.getStats(USER_ID, Set.of(StatGroup.HABITS)); + + assertEquals(1L, own.habits().underOneHourGames()); + mockMvc.perform(get("/api/public/profiles/library") + .header("X-Profile-Ticket", ticket(USER_ID, List.of("HABITS")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.stats.habits.underOneHourGames").value(own.habits().underOneHourGames())) + .andExpect(jsonPath("$.stats.habits.underOneHourShare").value(own.habits().underOneHourShare())); + } + @Test void publicLibrary_OnlyTheSharedStatGroups() throws Exception { mockMvc.perform(get("/api/public/profiles/library") diff --git a/src/test/java/core_service/modules/library/api/StatGamesFlowTest.java b/src/test/java/core_service/modules/library/api/StatGamesFlowTest.java index 37591c1..034d617 100644 --- a/src/test/java/core_service/modules/library/api/StatGamesFlowTest.java +++ b/src/test/java/core_service/modules/library/api/StatGamesFlowTest.java @@ -47,6 +47,7 @@ class StatGamesFlowTest extends AbstractMockedLibraryServicesTest { private static final long MANUAL_SWITCH = 5_000_010L; private static final long MANUAL_SWITCH_LOWER = 5_000_011L; private static final long BACK_TO_PILE = 5_000_012L; + private static final long SET_PSN = 5_000_013L; private static final int YEAR = LocalDate.now(ZoneId.of("Europe/Berlin")).getYear(); private static final JsonMapper JSON = JsonMapper.builder().build(); @@ -123,6 +124,7 @@ class StatGamesFlowTest extends AbstractMockedLibraryServicesTest { @BeforeEach void seed() { + jdbc.update("delete from user_psn_title where user_id in (?, ?)", USER, OTHER); jdbc.update("delete from user_game where user_id in (?, ?)", USER, OTHER); jdbc.update("delete from user_game_playtime_day where user_id in (?, ?)", USER, OTHER); jdbc.update("delete from game_link where appid in (?, ?)", EDITION_MAIN, EDITION_SECOND); @@ -147,6 +149,9 @@ class StatGamesFlowTest extends AbstractMockedLibraryServicesTest { game(USER, MANUAL_SWITCH_LOWER, 70, "BACKLOG", "MANUAL", "switch"); game(USER, BACK_TO_PILE, 10, "PLAYING", "STEAM", null); jdbc.update("update user_game set status = 'BACKLOG' where user_id = ? and appid = ?", USER, BACK_TO_PILE); + game(USER, SET_PSN, 0, "BACKLOG", "MANUAL", "Xbox"); + jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes) " + + "values (?, 'set-psn', 'STEAM_OWNED', ?, 'PS5', 120)", USER, SET_PSN); game(OTHER, FINISHED_TRACKED, 4000, "FINISHED", "STEAM", null); jdbc.update(""" @@ -220,6 +225,18 @@ class StatGamesFlowTest extends AbstractMockedLibraryServicesTest { assertThat(rows).extracting(r -> r.get("appid").asLong()).doesNotHaveDuplicates(); } + @Test + void setPlayedOnPlayStation_IsPlayedNotNeverStarted() throws Exception { + JsonNode stats = stats(); + List played = allRows("PLAYED", null).stream().map(r -> r.get("appid").asLong()).toList(); + List never = allRows("NEVER_STARTED", null).stream().map(r -> r.get("appid").asLong()).toList(); + + assertThat(played).contains(SET_PSN); + assertThat(never).doesNotContain(SET_PSN); + assertThat(stats.at("/overview/playedGames").asLong() + stats.at("/overview/neverStarted").asLong()) + .isEqualTo(stats.at("/overview/totalGames").asLong()); + } + @Test void mostPlayed_FirstFiveAreTheStatsTopGames() throws Exception { List top = new ArrayList<>(); diff --git a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java index 2c36aab..54bb2aa 100644 --- a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java @@ -631,6 +631,14 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { assertEquals(1, userGameRepository.countListedNeverPlayed(USER_ID)); } + @Test + void countListedNeverPlayed_UsesTheSetTotal() { + userGameRepository.flush(); + titlePlay("c9", 380L, "STEAM_OWNED", "PS5", 120); + + assertEquals(1, userGameRepository.countListedNeverPlayed(USER_ID)); + } + @Test void findRandomBacklogGame_PicksOnlyOwnActiveBacklogGames() { UserGame removed = userGameRepository.findById(new UserGameId(USER_ID, 700L)).orElseThrow(); diff --git a/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java b/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java index 0b0bba1..30db256 100644 --- a/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java @@ -179,12 +179,16 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest { .containsExactly(620L, 220L); } + private void psnPlay(long appid, int minutes) { + jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes) " + + "values (?, ?, 'STEAM_OWNED', ?, 'PS5', ?)", USER_ID, "c" + appid, appid, minutes); + } + private void seedEntryTotal() { userGameRepository.saveAllAndFlush(List.of( game(220L, 100, GameStatus.PLAYING, Instant.now()), game(620L, 300, GameStatus.PLAYING, Instant.now()))); - jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes) " - + "values (?, 'c1', 'STEAM_OWNED', 220, 'PS5', 500)", USER_ID); + psnPlay(220L, 500); } @Test @@ -282,6 +286,79 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest { assertThat(repository.playedCounts(USER_ID)).isEqualTo(new UserStats.PlayedCounts(2, 1)); } + private List appids(StatKey key) { + return repository.games(USER_ID, key, "", BERLIN, 0, 10).stream().map(UserStats.StatGame::appid).toList(); + } + + @Test + void playedCounts_ASetPlayedOnlyOnPlayStationIsPlayed() { + userGameRepository.saveAllAndFlush(List.of( + game(220L, 0, GameStatus.BACKLOG, Instant.now()), + game(620L, 0, GameStatus.BACKLOG, Instant.now()))); + psnPlay(220L, 120); + + assertThat(repository.playedCounts(USER_ID)).isEqualTo(new UserStats.PlayedCounts(1, 0)); + assertThat(repository.countGames(USER_ID, StatKey.PLAYED, "", BERLIN)).isEqualTo(1); + assertThat(appids(StatKey.PLAYED)).containsExactly(220L); + assertThat(repository.countGames(USER_ID, StatKey.NEVER_STARTED, "", BERLIN)).isEqualTo(1); + assertThat(appids(StatKey.NEVER_STARTED)).containsExactly(620L); + assertThat(repository.countGames(USER_ID, StatKey.UNDER_ONE_HOUR, "", BERLIN)).isZero(); + } + + @Test + void playedCounts_UnderOneHourIsJudgedByTheSetTotal() { + userGameRepository.saveAllAndFlush(List.of( + game(220L, 40, GameStatus.PLAYING, Instant.now()), + game(620L, 30, GameStatus.PLAYING, Instant.now()), + game(700L, 20, GameStatus.PLAYING, Instant.now()))); + link(220L, 620L); + + assertThat(repository.playedCounts(USER_ID)).isEqualTo(new UserStats.PlayedCounts(2, 1)); + assertThat(appids(StatKey.UNDER_ONE_HOUR)).containsExactly(700L); + } + + @Test + void neverStarted_ASetWithEveryEdition0IsCountedOnce() { + userGameRepository.saveAllAndFlush(List.of( + game(220L, 0, GameStatus.BACKLOG, Instant.now()), + game(620L, 0, GameStatus.BACKLOG, Instant.now()))); + link(220L, 620L); + + assertThat(repository.countGames(USER_ID, StatKey.NEVER_STARTED, "", BERLIN)).isEqualTo(1); + assertThat(appids(StatKey.NEVER_STARTED)).hasSize(1); + assertThat(repository.playedCounts(USER_ID)).isEqualTo(new UserStats.PlayedCounts(0, 0)); + } + + @Test + void playedCounts_ManualAndSteamSet() { + steamGameRepository.saveAndFlush(new SteamGame(8001L, "Manual", null, Instant.now())); + userGameRepository.saveAllAndFlush(List.of( + game(220L, 0, GameStatus.BACKLOG, Instant.now()), + UserGame.manual(USER_ID, 8001L, GameSource.MANUAL, "PS5", GameStatus.BACKLOG, 50, Instant.now()))); + link(220L, 8001L); + + assertThat(repository.playedCounts(USER_ID)).isEqualTo(new UserStats.PlayedCounts(1, 1)); + assertThat(repository.countGames(USER_ID, StatKey.NEVER_STARTED, "", BERLIN)).isZero(); + } + + @Test + void playedCounts_NoGames() { + assertThat(repository.playedCounts(USER_ID)).isEqualTo(new UserStats.PlayedCounts(0, 0)); + assertThat(repository.countGames(USER_ID, StatKey.PLAYED, "", BERLIN)).isZero(); + assertThat(repository.countGames(USER_ID, StatKey.NEVER_STARTED, "", BERLIN)).isZero(); + assertThat(repository.countGames(USER_ID, StatKey.UNDER_ONE_HOUR, "", BERLIN)).isZero(); + } + + @Test + void oldestUnplayed_LeavesOutAPileSetPlayedOnPlayStation() { + userGameRepository.saveAllAndFlush(List.of( + game(220L, 0, GameStatus.BACKLOG, Instant.EPOCH), + game(620L, 0, GameStatus.BACKLOG, Instant.parse("2026-01-01T00:00:00Z")))); + psnPlay(220L, 120); + + assertThat(repository.oldestUnplayed(USER_ID).orElseThrow().appid()).isEqualTo(620L); + } + @Test void finishedPerYear_CountsAGameOnceInTheYearItFirstGotFinished() { userGameRepository.saveAllAndFlush(List.of(