feat(library): show one entry per game in every list

Gitea #34 slice 2 of 3. Lists and stats count a set once.
V43 views map an entry to its editions and platforms.
Remove the hideDuplicates param; GameDto gets platforms[].
This commit is contained in:
lobo committed 2026-10-09 11:31:11 +02:00
1 parent 82d3b59165
commit 413381d404
23 files changed
+676 -87

No files matched your search

+24 -9
View File
@@ -116,8 +116,10 @@ flowchart LR
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). A linked set (see "Editions of one game") counts once: its most advanced edition (status, then
playtime, then the older `first_seen_at`; `counted_user_game`, V28), so the set is on the pile only when every owned edition is `BACKLOG`. Playtime
sums, most played, genres, moods and platforms still use every listed game.
playtime, then the older `first_seen_at`; `counted_user_game`, V28), so the set is on the pile only when every owned edition is `BACKLOG`. Most played, most loyal,
genres, moods, perfect games and the longest day list one entry per set, with the playtime of all its editions
(`user_game_entry_member`, V43; its list shows that sum as `minutes`). Platforms and playtime sums (`sumPlaytime`) stay
per edition: a Steam + PlayStation set counts on both platforms.
- **Drill-down**: every countable number opens the games behind it. A `StatKey` (library), `CommunityKey` (landing page)
or a Pile Points track/bonus row names a number; its SQL "game set" is the same text the number counts, so the
list and the number cannot drift. 40 games per page (at most page 250), a fixed order per key. A profile visitor
@@ -164,7 +166,7 @@ flowchart LR
sets** of the matched games: they count as achievements (platinum = perfect game) in the detail page, stats, tiles,
public profile, sync banner and follower digest. See "PlayStation link". Off without `APP_PSN_TOKEN_KEY`.
- **Public profile data**: totals, status counts, games in progress and finished games with rating, perfect games
and average completion (the numbers count a linked set once; the lists keep every edition). Readable only
and average completion (the numbers and the lists count a linked set once, as its counted edition). Readable only
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, derived in Auth from the profile tiles); none by
default. A section the user
@@ -216,7 +218,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `POST` | `/api/rewards/purchases` (`{item}`; `200` wallet, `409 ALREADY_OWNED` / `NOT_ENOUGH_POINTS` / `NOT_FOR_SALE`) | JWT |
| `GET` | `/api/rewards/progress/{track}/games?page=`, `/api/rewards/history/{kind}/{ref}/games?page=` (the games behind a track or a `MONTH` / `STREAK` bonus; `400 NOT_A_GAME_LIST`) | JWT |
| `PUT` | `/api/rewards/equipped/{slot}` (`{item}`, null = take off; `200` wallet, `409 NOT_OWNED`, `400 WRONG_SLOT`) | JWT |
| `GET` | `/api/library/games` (`hideDuplicates=true` leaves out the lesser editions of a linked set; `source=STEAM,PSN` keeps rows whose own source or a listed linked edition's source is selected; ignored with `hidden=true`; `400` on an unknown source), `/api/library/games/{appid}` | JWT |
| `GET` | `/api/library/games` (one row per set, the counted edition, with `platforms[]` of all its editions: Steam, PlayStation, then manual; `name` matches any edition; `source=STEAM,PSN` keeps rows with a platform of that source, a PlayStation title on a Steam row counts as `PSN`; ignored with `hidden=true`, which lists every hidden row; `400` on an unknown source; an old `hideDuplicates` param is ignored), `/api/library/games/{appid}` | JWT |
| `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | JWT |
| `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`, `time_to_beat` = `{normallyMinutes, completelyMinutes}`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR |
| `GET` | `/api/library/catalog?q=&page=&size=` (every game some user still has, by name part, `PageDto` of `{appid, name, imageUrl, platforms}`; `q` at most 100 characters; size 20, 40 or 80) | JWT, AUDITOR |
@@ -339,7 +341,7 @@ flowchart LR
`user_game` and `user_game_status_change` in SQL; the sync tells `follow` through `shared.event.LibraryActivityEvent`.
**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`, `account_name`, `game_alias`, `listed_user_game`, `counted_user_game`, `steam_game`, `game_mood`, `coming_game`, `user_game_status_change`, `deleted_user` and `blocked_user`; `account`
SQL, which ArchUnit cannot see: `stats` reads `listed_user`, `account_name`, `game_alias`, `listed_user_game`, `counted_user_game`, `user_game_entry_member`, `steam_game`, `game_mood`, `coming_game`, `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`, and for the picker `follow`, `public_user` and `account_name`; `rewards` reads
@@ -347,7 +349,10 @@ reads `deleted_user` and `blocked_user`, and for the picker `follow`, `public_us
The listed rule ("still owned, not hidden") is `UserGameRepository.LISTED` for JPQL and the view
`listed_user_game` for native SQL; V24 says what a migration of `user_game` must do with the view. The numbers that
count games once per linked set read `counted_user_game` (V28), built on `listed_user_game`: a migration that changes
a column of `user_game` drops this view first, then `listed_user_game`.
a column of `user_game` drops the views built on it first: `user_game_entry_platform`, `user_game_entry_member`, `counted_user_game`, then `listed_user_game`.
The two entry views (V43) map an entry (a counted row) to its editions and to their platforms: `user_game_entry_member`
is the one place that does it (stats and the library read it); `user_game_entry_platform` adds each edition's source
and platform and a PlayStation row for a `LINKED` or `STEAM_OWNED` title on that key.
Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain` (JPA entities) and `repo`
(repositories). `src/test/java/core_service/architecture/ArchitectureTest.java` checks these rules on every build:
@@ -550,11 +555,17 @@ IGDB id; remakes (8) keep their own id. `null` when IGDB does not know the game.
- `game_link_pair` is the auditor queue (`OPEN`, `DISMISSED`, `LINKED`). IGDB pairs are inserted when the queue is read. A row in any state keeps the pair out for good. An unlink dismisses the pairs of that key with the former
members. `game_link_request` holds who asked for a pair (the queue shows only the count). A user asks for a pair of
two games of their own library; at most 20 open requests per user (a per-user advisory lock makes the cap hold for parallel calls), a dismissed pair answers `409 PAIR_DISMISSED`.
- Pile, stats, snapshots and the dashboard count a set once (`counted_user_game`, see "Stats"); the library
filter `hideDuplicates=true` hides the lesser editions. Pile Points pay a set once (see "Pile Points"), and the
- Every list of the user's own games shows a set once, as its counted edition (`counted_user_game`, see "Stats"): the
library and its total and badge, the dashboard, the recent games, the public profile lists, stats and their
drill-downs. There is no "Hide duplicates" filter any more; a hidden game is its own row under `hidden=true`. The
friends row of the picker matches a friend's game to the caller's entry through `game_link`. Kept per edition on
purpose: the hidden list, the platform stat, the average achievement completion (per edition; the tile's game counts are per entry), `sumPlaytime` and the
community stats of the landing page. Card playtime is the counted edition's own; the set total comes with the
detail page. Pile Points pay a set once (see "Pile Points"), and the
`MONTH` bonus reads `backlog_snapshot`, which counts a set once.
- The game detail has `linked` and `editions` (the user's other listed games of the set); a game row has `editions`
(size of the set in the user's library, `null` below 2).
(size of the set in the user's library, `null` below 2) and `platforms` (`source` + `platform` per edition, for
the icons of the card).
```mermaid
flowchart LR
@@ -563,6 +574,10 @@ flowchart LR
Link --> Set["user_game_set"]
Split["user_game_split (user splits)"] -->|"minus"| Set
Set --> Counted["counted_user_game"]
Counted --> Member["user_game_entry_member"]
Member --> Platform["user_game_entry_platform"]
Platform -->|"platforms[]"| Card["library card, lists"]
Member -->|"summed playtime"| Card
Set -->|"join: align_user_game_set"| Row["user_game (status, rating, notes, priority)"]
Row -->|"update: user_game_set_share"| Row
Row -.->|"old values"| Backup["user_game_set_backup"]