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

+19 -4
View File
@@ -43,6 +43,15 @@ To Be Played is split into three repositories:
picker, Pile of Shame, activity, achievement summary, public profile and landing page stats. The sync still
updates its playtime, but no achievements, time to beat, moods or speedrun data are fetched for it. The library
lists hidden games with `hidden=true`.
- **Stats**: `UserStatsService` builds the numbers of the stats page per group, so every page asks only for the
groups it shows: totals and statuses; the pile (time to beat, weeks "at your pace" of the last 90 days, years of
40-hour weeks, oldest unplayed game); most played and most loyal game (most months with playtime); top IGDB genres
and moods; moon trips (Apollo 11, 195.3 h), longest day, share under one hour; finished games per year (from the
status history); the Pile of Shame trend (90 days of `backlog_snapshot`, plus "this month: added / done", where the
first sync of a Steam account is no "added", see `user_steam_sync.history_since`). Every number counts only listed games.
- **Status history**: a database trigger logs every status change of a library game in `user_game_status_change`
(old and new status, time, whether the user chose it), whatever code path made it. It starts with V14; older
changes are not known.
- **Wishlist**: synced from Steam with store price and discount.
- **Broken image repair**: the frontend reports a Steam cover or achievement icon that does not load. At 01:30
(Europe/Berlin) a job checks up to 200 reports. A cover that loads again is left alone; else the store's hashed
@@ -53,7 +62,8 @@ To Be Played is split into three repositories:
feature is off and the rest works.
- **Public profile data**: totals, status counts, games in progress and finished games with rating, perfect games
and average completion. Readable only
with a profile ticket issued by Auth. Never returns notes, priority or the Steam ID.
with a profile ticket issued by Auth. Never returns notes, priority or the Steam ID. `stats` holds only the stat
groups the user shares (the ticket's `profileStats` claim, set in Auth); none by default.
## Authentication
@@ -78,6 +88,7 @@ accepted only by `GET /api/public/profiles/library` in the `X-Profile-Ticket` he
| `POST` | `/api/library/sync` | JWT |
| `POST` | `/api/library/broken-images` (`{appid, kind}`, kind `COVER` or `ACHIEVEMENT_ICONS`; `204`) | JWT |
| `GET` | `/api/library/picker?maxMinutes=&mood=&count=&exclude=` | JWT |
| `GET` | `/api/library/stats?groups=` (`OVERVIEW`, `PILE`, `TOP_GAMES`, `TASTE`, `HABITS`, `FINISHED`, `TREND`; none = all) | JWT |
| `GET` | `/api/library/achievements/summary` | JWT |
| `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` | JWT |
| `GET` | `/api/public/profiles/library` | profile ticket |
@@ -152,12 +163,16 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain`
`POST /api/library/sync` starts one at once, but at most every 60 seconds (else `429` with `Retry-After`).
2. A nightly job (23:55, Europe/Berlin) syncs every account used in the last 14 days, one after the other. A
request without a `steamId` claim (the user unlinked the Steam account in Auth) takes the user out of it.
Afterwards it writes every user's pile size of the day to `backlog_snapshot`, also for users without Steam, and
dates finished games by the unlock of their ending achievement
(`FinishedEstimateService`, `user_game.finished_at_estimate`).
3. `LibrarySyncWorker` runs in the background. `user_steam_sync` lets only one run per user start.
4. The worker loads the wishlist and the owned games from Steam. It updates `steam_game` and `user_game`, adds
the playtime growth of the day to `user_game_playtime_day`, and soft-deletes Steam games the user no longer
owns. When Steam returns no games, the library stays as it is.
5. `TimeToBeatService` and `MoodService` ask IGDB for missing or old time-to-beat and mood data, for Steam games
and manual games. Hidden games only get their playtime updated.
owns. A BACKLOG game with new playtime becomes PLAYING, unless the user chose its status. When Steam returns no
games, the library stays as it is.
5. `TimeToBeatService` and `MoodService` ask IGDB for missing or old time-to-beat, mood and genre data, for Steam
games and manual games. Hidden games only get their playtime updated.
6. After the commit, `LibrarySyncedEvent` starts `AchievementSyncService` and `SpeedrunService` (backlog games only)
in the background. The achievement sync also fetches Steam's global unlock rates with each schema, and sets
FINISHED for a game whose ending achievement is unlocked while the user never chose its status.
@@ -1,5 +1,6 @@
package core_service.configuration.security;
import core_service.shared.security.ProfileTicket;
import core_service.shared.security.ProfileTicketVerifier;
import core_service.shared.security.UserPrincipal;
import io.jsonwebtoken.Claims;
@@ -7,7 +8,10 @@ import io.jsonwebtoken.JwtParser;
import io.jsonwebtoken.Jwts;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Verifies JWTs issued by tbp-auth-service. Verify-only: {@link JwksKeyLocator} finds the Ed25519 public key.
@@ -47,13 +51,18 @@ public class JwtService implements ProfileTicketVerifier {
}
@Override
public Optional<Long> verifyProfileTicket(String ticket) {
public Optional<ProfileTicket> verifyProfileTicket(String ticket) {
try {
Claims claims = claims(ticket);
if (!PROFILE_TICKET_TYPE.equals(claims.get(TYPE_CLAIM, String.class))) {
return Optional.empty();
}
return Optional.of(Long.parseLong(claims.get("profileUserId", String.class)));
long userId = Long.parseLong(claims.get("profileUserId", String.class));
// A ticket of an Auth from before the claim has none: then the profile shows no stats.
Set<String> profileStats = claims.get("profileStats") instanceof List<?> groups
? groups.stream().filter(String.class::isInstance).map(String.class::cast).collect(Collectors.toSet())
: Set.of();
return Optional.of(new ProfileTicket(userId, profileStats));
} catch (Exception e) {
return Optional.empty();
}
@@ -13,9 +13,11 @@ import core_service.modules.library.api.dto.PickerDto;
import core_service.modules.library.api.dto.StatusUpdateDto;
import core_service.modules.library.api.dto.SteamProfileDto;
import core_service.modules.library.api.dto.SyncStatusDto;
import core_service.modules.library.api.dto.UserStatsDto;
import core_service.modules.library.api.dto.WishlistDto;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.Mood;
import core_service.modules.library.domain.StatGroup;
import core_service.modules.library.service.BacklogService;
import core_service.modules.library.service.GameDetailService;
import core_service.modules.library.service.ImageRepairService;
@@ -25,6 +27,7 @@ import core_service.modules.library.service.MoodService;
import core_service.modules.library.service.SpeedrunService;
import core_service.modules.library.service.SteamProfileService;
import core_service.modules.library.service.SyncCooldownException;
import core_service.modules.library.service.UserStatsService;
import core_service.shared.security.UserPrincipal;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@@ -43,6 +46,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -69,6 +73,7 @@ public class LibraryController {
private final MoodService moodService;
private final SpeedrunService speedrunService;
private final ImageRepairService imageRepairService;
private final UserStatsService userStatsService;
@GetMapping("/games-count")
public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) {
@@ -244,6 +249,20 @@ public class LibraryController {
return ResponseEntity.ok(queryService.pickBacklogGames(userId, budget, mood, excluded, safeCount));
}
/**
* @param groups the groups to compute; none means all
*/
@GetMapping("/stats")
public ResponseEntity<UserStatsDto> getStats(@AuthenticationPrincipal UserPrincipal principal,
@RequestParam(required = false) Set<StatGroup> groups) {
Long userId = principal.getIdAsLong();
Set<StatGroup> asked = groups == null || groups.isEmpty() ? EnumSet.allOf(StatGroup.class) : groups;
if (asked.contains(StatGroup.TASTE)) {
moodService.refreshListedInBackground(userId);
}
return ResponseEntity.ok(userStatsService.getStats(userId, asked));
}
@GetMapping("/sync-status")
public ResponseEntity<SyncStatusDto> getSyncStatus(@AuthenticationPrincipal UserPrincipal principal) {
return queryService.getSyncStatus(principal.getIdAsLong())
@@ -1,6 +1,7 @@
package core_service.modules.library.api;
import core_service.modules.library.service.LibraryQueryService;
import core_service.shared.security.ProfileTicket;
import core_service.shared.security.ProfileTicketVerifier;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
@@ -27,8 +28,8 @@ public class PublicProfileController {
@GetMapping("/library")
public ResponseEntity<?> getPublicLibrary(@RequestHeader(value = "X-Profile-Ticket", required = false) String ticket) {
return (ticket == null ? Optional.<Long>empty() : profileTicketVerifier.verifyProfileTicket(ticket))
.<ResponseEntity<?>>map(userId -> ResponseEntity.ok(queryService.getPublicLibrary(userId)))
return (ticket == null ? Optional.<ProfileTicket>empty() : profileTicketVerifier.verifyProfileTicket(ticket))
.<ResponseEntity<?>>map(t -> ResponseEntity.ok(queryService.getPublicLibrary(t.userId(), t.profileStats())))
.orElseGet(() -> ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("code", "INVALID_TICKET", "message", "Missing, invalid or expired profile ticket")));
}
@@ -12,6 +12,7 @@ import java.util.Map;
* @param playing up to 8 games, most played first
* @param finished up to 12 FINISHED or COMPLETED games, best rated first
* @param averageCompletion see {@link AchievementSummaryDto#averageCompletion()}
* @param stats only the stat groups the user shares, the others null; null when the user shares none
*/
public record PublicLibraryDto(
long totalGames,
@@ -20,7 +21,8 @@ public record PublicLibraryDto(
List<Game> playing,
List<Game> finished,
int perfectGames,
Double averageCompletion
Double averageCompletion,
UserStatsDto stats
) {
/**
@@ -0,0 +1,76 @@
package core_service.modules.library.api.dto;
import core_service.modules.library.domain.DayMinutes;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.UserStats;
import java.util.List;
import java.util.Map;
/**
* One field per {@link core_service.modules.library.domain.StatGroup}; a group that was not asked for is null.
* Every number counts only listed games (not removed, not hidden). The playtime day sums (pace, longest day) also
* count removed games: those days were played.
*/
public record UserStatsDto(
Overview overview,
Pile pile,
TopGames topGames,
Taste taste,
Habits habits,
Finished finished,
Trend trend
) {
/**
* @param byStatus has every status, also those with 0 games
*/
public record Overview(long totalGames, long totalPlaytimeMinutes, long playedGames, long neverStarted,
Map<GameStatus, Long> byStatus) {}
/**
* The pile is every game with status BACKLOG. Times to beat only count games IGDB knows.
*
* @param minutesPerWeek average playtime per week of the last 90 days
* @param paceWeeks weeks to play the pile's normal time at that pace; null without playtime in the 90 days
* @param workYears the pile's normal time in years of 40-hour weeks
* @param oldestUnplayed the pile game with no playtime that is in the library longest; null when there is none
*/
public record Pile(long backlogGames, long normalMinutes, long completionistMinutes, long gamesWithTimeToBeat,
long minutesPerWeek, Double paceWeeks, double workYears, UserStats.OldestGame oldestUnplayed) {}
/**
* @param mostPlayed up to 5 games by total playtime
* @param mostLoyal the game played in the most months since the playtime history started; null without history
*/
public record TopGames(List<UserStats.GameMinutes> mostPlayed, UserStats.LoyalGame mostLoyal) {}
/**
* @param genres up to 8 IGDB genres by playtime
* @param moods every mood with games, by playtime
*/
public record Taste(List<UserStats.TagCount> genres, List<UserStats.TagCount> moods) {}
/**
* @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 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) {}
/**
* @param perYear games now FINISHED or COMPLETED, by the year they first got there (Europe/Berlin), oldest first;
* a game finished before the status history by the year its ending achievement was unlocked
* @param finishedBeforeTracking finished games with neither a finish in the status history nor an ending
* achievement date
*/
public record Finished(List<UserStats.YearCount> perYear, long finishedBeforeTracking) {}
/**
* @param days the pile per day of the last 90 days, oldest first; days before the trend started or without a
* nightly run are missing, today is always there
* @param thisMonth the moves of the current month (Europe/Berlin)
*/
public record Trend(List<UserStats.PileDay> days, UserStats.MonthMoves thisMonth) {}
}
@@ -0,0 +1,6 @@
package core_service.modules.library.domain;
/**
* A FINISHED or COMPLETED game without {@code finished_at_estimate} yet.
*/
public record FinishedWithoutDate(long userId, long appid) {}
@@ -30,6 +30,11 @@ public class GameMoods {
@Column(name = "moods", nullable = false)
private List<String> moods;
/** IGDB genre names, e.g. "Role-playing (RPG)". */
@JdbcTypeCode(SqlTypes.ARRAY)
@Column(name = "genres", nullable = false)
private List<String> genres;
@Column(name = "checked_at", nullable = false)
private Instant checkedAt;
@@ -8,7 +8,8 @@ public enum GameStatus {
DROPPED;
/**
* Status for a game the sync sees for the first time. Later syncs never change it.
* Status for a game the sync sees for the first time. Later syncs change it only from BACKLOG to PLAYING, when
* the game gets new playtime and the user never chose its status.
*/
public static GameStatus initialFor(int playtimeMinutes) {
return playtimeMinutes > 0 ? PLAYING : BACKLOG;
@@ -0,0 +1,44 @@
package core_service.modules.library.domain;
/**
* The math of the stats page, apart so it can be tested alone.
*/
public final class StatFormulas {
/** 40 hours a week, 52 weeks, no holidays. */
public static final double WORK_YEAR_HOURS = 40 * 52;
/** Apollo 11 from launch to splashdown: 8 days, 3 hours, 18 minutes. */
public static final double MOON_TRIP_HOURS = 8 * 24 + 3 + 18 / 60.0;
public static final int PACE_DAYS = 90;
private StatFormulas() {
}
public static double workYears(long minutes) {
return minutes / 60.0 / WORK_YEAR_HOURS;
}
public static double moonTrips(long minutes) {
return minutes / 60.0 / MOON_TRIP_HOURS;
}
public static long minutesPerWeek(long minutesInPaceDays) {
return Math.round(minutesInPaceDays * 7.0 / PACE_DAYS);
}
/**
* @return weeks to play the pile at the pace of the last {@link #PACE_DAYS} days; null without playtime then
*/
public static Double paceWeeks(long pileMinutes, long minutesInPaceDays) {
return minutesInPaceDays == 0 ? null : pileMinutes / (minutesInPaceDays * 7.0 / PACE_DAYS);
}
/**
* @return null when there is no whole
*/
public static Double share(long part, long whole) {
return whole == 0 ? null : (double) part / whole;
}
}
@@ -0,0 +1,27 @@
package core_service.modules.library.domain;
import java.util.Collection;
import java.util.EnumSet;
import java.util.Set;
/**
* The groups of the stats page. A client asks only for the groups it shows; the public profile gets only the groups
* the user shares.
*/
public enum StatGroup {
OVERVIEW, PILE, TOP_GAMES, TASTE, HABITS, FINISHED, TREND;
/**
* The groups a public profile shows, from the names in the profile ticket. OVERVIEW is never one: the profile
* has those numbers already. Unknown names are left out.
*/
public static Set<StatGroup> shared(Collection<String> names) {
Set<StatGroup> groups = EnumSet.noneOf(StatGroup.class);
for (StatGroup group : values()) {
if (group != OVERVIEW && names.contains(group.name())) {
groups.add(group);
}
}
return groups;
}
}
@@ -52,6 +52,10 @@ public class UserGame {
@Column(name = "status_set_by_user", nullable = false)
private boolean statusSetByUser;
/** The unlock of the ending achievement of a finished game; "finished per year" prefers it to the history. */
@Column(name = "finished_at_estimate")
private Instant finishedAtEstimate;
/** The finished hint the user answered with "Not yet"; see {@link FinishedHintType}. */
@Enumerated(EnumType.STRING)
@Column(name = "finished_hint_dismissed", length = 16)
@@ -0,0 +1,41 @@
package core_service.modules.library.domain;
import java.time.Instant;
import java.time.LocalDate;
/**
* The rows the stats queries return, see {@link StatGroup}.
*/
public final class UserStats {
private UserStats() {
}
/**
* @param inLibrarySince the first sync that saw the game, not the Steam purchase date
*/
public record OldestGame(long appid, String name, String imageUrl, Instant inLibrarySince) {}
public record GameMinutes(long appid, String name, String imageUrl, long minutes) {}
public record LoyalGame(long appid, String name, String imageUrl, long months, long minutes) {}
public record TagCount(String name, long games, long minutes) {}
public record YearCount(int year, long games) {}
/**
* @param backlogMinutes normal time to beat of the pile games IGDB knew on that day
*/
public record PileDay(LocalDate day, long backlogGames, long neverPlayed, long backlogMinutes) {}
/**
* @param added games that came onto the pile; not the games of the first sync of a Steam account
* @param done games that left the pile for another status; a removed or hidden game is not done
*/
public record MonthMoves(long added, long done) {}
public record PileTimes(long normalMinutes, long completionistMinutes, long gamesWithTimeToBeat) {}
public record PlayedCounts(long started, long underOneHour) {}
}
@@ -0,0 +1,67 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.UserStats;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;
import java.sql.Date;
import java.time.LocalDate;
import java.util.List;
/**
* The daily pile size per user, table backlog_snapshot. Counts listed games only, like {@link UserGameRepository#LISTED}.
*/
@Repository
@RequiredArgsConstructor
public class BacklogSnapshotRepository {
private static final String PILE_COLUMNS = """
count(*) filter (where ug.status = 'BACKLOG') as backlog_games,
count(*) filter (where ug.playtime_minutes = 0) as never_played,
coalesce(sum(t.normally_minutes) filter (where ug.status = 'BACKLOG'), 0) as backlog_minutes,
count(*) as listed_games
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
""";
private final NamedParameterJdbcTemplate jdbc;
/**
* One row per user with listed games, also users without a Steam sync; a second run on the same day replaces it.
*
* @return the number of users
*/
public int writeAll(LocalDate day) {
return jdbc.update("""
insert into backlog_snapshot (user_id, day, backlog_games, never_played, backlog_minutes, listed_games)
select ug.user_id, :day, %s group by ug.user_id
on conflict (user_id, day) do update set backlog_games = excluded.backlog_games,
never_played = excluded.never_played, backlog_minutes = excluded.backlog_minutes,
listed_games = excluded.listed_games
""".formatted(PILE_COLUMNS), new MapSqlParameterSource("day", Date.valueOf(day)));
}
/**
* @return oldest first
*/
public List<UserStats.PileDay> findSince(long userId, LocalDate from) {
return jdbc.query("""
select day, backlog_games, never_played, backlog_minutes from backlog_snapshot
where user_id = :userId and day >= :from order by day
""", new MapSqlParameterSource("userId", userId).addValue("from", Date.valueOf(from)),
(rs, i) -> new UserStats.PileDay(rs.getDate("day").toLocalDate(), rs.getLong("backlog_games"),
rs.getLong("never_played"), rs.getLong("backlog_minutes")));
}
/**
* The pile of now, for the day that has no row yet.
*/
public UserStats.PileDay live(long userId, LocalDate day) {
return jdbc.queryForObject("select " + PILE_COLUMNS + "and ug.user_id = :userId",
new MapSqlParameterSource("userId", userId),
(rs, i) -> new UserStats.PileDay(day, rs.getLong("backlog_games"), rs.getLong("never_played"),
rs.getLong("backlog_minutes")));
}
}
@@ -18,15 +18,16 @@ public interface GameMoodsRepository extends JpaRepository<GameMoods, Long> {
/**
* @param moods {@link core_service.modules.library.domain.Mood} names; empty when none fits
* @param genres IGDB genre names; empty when IGDB has none
*/
// An upsert, not save(): two users' syncs can store the same new game at the same time.
@Modifying
@Transactional
@Query(value = "insert into game_mood (appid, moods, endless, checked_at) " +
"values (:appid, cast(:moods as varchar(16)[]), :endless, :checkedAt) " +
"on conflict (appid) do update set moods = excluded.moods, endless = excluded.endless, " +
"checked_at = excluded.checked_at",
@Query(value = "insert into game_mood (appid, moods, genres, endless, checked_at) " +
"values (:appid, cast(:moods as varchar(16)[]), cast(:genres as varchar(64)[]), :endless, :checkedAt) " +
"on conflict (appid) do update set moods = excluded.moods, genres = excluded.genres, " +
"endless = excluded.endless, checked_at = excluded.checked_at",
nativeQuery = true)
void upsert(@Param("appid") Long appid, @Param("moods") String[] moods, @Param("endless") Boolean endless,
@Param("checkedAt") Instant checkedAt);
void upsert(@Param("appid") Long appid, @Param("moods") String[] moods, @Param("genres") String[] genres,
@Param("endless") Boolean endless, @Param("checkedAt") Instant checkedAt);
}
@@ -1,6 +1,7 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.AchievementProgress;
import core_service.modules.library.domain.FinishedWithoutDate;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.Mood;
import core_service.modules.library.domain.StatusCount;
@@ -19,7 +20,9 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.Locale;
import java.util.Optional;
@@ -75,6 +78,21 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
"where " + LISTED + "group by ug.status")
List<StatusCount> countByStatus(@Param("userId") Long userId);
/**
* Like {@link #countByStatus}, but with every status, also those with 0 games.
*/
default Map<GameStatus, Long> countEveryStatus(Long userId) {
Map<GameStatus, Long> byStatus = new EnumMap<>(GameStatus.class);
for (GameStatus status : GameStatus.values()) {
byStatus.put(status, 0L);
}
countByStatus(userId).forEach(c -> byStatus.put(c.status(), c.count()));
return byStatus;
}
@Query("select ug.appid from UserGame ug where " + LISTED)
List<Long> findAllListedAppids(@Param("userId") Long userId);
/**
* A random backlog game; a HIGH priority game is 4 times as likely as a LOW one or one without priority, a MEDIUM
* one twice.
@@ -207,6 +225,37 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
"core_service.modules.library.domain.GameStatus.PLAYING)")
int finishIfUntouched(@Param("userId") Long userId, @Param("appid") Long appid);
/**
* Only games with an unlocked achievement: the others cannot get an estimate.
*/
@Query(value = "select new core_service.modules.library.domain.FinishedWithoutDate(ug.userId, ug.appid) " +
"from UserGame ug where ug.removedAt is null and ug.finishedAtEstimate is null " +
"and ug.status in (core_service.modules.library.domain.GameStatus.FINISHED, " +
"core_service.modules.library.domain.GameStatus.COMPLETED) " +
"and exists (select 1 from UserGameAchievement a where a.userId = ug.userId " +
"and a.appid = ug.appid and a.unlocked = true) " +
"order by ug.appid, ug.userId")
List<FinishedWithoutDate> findFinishedWithoutDateCandidates();
@Modifying
@Transactional
@Query(value = "update user_game ug set finished_at_estimate = :finishedAt " +
"where ug.user_id = :userId and ug.appid = :appid and ug.finished_at_estimate is null",
nativeQuery = true)
int setFinishedAtEstimate(@Param("userId") Long userId, @Param("appid") Long appid,
@Param("finishedAt") Instant finishedAt);
/**
* Played from the pile: BACKLOG becomes PLAYING for games whose status the user never chose. One statement, not
* an entity change, so a status the user picks during the sync always wins. A hidden game keeps its status.
*/
@Modifying
@Query("update UserGame ug set ug.status = core_service.modules.library.domain.GameStatus.PLAYING " +
"where ug.userId = :userId and ug.appid in :appids and ug.removedAt is null and ug.hiddenAt is null " +
"and ug.statusSetByUser = false " +
"and ug.status = core_service.modules.library.domain.GameStatus.BACKLOG")
int playFromPileIfUntouched(@Param("userId") Long userId, @Param("appids") Collection<Long> appids);
/**
* Removes the Steam games the last sync did not see. Games added by hand never come from Steam and stay.
*/
@@ -0,0 +1,183 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.DayMinutes;
import core_service.modules.library.domain.UserStats;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;
import java.sql.Date;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import java.util.Optional;
/**
* The queries of the stats page that {@link UserGameRepository} does not have. Which games count is on UserStatsDto;
* the playtime days count like {@link PlaytimeDayRepository#minutesPerDay}.
*/
@Repository
@RequiredArgsConstructor
public class UserStatsRepository {
private static final String LISTED = "ug.user_id = :userId and ug.removed_at is null and ug.hidden_at is null ";
private static final String FINISHED = "('FINISHED', 'COMPLETED')";
/** A status change that finished the game: from another status; not a game that came in finished. */
private static final String FINISH_CHANGE =
"sc.to_status in %s and sc.from_status not in %s".formatted(FINISHED, FINISHED);
/** Per game, the first time it got finished. */
private static final String FIRST_FINISH = """
select sc.appid, min(sc.changed_at) as finished_at from user_game_status_change sc
where sc.user_id = :userId and %s
group by sc.appid
""".formatted(FINISH_CHANGE);
private static final String PLAYTIME_DAYS_NOT_HIDDEN = """
from user_game_playtime_day p
where p.user_id = :userId and not exists (select 1 from user_game ug where ug.user_id = p.user_id
and ug.appid = p.appid and ug.hidden_at is not null)
""";
private final NamedParameterJdbcTemplate jdbc;
public UserStats.PileTimes pileTimes(long userId) {
return jdbc.queryForObject("""
select coalesce(sum(t.normally_minutes), 0) as normal, coalesce(sum(t.completely_minutes), 0) as complete,
count(t.normally_minutes) as known
from user_game ug join game_time_to_beat t on t.appid = ug.appid
where %s and ug.status = 'BACKLOG'
""".formatted(LISTED), user(userId),
(rs, i) -> new UserStats.PileTimes(rs.getLong("normal"), rs.getLong("complete"), rs.getLong("known")));
}
public long minutesPlayedSince(long userId, LocalDate from) {
Long minutes = jdbc.queryForObject(
"select coalesce(sum(p.minutes), 0) " + PLAYTIME_DAYS_NOT_HIDDEN + "and p.day >= :from",
user(userId).addValue("from", Date.valueOf(from)), Long.class);
return minutes != null ? minutes : 0L;
}
public Optional<UserStats.OldestGame> oldestUnplayed(long userId) {
return jdbc.query("""
select ug.appid, sg.name, sg.image_url, ug.first_seen_at
from user_game ug join steam_game sg on sg.appid = ug.appid
where %s and ug.status = 'BACKLOG' and ug.playtime_minutes = 0
order by ug.first_seen_at, ug.appid limit 1
""".formatted(LISTED), 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();
}
public List<UserStats.GameMinutes> mostPlayed(long userId, int limit) {
return jdbc.query("""
select ug.appid, sg.name, sg.image_url, ug.playtime_minutes
from user_game ug join steam_game sg on sg.appid = ug.appid
where %s and ug.playtime_minutes > 0
order by ug.playtime_minutes desc, ug.appid limit :limit
""".formatted(LISTED), user(userId).addValue("limit", limit),
(rs, i) -> new UserStats.GameMinutes(rs.getLong("appid"), rs.getString("name"),
rs.getString("image_url"), rs.getLong("playtime_minutes")));
}
/**
* The listed game with playtime in the most months; more minutes break a tie.
*/
public Optional<UserStats.LoyalGame> mostLoyal(long userId) {
return jdbc.query("""
select l.appid, sg.name, sg.image_url, l.months, l.minutes
from (select p.appid, count(distinct date_trunc('month', p.day)) as months, sum(p.minutes) as minutes
%s group by p.appid) l
join user_game ug on ug.user_id = :userId and ug.appid = l.appid and ug.removed_at is null
join steam_game sg on sg.appid = l.appid
order by l.months desc, l.minutes desc, l.appid limit 1
""".formatted(PLAYTIME_DAYS_NOT_HIDDEN), user(userId),
(rs, i) -> new UserStats.LoyalGame(rs.getLong("appid"), rs.getString("name"),
rs.getString("image_url"), rs.getLong("months"), rs.getLong("minutes")))
.stream().findFirst();
}
public List<UserStats.TagCount> genres(long userId, int limit) {
return tagCounts(userId, "genres", limit);
}
public List<UserStats.TagCount> moods(long userId) {
return tagCounts(userId, "moods", Integer.MAX_VALUE);
}
private List<UserStats.TagCount> tagCounts(long userId, String column, int limit) {
return jdbc.query("""
select tag.name, count(*) as games, coalesce(sum(ug.playtime_minutes), 0) as minutes
from user_game ug join game_mood m on m.appid = ug.appid cross join unnest(m.%s) as tag(name)
where %s
group by tag.name order by minutes desc, games desc, tag.name limit :limit
""".formatted(column, LISTED), user(userId).addValue("limit", limit),
(rs, i) -> new UserStats.TagCount(rs.getString("name"), rs.getLong("games"), rs.getLong("minutes")));
}
public Optional<DayMinutes> longestDay(long userId) {
return jdbc.query("select p.day, sum(p.minutes) as minutes " + PLAYTIME_DAYS_NOT_HIDDEN +
"group by p.day order by minutes desc, p.day desc limit 1", user(userId),
(rs, i) -> new DayMinutes(rs.getDate("day").toLocalDate(), rs.getLong("minutes")))
.stream().findFirst();
}
public UserStats.PlayedCounts playedCounts(long userId) {
return jdbc.queryForObject("""
select count(*) filter (where ug.playtime_minutes > 0) as started,
count(*) filter (where ug.playtime_minutes between 1 and 59) as under_one_hour
from user_game ug where %s
""".formatted(LISTED), user(userId),
(rs, i) -> new UserStats.PlayedCounts(rs.getLong("started"), rs.getLong("under_one_hour")));
}
/**
* Only games that are FINISHED or COMPLETED now; FINISHED and later COMPLETED counts once, in the first year. The
* unlock of the ending achievement ({@code finished_at_estimate}) wins over the status history.
*/
public List<UserStats.YearCount> finishedPerYear(long userId, ZoneId zone) {
return jdbc.query("""
select cast(extract(year from coalesce(ug.finished_at_estimate, f.finished_at) at time zone :zone)
as integer) as year, count(*) as games
from user_game ug left join (%s) f on f.appid = ug.appid
where %s and ug.status in %s and coalesce(ug.finished_at_estimate, f.finished_at) is not null
group by year order by year
""".formatted(FIRST_FINISH, LISTED, FINISHED), user(userId).addValue("zone", zone.getId()),
(rs, i) -> new UserStats.YearCount(rs.getInt("year"), rs.getLong("games")));
}
public long finishedBeforeTracking(long userId) {
Long games = jdbc.queryForObject("""
select count(*) from user_game ug
where %s and ug.status in %s and ug.finished_at_estimate is null
and not exists (select 1 from user_game_status_change sc
where sc.user_id = ug.user_id and sc.appid = ug.appid and %s)
""".formatted(LISTED, FINISHED, FINISH_CHANGE), user(userId), Long.class);
return games != null ? games : 0L;
}
/**
* "Added": a game came onto the pile by the user, by a status move, or as a new Steam game after the first sync
* of the current Steam account; the games of that first sync are the import. "Done": a game left the pile.
*
* @param monthStart counts status changes from then on
*/
public UserStats.MonthMoves pileMoves(long userId, Instant monthStart) {
return jdbc.queryForObject("""
select count(distinct sc.appid) filter (where sc.to_status = 'BACKLOG' and (sc.by_user
or sc.from_status is not null or sc.changed_at > uss.history_since)) as added,
count(distinct sc.appid) filter (where sc.from_status = 'BACKLOG' and sc.to_status <> 'BACKLOG')
as done
from user_game_status_change sc join user_game ug on ug.user_id = sc.user_id and ug.appid = sc.appid
left join user_steam_sync uss on uss.user_id = sc.user_id
where %s and sc.changed_at >= :monthStart
""".formatted(LISTED), user(userId).addValue("monthStart", Timestamp.from(monthStart)),
(rs, i) -> new UserStats.MonthMoves(rs.getLong("added"), rs.getLong("done")));
}
private static MapSqlParameterSource user(long userId) {
return new MapSqlParameterSource("userId", userId);
}
}
@@ -43,8 +43,15 @@ public interface UserSteamSyncRepository extends JpaRepository<UserSteamSync, Lo
@Param("status") String status,
@Param("now") Instant now);
/**
* history_since changes only with the account: the games of that first sync are no "added" in the pile trend.
*/
@Modifying
@Query(value = "UPDATE user_steam_sync SET history_steam_id = :steamId WHERE user_id = :userId", nativeQuery = true)
// NOW(), not the app clock: in the sync's transaction it equals the changed_at of the import rows, which the trend
// compares with it. So this must run in the transaction that inserts them.
@Query(value = "UPDATE user_steam_sync SET history_since = CASE WHEN history_steam_id IS DISTINCT FROM :steamId " +
"THEN NOW() ELSE history_since END, history_steam_id = :steamId WHERE user_id = :userId",
nativeQuery = true)
void markHistoryBaseline(@Param("userId") Long userId, @Param("steamId") String steamId);
@Modifying
@@ -0,0 +1,57 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.EndingAchievement;
import core_service.modules.library.domain.FinishedWithoutDate;
import core_service.modules.library.domain.UserGameAchievement;
import core_service.modules.library.repo.GameAchievementSchemaRepository;
import core_service.modules.library.repo.UserGameAchievementRepository;
import core_service.modules.library.repo.UserGameRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Dates finished games by the unlock of their ending achievement: the status history knows only when the status was
* set, which for an old game is long after the ending. The game is FINISHED already, so a hint-level ending (not
* only a certain one) is good enough for the year.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class FinishedEstimateService {
private final UserGameRepository userGameRepository;
private final GameAchievementSchemaRepository schemaRepository;
private final UserGameAchievementRepository userAchievementRepository;
/**
* @return the number of games that got a date
*/
public int fillMissing() {
Map<Long, List<FinishedWithoutDate>> byApp = userGameRepository.findFinishedWithoutDateCandidates().stream()
.collect(Collectors.groupingBy(FinishedWithoutDate::appid));
int dated = 0;
for (Map.Entry<Long, List<FinishedWithoutDate>> app : byApp.entrySet()) {
Optional<EndingAchievement> ending = EndingAchievement.of(schemaRepository.findByAppid(app.getKey()));
if (ending.isEmpty()) continue;
String apiName = ending.get().achievement().getApiName();
for (FinishedWithoutDate game : app.getValue()) {
Optional<UserGameAchievement> unlock = userAchievementRepository
.findByUserIdAndAppid(game.userId(), game.appid()).stream()
.filter(a -> a.isUnlocked() && a.getUnlockedAt() != null && a.getApiName().equals(apiName))
.findFirst();
if (unlock.isPresent()) {
dated += userGameRepository.setFinishedAtEstimate(game.userId(), game.appid(),
unlock.get().getUnlockedAt());
}
}
}
log.info("Finished date estimated for {} games", dated);
return dated;
}
}
@@ -15,6 +15,7 @@ import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.Mood;
import core_service.modules.library.domain.StatGroup;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameAchievementMeta;
@@ -39,11 +40,12 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.Collection;
import java.util.Comparator;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -65,6 +67,7 @@ public class LibraryQueryService {
private final GameTimeToBeatRepository gameTimeToBeatRepository;
private final GameAchievementSchemaMetaRepository schemaMetaRepository;
private final UserGameAchievementMetaRepository userAchievementMetaRepository;
private final UserStatsService userStatsService;
@Value("${app.library.zone:Europe/Berlin}")
private ZoneId zone;
@@ -154,7 +157,7 @@ public class LibraryQueryService {
* @param excludeAppid the current suggestion, so a new pick differs from it; null for none
*/
public LibrarySummaryDto getSummary(Long userId, Long excludeAppid) {
Map<GameStatus, Long> byStatus = countByStatus(userId);
Map<GameStatus, Long> byStatus = userGameRepository.countEveryStatus(userId);
long total = byStatus.values().stream().mapToLong(Long::longValue).sum();
long neverPlayed = userGameRepository.countListedNeverPlayed(userId);
GameDto suggestion = userGameRepository.findRandomBacklogGame(userId, excludeAppid != null ? excludeAppid : UserGameRepository.NO_GAME)
@@ -191,8 +194,11 @@ public class LibraryQueryService {
return new ActivityDto(perDay, total, recentGames);
}
public PublicLibraryDto getPublicLibrary(Long userId) {
Map<GameStatus, Long> byStatus = countByStatus(userId);
/**
* @param profileStats the stat group names of the profile ticket
*/
public PublicLibraryDto getPublicLibrary(Long userId, Collection<String> profileStats) {
Map<GameStatus, Long> byStatus = userGameRepository.countEveryStatus(userId);
List<UserGame> playing = userGameRepository.findListedByStatusIn(userId,
List.of(GameStatus.PLAYING),
PageRequest.of(0, PUBLIC_PLAYING, Sort.by(Sort.Direction.DESC, "playtimeMinutes").and(Sort.by("appid"))));
@@ -204,19 +210,11 @@ public class LibraryQueryService {
.stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g));
long total = byStatus.values().stream().mapToLong(Long::longValue).sum();
List<AchievementProgress> started = startedGames(userGameRepository.findAchievementProgress(userId));
Set<StatGroup> shared = StatGroup.shared(profileStats);
return new PublicLibraryDto(total, userGameRepository.sumPlaytime(userId), byStatus,
toPublicGames(playing, meta), toPublicGames(finished, meta),
perfectGames(started), averageCompletion(started));
}
// Every status, also those with 0 games.
private Map<GameStatus, Long> countByStatus(Long userId) {
Map<GameStatus, Long> byStatus = new EnumMap<>(GameStatus.class);
for (GameStatus status : GameStatus.values()) {
byStatus.put(status, 0L);
}
userGameRepository.countByStatus(userId).forEach(c -> byStatus.put(c.status(), c.count()));
return byStatus;
perfectGames(started), averageCompletion(started),
shared.isEmpty() ? null : userStatsService.getStats(userId, shared));
}
private static List<PublicLibraryDto.Game> toPublicGames(List<UserGame> games, Map<Long, SteamGame> meta) {
@@ -1,6 +1,7 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.repo.BacklogSnapshotRepository;
import core_service.modules.library.repo.UserSteamSyncRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -10,6 +11,8 @@ import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
@Service
@@ -19,6 +22,8 @@ public class LibrarySyncService {
private final UserSteamSyncRepository userSteamSyncRepository;
private final LibrarySyncWorker worker;
private final BacklogSnapshotRepository backlogSnapshotRepository;
private final FinishedEstimateService finishedEstimateService;
@Value("${app.library.sync-ttl-hours:24}")
private long syncTtlHours;
@@ -35,6 +40,9 @@ public class LibrarySyncService {
@Value("${app.library.nightly-active-days:14}")
private long nightlyActiveDays;
@Value("${app.library.zone:Europe/Berlin}")
private ZoneId zone;
/**
* Trigger sync only if data is older than TTL or was synced for another Steam ID.
* Cheap gate check on caller thread, actual sync runs async via worker.
@@ -55,14 +63,39 @@ public class LibrarySyncService {
/**
* Syncs every known Steam account shortly before midnight, so the playtime history gets the growth of each
* day even when the user does not open the app. Accounts are synced one after the other on the scheduler
* thread; accounts without a user request in the last {@code nightly-active-days} are skipped.
* thread; accounts without a user request in the last {@code nightly-active-days} are skipped. Afterwards every
* user's pile size of the day goes to backlog_snapshot, and old finished games get a date from their ending
* achievement.
*/
@Scheduled(cron = "${app.library.nightly-sync-cron:0 55 23 * * *}", zone = "${app.library.zone:Europe/Berlin}")
public void syncAllNightly() {
Instant activeSince = Instant.now().minus(Duration.ofDays(nightlyActiveDays));
List<UserSteamSync> accounts = userSteamSyncRepository.findByUserSeenAtAfter(activeSince);
log.info("Nightly sync for {} Steam accounts", accounts.size());
accounts.forEach(sync -> worker.runSyncNow(sync.getUserId(), sync.getSteamId()));
// Taken before the syncs: the loop can run past midnight.
LocalDate day = LocalDate.now(zone);
try {
accounts.forEach(sync -> worker.runSyncNow(sync.getUserId(), sync.getSteamId()));
} finally {
writeBacklogSnapshots(day);
estimateFinishDates();
}
}
private void estimateFinishDates() {
try {
finishedEstimateService.fillMissing();
} catch (Exception e) {
log.error("Finished date estimate failed", e);
}
}
private void writeBacklogSnapshots(LocalDate day) {
try {
log.info("Backlog snapshot of {} for {} users", day, backlogSnapshotRepository.writeAll(day));
} catch (Exception e) {
log.error("Backlog snapshot of {} failed", day, e);
}
}
/**
@@ -109,10 +109,16 @@ public class LibrarySyncWorker {
return;
}
List<Long> played = new ArrayList<>();
for (SteamOwnedGamesResponse.Game g : games) {
if (g.appid() == null) continue;
upsertGame(g, syncStartedAt);
upsertUserGame(userId, g, syncStartedAt, recordPlaytime ? today : null);
if (upsertUserGame(userId, g, syncStartedAt, recordPlaytime ? today : null)) {
played.add(g.appid().longValue());
}
}
if (!played.isEmpty()) {
userGameRepository.playFromPileIfUntouched(userId, played);
}
int removed = userGameRepository.softDeleteMissingSince(userId, syncStartedAt, Instant.now());
@@ -185,8 +191,9 @@ public class LibrarySyncWorker {
/**
* @param playedOn the day that gets the playtime growth, null to record none
* @return true when playtime growth was recorded
*/
private void upsertUserGame(Long userId, SteamOwnedGamesResponse.Game g, Instant now, LocalDate playedOn) {
private boolean upsertUserGame(Long userId, SteamOwnedGamesResponse.Game g, Instant now, LocalDate playedOn) {
Long appid = g.appid().longValue();
UserGame existing = userGameRepository.findById(new UserGameId(userId, appid)).orElse(null);
int playtime = g.playtimeForever() != null ? g.playtimeForever() : 0;
@@ -209,6 +216,8 @@ public class LibrarySyncWorker {
// A Steam recount can lower playtime_forever; the growth back to the old value counts again. Accepted.
if (playedOn != null && !takesOverManualGame && playtime > previousPlaytime) {
playtimeDayRepository.addMinutes(userId, appid, playedOn, playtime - previousPlaytime);
return true;
}
return false;
}
}
@@ -20,7 +20,7 @@ import java.util.Set;
import java.util.TreeSet;
/**
* Stores the {@link Mood}s of games from IGDB's genres, themes and game modes, the same way
* Stores the IGDB genres of games and the {@link Mood}s from IGDB's genres, themes and game modes, the same way
* {@link TimeToBeatService} stores the time to beat: only for games without a fresh row, and nothing when IGDB fails.
*/
@Service
@@ -73,7 +73,10 @@ public class MoodService {
IgdbApiService.GameTags gameTags = tags.get(appid);
Set<Mood> moods = gameTags == null ? Set.of() : Mood.of(gameTags.genres(), gameTags.themes(), gameTags.modes());
Boolean endless = gameTags == null ? null : Mood.endless(gameTags.genres(), gameTags.themes(), gameTags.modes());
gameMoodsRepository.upsert(appid, moods.stream().map(Mood::name).sorted().toArray(String[]::new), endless, now);
String[] genres = gameTags == null ? new String[0]
: gameTags.genres().stream().distinct().sorted().toArray(String[]::new);
gameMoodsRepository.upsert(appid, moods.stream().map(Mood::name).sorted().toArray(String[]::new), genres,
endless, now);
}
}
@@ -89,6 +92,18 @@ public class MoodService {
}
}
/**
* For the genres and moods of the stats page: a user without a Steam sync gets them only here.
*/
@Async
public void refreshListedInBackground(Long userId) {
try {
refresh(userGameRepository.findAllListedAppids(userId));
} catch (Exception e) {
log.warn("Moods of the library of user {} not refreshed: {}", userId, e.getMessage());
}
}
/**
* For the picker: a user without a Steam sync gets moods only here, and a Steam user before the next sync.
*/
@@ -0,0 +1,106 @@
package core_service.modules.library.service;
import core_service.modules.library.api.dto.UserStatsDto;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.StatFormulas;
import core_service.modules.library.domain.StatGroup;
import core_service.modules.library.domain.UserStats;
import core_service.modules.library.repo.BacklogSnapshotRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.repo.UserStatsRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* The numbers of the stats page, built once per {@link StatGroup} for every page that shows them.
*/
@Service
@RequiredArgsConstructor
public class UserStatsService {
private static final int MOST_PLAYED = 5;
private static final int TOP_GENRES = 8;
private static final int TREND_DAYS = 90;
private final UserGameRepository userGameRepository;
private final UserStatsRepository userStatsRepository;
private final BacklogSnapshotRepository backlogSnapshotRepository;
@Value("${app.library.zone:Europe/Berlin}")
private ZoneId zone;
/**
* Runs only the queries of the asked groups; the others stay null.
*/
public UserStatsDto getStats(long userId, Set<StatGroup> groups) {
Map<GameStatus, Long> byStatus = groups.contains(StatGroup.OVERVIEW) || groups.contains(StatGroup.PILE)
? userGameRepository.countEveryStatus(userId) : Map.of();
long playtime = groups.contains(StatGroup.OVERVIEW) || groups.contains(StatGroup.HABITS)
? userGameRepository.sumPlaytime(userId) : 0;
return new UserStatsDto(
groups.contains(StatGroup.OVERVIEW) ? overview(userId, byStatus, playtime) : null,
groups.contains(StatGroup.PILE) ? pile(userId, byStatus.get(GameStatus.BACKLOG)) : null,
groups.contains(StatGroup.TOP_GAMES) ? topGames(userId) : null,
groups.contains(StatGroup.TASTE) ? taste(userId) : null,
groups.contains(StatGroup.HABITS) ? habits(userId, playtime) : null,
groups.contains(StatGroup.FINISHED) ? finished(userId) : null,
groups.contains(StatGroup.TREND) ? trend(userId) : null);
}
private UserStatsDto.Overview overview(long userId, Map<GameStatus, Long> byStatus, long playtime) {
long total = byStatus.values().stream().mapToLong(Long::longValue).sum();
long neverStarted = userGameRepository.countListedNeverPlayed(userId);
return new UserStatsDto.Overview(total, playtime, total - neverStarted, neverStarted, byStatus);
}
private UserStatsDto.Pile pile(long userId, long backlogGames) {
UserStats.PileTimes times = userStatsRepository.pileTimes(userId);
LocalDate from = LocalDate.now(zone).minusDays(StatFormulas.PACE_DAYS - 1L);
long recentMinutes = userStatsRepository.minutesPlayedSince(userId, from);
return new UserStatsDto.Pile(backlogGames, times.normalMinutes(), times.completionistMinutes(),
times.gamesWithTimeToBeat(), StatFormulas.minutesPerWeek(recentMinutes),
StatFormulas.paceWeeks(times.normalMinutes(), recentMinutes),
StatFormulas.workYears(times.normalMinutes()), userStatsRepository.oldestUnplayed(userId).orElse(null));
}
private UserStatsDto.TopGames topGames(long userId) {
return new UserStatsDto.TopGames(userStatsRepository.mostPlayed(userId, MOST_PLAYED),
userStatsRepository.mostLoyal(userId).orElse(null));
}
private UserStatsDto.Taste taste(long userId) {
return new UserStatsDto.Taste(userStatsRepository.genres(userId, TOP_GENRES), userStatsRepository.moods(userId));
}
private UserStatsDto.Habits habits(long userId, long playtime) {
UserStats.PlayedCounts played = userStatsRepository.playedCounts(userId);
return new UserStatsDto.Habits(StatFormulas.moonTrips(playtime),
userStatsRepository.longestDay(userId).orElse(null), played.underOneHour(),
StatFormulas.share(played.underOneHour(), played.started()));
}
private UserStatsDto.Finished finished(long userId) {
return new UserStatsDto.Finished(userStatsRepository.finishedPerYear(userId, zone),
userStatsRepository.finishedBeforeTracking(userId));
}
private UserStatsDto.Trend trend(long userId) {
LocalDate today = LocalDate.now(zone);
List<UserStats.PileDay> days = new ArrayList<>(
backlogSnapshotRepository.findSince(userId, today.minusDays(TREND_DAYS - 1L)));
if (days.isEmpty() || !days.getLast().day().equals(today)) {
days.add(backlogSnapshotRepository.live(userId, today));
}
UserStats.MonthMoves moves = userStatsRepository.pileMoves(userId,
today.withDayOfMonth(1).atStartOfDay(zone).toInstant());
return new UserStatsDto.Trend(days, moves);
}
}
@@ -0,0 +1,10 @@
package core_service.shared.security;
import java.util.Set;
/**
* A valid public-profile ticket from Auth.
*
* @param profileStats the names of the stat groups the user shows on the profile; empty for a ticket without them
*/
public record ProfileTicket(long userId, Set<String> profileStats) {}
@@ -8,7 +8,7 @@ import java.util.Optional;
public interface ProfileTicketVerifier {
/**
* @return the user of a valid, unexpired ticket; empty for anything else, also for a login token
* @return a valid, unexpired ticket; empty for anything else, also for a login token
*/
Optional<Long> verifyProfileTicket(String ticket);
Optional<ProfileTicket> verifyProfileTicket(String ticket);
}
@@ -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;
@@ -1,5 +1,6 @@
package core_service.configuration.security;
import core_service.shared.security.ProfileTicket;
import core_service.shared.security.UserPrincipal;
import core_service.shared.test.AbstractUnitTest;
import io.jsonwebtoken.Jwts;
@@ -13,7 +14,9 @@ import java.security.KeyPairGenerator;
import java.security.PrivateKey;
import java.util.Base64;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -102,7 +105,17 @@ class JwtServiceTest extends AbstractUnitTest {
@Test
void verifyProfileTicket_ReturnsTheUser() {
assertEquals(Optional.of(42L), jwtService.verifyProfileTicket(signTicket("public-profile", "42", 60_000)));
assertEquals(Optional.of(new ProfileTicket(42L, Set.of())),
jwtService.verifyProfileTicket(signTicket("public-profile", "42", 60_000)));
}
@Test
void verifyProfileTicket_ReadsTheSharedStatGroups() {
String ticket = Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", "42")
.claim("profileStats", List.of("PILE", "TREND"))
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact();
assertEquals(Optional.of(new ProfileTicket(42L, Set.of("PILE", "TREND"))), jwtService.verifyProfileTicket(ticket));
}
@Test
@@ -91,7 +91,7 @@ class LibraryControllerFinishedHintTest extends AbstractIntegrationTest {
userAchievementMetaRepository.save(new UserGameAchievementMeta(USER_ID, ENDING_GAME, Instant.now(), true, 1, 600));
gameTimeToBeatRepository.save(new GameTimeToBeat(MANUAL_GAME, null, 600, null, Instant.now()));
gameMoodsRepository.upsert(MANUAL_GAME, new String[]{"STORY"}, false, Instant.now());
gameMoodsRepository.upsert(MANUAL_GAME, new String[]{"STORY"}, new String[0], false, Instant.now());
}
private UserGame steamGame(boolean statusSetByUser) {
@@ -93,9 +93,9 @@ class LibraryControllerPickerTest extends AbstractIntegrationTest {
game(20L, GameStatus.BACKLOG, null, null);
game(30L, GameStatus.BACKLOG, null, null);
game(40L, GameStatus.FINISHED, null, null);
gameMoodsRepository.upsert(10L, new String[]{"CHILL", "COOP"}, false, Instant.now());
gameMoodsRepository.upsert(20L, new String[]{"ACTION"}, false, Instant.now());
gameMoodsRepository.upsert(40L, new String[]{"CHILL"}, false, Instant.now());
gameMoodsRepository.upsert(10L, new String[]{"CHILL", "COOP"}, new String[0], false, Instant.now());
gameMoodsRepository.upsert(20L, new String[]{"ACTION"}, new String[0], false, Instant.now());
gameMoodsRepository.upsert(40L, new String[]{"CHILL"}, new String[0], false, Instant.now());
mockMvc.perform(get("/api/library/picker").param("mood", "CHILL"))
.andExpect(status().isOk())
@@ -124,8 +124,8 @@ class LibraryControllerPickerTest extends AbstractIntegrationTest {
void picker_MoodAndBudget_BothMustFit() throws Exception {
game(10L, GameStatus.BACKLOG, null, 300);
game(20L, GameStatus.BACKLOG, null, 3000);
gameMoodsRepository.upsert(10L, new String[]{"STORY"}, false, Instant.now());
gameMoodsRepository.upsert(20L, new String[]{"STORY"}, false, Instant.now());
gameMoodsRepository.upsert(10L, new String[]{"STORY"}, new String[0], false, Instant.now());
gameMoodsRepository.upsert(20L, new String[]{"STORY"}, new String[0], false, Instant.now());
mockMvc.perform(get("/api/library/picker").param("mood", "STORY").param("maxMinutes", "900"))
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10)));
@@ -0,0 +1,107 @@
package core_service.modules.library.api;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.service.LibrarySyncService;
import core_service.modules.library.service.MoodService;
import core_service.modules.library.service.SpeedrunService;
import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.util.List;
import static org.hamcrest.Matchers.nullValue;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* The dev profile authenticates every request as user 1.
*/
@AutoConfigureMockMvc
class LibraryControllerStatsTest extends AbstractIntegrationTest {
private static final long USER_ID = 1L;
@Autowired
private MockMvc mockMvc;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private GameTimeToBeatRepository gameTimeToBeatRepository;
// The same mocks as LibraryControllerPickerTest, so both share one Spring context.
@MockitoBean
private LibrarySyncService syncService;
@MockitoBean
private MoodService moodService;
@MockitoBean
private SpeedrunService speedrunService;
@BeforeEach
void seed() {
userGameRepository.deleteAll();
steamGameRepository.saveAll(List.of(
new SteamGame(220L, "Half-Life 2", null, Instant.now()),
new SteamGame(620L, "Portal 2", null, Instant.now())));
UserGame finished = new UserGame(USER_ID, 620L, 600, Instant.now(), Instant.now());
finished.setStatus(GameStatus.FINISHED);
userGameRepository.saveAll(List.of(new UserGame(USER_ID, 220L, 0, Instant.now(), Instant.now()), finished));
gameTimeToBeatRepository.save(new GameTimeToBeat(220L, 500, 700, 900, Instant.now()));
}
@Test
void stats_NoGroups_HasEveryGroup() throws Exception {
mockMvc.perform(get("/api/library/stats"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.overview.totalGames").value(2))
.andExpect(jsonPath("$.overview.neverStarted").value(1))
.andExpect(jsonPath("$.overview.byStatus.DROPPED").value(0))
.andExpect(jsonPath("$.pile.backlogGames").value(1))
.andExpect(jsonPath("$.pile.normalMinutes").value(700))
.andExpect(jsonPath("$.pile.paceWeeks").value(nullValue()))
.andExpect(jsonPath("$.pile.oldestUnplayed.name").value("Half-Life 2"))
.andExpect(jsonPath("$.topGames.mostPlayed[0].appid").value(620))
.andExpect(jsonPath("$.taste.genres").isArray())
.andExpect(jsonPath("$.habits.underOneHourGames").value(0))
.andExpect(jsonPath("$.finished.finishedBeforeTracking").value(1));
verify(moodService).refreshListedInBackground(USER_ID);
}
@Test
void stats_Groups_OnlyThose() throws Exception {
mockMvc.perform(get("/api/library/stats").param("groups", "PILE,HABITS"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.pile.backlogGames").value(1))
.andExpect(jsonPath("$.habits").exists())
.andExpect(jsonPath("$.overview").value(nullValue()))
.andExpect(jsonPath("$.taste").value(nullValue()));
verify(moodService, never()).refreshListedInBackground(USER_ID);
}
@Test
void stats_UnknownGroup_Is400() throws Exception {
mockMvc.perform(get("/api/library/stats").param("groups", "MONEY"))
.andExpect(status().isBadRequest());
}
}
@@ -23,6 +23,7 @@ import java.util.Date;
import java.util.List;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.nullValue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@@ -52,6 +53,12 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
}
private static String ticket(long userId, List<String> profileStats) {
return Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", String.valueOf(userId))
.claim("profileStats", profileStats)
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
}
private UserGame game(long appid, int playtime, GameStatus status, Integer rating) {
UserGame game = new UserGame(USER_ID, appid, playtime, Instant.now(), Instant.now());
game.setStatus(status);
@@ -114,6 +121,27 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.averageCompletion").value(62.5));
}
@Test
void publicLibrary_TicketWithoutStatGroups_HasNoStats() throws Exception {
mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket(USER_ID)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.stats").value(nullValue()));
mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket(USER_ID, List.of())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.stats").value(nullValue()));
}
@Test
void publicLibrary_OnlyTheSharedStatGroups() throws Exception {
mockMvc.perform(get("/api/public/profiles/library")
.header("X-Profile-Ticket", ticket(USER_ID, List.of("TOP_GAMES", "OVERVIEW", "MONEY"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.stats.topGames.mostPlayed[0].name").value("Portal 2"))
.andExpect(jsonPath("$.stats.overview").value(nullValue()))
.andExpect(jsonPath("$.stats.pile").value(nullValue()))
.andExpect(jsonPath("$.stats.trend").value(nullValue()));
}
@Test
void publicLibrary_WithoutOrWithABadTicket_Is401() throws Exception {
mockMvc.perform(get("/api/public/profiles/library")).andExpect(status().isUnauthorized());
@@ -0,0 +1,42 @@
package core_service.modules.library.domain;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.within;
class StatFormulasTest extends AbstractUnitTest {
@Test
void workYears_Are2080HoursEach() {
assertThat(StatFormulas.workYears(2080 * 60 * 3L)).isEqualTo(3.0, within(1e-9));
}
@Test
void moonTrips_Are195Point3HoursEach() {
assertThat(StatFormulas.moonTrips(Math.round(195.3 * 60 * 2))).isEqualTo(2.0, within(1e-9));
}
@Test
void minutesPerWeek_IsTheAverageOfThe90Days() {
assertThat(StatFormulas.minutesPerWeek(90 * 60)).isEqualTo(420);
}
@Test
void paceWeeks_PileDividedByTheWeeklyPlaytime() {
// 420 minutes a week, 4200 minutes of pile.
assertThat(StatFormulas.paceWeeks(4200, 90 * 60)).isEqualTo(10.0, within(1e-9));
}
@Test
void paceWeeks_NoPlaytime_IsNull() {
assertThat(StatFormulas.paceWeeks(4200, 0)).isNull();
}
@Test
void share_NoWhole_IsNull() {
assertThat(StatFormulas.share(0, 0)).isNull();
assertThat(StatFormulas.share(1, 4)).isEqualTo(0.25);
}
}
@@ -0,0 +1,19 @@
package core_service.modules.library.domain;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class StatGroupTest extends AbstractUnitTest {
@Test
void shared_LeavesOutOverviewAndUnknownNames() {
assertThat(StatGroup.shared(List.of("PILE", "OVERVIEW", "MONEY", "TREND")))
.isEqualTo(Set.of(StatGroup.PILE, StatGroup.TREND));
assertThat(StatGroup.shared(List.of())).isEmpty();
}
}
@@ -0,0 +1,106 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserStats;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@Import(BacklogSnapshotRepository.class)
class BacklogSnapshotRepositoryTest extends AbstractPersistenceTest {
private static final LocalDate DAY = LocalDate.of(2026, 9, 27);
@Autowired
private BacklogSnapshotRepository repository;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private GameTimeToBeatRepository gameTimeToBeatRepository;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
// V16 wrote a snapshot of the migration day; the tests start empty.
jdbc.update("delete from backlog_snapshot");
steamGameRepository.saveAllAndFlush(List.of(
new SteamGame(220L, "Half-Life 2", null, Instant.now()),
new SteamGame(620L, "Portal 2", null, Instant.now()),
new SteamGame(1000L, "Playtest", null, Instant.now()),
new SteamGame(-1942L, "Zelda: Tears of the Kingdom", null, Instant.now())));
gameTimeToBeatRepository.saveAllAndFlush(List.of(new GameTimeToBeat(220L, 1, 600, 1, Instant.now()),
new GameTimeToBeat(1000L, 1, 9999, 1, Instant.now())));
}
private UserGame game(long userId, long appid, int playtime, GameStatus status) {
UserGame game = new UserGame(userId, appid, playtime, Instant.now(), Instant.now());
game.setStatus(status);
return game;
}
@Test
void writeAll_CountsTheListedGamesOfEveryUser() {
UserGame hidden = game(1L, 1000L, 0, GameStatus.BACKLOG);
hidden.setHiddenAt(Instant.now());
UserGame removed = game(1L, 620L, 0, GameStatus.BACKLOG);
removed.setRemovedAt(Instant.now());
userGameRepository.saveAllAndFlush(List.of(
game(1L, 220L, 0, GameStatus.BACKLOG), hidden, removed,
UserGame.manual(2L, -1942L, "Switch", GameStatus.PLAYING, 0, Instant.now())));
assertThat(repository.writeAll(DAY)).isEqualTo(2);
assertThat(repository.findSince(1L, DAY)).containsExactly(new UserStats.PileDay(DAY, 1, 1, 600));
assertThat(repository.findSince(2L, DAY)).containsExactly(new UserStats.PileDay(DAY, 0, 1, 0));
}
@Test
void writeAll_SecondRunOnTheSameDay_ReplacesTheRow() {
UserGame game = game(1L, 220L, 0, GameStatus.BACKLOG);
userGameRepository.saveAndFlush(game);
repository.writeAll(DAY);
jdbc.update("update user_game set status = 'PLAYING', playtime_minutes = 30 where appid = 220");
repository.writeAll(DAY);
assertThat(repository.findSince(1L, DAY)).containsExactly(new UserStats.PileDay(DAY, 0, 0, 0));
}
@Test
void findSince_LeavesOutOlderDays() {
userGameRepository.saveAndFlush(game(1L, 220L, 0, GameStatus.BACKLOG));
repository.writeAll(DAY.minusDays(100));
repository.writeAll(DAY.minusDays(1));
repository.writeAll(DAY);
assertThat(repository.findSince(1L, DAY.minusDays(89))).extracting(UserStats.PileDay::day)
.containsExactly(DAY.minusDays(1), DAY);
}
@Test
void live_IsThePileOfNow() {
userGameRepository.saveAndFlush(game(1L, 220L, 0, GameStatus.BACKLOG));
assertThat(repository.live(1L, DAY)).isEqualTo(new UserStats.PileDay(DAY, 1, 1, 600));
assertThat(repository.live(3L, DAY)).isEqualTo(new UserStats.PileDay(DAY, 0, 0, 0));
}
}
@@ -28,30 +28,41 @@ class GameMoodsRepositoryTest extends AbstractPersistenceTest {
@Test
void upsert_StoresTheEndlessFlag() {
gameMoodsRepository.upsert(427520L, new String[]{"CHILL"}, true, Instant.now());
gameMoodsRepository.upsert(427520L, new String[]{"CHILL"}, new String[0], true, Instant.now());
entityManager.clear();
assertThat(gameMoodsRepository.findById(427520L).orElseThrow().getEndless()).isTrue();
gameMoodsRepository.upsert(427520L, new String[]{"CHILL"}, false, Instant.now());
gameMoodsRepository.upsert(427520L, new String[]{"CHILL"}, new String[0], false, Instant.now());
entityManager.clear();
assertThat(gameMoodsRepository.findById(427520L).orElseThrow().getEndless()).isFalse();
}
@Test
void upsert_StoresTheMoodsAndReplacesThemOnTheNextRefresh() {
gameMoodsRepository.upsert(620L, new String[]{"CHILL", "COOP"}, false, Instant.now());
gameMoodsRepository.upsert(620L, new String[]{"CHILL", "COOP"}, new String[0], false, Instant.now());
assertThat(gameMoodsRepository.findById(620L).orElseThrow().getMoods()).containsExactly("CHILL", "COOP");
gameMoodsRepository.upsert(620L, new String[0], false, Instant.now());
gameMoodsRepository.upsert(620L, new String[0], new String[0], false, Instant.now());
// The native upsert passes the persistence context; read the row again.
entityManager.clear();
assertThat(gameMoodsRepository.findById(620L).orElseThrow().getMoods()).isEmpty();
}
@Test
void upsert_StoresTheGenresAndReplacesThemOnTheNextRefresh() {
gameMoodsRepository.upsert(620L, new String[0], new String[]{"Platform", "Puzzle"}, false, Instant.now());
entityManager.clear();
assertThat(gameMoodsRepository.findById(620L).orElseThrow().getGenres()).containsExactly("Platform", "Puzzle");
gameMoodsRepository.upsert(620L, new String[0], new String[]{"Puzzle"}, false, Instant.now());
entityManager.clear();
assertThat(gameMoodsRepository.findById(620L).orElseThrow().getGenres()).containsExactly("Puzzle");
}
@Test
void findFreshAppids_LeavesOutOldAndMissingRows() {
gameMoodsRepository.upsert(620L, new String[]{"STORY"}, false, Instant.now());
gameMoodsRepository.upsert(220L, new String[]{"ACTION"}, false, Instant.now().minus(Duration.ofDays(40)));
gameMoodsRepository.upsert(620L, new String[]{"STORY"}, new String[0], false, Instant.now());
gameMoodsRepository.upsert(220L, new String[]{"ACTION"}, new String[0], false, Instant.now().minus(Duration.ofDays(40)));
assertThat(gameMoodsRepository.findFreshAppids(List.of(620L, 220L, 700L), Instant.now().minus(Duration.ofDays(30))))
.isEqualTo(Set.of(620L));
@@ -0,0 +1,111 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import core_service.shared.test.AbstractPersistenceTest;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The trigger of V14 that writes user_game_status_change.
*/
class StatusChangeLogTest extends AbstractPersistenceTest {
private static final long USER_ID = 1L;
private static final long APPID = 620L;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private EntityManager entityManager;
@BeforeEach
void seed() {
steamGameRepository.save(new SteamGame(APPID, "Portal 2", null, Instant.now()));
steamGameRepository.save(new SteamGame(-1942L, "Zelda: Tears of the Kingdom", null, Instant.now()));
}
/** Each row as "from->to by_user". */
private List<String> log() {
entityManager.flush();
@SuppressWarnings("unchecked")
List<Object[]> rows = entityManager.createNativeQuery(
"select from_status, to_status, by_user from user_game_status_change order by id").getResultList();
return rows.stream().map(row -> row[0] + "->" + row[1] + " " + row[2]).toList();
}
private UserGame game(long appid) {
return userGameRepository.findById(new UserGameId(USER_ID, appid)).orElseThrow();
}
@Test
void syncInsert_LogsTheFirstStatusNotByUser() {
userGameRepository.save(new UserGame(USER_ID, APPID, 0, Instant.now(), Instant.now()));
assertThat(log()).containsExactly("null->BACKLOG false");
}
@Test
void manualAdd_LogsTheStatusByUser() {
userGameRepository.save(UserGame.manual(USER_ID, -1942L, "Switch", GameStatus.PLAYING, 0, Instant.now()));
assertThat(log()).containsExactly("null->PLAYING true");
}
@Test
void statusChangedByUser_IsLoggedWithTheOldStatus() {
userGameRepository.save(new UserGame(USER_ID, APPID, 0, Instant.now(), Instant.now()));
entityManager.flush();
UserGame game = game(APPID);
game.setStatus(GameStatus.DROPPED);
game.setStatusSetByUser(true);
assertThat(log()).containsExactly("null->BACKLOG false", "BACKLOG->DROPPED true");
}
@Test
void otherFieldsOrTheSameStatus_LogNothing() {
userGameRepository.save(new UserGame(USER_ID, APPID, 0, Instant.now(), Instant.now()));
entityManager.flush();
UserGame game = game(APPID);
game.setStatus(GameStatus.BACKLOG);
game.setStatusSetByUser(true);
game.setRating(4);
game.setNotes("later");
assertThat(log()).containsExactly("null->BACKLOG false");
}
@Test
void finishIfUntouched_BulkUpdateIsLoggedNotByUser() {
userGameRepository.save(new UserGame(USER_ID, APPID, 0, Instant.now(), Instant.now()));
entityManager.flush();
assertThat(userGameRepository.finishIfUntouched(USER_ID, APPID)).isEqualTo(1);
assertThat(log()).containsExactly("null->BACKLOG false", "BACKLOG->FINISHED false");
}
@Test
void deletedGame_TakesItsLogRowsAlong() {
userGameRepository.save(UserGame.manual(USER_ID, -1942L, "Switch", GameStatus.PLAYING, 0, Instant.now()));
entityManager.flush();
userGameRepository.delete(game(-1942L));
assertThat(log()).isEmpty();
}
}
@@ -9,6 +9,7 @@ import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import core_service.shared.test.AbstractPersistenceTest;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -45,6 +46,9 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
@Autowired
private PlatformTransactionManager transactionManager;
@Autowired
private EntityManager entityManager;
@BeforeEach
void seed() {
steamGameRepository.saveAll(List.of(
@@ -65,6 +69,37 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
.map(UserGame::getAppid).getContent();
}
@Test
void playFromPileIfUntouched_OnlyUntouchedListedBacklogGames() {
userGameRepository.findById(new UserGameId(USER_ID, 220L)).orElseThrow().setStatus(GameStatus.BACKLOG);
userGameRepository.findById(new UserGameId(USER_ID, 380L)).orElseThrow().setStatusSetByUser(true);
userGameRepository.findById(new UserGameId(USER_ID, 700L)).orElseThrow().setHiddenAt(Instant.now());
userGameRepository.flush();
int changed = userGameRepository.playFromPileIfUntouched(USER_ID, List.of(220L, 380L, 620L, 700L));
entityManager.clear();
assertEquals(1, changed);
assertEquals(GameStatus.PLAYING, status(220L));
assertEquals(GameStatus.BACKLOG, status(380L));
assertEquals(GameStatus.PLAYING, status(620L));
assertEquals(GameStatus.BACKLOG, status(700L));
}
@Test
void playFromPileIfUntouched_AStatusTheUserChoseWins() {
UserGame game = userGameRepository.findById(new UserGameId(USER_ID, 380L)).orElseThrow();
game.setStatus(GameStatus.FINISHED);
game.setStatusSetByUser(true);
userGameRepository.flush();
assertEquals(0, userGameRepository.playFromPileIfUntouched(USER_ID, List.of(380L)));
}
private GameStatus status(long appid) {
return userGameRepository.findById(new UserGameId(USER_ID, appid)).orElseThrow().getStatus();
}
@Test
void softDeleteMissingSince_KeepsGamesAddedByHand() {
steamGameRepository.save(new SteamGame(-1942L, "Zelda: Tears of the Kingdom", null, Instant.now()));
@@ -0,0 +1,300 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.DayMinutes;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserStats;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@Import(UserStatsRepository.class)
class UserStatsRepositoryTest extends AbstractPersistenceTest {
private static final long USER_ID = 1L;
private static final long OTHER_USER = 2L;
private static final long HIDDEN = 1000L;
private static final long REMOVED = 2000L;
private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin");
@Autowired
private UserStatsRepository repository;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private GameTimeToBeatRepository gameTimeToBeatRepository;
@Autowired
private GameMoodsRepository gameMoodsRepository;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
steamGameRepository.saveAllAndFlush(List.of(
new SteamGame(220L, "Half-Life 2", null, Instant.now()),
new SteamGame(620L, "Portal 2", null, Instant.now()),
new SteamGame(700L, "100% Orange Juice", null, Instant.now()),
new SteamGame(HIDDEN, "Playtest", null, Instant.now()),
new SteamGame(REMOVED, "Refunded", null, Instant.now())));
}
private UserGame game(long appid, int playtime, GameStatus status, Instant firstSeenAt) {
UserGame game = new UserGame(USER_ID, appid, playtime, firstSeenAt, firstSeenAt);
game.setStatus(status);
return game;
}
/** A hidden and a removed game that must never count, with the biggest numbers of all. */
private void seedHiddenAndRemoved() {
UserGame hidden = game(HIDDEN, 90_000, GameStatus.BACKLOG, Instant.EPOCH);
hidden.setHiddenAt(Instant.now());
UserGame removed = game(REMOVED, 0, GameStatus.BACKLOG, Instant.EPOCH);
removed.setRemovedAt(Instant.now());
userGameRepository.saveAllAndFlush(List.of(hidden, removed));
gameTimeToBeatRepository.saveAllAndFlush(List.of(new GameTimeToBeat(HIDDEN, 1, 99_999, 99_999, Instant.now()),
new GameTimeToBeat(REMOVED, 1, 99_999, 99_999, Instant.now())));
gameMoodsRepository.upsert(HIDDEN, new String[]{"CHILL"}, new String[]{"Simulator"}, true, Instant.now());
addDay(HIDDEN, LocalDate.now(BERLIN), 5000);
}
private void addDay(long appid, LocalDate day, int minutes) {
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, ?, ?, ?)",
USER_ID, day, appid, minutes);
}
@Test
void pileTimes_SumsTheListedBacklogOnly() {
userGameRepository.saveAllAndFlush(List.of(
game(220L, 0, GameStatus.BACKLOG, Instant.now()),
game(620L, 0, GameStatus.FINISHED, Instant.now()),
game(700L, 0, GameStatus.BACKLOG, Instant.now())));
gameTimeToBeatRepository.saveAllAndFlush(List.of(new GameTimeToBeat(220L, 300, 600, 1200, Instant.now()),
new GameTimeToBeat(620L, 300, 500, 800, Instant.now())));
seedHiddenAndRemoved();
assertThat(repository.pileTimes(USER_ID)).isEqualTo(new UserStats.PileTimes(600, 1200, 1));
}
@Test
void minutesPlayedSince_LeavesOutOlderDaysAndHiddenGames() {
userGameRepository.saveAllAndFlush(List.of(game(220L, 300, GameStatus.PLAYING, Instant.now())));
seedHiddenAndRemoved();
LocalDate today = LocalDate.now(BERLIN);
addDay(220L, today, 60);
addDay(220L, today.minusDays(89), 30);
addDay(220L, today.minusDays(90), 1000);
assertThat(repository.minutesPlayedSince(USER_ID, today.minusDays(89))).isEqualTo(90);
}
@Test
void oldestUnplayed_IsTheFirstSeenUnplayedPileGame_LowestAppidOnATie() {
Instant firstSync = Instant.parse("2026-01-01T00:00:00Z");
userGameRepository.saveAllAndFlush(List.of(
game(700L, 0, GameStatus.BACKLOG, firstSync),
game(620L, 0, GameStatus.BACKLOG, firstSync),
game(220L, 10, GameStatus.BACKLOG, Instant.EPOCH)));
seedHiddenAndRemoved();
UserStats.OldestGame oldest = repository.oldestUnplayed(USER_ID).orElseThrow();
assertThat(oldest.appid()).isEqualTo(620L);
assertThat(oldest.name()).isEqualTo("Portal 2");
assertThat(oldest.inLibrarySince()).isEqualTo(firstSync);
}
@Test
void mostPlayed_OrdersByPlaytimeAndLeavesOutUnplayedAndHidden() {
userGameRepository.saveAllAndFlush(List.of(
game(220L, 300, GameStatus.PLAYING, Instant.now()),
game(620L, 900, GameStatus.FINISHED, Instant.now()),
game(700L, 0, GameStatus.BACKLOG, Instant.now())));
seedHiddenAndRemoved();
assertThat(repository.mostPlayed(USER_ID, 5)).extracting(UserStats.GameMinutes::appid)
.containsExactly(620L, 220L);
}
@Test
void mostLoyal_MostMonthsWins_MoreMinutesBreakATie() {
userGameRepository.saveAllAndFlush(List.of(
game(220L, 300, GameStatus.PLAYING, Instant.now()),
game(620L, 900, GameStatus.PLAYING, Instant.now()),
game(700L, 900, GameStatus.PLAYING, Instant.now())));
seedHiddenAndRemoved();
addDay(220L, LocalDate.of(2026, 7, 1), 10);
addDay(220L, LocalDate.of(2026, 8, 1), 10);
addDay(620L, LocalDate.of(2026, 7, 1), 500);
addDay(620L, LocalDate.of(2026, 7, 2), 500);
addDay(700L, LocalDate.of(2026, 7, 3), 30);
addDay(700L, LocalDate.of(2026, 9, 3), 30);
UserStats.LoyalGame loyal = repository.mostLoyal(USER_ID).orElseThrow();
assertThat(loyal.appid()).isEqualTo(700L);
assertThat(loyal.months()).isEqualTo(2);
assertThat(loyal.minutes()).isEqualTo(60);
}
@Test
void mostLoyal_ARemovedGameIsNoCandidate() {
userGameRepository.saveAllAndFlush(List.of(game(220L, 300, GameStatus.PLAYING, Instant.now())));
seedHiddenAndRemoved();
addDay(220L, LocalDate.of(2026, 7, 1), 10);
addDay(REMOVED, LocalDate.of(2026, 7, 1), 10);
addDay(REMOVED, LocalDate.of(2026, 8, 1), 10);
assertThat(repository.mostLoyal(USER_ID)).map(UserStats.LoyalGame::appid).contains(220L);
}
@Test
void genresAndMoods_CountGamesAndPlaytimePerTag() {
userGameRepository.saveAllAndFlush(List.of(
game(220L, 300, GameStatus.PLAYING, Instant.now()),
game(620L, 900, GameStatus.FINISHED, Instant.now())));
seedHiddenAndRemoved();
gameMoodsRepository.upsert(220L, new String[]{"ACTION"}, new String[]{"Shooter"}, false, Instant.now());
gameMoodsRepository.upsert(620L, new String[]{"CHILL", "COOP"}, new String[]{"Platform", "Shooter"}, false,
Instant.now());
assertThat(repository.genres(USER_ID, 8)).containsExactly(
new UserStats.TagCount("Shooter", 2, 1200),
new UserStats.TagCount("Platform", 1, 900));
assertThat(repository.moods(USER_ID)).containsExactly(
new UserStats.TagCount("CHILL", 1, 900),
new UserStats.TagCount("COOP", 1, 900),
new UserStats.TagCount("ACTION", 1, 300));
}
@Test
void longestDay_SumsAllGamesOfTheDay() {
userGameRepository.saveAllAndFlush(List.of(
game(220L, 300, GameStatus.PLAYING, Instant.now()),
game(620L, 900, GameStatus.PLAYING, Instant.now())));
seedHiddenAndRemoved();
addDay(220L, LocalDate.of(2026, 7, 1), 100);
addDay(620L, LocalDate.of(2026, 7, 1), 100);
addDay(620L, LocalDate.of(2026, 7, 2), 150);
assertThat(repository.longestDay(USER_ID)).contains(new DayMinutes(LocalDate.of(2026, 7, 1), 200));
}
@Test
void playedCounts_UnderOneHourIsOneTo59Minutes() {
userGameRepository.saveAllAndFlush(List.of(
game(220L, 59, GameStatus.PLAYING, Instant.now()),
game(620L, 60, GameStatus.PLAYING, Instant.now()),
game(700L, 0, GameStatus.BACKLOG, Instant.now())));
seedHiddenAndRemoved();
assertThat(repository.playedCounts(USER_ID)).isEqualTo(new UserStats.PlayedCounts(2, 1));
}
@Test
void finishedPerYear_CountsAGameOnceInTheYearItFirstGotFinished() {
userGameRepository.saveAllAndFlush(List.of(
game(220L, 300, GameStatus.BACKLOG, Instant.now()),
game(620L, 300, GameStatus.BACKLOG, Instant.now()),
game(700L, 300, GameStatus.BACKLOG, Instant.now())));
// 23:30 UTC on New Year's Eve is already the next year in Berlin.
jdbc.update("insert into user_game_status_change (user_id, appid, from_status, to_status, by_user, changed_at) " +
"values (?, 220, 'BACKLOG', 'FINISHED', true, timestamptz '2024-12-31 23:30:00+00')", USER_ID);
setStatus(220L, "FINISHED");
setStatus(620L, "FINISHED");
setStatus(620L, "COMPLETED");
setStatus(700L, "FINISHED");
setStatus(700L, "PLAYING");
int thisYear = LocalDate.now(BERLIN).getYear();
assertThat(repository.finishedPerYear(USER_ID, BERLIN)).containsExactly(
new UserStats.YearCount(2025, 1),
new UserStats.YearCount(thisYear, 1));
}
@Test
void finishedBeforeTracking_FinishedGamesThatCameInFinished() {
userGameRepository.saveAllAndFlush(List.of(
game(220L, 300, GameStatus.FINISHED, Instant.now()),
game(620L, 300, GameStatus.BACKLOG, Instant.now())));
setStatus(620L, "COMPLETED");
assertThat(repository.finishedBeforeTracking(USER_ID)).isEqualTo(1);
}
@Test
void finished_HiddenAndOtherUsersGamesDoNotCount() {
UserGame hidden = game(HIDDEN, 0, GameStatus.BACKLOG, Instant.now());
hidden.setHiddenAt(Instant.now());
UserGame other = new UserGame(OTHER_USER, 220L, 0, Instant.now(), Instant.now());
userGameRepository.saveAllAndFlush(List.of(hidden, other));
jdbc.update("update user_game set status = 'FINISHED' where appid in (?, 220)", HIDDEN);
assertThat(repository.finishedPerYear(USER_ID, BERLIN)).isEmpty();
assertThat(repository.finishedBeforeTracking(USER_ID)).isZero();
}
@Test
void pileMoves_SyncInsertsAreNoAddBeforeTheBaseline_MovesAre() {
Instant monthStart = Instant.now().minusSeconds(3600);
userGameRepository.saveAllAndFlush(List.of(
game(220L, 0, GameStatus.BACKLOG, Instant.now()),
game(620L, 0, GameStatus.BACKLOG, Instant.now())));
setStatus(220L, "DROPPED");
setStatus(620L, "PLAYING");
setStatus(620L, "BACKLOG");
setStatus(620L, "FINISHED");
// No Steam baseline: the two inserts are the import; 620 back onto the pile is an add.
assertThat(repository.pileMoves(USER_ID, monthStart)).isEqualTo(new UserStats.MonthMoves(1, 2));
}
@Test
void pileMoves_GamesAddedByHandAreAdded() {
UserGame manual = UserGame.manual(USER_ID, 700L, "Switch", GameStatus.BACKLOG, 0, Instant.now());
userGameRepository.saveAndFlush(manual);
assertThat(repository.pileMoves(USER_ID, Instant.now().minusSeconds(3600)))
.isEqualTo(new UserStats.MonthMoves(1, 0));
}
@Test
void pileMoves_RemovedOrHiddenGamesAndOlderChangesDoNotCount() {
userGameRepository.saveAllAndFlush(List.of(
game(220L, 0, GameStatus.BACKLOG, Instant.now()),
game(620L, 0, GameStatus.BACKLOG, Instant.now()),
game(700L, 0, GameStatus.BACKLOG, Instant.now())));
setStatus(220L, "DROPPED");
setStatus(620L, "DROPPED");
jdbc.update("update user_game set removed_at = now() where appid = 220");
jdbc.update("update user_game set hidden_at = now() where appid = 620");
jdbc.update("insert into user_game_status_change (user_id, appid, from_status, to_status, by_user, changed_at) " +
"values (?, 700, 'BACKLOG', 'PLAYING', true, now() - interval '40 days')", USER_ID);
jdbc.update("insert into user_steam_sync (user_id, steam_id, history_steam_id, history_since) " +
"values (?, '76561198000000001', '76561198000000001', now() - interval '1 day')", USER_ID);
assertThat(repository.pileMoves(USER_ID, Instant.now().minusSeconds(86400)))
.isEqualTo(new UserStats.MonthMoves(1, 0));
}
private void setStatus(long appid, String status) {
jdbc.update("update user_game set status = ? where user_id = ? and appid = ?", status, USER_ID, appid);
}
}
@@ -0,0 +1,122 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.GameAchievementSchema;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameAchievement;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.domain.UserStats;
import core_service.modules.library.repo.GameAchievementSchemaRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameAchievementRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.repo.UserStatsRepository;
import core_service.shared.test.AbstractPersistenceTest;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Instant;
import java.time.ZoneId;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Against the real tables: the candidates query, the guarded update and the stats that read the estimate.
*/
@Import({FinishedEstimateService.class, UserStatsRepository.class})
class FinishedEstimateServiceTest extends AbstractPersistenceTest {
private static final long USER_ID = 1L;
private static final long ENDING_GAME = 220L;
private static final long NO_ENDING_GAME = 620L;
private static final Instant ENDING_UNLOCKED = Instant.parse("2021-05-04T20:00:00Z");
@Autowired
private FinishedEstimateService service;
@Autowired
private UserStatsRepository userStatsRepository;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private GameAchievementSchemaRepository schemaRepository;
@Autowired
private UserGameAchievementRepository userAchievementRepository;
@Autowired
private EntityManager entityManager;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
steamGameRepository.saveAllAndFlush(List.of(
new SteamGame(ENDING_GAME, "Half-Life 2", null, Instant.now()),
new SteamGame(NO_ENDING_GAME, "Portal 2", null, Instant.now())));
GameAchievementSchema ending = new GameAchievementSchema(ENDING_GAME, "ACH_END", "The End", "Complete the game.",
null, null, false);
ending.setGlobalPercent(40.0);
GameAchievementSchema other = new GameAchievementSchema(NO_ENDING_GAME, "ACH_JUMP", "Jumper", "Jump 100 times",
null, null, false);
other.setGlobalPercent(50.0);
schemaRepository.saveAllAndFlush(List.of(ending, other));
// Came into the library finished: no finish in the status history.
UserGame finished = new UserGame(USER_ID, ENDING_GAME, 600, Instant.now(), Instant.now());
finished.setStatus(GameStatus.FINISHED);
UserGame completed = new UserGame(USER_ID, NO_ENDING_GAME, 900, Instant.now(), Instant.now());
completed.setStatus(GameStatus.COMPLETED);
userGameRepository.saveAllAndFlush(List.of(finished, completed));
userAchievementRepository.saveAllAndFlush(List.of(
new UserGameAchievement(USER_ID, ENDING_GAME, "ACH_END", true, ENDING_UNLOCKED),
new UserGameAchievement(USER_ID, NO_ENDING_GAME, "ACH_JUMP", true, Instant.now())));
}
private Instant estimate(long appid) {
entityManager.clear();
return userGameRepository.findById(new UserGameId(USER_ID, appid)).orElseThrow().getFinishedAtEstimate();
}
@Test
void fillMissing_DatesTheGameByTheUnlockOfItsEnding() {
assertThat(service.fillMissing()).isEqualTo(1);
assertThat(estimate(ENDING_GAME)).isEqualTo(ENDING_UNLOCKED);
assertThat(estimate(NO_ENDING_GAME)).isNull();
assertThat(userStatsRepository.finishedPerYear(USER_ID, ZoneId.of("Europe/Berlin")))
.containsExactly(new UserStats.YearCount(2021, 1));
assertThat(userStatsRepository.finishedBeforeTracking(USER_ID)).isEqualTo(1);
}
// E.g. Core's own FINISHED on the first sync of an old library, or the user marking old games finished.
@Test
void fillMissing_TheEndingWinsOverALaterFinishInTheStatusHistory() {
jdbc.update("update user_game set status = 'PLAYING' where appid = ?", ENDING_GAME);
jdbc.update("update user_game set status = 'FINISHED' where appid = ?", ENDING_GAME);
service.fillMissing();
assertThat(estimate(ENDING_GAME)).isEqualTo(ENDING_UNLOCKED);
assertThat(userStatsRepository.finishedPerYear(USER_ID, ZoneId.of("Europe/Berlin")))
.containsExactly(new UserStats.YearCount(2021, 1));
}
@Test
void fillMissing_SecondRun_ChangesNothing() {
service.fillMissing();
assertThat(service.fillMissing()).isZero();
}
}
@@ -1,23 +1,30 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.repo.BacklogSnapshotRepository;
import core_service.modules.library.repo.UserSteamSyncRepository;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InOrder;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@@ -34,6 +41,12 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
@Mock
private LibrarySyncWorker worker;
@Mock
private BacklogSnapshotRepository backlogSnapshotRepository;
@Mock
private FinishedEstimateService finishedEstimateService;
@InjectMocks
private LibrarySyncService syncService;
@@ -43,6 +56,7 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
ReflectionTestUtils.setField(syncService, "forceSyncCooldownSeconds", 60L);
ReflectionTestUtils.setField(syncService, "runningStaleMinutes", 5L);
ReflectionTestUtils.setField(syncService, "failedRetryMinutes", 15L);
ReflectionTestUtils.setField(syncService, "zone", ZoneId.of("Europe/Berlin"));
}
private void givenSync(String steamId, UserSteamSync.Status status, Duration age) {
@@ -106,6 +120,41 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
verify(worker, never()).runSync(anyLong(), anyString());
}
@Test
void syncAllNightly_WritesTheBacklogSnapshotOfTheStartDayAfterTheSyncs() {
when(userSteamSyncRepository.findByUserSeenAtAfter(any())).thenReturn(List.of(
new UserSteamSync(1L, "76561198000000001")));
LocalDate today = LocalDate.now(ZoneId.of("Europe/Berlin"));
syncService.syncAllNightly();
InOrder order = inOrder(worker, backlogSnapshotRepository, finishedEstimateService);
order.verify(worker).runSyncNow(1L, "76561198000000001");
order.verify(backlogSnapshotRepository).writeAll(today);
order.verify(finishedEstimateService).fillMissing();
}
@Test
void syncAllNightly_SnapshotFails_TheFinishDatesAreStillEstimated() {
when(userSteamSyncRepository.findByUserSeenAtAfter(any())).thenReturn(List.of());
when(backlogSnapshotRepository.writeAll(any())).thenThrow(new IllegalStateException("db down"));
syncService.syncAllNightly();
verify(finishedEstimateService).fillMissing();
}
@Test
void syncAllNightly_ASyncThrows_TheSnapshotIsStillWritten() {
when(userSteamSyncRepository.findByUserSeenAtAfter(any())).thenReturn(List.of(
new UserSteamSync(1L, "76561198000000001")));
doThrow(new IllegalStateException("db down")).when(worker).runSyncNow(anyLong(), anyString());
assertThrows(IllegalStateException.class, () -> syncService.syncAllNightly());
verify(backlogSnapshotRepository).writeAll(any());
}
@Test
void syncAllNightly_SyncsRecentlyUsedAccountsOneAfterTheOther() {
when(userSteamSyncRepository.findByUserSeenAtAfter(any())).thenReturn(List.of(
@@ -239,6 +239,38 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
verify(playtimeDayRepository).addMinutes(USER_ID, 220L, today(), 60);
}
@Test
void runSync_PlayedGames_GoFromThePileWithOneGuardedUpdate() {
UserGame existing = owned(0);
steamReturns(game(30, 30), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
verify(userGameRepository).playFromPileIfUntouched(USER_ID, List.of(220L));
// Never through the entity: a status the user saves during the sync would be overwritten.
assertEquals(GameStatus.BACKLOG, existing.getStatus());
}
@Test
void runSync_NoPlaytimeGrowth_LeavesThePileAlone() {
owned(100);
steamReturns(game(100, 0), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
verify(userGameRepository, never()).playFromPileIfUntouched(anyLong(), any());
}
@Test
void runSync_FirstSyncWithOldPlaytime_LeavesThePileAlone() {
owned(0);
steamReturns(game(5000, 0), null);
worker.runSync(USER_ID, STEAM_ID);
verify(userGameRepository, never()).playFromPileIfUntouched(anyLong(), any());
}
@Test
void runSync_PlaytimeUnchangedOrLower_RecordsNothing() {
owned(100);
@@ -55,9 +55,9 @@ class MoodServiceTest extends AbstractUnitTest {
service.refresh(List.of(220L, 620L, 700L));
verify(gameMoodsRepository).upsert(eq(620L), aryEq(new String[]{"CHILL", "COOP"}), eq(false), any(Instant.class));
verify(gameMoodsRepository).upsert(eq(700L), aryEq(new String[0]), isNull(), any(Instant.class));
verify(gameMoodsRepository, never()).upsert(eq(220L), any(), anyBoolean(), any());
verify(gameMoodsRepository).upsert(eq(620L), aryEq(new String[]{"CHILL", "COOP"}), aryEq(new String[]{"Puzzle"}), eq(false), any(Instant.class));
verify(gameMoodsRepository).upsert(eq(700L), aryEq(new String[0]), aryEq(new String[0]), isNull(), any(Instant.class));
verify(gameMoodsRepository, never()).upsert(eq(220L), any(), any(), anyBoolean(), any());
}
@Test
@@ -69,7 +69,7 @@ class MoodServiceTest extends AbstractUnitTest {
service.refresh(List.of(427520L));
verify(gameMoodsRepository).upsert(eq(427520L), any(), eq(true), any(Instant.class));
verify(gameMoodsRepository).upsert(eq(427520L), any(), any(), eq(true), any(Instant.class));
}
@Test
@@ -81,7 +81,7 @@ class MoodServiceTest extends AbstractUnitTest {
service.refreshBacklogInBackground(7L);
verify(gameMoodsRepository).upsert(eq(620L), aryEq(new String[0]), isNull(), any(Instant.class));
verify(gameMoodsRepository).upsert(eq(620L), aryEq(new String[0]), aryEq(new String[0]), isNull(), any(Instant.class));
}
@Test
@@ -93,7 +93,7 @@ class MoodServiceTest extends AbstractUnitTest {
service.refresh(List.of(-119388L));
verify(gameMoodsRepository).upsert(eq(-119388L), aryEq(new String[]{"STORY"}), eq(false), any(Instant.class));
verify(gameMoodsRepository).upsert(eq(-119388L), aryEq(new String[]{"STORY"}), aryEq(new String[]{"Adventure"}), eq(false), any(Instant.class));
}
@Test
@@ -104,7 +104,7 @@ class MoodServiceTest extends AbstractUnitTest {
service.refresh(List.of(620L));
verify(gameMoodsRepository, never()).upsert(any(), any(), anyBoolean(), any());
verify(gameMoodsRepository, never()).upsert(any(), any(), any(), anyBoolean(), any());
}
@Test
@@ -0,0 +1,97 @@
package core_service.modules.library.service;
import core_service.modules.library.api.dto.UserStatsDto;
import core_service.modules.library.domain.StatGroup;
import core_service.modules.library.domain.UserStats;
import core_service.modules.library.repo.BacklogSnapshotRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.repo.UserStatsRepository;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class UserStatsServiceTest extends AbstractUnitTest {
private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin");
@Mock
private UserGameRepository userGameRepository;
@Mock
private UserStatsRepository userStatsRepository;
@Mock
private BacklogSnapshotRepository backlogSnapshotRepository;
@InjectMocks
private UserStatsService service;
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(service, "zone", BERLIN);
}
@Test
void getStats_OnlyTheAskedGroups_TheOthersAreNullAndNotQueried() {
when(userStatsRepository.mostPlayed(7L, 5)).thenReturn(List.of());
when(userStatsRepository.mostLoyal(7L)).thenReturn(Optional.empty());
UserStatsDto stats = service.getStats(7L, Set.of(StatGroup.TOP_GAMES));
assertThat(stats.topGames()).isNotNull();
assertThat(stats.overview()).isNull();
assertThat(stats.pile()).isNull();
assertThat(stats.taste()).isNull();
assertThat(stats.habits()).isNull();
assertThat(stats.finished()).isNull();
assertThat(stats.trend()).isNull();
verifyNoInteractions(userGameRepository, backlogSnapshotRepository);
}
@Test
void trend_NoSnapshotToday_AddsTheLivePile() {
LocalDate today = LocalDate.now(BERLIN);
UserStats.PileDay yesterday = new UserStats.PileDay(today.minusDays(1), 10, 5, 600);
UserStats.PileDay live = new UserStats.PileDay(today, 9, 5, 500);
when(backlogSnapshotRepository.findSince(7L, today.minusDays(89))).thenReturn(List.of(yesterday));
when(backlogSnapshotRepository.live(7L, today)).thenReturn(live);
when(userStatsRepository.pileMoves(7L, today.withDayOfMonth(1).atStartOfDay(BERLIN).toInstant()))
.thenReturn(new UserStats.MonthMoves(0, 1));
UserStatsDto.Trend trend = service.getStats(7L, Set.of(StatGroup.TREND)).trend();
assertThat(trend.days()).containsExactly(yesterday, live);
assertThat(trend.thisMonth()).isEqualTo(new UserStats.MonthMoves(0, 1));
}
@Test
void trend_NoSnapshotYet_IsOnlyTheLivePile() {
LocalDate today = LocalDate.now(BERLIN);
when(backlogSnapshotRepository.live(7L, today)).thenReturn(new UserStats.PileDay(today, 9, 5, 500));
when(userStatsRepository.pileMoves(7L, today.withDayOfMonth(1).atStartOfDay(BERLIN).toInstant()))
.thenReturn(new UserStats.MonthMoves(0, 0));
assertThat(service.getStats(7L, Set.of(StatGroup.TREND)).trend().days()).hasSize(1);
}
@Test
void getStats_NoGroups_RunsNoQuery() {
UserStatsDto stats = service.getStats(7L, Set.of());
assertThat(stats).isEqualTo(new UserStatsDto(null, null, null, null, null, null, null));
verifyNoInteractions(userGameRepository, userStatsRepository, backlogSnapshotRepository);
}
}
@@ -0,0 +1,88 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.StatGroup;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.repo.BacklogSnapshotRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.repo.UserStatsRepository;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The trend group against the real status log and snapshots.
*/
@Import({UserStatsService.class, UserStatsRepository.class, BacklogSnapshotRepository.class})
class UserStatsServiceTrendTest extends AbstractPersistenceTest {
private static final long USER_ID = 1L;
@Autowired
private UserStatsService service;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
jdbc.update("delete from backlog_snapshot");
jdbc.update("delete from user_steam_sync");
steamGameRepository.saveAllAndFlush(List.of(
new SteamGame(-1942L, "Zelda: Tears of the Kingdom", null, Instant.now()),
new SteamGame(220L, "Half-Life 2", null, Instant.now()),
new SteamGame(620L, "Portal 2", null, Instant.now()),
new SteamGame(700L, "100% Orange Juice", null, Instant.now())));
}
@Test
void thisMonth_FirstSteamImportAfterManualGames_IsNoAdd() {
// Last month the user had only a game added by hand, so the nightly job wrote a snapshot then.
userGameRepository.saveAndFlush(UserGame.manual(USER_ID, -1942L, "Switch", GameStatus.BACKLOG, 0, Instant.now()));
jdbc.update("update user_game_status_change set changed_at = now() - interval '40 days' where appid = -1942");
LocalDate today = LocalDate.now(ZoneId.of("Europe/Berlin"));
jdbc.update("insert into backlog_snapshot (user_id, day, backlog_games, never_played, backlog_minutes, " +
"listed_games) values (?, ?, 1, 1, 0, 1)", USER_ID, today.minusDays(40));
// Today the user links Steam: the first sync imports the Steam library and then sets history_since.
importSteamGames();
jdbc.update("insert into user_steam_sync (user_id, steam_id, history_steam_id, history_since) " +
"values (?, '76561198000000001', '76561198000000001', now())", USER_ID);
assertThat(service.getStats(USER_ID, Set.of(StatGroup.TREND)).trend().thisMonth().added()).isZero();
}
@Test
void thisMonth_NewSteamGamesAfterTheFirstSync_AreAdded() {
jdbc.update("insert into user_steam_sync (user_id, steam_id, history_steam_id, history_since) " +
"values (?, '76561198000000001', '76561198000000001', now() - interval '1 day')", USER_ID);
importSteamGames();
assertThat(service.getStats(USER_ID, Set.of(StatGroup.TREND)).trend().thisMonth().added()).isEqualTo(3);
}
private void importSteamGames() {
userGameRepository.saveAllAndFlush(List.of(
new UserGame(USER_ID, 220L, 0, Instant.now(), Instant.now()),
new UserGame(USER_ID, 620L, 0, Instant.now(), Instant.now()),
new UserGame(USER_ID, 700L, 0, Instant.now(), Instant.now())));
}
}