diff --git a/readme.md b/readme.md
index 0dbc592..e2d95b9 100644
--- a/readme.md
+++ b/readme.md
@@ -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
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
+ 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
`listed_user_game`, V24).
- **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/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/stats?groups=` (`OVERVIEW`, `PILE`, `TOP_GAMES`, `TASTE`, `HABITS`, `FINISHED`, `TREND`, `PLATFORMS`; 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 |
-| `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/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 |
@@ -180,7 +180,7 @@ flowchart LR
Igdb -->|"OAuth token"| Twitch["Twitch"]
Speedrun --> SpeedrunApi["speedrun.com API"]
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
/internal/accounts"| Account
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 |
**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
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.
diff --git a/src/main/java/core_service/modules/library/api/dto/UserStatsDto.java b/src/main/java/core_service/modules/library/api/dto/UserStatsDto.java
index d3b804d..aa9f4ee 100644
--- a/src/main/java/core_service/modules/library/api/dto/UserStatsDto.java
+++ b/src/main/java/core_service/modules/library/api/dto/UserStatsDto.java
@@ -19,7 +19,8 @@ public record UserStatsDto(
Taste taste,
Habits habits,
Finished finished,
- Trend trend
+ Trend trend,
+ Platforms platforms
) {
/**
@@ -73,4 +74,10 @@ public record UserStatsDto(
*/
public record Trend(List 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 perPlatform) {}
+
}
diff --git a/src/main/java/core_service/modules/library/domain/StatGroup.java b/src/main/java/core_service/modules/library/domain/StatGroup.java
index 2d4091d..e8e1298 100644
--- a/src/main/java/core_service/modules/library/domain/StatGroup.java
+++ b/src/main/java/core_service/modules/library/domain/StatGroup.java
@@ -9,7 +9,7 @@ import java.util.Set;
* the user shares.
*/
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
diff --git a/src/main/java/core_service/modules/library/repo/UserStatsRepository.java b/src/main/java/core_service/modules/library/repo/UserStatsRepository.java
index 15afea8..4cdada3 100644
--- a/src/main/java/core_service/modules/library/repo/UserStatsRepository.java
+++ b/src/main/java/core_service/modules/library/repo/UserStatsRepository.java
@@ -3,6 +3,7 @@ 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.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;
@@ -40,6 +41,9 @@ public class UserStatsRepository {
and ug.appid = p.appid and ug.hidden_at is not null)
""";
+ private static final RowMapper TAG_COUNT =
+ (rs, i) -> new UserStats.TagCount(rs.getString("name"), rs.getLong("games"), rs.getLong("minutes"));
+
private final NamedParameterJdbcTemplate jdbc;
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)
where %s
group by tag.name order by minutes desc, games desc, tag.name limit :limit
- """.formatted(column, OF_USER), user(userId).addValue("limit", limit),
- (rs, i) -> new UserStats.TagCount(rs.getString("name"), rs.getLong("games"), rs.getLong("minutes")));
+ """.formatted(column, OF_USER), user(userId).addValue("limit", limit), TAG_COUNT);
+ }
+
+ /**
+ * 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 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 longestDay(long userId) {
diff --git a/src/main/java/core_service/modules/library/service/UserStatsService.java b/src/main/java/core_service/modules/library/service/UserStatsService.java
index 3bf7fd9..b6a2d72 100644
--- a/src/main/java/core_service/modules/library/service/UserStatsService.java
+++ b/src/main/java/core_service/modules/library/service/UserStatsService.java
@@ -67,7 +67,8 @@ public class UserStatsService {
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);
+ groups.contains(StatGroup.TREND) ? trend(userId) : null,
+ groups.contains(StatGroup.PLATFORMS) ? platforms(userId) : null);
}
private UserStatsDto.Overview overview(long userId, Map byStatus, long playtime) {
@@ -107,6 +108,10 @@ public class UserStatsService {
userStatsRepository.finishedBeforeTracking(userId));
}
+ private UserStatsDto.Platforms platforms(long userId) {
+ return new UserStatsDto.Platforms(userStatsRepository.gamesPerPlatform(userId));
+ }
+
private UserStatsDto.Trend trend(long userId) {
LocalDate today = LocalDate.now(zone);
List days = new ArrayList<>(
diff --git a/src/main/java/core_service/modules/stats/api/dto/PublicStatsDto.java b/src/main/java/core_service/modules/stats/api/dto/PublicStatsDto.java
index d7ac363..cfc3a00 100644
--- a/src/main/java/core_service/modules/stats/api/dto/PublicStatsDto.java
+++ b/src/main/java/core_service/modules/stats/api/dto/PublicStatsDto.java
@@ -13,10 +13,17 @@ public record PublicStatsDto(
long biggestLibrary,
long biggestBacklog,
List topOwnedGames,
- List topPlayedGames
+ List topPlayedGames,
+ List gamesPerPlatform,
+ List topGenres,
+ List finishedPerYear
) {
/**
* @param imageUrl see GameDto
*/
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) {}
}
diff --git a/src/main/java/core_service/modules/stats/repo/StatsRepository.java b/src/main/java/core_service/modules/stats/repo/StatsRepository.java
index 8ed17ff..c9046b1 100644
--- a/src/main/java/core_service/modules/stats/repo/StatsRepository.java
+++ b/src/main/java/core_service/modules/stats/repo/StatsRepository.java
@@ -93,4 +93,53 @@ public class StatsRepository {
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