Public Access
feat(library): sort by name, last played and time to beat (#56)
Sort by name ignores upper and lower case. Games never played, or without a time to beat, come last.
This commit is contained in:
1 parent
4fbf572f32
commit
c42526bc16
6 files changed
+115
-6
No files matched your search
@@ -253,7 +253,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
|
|||||||
| `GET` | `/api/rewards/games/{appid}` (settles, then the paid rows of the game and its set, `bookedNow`, one reason per missing kind; `404 GAME_NOT_FOUND`) | JWT |
|
| `GET` | `/api/rewards/games/{appid}` (settles, then the paid rows of the game and its set, `bookedNow`, one reason per missing kind; `404 GAME_NOT_FOUND`) | 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 |
|
| `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 |
|
| `PUT` | `/api/rewards/equipped/{slot}` (`{item}`, null = take off; `200` wallet, `409 NOT_OWNED`, `400 WRONG_SLOT`) | JWT |
|
||||||
| `GET` | `/api/library/games` (one row per set, the counted edition, with `platforms[]` of all its editions: Steam, PlayStation, then manual, and `totalPlaytimeMinutes` (all platforms; `playtimeMinutes` stays the counted edition's own); `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) | JWT |
|
| `GET` | `/api/library/games` (`sort` = `playtimeMinutes`, `firstSeenAt`, `lastSeenAt`, `rating`, `priority`, `name`, `lastPlayedAt` or `timeToBeat` plus `,asc` or `,desc`; a game never played or without a time to beat comes last; one row per set, the counted edition, with `platforms[]` of all its editions: Steam, PlayStation, then manual, and `totalPlaytimeMinutes` (all platforms; `playtimeMinutes` stays the counted edition's own); `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) | JWT |
|
||||||
| `GET` | `/api/library/dashboard-games?top=10&playing=8` (`{top, playing}`: the Dashboard tiles in one call, listed entries by set playtime, most first; `playing` only status PLAYING; same rows and order as `/games` with `sort=playtimeMinutes,desc` / `status=PLAYING`, no count query; limits clamped to 1..20; calls `syncIfStale` like `/games`) | JWT |
|
| `GET` | `/api/library/dashboard-games?top=10&playing=8` (`{top, playing}`: the Dashboard tiles in one call, listed entries by set playtime, most first; `playing` only status PLAYING; same rows and order as `/games` with `sort=playtimeMinutes,desc` / `status=PLAYING`, no count query; limits clamped to 1..20; calls `syncIfStale` like `/games`) | JWT |
|
||||||
| `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | 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 |
|
| `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 |
|
||||||
|
|||||||
@@ -103,7 +103,8 @@ public class LibraryController {
|
|||||||
private static final int MAX_PICKER_EXCLUDES = 100;
|
private static final int MAX_PICKER_EXCLUDES = 100;
|
||||||
private static final int MAX_BACKLOG_GAMES = 20;
|
private static final int MAX_BACKLOG_GAMES = 20;
|
||||||
private static final int MAX_DASHBOARD_GAMES = 20;
|
private static final int MAX_DASHBOARD_GAMES = 20;
|
||||||
private static final Set<String> SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid", "rating", "priority");
|
private static final Set<String> SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid", "rating", "priority",
|
||||||
|
"name", "lastPlayedAt", "timeToBeat");
|
||||||
|
|
||||||
private final LibrarySyncService syncService;
|
private final LibrarySyncService syncService;
|
||||||
private final LibraryQueryService queryService;
|
private final LibraryQueryService queryService;
|
||||||
|
|||||||
@@ -366,7 +366,8 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
|||||||
// ponytail: the view is joined on every page, not only for the playtime sort: about +4 ms per page. Upgrade path:
|
// ponytail: the view is joined on every page, not only for the playtime sort: about +4 ms per page. Upgrade path:
|
||||||
// join it only for the playtime sort or the PLAYING status, with a second query.
|
// join it only for the playtime sort or the PLAYING status, with a second query.
|
||||||
@Query(value = "select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " +
|
@Query(value = "select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " +
|
||||||
"left join UserGameEntryPlaytime ep on ep.userId = ug.userId and ep.appid = ug.appid " + OWNED_WHERE,
|
"left join UserGameEntryPlaytime ep on ep.userId = ug.userId and ep.appid = ug.appid " +
|
||||||
|
"left join GameTimeToBeat tb on tb.appid = ug.appid " + OWNED_WHERE,
|
||||||
countQuery = "select count(ug) from UserGame ug join SteamGame sg on sg.appid = ug.appid " + OWNED_WHERE)
|
countQuery = "select count(ug) from UserGame ug join SteamGame sg on sg.appid = ug.appid " + OWNED_WHERE)
|
||||||
Page<UserGame> findOwnedEntries(@Param("userId") Long userId,
|
Page<UserGame> findOwnedEntries(@Param("userId") Long userId,
|
||||||
@Param("hidden") boolean hidden,
|
@Param("hidden") boolean hidden,
|
||||||
@@ -418,7 +419,9 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
|||||||
/**
|
/**
|
||||||
* Makes a {@link #findOwnedGames} sort stable and meaningful: games without a rating or priority come last
|
* Makes a {@link #findOwnedGames} sort stable and meaningful: games without a rating or priority come last
|
||||||
* in both directions, priority sorts by HIGH, MEDIUM, LOW (the enum names would sort alphabetically), and
|
* in both directions, priority sorts by HIGH, MEDIUM, LOW (the enum names would sort alphabetically), and
|
||||||
* appid breaks every tie so pages do not repeat or skip games. Playtime sorts by the set total.
|
* appid breaks every tie so pages do not repeat or skip games. Playtime sorts by the set total. A game never
|
||||||
|
* played or without a known time to beat comes last in both directions; the name sorts case-insensitive.
|
||||||
|
* The time to beat needs {@code left join GameTimeToBeat tb on tb.appid = ug.appid}.
|
||||||
*/
|
*/
|
||||||
static Sort querySort(Sort requested) {
|
static Sort querySort(Sort requested) {
|
||||||
Sort result = Sort.unsorted();
|
Sort result = Sort.unsorted();
|
||||||
@@ -430,6 +433,10 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
|||||||
case "priority" -> Sort.by(JpaSort.unsafe(order.getDirection(),
|
case "priority" -> Sort.by(JpaSort.unsafe(order.getDirection(),
|
||||||
"(case ug.priority when 'HIGH' then 3 when 'MEDIUM' then 2 when 'LOW' then 1 end)")
|
"(case ug.priority when 'HIGH' then 3 when 'MEDIUM' then 2 when 'LOW' then 1 end)")
|
||||||
.iterator().next().nullsLast());
|
.iterator().next().nullsLast());
|
||||||
|
case "name" -> Sort.by(JpaSort.unsafe(order.getDirection(), "lower(sg.name)").iterator().next());
|
||||||
|
case "lastPlayedAt" -> Sort.by(order.nullsLast());
|
||||||
|
case "timeToBeat" -> Sort.by(JpaSort.unsafe(order.getDirection(), "tb.normallyMinutes")
|
||||||
|
.iterator().next().nullsLast());
|
||||||
default -> Sort.by(order);
|
default -> Sort.by(order);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package core_service.modules.library.api;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.SteamGame;
|
||||||
|
import core_service.modules.library.domain.UserGame;
|
||||||
|
import core_service.modules.library.repo.SteamGameRepository;
|
||||||
|
import core_service.modules.library.repo.UserGameRepository;
|
||||||
|
import core_service.shared.test.AbstractMockedAdaptersTest;
|
||||||
|
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.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
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 LibraryControllerSortTest extends AbstractMockedAdaptersTest {
|
||||||
|
|
||||||
|
private static final long USER_ID = 1L;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SteamGameRepository steamGameRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserGameRepository userGameRepository;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void seed() {
|
||||||
|
userGameRepository.deleteAll();
|
||||||
|
steamGameRepository.save(new SteamGame(220L, "Half-Life 2", null, Instant.now()));
|
||||||
|
steamGameRepository.save(new SteamGame(620L, "portal 2", null, Instant.now()));
|
||||||
|
steamGameRepository.save(new SteamGame(700L, "Antichamber", null, Instant.now()));
|
||||||
|
userGameRepository.save(new UserGame(USER_ID, 220L, 300, Instant.now(), Instant.now()));
|
||||||
|
userGameRepository.save(new UserGame(USER_ID, 620L, 900, Instant.now(), Instant.now()));
|
||||||
|
userGameRepository.save(new UserGame(USER_ID, 700L, 0, Instant.now(), Instant.now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sortByName_ListsTheNamesAZ() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/library/games?sort=name,asc"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.content[0].appid").value(700))
|
||||||
|
.andExpect(jsonPath("$.content[1].appid").value(220))
|
||||||
|
.andExpect(jsonPath("$.content[2].appid").value(620));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sortByTimeToBeatAndLastPlayed_AreAccepted() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/library/games?sort=timeToBeat,asc")).andExpect(status().isOk());
|
||||||
|
mockMvc.perform(get("/api/library/games?sort=lastPlayedAt,desc")).andExpect(status().isOk());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sortByLastSeenAt_StillWorks() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/library/games?sort=lastSeenAt,desc"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.content.length()").value(3));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,7 +36,8 @@ class OwnedGamesQueryGuardTest extends AbstractPersistenceTest {
|
|||||||
|
|
||||||
private static final String OLD_FROM = "from UserGame ug join SteamGame sg on sg.appid = ug.appid ";
|
private static final String OLD_FROM = "from UserGame ug join SteamGame sg on sg.appid = ug.appid ";
|
||||||
private static final String OLD_SELECT = "select ug " + OLD_FROM
|
private static final String OLD_SELECT = "select ug " + OLD_FROM
|
||||||
+ "left join UserGameEntryPlaytime ep on ep.userId = ug.userId and ep.appid = ug.appid ";
|
+ "left join UserGameEntryPlaytime ep on ep.userId = ug.userId and ep.appid = ug.appid "
|
||||||
|
+ "left join GameTimeToBeat tb on tb.appid = ug.appid ";
|
||||||
private static final String OLD_COUNT = "select count(ug) " + OLD_FROM;
|
private static final String OLD_COUNT = "select count(ug) " + OLD_FROM;
|
||||||
private static final String OLD_WHERE = "where ug.userId = :userId and ug.removedAt is null " +
|
private static final String OLD_WHERE = "where ug.userId = :userId and ug.removedAt is null " +
|
||||||
"and ((:hidden = true and ug.hiddenAt is not null) or (:hidden = false and ug.hiddenAt is null)) " +
|
"and ((:hidden = true and ug.hiddenAt is not null) or (:hidden = false and ug.hiddenAt is null)) " +
|
||||||
@@ -55,7 +56,9 @@ class OwnedGamesQueryGuardTest extends AbstractPersistenceTest {
|
|||||||
private static final List<Sort> SORTS = List.of(
|
private static final List<Sort> SORTS = List.of(
|
||||||
Sort.by(Sort.Direction.DESC, "playtimeMinutes"), Sort.by(Sort.Direction.ASC, "playtimeMinutes"),
|
Sort.by(Sort.Direction.DESC, "playtimeMinutes"), Sort.by(Sort.Direction.ASC, "playtimeMinutes"),
|
||||||
Sort.by(Sort.Direction.DESC, "lastSeenAt"), Sort.by(Sort.Direction.DESC, "firstSeenAt"),
|
Sort.by(Sort.Direction.DESC, "lastSeenAt"), Sort.by(Sort.Direction.DESC, "firstSeenAt"),
|
||||||
Sort.by(Sort.Direction.DESC, "rating"), Sort.by(Sort.Direction.DESC, "priority"));
|
Sort.by(Sort.Direction.DESC, "rating"), Sort.by(Sort.Direction.DESC, "priority"),
|
||||||
|
Sort.by(Sort.Direction.ASC, "name"), Sort.by(Sort.Direction.DESC, "lastPlayedAt"),
|
||||||
|
Sort.by(Sort.Direction.ASC, "timeToBeat"));
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private UserGameRepository userGameRepository;
|
private UserGameRepository userGameRepository;
|
||||||
|
|||||||
@@ -348,6 +348,35 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
|||||||
assertEquals(List.of(220L, 700L, 380L, 620L), sortedAppids(Sort.by(Sort.Direction.ASC, "priority")));
|
assertEquals(List.of(220L, 700L, 380L, 620L), sortedAppids(Sort.by(Sort.Direction.ASC, "priority")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void querySort_NameAsc_IsCaseInsensitiveAZ() {
|
||||||
|
steamGameRepository.saveAndFlush(new SteamGame(620L, "portal 2", null, Instant.now()));
|
||||||
|
|
||||||
|
assertEquals(List.of(700L, 220L, 380L, 620L), sortedAppids(Sort.by(Sort.Direction.ASC, "name")));
|
||||||
|
assertEquals(List.of(620L, 380L, 220L, 700L), sortedAppids(Sort.by(Sort.Direction.DESC, "name")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void querySort_LastPlayedDesc_PutsNeverPlayedLast() {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
userGameRepository.setLastPlayedAt(USER_ID, 380L, now.minusSeconds(3600));
|
||||||
|
userGameRepository.setLastPlayedAt(USER_ID, 700L, now);
|
||||||
|
|
||||||
|
assertEquals(List.of(700L, 380L, 220L, 620L), sortedAppids(Sort.by(Sort.Direction.DESC, "lastPlayedAt")));
|
||||||
|
assertEquals(List.of(380L, 700L, 220L, 620L), sortedAppids(Sort.by(Sort.Direction.ASC, "lastPlayedAt")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void querySort_TimeToBeatAsc_PutsUnknownLast() {
|
||||||
|
gameTimeToBeatRepository.saveAll(List.of(
|
||||||
|
new GameTimeToBeat(620L, 300, 480, 900, Instant.now()),
|
||||||
|
new GameTimeToBeat(220L, 500, 700, 1000, Instant.now()),
|
||||||
|
new GameTimeToBeat(380L, 100, null, 200, Instant.now())));
|
||||||
|
|
||||||
|
assertEquals(List.of(620L, 220L, 380L, 700L), sortedAppids(Sort.by(Sort.Direction.ASC, "timeToBeat")));
|
||||||
|
assertEquals(List.of(220L, 620L, 380L, 700L), sortedAppids(Sort.by(Sort.Direction.DESC, "timeToBeat")));
|
||||||
|
}
|
||||||
|
|
||||||
private void setBacklog(long appid, Integer rating, GamePriority priority) {
|
private void setBacklog(long appid, Integer rating, GamePriority priority) {
|
||||||
UserGame game = userGameRepository.findById(new UserGameId(USER_ID, appid)).orElseThrow();
|
UserGame game = userGameRepository.findById(new UserGameId(USER_ID, appid)).orElseThrow();
|
||||||
game.setRating(rating);
|
game.setRating(rating);
|
||||||
|
|||||||
Reference in new issue
Block a user