Public Access
feat(stats): add PLATFORMS stat group and landing page statistics
Add PLATFORMS stat group to user stats: games and playtime per platform. Add platforms, genres, and finished-per-year statistics to public stats for the landing page.
This commit is contained in:
1 parent
216c1af62f
commit
732970697d
12 files changed
+244
-14
No files matched your search
@@ -51,7 +51,7 @@ To Be Played is split into three repositories:
|
|||||||
groups it shows: totals and statuses; the pile (time to beat, weeks "at your pace" of the last 90 days, years of
|
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
|
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
|
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
|
status history); games and playtime per platform (`PLATFORMS`: Steam games as "Steam", manual games by platform, trimmed and ignoring case, the most used spelling is the label); 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 (still owned, not hidden: the view
|
first sync of a Steam account is no "added", see `user_steam_sync.history_since`). Every number counts only listed games (still owned, not hidden: the view
|
||||||
`listed_user_game`, V24).
|
`listed_user_game`, V24).
|
||||||
- **Status history**: a database trigger logs every status change of a library game in `user_game_status_change`
|
- **Status history**: a database trigger logs every status change of a library game in `user_game_status_change`
|
||||||
@@ -129,11 +129,11 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
|
|||||||
| `POST` | `/api/library/sync` | JWT |
|
| `POST` | `/api/library/sync` | JWT |
|
||||||
| `POST` | `/api/library/broken-images` (`{appid, kind}`, kind `COVER` or `ACHIEVEMENT_ICONS`; `204`) | 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/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/stats?groups=` (`OVERVIEW`, `PILE`, `TOP_GAMES`, `TASTE`, `HABITS`, `FINISHED`, `TREND`, `PLATFORMS`; none = all) | JWT |
|
||||||
| `GET` | `/api/library/achievements/summary` | JWT |
|
| `GET` | `/api/library/achievements/summary` | JWT |
|
||||||
| `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` | JWT |
|
| `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` | JWT |
|
||||||
| `GET` | `/api/public/profiles/library` | profile ticket |
|
| `GET` | `/api/public/profiles/library` | profile ticket |
|
||||||
| `GET` | `/api/stats/public` | public |
|
| `GET` | `/api/stats/public` (+ games per platform, top genres, finished per year) | public |
|
||||||
| `POST`, `PUT` | `/internal/accounts/deleted` (`{userIds}`; POST adds, PUT replaces the whole set; `204`) | service token |
|
| `POST`, `PUT` | `/internal/accounts/deleted` (`{userIds}`; POST adds, PUT replaces the whole set; `204`) | service token |
|
||||||
| `POST`, `PUT` | `/internal/accounts/states` (`{accounts: [{userId, steamId, blocked}]}`; POST updates, PUT replaces the whole set; `204`) | service token |
|
| `POST`, `PUT` | `/internal/accounts/states` (`{accounts: [{userId, steamId, blocked}]}`; POST updates, PUT replaces the whole set; `204`) | service token |
|
||||||
| `POST` | `/internal/accounts/purge` (`{userIds}`), `/internal/accounts/prune` (`{knownUserIds}`, not empty) | service token |
|
| `POST` | `/internal/accounts/purge` (`{userIds}`), `/internal/accounts/prune` (`{knownUserIds}`, not empty) | service token |
|
||||||
@@ -180,7 +180,7 @@ flowchart LR
|
|||||||
Igdb -->|"OAuth token"| Twitch["Twitch"]
|
Igdb -->|"OAuth token"| Twitch["Twitch"]
|
||||||
Speedrun --> SpeedrunApi["speedrun.com API"]
|
Speedrun --> SpeedrunApi["speedrun.com API"]
|
||||||
LibRepo --> Db[("PostgreSQL tbp_core")]
|
LibRepo --> Db[("PostgreSQL tbp_core")]
|
||||||
Stats -->|"listed_user_game, deleted_user, blocked_user"| Db
|
Stats -->|"listed_user_game, game_mood, user_game_status_change, deleted_user, blocked_user"| Db
|
||||||
Auth -->|"service token<br/>/internal/accounts"| Account
|
Auth -->|"service token<br/>/internal/accounts"| Account
|
||||||
Account -->|"per-user rows, deleted_user, blocked_user, user_seen_at"| Db
|
Account -->|"per-user rows, deleted_user, blocked_user, user_seen_at"| Db
|
||||||
```
|
```
|
||||||
@@ -196,7 +196,7 @@ flowchart LR
|
|||||||
| `speedrun_adapter` | speedrun.com client: game search by name, verified runs |
|
| `speedrun_adapter` | speedrun.com client: game search by name, verified runs |
|
||||||
|
|
||||||
**One shared schema.** All modules use one database `tbp_core`, and some read or write another module's tables in
|
**One shared schema.** All modules use one database `tbp_core`, and some read or write another module's tables in
|
||||||
SQL, which ArchUnit cannot see: `stats` reads `listed_user_game`, `steam_game`, `deleted_user` and `blocked_user`; `account`
|
SQL, which ArchUnit cannot see: `stats` reads `listed_user_game`, `steam_game`, `game_mood`, `user_game_status_change`, `deleted_user` and `blocked_user`; `account`
|
||||||
deletes the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes
|
deletes the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes
|
||||||
into its list) and writes `user_seen_at` and `last_synced_at` of `user_steam_sync` (`AccountStateRepository`); `library`
|
into its list) and writes `user_seen_at` and `last_synced_at` of `user_steam_sync` (`AccountStateRepository`); `library`
|
||||||
reads `deleted_user` and `blocked_user`. A module that moves to its own service takes these reads along as calls.
|
reads `deleted_user` and `blocked_user`. A module that moves to its own service takes these reads along as calls.
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ public record UserStatsDto(
|
|||||||
Taste taste,
|
Taste taste,
|
||||||
Habits habits,
|
Habits habits,
|
||||||
Finished finished,
|
Finished finished,
|
||||||
Trend trend
|
Trend trend,
|
||||||
|
Platforms platforms
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,4 +74,10 @@ public record UserStatsDto(
|
|||||||
*/
|
*/
|
||||||
public record Trend(List<UserStats.PileDay> days, UserStats.MonthMoves thisMonth) {}
|
public record Trend(List<UserStats.PileDay> days, UserStats.MonthMoves thisMonth) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param perPlatform Steam games as "Steam", manual games by their platform (the spelling of the most games wins),
|
||||||
|
* most games first
|
||||||
|
*/
|
||||||
|
public record Platforms(List<UserStats.TagCount> perPlatform) {}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -9,7 +9,7 @@ import java.util.Set;
|
|||||||
* the user shares.
|
* the user shares.
|
||||||
*/
|
*/
|
||||||
public enum StatGroup {
|
public enum StatGroup {
|
||||||
OVERVIEW, PILE, TOP_GAMES, TASTE, HABITS, FINISHED, TREND;
|
OVERVIEW, PILE, TOP_GAMES, TASTE, HABITS, FINISHED, TREND, PLATFORMS;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The groups a public profile shows, from the names in the profile ticket. OVERVIEW is never one: the profile
|
* The groups a public profile shows, from the names in the profile ticket. OVERVIEW is never one: the profile
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package core_service.modules.library.repo;
|
|||||||
import core_service.modules.library.domain.DayMinutes;
|
import core_service.modules.library.domain.DayMinutes;
|
||||||
import core_service.modules.library.domain.UserStats;
|
import core_service.modules.library.domain.UserStats;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.jdbc.core.RowMapper;
|
||||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
@@ -40,6 +41,9 @@ public class UserStatsRepository {
|
|||||||
and ug.appid = p.appid and ug.hidden_at is not null)
|
and ug.appid = p.appid and ug.hidden_at is not null)
|
||||||
""";
|
""";
|
||||||
|
|
||||||
|
private static final RowMapper<UserStats.TagCount> TAG_COUNT =
|
||||||
|
(rs, i) -> new UserStats.TagCount(rs.getString("name"), rs.getLong("games"), rs.getLong("minutes"));
|
||||||
|
|
||||||
private final NamedParameterJdbcTemplate jdbc;
|
private final NamedParameterJdbcTemplate jdbc;
|
||||||
|
|
||||||
public UserStats.PileTimes pileTimes(long userId) {
|
public UserStats.PileTimes pileTimes(long userId) {
|
||||||
@@ -113,8 +117,24 @@ public class UserStatsRepository {
|
|||||||
from listed_user_game ug join game_mood m on m.appid = ug.appid cross join unnest(m.%s) as tag(name)
|
from listed_user_game ug join game_mood m on m.appid = ug.appid cross join unnest(m.%s) as tag(name)
|
||||||
where %s
|
where %s
|
||||||
group by tag.name order by minutes desc, games desc, tag.name limit :limit
|
group by tag.name order by minutes desc, games desc, tag.name limit :limit
|
||||||
""".formatted(column, OF_USER), user(userId).addValue("limit", limit),
|
""".formatted(column, OF_USER), user(userId).addValue("limit", limit), TAG_COUNT);
|
||||||
(rs, i) -> new UserStats.TagCount(rs.getString("name"), rs.getLong("games"), rs.getLong("minutes")));
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Games and playtime per platform: the name is trimmed and compared ignoring case, the label is the most used
|
||||||
|
* spelling (a tie: the first in alphabetical order). Steam games are "Steam", so a manual game with the platform
|
||||||
|
* "Steam" joins them. Most games first.
|
||||||
|
*/
|
||||||
|
public List<UserStats.TagCount> gamesPerPlatform(long userId) {
|
||||||
|
return jdbc.query("""
|
||||||
|
select mode() within group (order by g.name) as name, count(*) as games,
|
||||||
|
coalesce(sum(g.playtime_minutes), 0) as minutes
|
||||||
|
from (select case when ug.source = 'STEAM' then 'Steam' else trim(ug.platform) end as name,
|
||||||
|
ug.playtime_minutes
|
||||||
|
from listed_user_game ug where %s) g
|
||||||
|
where g.name <> ''
|
||||||
|
group by lower(g.name) order by games desc, minutes desc, name
|
||||||
|
""".formatted(OF_USER), user(userId), TAG_COUNT);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Optional<DayMinutes> longestDay(long userId) {
|
public Optional<DayMinutes> longestDay(long userId) {
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ public class UserStatsService {
|
|||||||
groups.contains(StatGroup.TASTE) ? taste(userId) : null,
|
groups.contains(StatGroup.TASTE) ? taste(userId) : null,
|
||||||
groups.contains(StatGroup.HABITS) ? habits(userId, playtime) : null,
|
groups.contains(StatGroup.HABITS) ? habits(userId, playtime) : null,
|
||||||
groups.contains(StatGroup.FINISHED) ? finished(userId) : null,
|
groups.contains(StatGroup.FINISHED) ? finished(userId) : null,
|
||||||
groups.contains(StatGroup.TREND) ? trend(userId) : null);
|
groups.contains(StatGroup.TREND) ? trend(userId) : null,
|
||||||
|
groups.contains(StatGroup.PLATFORMS) ? platforms(userId) : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private UserStatsDto.Overview overview(long userId, Map<GameStatus, Long> byStatus, long playtime) {
|
private UserStatsDto.Overview overview(long userId, Map<GameStatus, Long> byStatus, long playtime) {
|
||||||
@@ -107,6 +108,10 @@ public class UserStatsService {
|
|||||||
userStatsRepository.finishedBeforeTracking(userId));
|
userStatsRepository.finishedBeforeTracking(userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private UserStatsDto.Platforms platforms(long userId) {
|
||||||
|
return new UserStatsDto.Platforms(userStatsRepository.gamesPerPlatform(userId));
|
||||||
|
}
|
||||||
|
|
||||||
private UserStatsDto.Trend trend(long userId) {
|
private UserStatsDto.Trend trend(long userId) {
|
||||||
LocalDate today = LocalDate.now(zone);
|
LocalDate today = LocalDate.now(zone);
|
||||||
List<UserStats.PileDay> days = new ArrayList<>(
|
List<UserStats.PileDay> days = new ArrayList<>(
|
||||||
|
|||||||
@@ -13,10 +13,17 @@ public record PublicStatsDto(
|
|||||||
long biggestLibrary,
|
long biggestLibrary,
|
||||||
long biggestBacklog,
|
long biggestBacklog,
|
||||||
List<TopGame> topOwnedGames,
|
List<TopGame> topOwnedGames,
|
||||||
List<TopGame> topPlayedGames
|
List<TopGame> topPlayedGames,
|
||||||
|
List<NameCount> gamesPerPlatform,
|
||||||
|
List<NameCount> topGenres,
|
||||||
|
List<YearCount> finishedPerYear
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* @param imageUrl see GameDto
|
* @param imageUrl see GameDto
|
||||||
*/
|
*/
|
||||||
public record TopGame(Long appid, String name, String imgIconUrl, String imageUrl, long value) {}
|
public record TopGame(Long appid, String name, String imgIconUrl, String imageUrl, long value) {}
|
||||||
|
|
||||||
|
public record NameCount(String name, long games, long minutes) {}
|
||||||
|
|
||||||
|
public record YearCount(int year, long games) {}
|
||||||
}
|
}
|
||||||
@@ -93,4 +93,53 @@ public class StatsRepository {
|
|||||||
LIMIT ?
|
LIMIT ?
|
||||||
""".formatted(NOT_DELETED_OR_BLOCKED), limit);
|
""".formatted(NOT_DELETED_OR_BLOCKED), limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per platform: the name is trimmed and compared ignoring case, the label is the most used spelling; Steam games
|
||||||
|
* are "Steam". Columns: name, games, minutes.
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> gamesPerPlatform() {
|
||||||
|
return jdbc.queryForList("""
|
||||||
|
SELECT MODE() WITHIN GROUP (ORDER BY g.name) AS name, COUNT(*) AS games,
|
||||||
|
COALESCE(SUM(g.playtime_minutes), 0) AS minutes
|
||||||
|
FROM (SELECT CASE WHEN source = 'STEAM' THEN 'Steam' ELSE TRIM(platform) END AS name, playtime_minutes
|
||||||
|
FROM listed_user_game WHERE %s) g
|
||||||
|
WHERE g.name <> ''
|
||||||
|
GROUP BY LOWER(g.name) ORDER BY games DESC, minutes DESC, name
|
||||||
|
""".formatted(NOT_DELETED_OR_BLOCKED));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IGDB genres by playtime of all listed games. Columns: name, games, minutes.
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> topGenres(int limit) {
|
||||||
|
return jdbc.queryForList("""
|
||||||
|
SELECT tag.name, COUNT(*) AS games, COALESCE(SUM(ug.playtime_minutes), 0) AS minutes
|
||||||
|
FROM listed_user_game ug JOIN game_mood m ON m.appid = ug.appid
|
||||||
|
CROSS JOIN UNNEST(m.genres) AS tag(name)
|
||||||
|
WHERE %s
|
||||||
|
GROUP BY tag.name ORDER BY minutes DESC, games DESC, tag.name
|
||||||
|
LIMIT ?
|
||||||
|
""".formatted(NOT_DELETED_OR_BLOCKED), limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same rule as the Finished group of a user: games now FINISHED or COMPLETED, counted once in the year of their
|
||||||
|
* first move into FINISHED or COMPLETED; {@code finished_at_estimate} wins. Columns: year, games.
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> finishedPerYear(String zone) {
|
||||||
|
return jdbc.queryForList("""
|
||||||
|
SELECT CAST(EXTRACT(YEAR FROM COALESCE(ug.finished_at_estimate, f.finished_at) AT TIME ZONE ?)
|
||||||
|
AS INTEGER) AS year, COUNT(*) AS games
|
||||||
|
FROM listed_user_game ug
|
||||||
|
LEFT JOIN (SELECT sc.user_id AS uid, sc.appid, MIN(sc.changed_at) AS finished_at
|
||||||
|
FROM user_game_status_change sc
|
||||||
|
WHERE sc.to_status IN ('FINISHED', 'COMPLETED')
|
||||||
|
AND sc.from_status NOT IN ('FINISHED', 'COMPLETED')
|
||||||
|
GROUP BY sc.user_id, sc.appid) f ON f.uid = ug.user_id AND f.appid = ug.appid
|
||||||
|
WHERE %s AND ug.status IN ('FINISHED', 'COMPLETED')
|
||||||
|
AND COALESCE(ug.finished_at_estimate, f.finished_at) IS NOT NULL
|
||||||
|
GROUP BY year ORDER BY year
|
||||||
|
""".formatted(NOT_DELETED_OR_BLOCKED), zone);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -4,9 +4,11 @@ import core_service.modules.stats.api.dto.PublicStatsDto;
|
|||||||
import core_service.modules.stats.repo.StatsRepository;
|
import core_service.modules.stats.repo.StatsRepository;
|
||||||
import core_service.shared.security.Public;
|
import core_service.shared.security.Public;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.cache.annotation.Cacheable;
|
import org.springframework.cache.annotation.Cacheable;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.ZoneId;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -16,9 +18,13 @@ import java.util.Map;
|
|||||||
public class PublicStatsService {
|
public class PublicStatsService {
|
||||||
|
|
||||||
private static final int TOP_LIMIT = 5;
|
private static final int TOP_LIMIT = 5;
|
||||||
|
private static final int TOP_GENRES = 8;
|
||||||
|
|
||||||
private final StatsRepository statsRepository;
|
private final StatsRepository statsRepository;
|
||||||
|
|
||||||
|
@Value("${app.library.zone:Europe/Berlin}")
|
||||||
|
private ZoneId zone;
|
||||||
|
|
||||||
@Cacheable(value = "publicStats", key = "'global'")
|
@Cacheable(value = "publicStats", key = "'global'")
|
||||||
public PublicStatsDto getPublicStats() {
|
public PublicStatsDto getPublicStats() {
|
||||||
long distinctGames = statsRepository.countDistinctGames();
|
long distinctGames = statsRepository.countDistinctGames();
|
||||||
@@ -45,7 +51,10 @@ public class PublicStatsService {
|
|||||||
biggestLibrary,
|
biggestLibrary,
|
||||||
biggestBacklog,
|
biggestBacklog,
|
||||||
topOwned,
|
topOwned,
|
||||||
topPlayed
|
topPlayed,
|
||||||
|
mapCounts(statsRepository.gamesPerPlatform()),
|
||||||
|
mapCounts(statsRepository.topGenres(TOP_GENRES)),
|
||||||
|
mapYears(statsRepository.finishedPerYear(zone.getId()))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,4 +69,23 @@ public class PublicStatsService {
|
|||||||
))
|
))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<PublicStatsDto.NameCount> mapCounts(List<Map<String, Object>> rows) {
|
||||||
|
return rows.stream()
|
||||||
|
.map(r -> new PublicStatsDto.NameCount(
|
||||||
|
(String) r.get("name"),
|
||||||
|
((Number) r.get("games")).longValue(),
|
||||||
|
((Number) r.get("minutes")).longValue()
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<PublicStatsDto.YearCount> mapYears(List<Map<String, Object>> rows) {
|
||||||
|
return rows.stream()
|
||||||
|
.map(r -> new PublicStatsDto.YearCount(
|
||||||
|
((Number) r.get("year")).intValue(),
|
||||||
|
((Number) r.get("games")).longValue()
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -81,7 +81,9 @@ class LibraryControllerStatsTest extends AbstractMockedLibraryServicesTest {
|
|||||||
.andExpect(jsonPath("$.topGames.mostPlayed[0].appid").value(620))
|
.andExpect(jsonPath("$.topGames.mostPlayed[0].appid").value(620))
|
||||||
.andExpect(jsonPath("$.taste.genres").isArray())
|
.andExpect(jsonPath("$.taste.genres").isArray())
|
||||||
.andExpect(jsonPath("$.habits.underOneHourGames").value(0))
|
.andExpect(jsonPath("$.habits.underOneHourGames").value(0))
|
||||||
.andExpect(jsonPath("$.finished.finishedBeforeTracking").value(1));
|
.andExpect(jsonPath("$.finished.finishedBeforeTracking").value(1))
|
||||||
|
.andExpect(jsonPath("$.platforms.perPlatform[0].name").value("Steam"))
|
||||||
|
.andExpect(jsonPath("$.platforms.perPlatform[0].games").value(2));
|
||||||
verify(moodService).refreshListedInBackground(USER_ID);
|
verify(moodService).refreshListedInBackground(USER_ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -228,6 +228,26 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
|
|||||||
new UserStats.YearCount(thisYear, 1));
|
new UserStats.YearCount(thisYear, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void gamesPerPlatform_GroupsTrimmedIgnoringCase_LabelIsTheMostUsedSpelling() {
|
||||||
|
steamGameRepository.saveAllAndFlush(List.of(
|
||||||
|
new SteamGame(8001L, "A", null, Instant.now()), new SteamGame(8002L, "B", null, Instant.now()),
|
||||||
|
new SteamGame(8003L, "C", null, Instant.now()), new SteamGame(8004L, "D", null, Instant.now())));
|
||||||
|
Instant now = Instant.now();
|
||||||
|
userGameRepository.saveAllAndFlush(List.of(
|
||||||
|
game(220L, 100, GameStatus.BACKLOG, now), game(620L, 50, GameStatus.BACKLOG, now),
|
||||||
|
UserGame.manual(USER_ID, 8001L, "PS5", GameStatus.BACKLOG, 10, now),
|
||||||
|
UserGame.manual(USER_ID, 8002L, "PS5", GameStatus.BACKLOG, 20, now),
|
||||||
|
UserGame.manual(USER_ID, 8003L, "ps5 ", GameStatus.BACKLOG, 30, now),
|
||||||
|
UserGame.manual(USER_ID, 8004L, "Switch", GameStatus.BACKLOG, 5, now)));
|
||||||
|
seedHiddenAndRemoved();
|
||||||
|
|
||||||
|
assertThat(repository.gamesPerPlatform(USER_ID)).containsExactly(
|
||||||
|
new UserStats.TagCount("PS5", 3, 60),
|
||||||
|
new UserStats.TagCount("Steam", 2, 150),
|
||||||
|
new UserStats.TagCount("Switch", 1, 5));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void finishedBeforeTracking_FinishedGamesThatCameInFinished() {
|
void finishedBeforeTracking_FinishedGamesThatCameInFinished() {
|
||||||
userGameRepository.saveAllAndFlush(List.of(
|
userGameRepository.saveAllAndFlush(List.of(
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ class UserStatsServiceTest extends AbstractUnitTest {
|
|||||||
assertThat(stats.habits()).isNull();
|
assertThat(stats.habits()).isNull();
|
||||||
assertThat(stats.finished()).isNull();
|
assertThat(stats.finished()).isNull();
|
||||||
assertThat(stats.trend()).isNull();
|
assertThat(stats.trend()).isNull();
|
||||||
|
assertThat(stats.platforms()).isNull();
|
||||||
verifyNoInteractions(userGameRepository, backlogSnapshotRepository);
|
verifyNoInteractions(userGameRepository, backlogSnapshotRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +92,7 @@ class UserStatsServiceTest extends AbstractUnitTest {
|
|||||||
void getStats_NoGroups_RunsNoQuery() {
|
void getStats_NoGroups_RunsNoQuery() {
|
||||||
UserStatsDto stats = service.getStats(7L, Set.of());
|
UserStatsDto stats = service.getStats(7L, Set.of());
|
||||||
|
|
||||||
assertThat(stats).isEqualTo(new UserStatsDto(null, null, null, null, null, null, null));
|
assertThat(stats).isEqualTo(new UserStatsDto(null, null, null, null, null, null, null, null));
|
||||||
verifyNoInteractions(userGameRepository, userStatsRepository, backlogSnapshotRepository);
|
verifyNoInteractions(userGameRepository, userStatsRepository, backlogSnapshotRepository);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,24 @@
|
|||||||
package core_service.modules.stats.repo;
|
package core_service.modules.stats.repo;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.GameStatus;
|
||||||
import core_service.modules.library.domain.SteamGame;
|
import core_service.modules.library.domain.SteamGame;
|
||||||
import core_service.modules.library.domain.UserGame;
|
import core_service.modules.library.domain.UserGame;
|
||||||
|
import core_service.modules.library.repo.GameMoodsRepository;
|
||||||
import core_service.modules.library.repo.SteamGameRepository;
|
import core_service.modules.library.repo.SteamGameRepository;
|
||||||
import core_service.modules.library.repo.UserGameRepository;
|
import core_service.modules.library.repo.UserGameRepository;
|
||||||
|
import core_service.modules.stats.api.dto.PublicStatsDto;
|
||||||
|
import core_service.modules.stats.service.PublicStatsService;
|
||||||
import core_service.shared.test.AbstractPersistenceTest;
|
import core_service.shared.test.AbstractPersistenceTest;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.context.annotation.Import;
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneId;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -29,6 +36,9 @@ class StatsRepositoryTest extends AbstractPersistenceTest {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private UserGameRepository userGameRepository;
|
private UserGameRepository userGameRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private GameMoodsRepository gameMoodsRepository;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private JdbcTemplate jdbc;
|
private JdbcTemplate jdbc;
|
||||||
|
|
||||||
@@ -62,19 +72,98 @@ class StatsRepositoryTest extends AbstractPersistenceTest {
|
|||||||
assertEquals(List.of(220L), appids(statsRepository.topPlayedGames(5)));
|
assertEquals(List.of(220L), appids(statsRepository.topPlayedGames(5)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void gamesPerPlatform_GroupsIgnoringCase_LeavesDeletedUsersOut() {
|
||||||
|
steamGameRepository.saveAllAndFlush(List.of(new SteamGame(8001L, "A", null, Instant.now()),
|
||||||
|
new SteamGame(8002L, "B", null, Instant.now()), new SteamGame(8003L, "C", null, Instant.now())));
|
||||||
|
userGameRepository.saveAllAndFlush(List.of(
|
||||||
|
UserGame.manual(1L, 8001L, "ps5", GameStatus.BACKLOG, 10, Instant.now()),
|
||||||
|
UserGame.manual(2L, 8002L, "PS5", GameStatus.BACKLOG, 20, Instant.now()),
|
||||||
|
UserGame.manual(3L, 8003L, "Switch", GameStatus.BACKLOG, 20, Instant.now())));
|
||||||
|
jdbc.update("INSERT INTO deleted_user (user_id) VALUES (3)");
|
||||||
|
|
||||||
|
List<Map<String, Object>> rows = statsRepository.gamesPerPlatform();
|
||||||
|
|
||||||
|
assertEquals(List.of("PS5", "Steam"), rows.stream().map(row -> row.get("name")).toList());
|
||||||
|
assertEquals(List.of(2L, 1L), rows.stream().map(row -> ((Number) row.get("games")).longValue()).toList());
|
||||||
|
assertEquals(30L, ((Number) rows.get(0).get("minutes")).longValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void topGenres_OrderByPlaytime_LeaveHiddenGamesOut() {
|
||||||
|
gameMoodsRepository.upsert(220L, new String[]{}, new String[]{"RPG", "Action"}, false, Instant.now());
|
||||||
|
gameMoodsRepository.upsert(1000L, new String[]{}, new String[]{"Simulator"}, false, Instant.now());
|
||||||
|
|
||||||
|
List<Map<String, Object>> rows = statsRepository.topGenres(8);
|
||||||
|
|
||||||
|
assertEquals(List.of("Action", "RPG"), rows.stream().map(row -> row.get("name")).toList());
|
||||||
|
assertEquals(300L, ((Number) rows.get(0).get("minutes")).longValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void finishedPerYear_CountsTheFirstMoveIntoFinished_EstimateWins() {
|
||||||
|
steamGameRepository.saveAllAndFlush(List.of(new SteamGame(620L, "Portal 2", null, Instant.now()),
|
||||||
|
new SteamGame(700L, "OJ", null, Instant.now())));
|
||||||
|
UserGame estimated = new UserGame(1L, 620L, 0, Instant.now(), Instant.now());
|
||||||
|
estimated.setFinishedAtEstimate(Instant.parse("2025-06-01T00:00:00Z"));
|
||||||
|
userGameRepository.saveAllAndFlush(List.of(estimated, new UserGame(1L, 700L, 0, Instant.now(), Instant.now())));
|
||||||
|
jdbc.update("UPDATE user_game SET status = 'FINISHED' WHERE user_id = 1 AND appid = 620");
|
||||||
|
jdbc.update("UPDATE user_game SET status = 'FINISHED' WHERE user_id = 1 AND appid = 700");
|
||||||
|
// 220 is saved as FINISHED without a move: no year.
|
||||||
|
jdbc.update("UPDATE user_game SET status = 'FINISHED' WHERE user_id = 1 AND appid = 220");
|
||||||
|
jdbc.update("DELETE FROM user_game_status_change WHERE appid = 220");
|
||||||
|
|
||||||
|
List<Map<String, Object>> rows = statsRepository.finishedPerYear("Europe/Berlin");
|
||||||
|
|
||||||
|
assertEquals(List.of(2025, LocalDate.now().getYear()),
|
||||||
|
rows.stream().map(row -> ((Number) row.get("year")).intValue()).toList());
|
||||||
|
assertEquals(List.of(1L, 1L), rows.stream().map(row -> ((Number) row.get("games")).longValue()).toList());
|
||||||
|
}
|
||||||
|
|
||||||
private static List<Long> appids(List<Map<String, Object>> rows) {
|
private static List<Long> appids(List<Map<String, Object>> rows) {
|
||||||
return rows.stream().map(row -> ((Number) row.get("appid")).longValue()).toList();
|
return rows.stream().map(row -> ((Number) row.get("appid")).longValue()).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void finishWithGenreForUser3() {
|
||||||
|
jdbc.update("UPDATE user_game SET status = 'FINISHED' WHERE user_id = 3 AND appid = 620");
|
||||||
|
gameMoodsRepository.upsert(620L, new String[]{}, new String[]{"Horror"}, false, Instant.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertLandingListsIgnoreUser3() {
|
||||||
|
assertEquals(List.of(), statsRepository.topGenres(8));
|
||||||
|
assertEquals(List.of(), statsRepository.finishedPerYear("Europe/Berlin"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void publicStats_ReadEveryRepositoryKey() {
|
||||||
|
gameMoodsRepository.upsert(220L, new String[]{}, new String[]{"RPG"}, false, Instant.now());
|
||||||
|
steamGameRepository.saveAndFlush(new SteamGame(620L, "Portal 2", null, Instant.now()));
|
||||||
|
UserGame estimated = new UserGame(1L, 620L, 0, Instant.now(), Instant.now());
|
||||||
|
estimated.setFinishedAtEstimate(Instant.parse("2025-06-01T00:00:00Z"));
|
||||||
|
userGameRepository.saveAndFlush(estimated);
|
||||||
|
jdbc.update("UPDATE user_game SET status = 'FINISHED' WHERE user_id = 1 AND appid = 620");
|
||||||
|
PublicStatsService service = new PublicStatsService(statsRepository);
|
||||||
|
ReflectionTestUtils.setField(service, "zone", ZoneId.of("Europe/Berlin"));
|
||||||
|
|
||||||
|
PublicStatsDto dto = service.getPublicStats();
|
||||||
|
|
||||||
|
assertEquals("Steam", dto.gamesPerPlatform().get(0).name());
|
||||||
|
assertEquals(300L, dto.gamesPerPlatform().get(0).minutes());
|
||||||
|
assertEquals("RPG", dto.topGenres().get(0).name());
|
||||||
|
assertEquals(2025, dto.finishedPerYear().get(0).year());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void deletedUsers_LeaveEveryNumberAndTheTopLists() {
|
void deletedUsers_LeaveEveryNumberAndTheTopLists() {
|
||||||
steamGameRepository.saveAndFlush(new SteamGame(620L, "Portal 2", null, Instant.now()));
|
steamGameRepository.saveAndFlush(new SteamGame(620L, "Portal 2", null, Instant.now()));
|
||||||
userGameRepository.saveAllAndFlush(List.of(new UserGame(3L, 620L, 900, Instant.now(), Instant.now()),
|
userGameRepository.saveAllAndFlush(List.of(new UserGame(3L, 620L, 900, Instant.now(), Instant.now()),
|
||||||
new UserGame(3L, 220L, 0, Instant.now(), Instant.now())));
|
new UserGame(3L, 220L, 0, Instant.now(), Instant.now())));
|
||||||
|
finishWithGenreForUser3();
|
||||||
jdbc.update("INSERT INTO deleted_user (user_id) VALUES (3)");
|
jdbc.update("INSERT INTO deleted_user (user_id) VALUES (3)");
|
||||||
|
|
||||||
hiddenGames_LeaveEveryNumber();
|
hiddenGames_LeaveEveryNumber();
|
||||||
hiddenGames_LeaveTheTopLists();
|
hiddenGames_LeaveTheTopLists();
|
||||||
|
assertLandingListsIgnoreUser3();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -82,9 +171,11 @@ class StatsRepositoryTest extends AbstractPersistenceTest {
|
|||||||
steamGameRepository.saveAndFlush(new SteamGame(620L, "Portal 2", null, Instant.now()));
|
steamGameRepository.saveAndFlush(new SteamGame(620L, "Portal 2", null, Instant.now()));
|
||||||
userGameRepository.saveAllAndFlush(List.of(new UserGame(3L, 620L, 900, Instant.now(), Instant.now()),
|
userGameRepository.saveAllAndFlush(List.of(new UserGame(3L, 620L, 900, Instant.now(), Instant.now()),
|
||||||
new UserGame(3L, 220L, 0, Instant.now(), Instant.now())));
|
new UserGame(3L, 220L, 0, Instant.now(), Instant.now())));
|
||||||
|
finishWithGenreForUser3();
|
||||||
jdbc.update("INSERT INTO blocked_user (user_id) VALUES (3)");
|
jdbc.update("INSERT INTO blocked_user (user_id) VALUES (3)");
|
||||||
|
|
||||||
hiddenGames_LeaveEveryNumber();
|
hiddenGames_LeaveEveryNumber();
|
||||||
hiddenGames_LeaveTheTopLists();
|
hiddenGames_LeaveTheTopLists();
|
||||||
|
assertLandingListsIgnoreUser3();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user