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

@@ -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) {}
@@ -123,7 +123,8 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
nativeQuery = true)
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);
@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(
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);
}
@@ -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")
@@ -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<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
void mostPlayed_FirstFiveAreTheStatsTopGames() throws Exception {
List<Long> top = new ArrayList<>();
@@ -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();
@@ -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<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
void finishedPerYear_CountsAGameOnceInTheYearItFirstGotFinished() {
userGameRepository.saveAllAndFlush(List.of(