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.
This commit is contained in:
lobo committed 2026-10-11 11:52:45 +02:00
1 parent 0550f3fe9c
commit 8f884040d8
8 files changed
+142 -12

No files matched your search

+4 -2
View File
@@ -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 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, (`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 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: `user_game_entry_playtime` (V45), the same as the library card, sort and public profile. So do "played", "never
a Steam + PlayStation set counts on both platforms. 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) - **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 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 list and the number cannot drift. 40 games per page (at most page 250), a fixed order per key. A profile visitor
@@ -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 * @param byStatus has every status, also those with 0 games
*/ */
public record Overview(long totalGames, long totalPlaytimeMinutes, long playedGames, long neverStarted, 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 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 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 * @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) {} public record Habits(double moonTrips, DayMinutes longestDay, long underOneHourGames, Double underOneHourShare) {}
@@ -123,7 +123,8 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
nativeQuery = true) nativeQuery = true)
List<RecentEntry> findEntriesPlayedInTwoWeeks(@Param("userId") Long userId, @Param("limit") int limit); List<RecentEntry> 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); long countListedNeverPlayed(@Param("userId") Long userId);
@Query("select new core_service.modules.library.domain.StatusCount(c.status, count(c)) from CountedUserGame c " + @Query("select new core_service.modules.library.domain.StatusCount(c.status, count(c)) from CountedUserGame c " +
@@ -114,11 +114,11 @@ public class UserStatsRepository {
private static final Map<StatKey, String> SETS = Map.ofEntries( private static final Map<StatKey, String> SETS = Map.ofEntries(
Map.entry(StatKey.TOTAL, counted("true")), Map.entry(StatKey.TOTAL, counted("true")),
Map.entry(StatKey.STATUS, counted("ug.status = :arg")), Map.entry(StatKey.STATUS, counted("ug.status = :arg")),
Map.entry(StatKey.PLAYED, counted("ug.playtime_minutes > 0")), Map.entry(StatKey.PLAYED, entries("ug.playtime_minutes > 0")),
Map.entry(StatKey.NEVER_STARTED, counted("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.PILE, counted("ug.status = 'BACKLOG' and " + UserGameRepository.NOT_COMING)),
Map.entry(StatKey.FINISHED_ALL, counted("ug.status in " + FINISHED)), 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.FINISHED_UNTRACKED, counted(UNTRACKED)),
Map.entry(StatKey.PILE_TIME_TO_BEAT, PILE_TTB_SET), 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 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(""" return jdbc.query("""
select ug.appid, sg.name, sg.image_url, ug.first_seen_at 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 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 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, i) -> new UserStats.OldestGame(rs.getLong("appid"), rs.getString("name"),
rs.getString("image_url"), rs.getTimestamp("first_seen_at").toInstant())) rs.getString("image_url"), rs.getTimestamp("first_seen_at").toInstant()))
.stream().findFirst(); .stream().findFirst();
@@ -251,8 +252,8 @@ public class UserStatsRepository {
return jdbc.queryForObject(""" return jdbc.queryForObject("""
select count(*) filter (where ug.playtime_minutes > 0) as started, select count(*) filter (where ug.playtime_minutes > 0) as started,
count(*) filter (where %s) as under_one_hour count(*) filter (where %s) as under_one_hour
from counted_user_game ug where %s from %s ug
""".formatted(UNDER_ONE_HOUR, OF_USER), user(userId), """.formatted(UNDER_ONE_HOUR, ENTRY_PLAYTIME), user(userId),
(rs, i) -> new UserStats.PlayedCounts(rs.getLong("started"), rs.getLong("under_one_hour"))); (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; 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) { 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); return "select ug.appid, %s from counted_user_game ug where %s and %s".formatted(NO_STAT, OF_USER, where);
} }
@@ -101,6 +101,7 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
@BeforeEach @BeforeEach
void seed() { void seed() {
jdbc.update("delete from game_link where set_id = 980002"); jdbc.update("delete from game_link where set_id = 980002");
jdbc.update("delete from user_psn_title where user_id = ?", USER_ID);
gameReleaseRepository.deleteAll(); gameReleaseRepository.deleteAll();
userGameRepository.deleteAll(); userGameRepository.deleteAll();
schemaMetaRepository.deleteAll(); schemaMetaRepository.deleteAll();
@@ -237,6 +238,21 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.stats.topGames.mostPlayed[0].minutes").value(1000)); .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 @Test
void publicLibrary_OnlyTheSharedStatGroups() throws Exception { void publicLibrary_OnlyTheSharedStatGroups() throws Exception {
mockMvc.perform(get("/api/public/profiles/library") mockMvc.perform(get("/api/public/profiles/library")
@@ -47,6 +47,7 @@ class StatGamesFlowTest extends AbstractMockedLibraryServicesTest {
private static final long MANUAL_SWITCH = 5_000_010L; private static final long MANUAL_SWITCH = 5_000_010L;
private static final long MANUAL_SWITCH_LOWER = 5_000_011L; private static final long MANUAL_SWITCH_LOWER = 5_000_011L;
private static final long BACK_TO_PILE = 5_000_012L; 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 int YEAR = LocalDate.now(ZoneId.of("Europe/Berlin")).getYear();
private static final JsonMapper JSON = JsonMapper.builder().build(); private static final JsonMapper JSON = JsonMapper.builder().build();
@@ -123,6 +124,7 @@ class StatGamesFlowTest extends AbstractMockedLibraryServicesTest {
@BeforeEach @BeforeEach
void seed() { 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 where user_id in (?, ?)", USER, OTHER);
jdbc.update("delete from user_game_playtime_day 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); 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, MANUAL_SWITCH_LOWER, 70, "BACKLOG", "MANUAL", "switch");
game(USER, BACK_TO_PILE, 10, "PLAYING", "STEAM", null); 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); 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); game(OTHER, FINISHED_TRACKED, 4000, "FINISHED", "STEAM", null);
jdbc.update(""" jdbc.update("""
@@ -220,6 +225,18 @@ class StatGamesFlowTest extends AbstractMockedLibraryServicesTest {
assertThat(rows).extracting(r -> r.get("appid").asLong()).doesNotHaveDuplicates(); assertThat(rows).extracting(r -> r.get("appid").asLong()).doesNotHaveDuplicates();
} }
@Test
void setPlayedOnPlayStation_IsPlayedNotNeverStarted() throws Exception {
JsonNode stats = stats();
List<Long> played = allRows("PLAYED", null).stream().map(r -> r.get("appid").asLong()).toList();
List<Long> 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 @Test
void mostPlayed_FirstFiveAreTheStatsTopGames() throws Exception { void mostPlayed_FirstFiveAreTheStatsTopGames() throws Exception {
List<Long> top = new ArrayList<>(); List<Long> top = new ArrayList<>();
@@ -631,6 +631,14 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
assertEquals(1, userGameRepository.countListedNeverPlayed(USER_ID)); 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 @Test
void findRandomBacklogGame_PicksOnlyOwnActiveBacklogGames() { void findRandomBacklogGame_PicksOnlyOwnActiveBacklogGames() {
UserGame removed = userGameRepository.findById(new UserGameId(USER_ID, 700L)).orElseThrow(); UserGame removed = userGameRepository.findById(new UserGameId(USER_ID, 700L)).orElseThrow();
@@ -179,12 +179,16 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
.containsExactly(620L, 220L); .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() { private void seedEntryTotal() {
userGameRepository.saveAllAndFlush(List.of( userGameRepository.saveAllAndFlush(List.of(
game(220L, 100, GameStatus.PLAYING, Instant.now()), game(220L, 100, GameStatus.PLAYING, Instant.now()),
game(620L, 300, 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) " psnPlay(220L, 500);
+ "values (?, 'c1', 'STEAM_OWNED', 220, 'PS5', 500)", USER_ID);
} }
@Test @Test
@@ -282,6 +286,79 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.playedCounts(USER_ID)).isEqualTo(new UserStats.PlayedCounts(2, 1)); assertThat(repository.playedCounts(USER_ID)).isEqualTo(new UserStats.PlayedCounts(2, 1));
} }
private List<Long> 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 @Test
void finishedPerYear_CountsAGameOnceInTheYearItFirstGotFinished() { void finishedPerYear_CountsAGameOnceInTheYearItFirstGotFinished() {
userGameRepository.saveAllAndFlush(List.of( userGameRepository.saveAllAndFlush(List.of(