fix(sync): keep old playtime off the day a game shows up

A game the sync saw for the first time put its whole playtime on that
day (40 h "longest gaming day" after skip_unvetted_apps=0). Growth now
counts at most Steam's last two weeks, and one game gets at most 24 h
per day. V18 drops the bad rows and caps old ones.
This commit is contained in:
lobo committed 2026-09-27 10:00:53 +02:00
1 parent 0fa2a37f47
commit 01552ecd89
6 files changed
+88 -9

No files matched your search

@@ -14,10 +14,16 @@ import java.util.List;
public interface PlaytimeDayRepository extends JpaRepository<PlaytimeDay, PlaytimeDayId> {
/**
* At most 24 hours per game and day, also over several syncs: after a gap between syncs, the playtime of the
* whole gap lands on one day.
*/
@Modifying
@Transactional
@Query(value = "insert into user_game_playtime_day (user_id, day, appid, minutes) values (:userId, :day, :appid, :minutes) " +
"on conflict (user_id, day, appid) do update set minutes = user_game_playtime_day.minutes + excluded.minutes",
@Query(value = "insert into user_game_playtime_day (user_id, day, appid, minutes) " +
"values (:userId, :day, :appid, least(:minutes, 1440)) " +
"on conflict (user_id, day, appid) do update " +
"set minutes = least(user_game_playtime_day.minutes + excluded.minutes, 1440)",
nativeQuery = true)
void addMinutes(@Param("userId") Long userId, @Param("appid") Long appid,
@Param("day") LocalDate day, @Param("minutes") int minutes);
@@ -191,7 +191,7 @@ public class LibrarySyncWorker {
/**
* @param playedOn the day that gets the playtime growth, null to record none
* @return true when playtime growth was recorded
* @return true when the game was played since the last sync
*/
private boolean upsertUserGame(Long userId, SteamOwnedGamesResponse.Game g, Instant now, LocalDate playedOn) {
Long appid = g.appid().longValue();
@@ -213,11 +213,16 @@ public class LibrarySyncWorker {
existing.setSource(GameSource.STEAM);
existing.setPlatform(null);
}
// 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;
if (playedOn == null || takesOverManualGame) {
return false;
}
return false;
// A Steam recount can lower playtime_forever; the growth back to the old value counts again. Accepted.
int growth = playtime - previousPlaytime;
// Growth beyond Steam's last two weeks is older playtime, e.g. of a game this sync sees for the first time.
int minutesToday = Math.min(growth, playtime2Weeks);
if (minutesToday > 0) {
playtimeDayRepository.addMinutes(userId, appid, playedOn, minutesToday);
}
return existing == null ? minutesToday > 0 : growth > 0;
}
}
@@ -0,0 +1,17 @@
-- Until now, a game a sync saw for the first time put its whole playtime_forever on that day, e.g. 40 hours on one
-- day for an app that skip_unvetted_apps=0 brought in. Drop these rows: the minutes such a game really got on that
-- day cannot be told apart from its older playtime.
-- The games of the first sync are the baseline: they share one first_seen_at, and their rows on that day are real.
WITH baseline AS (
SELECT user_id, mode() WITHIN GROUP (ORDER BY first_seen_at) AS first_seen_at
FROM user_game
GROUP BY user_id
)
DELETE FROM user_game_playtime_day p
USING user_game ug, baseline b
WHERE ug.user_id = p.user_id AND ug.appid = p.appid AND b.user_id = ug.user_id
AND (ug.first_seen_at AT TIME ZONE 'Europe/Berlin')::date = p.day
AND ug.first_seen_at <> b.first_seen_at;
-- A gap between syncs put several days on one day; one game now gets at most one day per day.
UPDATE user_game_playtime_day SET minutes = 1440 WHERE minutes > 1440;
@@ -35,6 +35,16 @@ class PlaytimeDayRepositoryTest extends AbstractPersistenceTest {
assertEquals(List.of(new DayMinutes(DAY, 75)), playtimeDayRepository.minutesPerDay(USER_ID, DAY));
}
@Test
void addMinutes_OneGameGetsAtMostOneDay() {
playtimeDayRepository.addMinutes(USER_ID, 220L, DAY, 2378);
playtimeDayRepository.addMinutes(USER_ID, 620L, DAY.plusDays(1), 1400);
playtimeDayRepository.addMinutes(USER_ID, 620L, DAY.plusDays(1), 180);
assertEquals(List.of(new DayMinutes(DAY, 1440), new DayMinutes(DAY.plusDays(1), 1440)),
playtimeDayRepository.minutesPerDay(USER_ID, DAY));
}
@Test
void minutesPerDay_SumsAllGamesOfTheUserFromTheGivenDay() {
playtimeDayRepository.addMinutes(USER_ID, 220L, DAY.minusDays(1), 999);
@@ -290,6 +290,46 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
verify(playtimeDayRepository).addMinutes(USER_ID, 220L, today(), 45);
}
@Test
void runSync_NewGameWithOldPlaytime_RecordsOnlyTheLastTwoWeeks() {
steamReturns(game(2378, 30), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
verify(playtimeDayRepository).addMinutes(USER_ID, 220L, today(), 30);
}
@Test
void runSync_NewGameNotPlayedForTwoWeeks_RecordsNothingAndLeavesThePileAlone() {
steamReturns(game(2378, 0), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
verify(userGameRepository, never()).playFromPileIfUntouched(anyLong(), any());
}
@Test
void runSync_GrowthOverManyDays_RecordsAtMostTheLastTwoWeeks() {
owned(100);
steamReturns(game(1000, 60), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
verify(playtimeDayRepository).addMinutes(USER_ID, 220L, today(), 60);
}
@Test
void runSync_GrowthOlderThanTwoWeeks_RecordsNothingButTakesTheGameFromThePile() {
owned(100);
steamReturns(game(160, 0), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
verify(userGameRepository).playFromPileIfUntouched(USER_ID, List.of(220L));
}
@Test
void runSync_FirstSync_IsOnlyTheBaseline() {
steamReturns(game(5000, 60), null);