From 4d16e8c68502512659c07797c636232b2e13c676 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Wed, 29 Apr 2026 09:31:30 +0200 Subject: [PATCH] fix(analytics): use activity end time for achievement earnedAt #24 Signed-off-by: Marcus Fihlon --- .../fitpub/repository/ActivityRepository.java | 8 + .../fitpub/service/AchievementService.java | 409 ++++++++++++------ .../service/AchievementServiceTest.java | 168 +++---- 3 files changed, 380 insertions(+), 205 deletions(-) diff --git a/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java b/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java index 55483a2..910c66e 100644 --- a/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java +++ b/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java @@ -31,6 +31,14 @@ public interface ActivityRepository extends JpaRepository { */ List findByUserIdOrderByStartedAtDesc(UUID userId); + /** + * Find all activities for a specific user in chronological order. + * + * @param userId the user ID + * @return list of activities + */ + List findByUserIdOrderByStartedAtAsc(UUID userId); + /** * Find all activities for a user within a date range. * diff --git a/src/main/java/net/javahippie/fitpub/service/AchievementService.java b/src/main/java/net/javahippie/fitpub/service/AchievementService.java index df38231..d52ed2e 100644 --- a/src/main/java/net/javahippie/fitpub/service/AchievementService.java +++ b/src/main/java/net/javahippie/fitpub/service/AchievementService.java @@ -43,11 +43,13 @@ public class AchievementService { public List checkAndAwardAchievements(Activity activity) { List newAchievements = new ArrayList<>(); - if (activity.getUserId() == null) { + if (activity.getUserId() == null || activity.getStartedAt() == null || activity.getEndedAt() == null) { return newAchievements; } UUID userId = activity.getUserId(); + List activityHistory = activityRepository.findByUserIdOrderByStartedAtAsc(userId); + ActivityProgress progress = ActivityProgress.fromHistory(activityHistory, activity); // Load all of the user's existing achievement types in a single query so the // sub-checks below can do an in-memory `contains()` instead of an EXISTS query @@ -58,28 +60,28 @@ public class AchievementService { } // Check first activity achievements - newAchievements.addAll(checkFirstActivityAchievements(userId, activity, existing)); + newAchievements.addAll(checkFirstActivityAchievements(userId, activity, progress, existing)); // Check distance milestones - newAchievements.addAll(checkDistanceMilestones(userId, existing)); + newAchievements.addAll(checkDistanceMilestones(userId, activity, progress, existing)); // Check activity count milestones - newAchievements.addAll(checkActivityCountMilestones(userId, existing)); + newAchievements.addAll(checkActivityCountMilestones(userId, activity, progress, existing)); // Check streak achievements - newAchievements.addAll(checkStreakAchievements(userId, existing)); + newAchievements.addAll(checkStreakAchievements(userId, activity, progress, existing)); // Check time-based achievements - newAchievements.addAll(checkTimeBasedAchievements(userId, activity, existing)); + newAchievements.addAll(checkTimeBasedAchievements(userId, activity, progress, existing)); // Check elevation achievements - newAchievements.addAll(checkElevationAchievements(userId, activity, existing)); + newAchievements.addAll(checkElevationAchievements(userId, activity, progress, existing)); // Check variety achievements - newAchievements.addAll(checkVarietyAchievements(userId, existing)); + newAchievements.addAll(checkVarietyAchievements(userId, activity, progress, existing)); // Check speed achievements - newAchievements.addAll(checkSpeedAchievements(userId, activity, existing)); + newAchievements.addAll(checkSpeedAchievements(userId, activity, progress, existing)); return newAchievements; } @@ -88,12 +90,14 @@ public class AchievementService { * Check first activity achievements. */ private List checkFirstActivityAchievements(UUID userId, Activity activity, + ActivityProgress progress, Set existing) { List achievements = new ArrayList<>(); // First activity overall - long totalActivities = activityRepository.countByUserId(userId); - if (totalActivities == 1 && !existing.contains(Achievement.AchievementType.FIRST_ACTIVITY)) { + if (progress.previousActivityCount() == 0 && + progress.currentActivityCount() == 1 && + !existing.contains(Achievement.AchievementType.FIRST_ACTIVITY)) { achievements.add(awardAchievement( userId, Achievement.AchievementType.FIRST_ACTIVITY, @@ -102,15 +106,17 @@ public class AchievementService { "🎉", "#ff00ff", activity.getId(), + activity.getEndedAt(), null )); } // First activity by type String activityType = activity.getActivityType().name(); - long typeCount = activityRepository.countByUserIdAndActivityType(userId, activity.getActivityType()); + long previousTypeCount = progress.previousActivityTypeCount(activity.getActivityType()); + long currentTypeCount = progress.currentActivityTypeCount(activity.getActivityType()); - if (typeCount == 1) { + if (previousTypeCount == 0 && currentTypeCount == 1) { Achievement.AchievementType achievementType = switch (activityType) { case "RUN" -> Achievement.AchievementType.FIRST_RUN; case "RIDE" -> Achievement.AchievementType.FIRST_RIDE; @@ -127,6 +133,7 @@ public class AchievementService { getActivityEmoji(activityType), "#00ffff", activity.getId(), + activity.getEndedAt(), null )); } @@ -138,16 +145,13 @@ public class AchievementService { /** * Check distance milestone achievements. */ - private List checkDistanceMilestones(UUID userId, Set existing) { + private List checkDistanceMilestones(UUID userId, Activity activity, + ActivityProgress progress, + Set existing) { List achievements = new ArrayList<>(); - // Calculate total distance - BigDecimal totalDistance = activityRepository.sumDistanceByUserId(userId); - if (totalDistance == null) { - return achievements; - } - - double totalKm = totalDistance.doubleValue() / 1000.0; + double previousKm = progress.previousDistanceMeters().doubleValue() / 1000.0; + double currentKm = progress.currentDistanceMeters().doubleValue() / 1000.0; // Check milestones Map milestones = Map.of( @@ -159,7 +163,9 @@ public class AchievementService { ); for (Map.Entry entry : milestones.entrySet()) { - if (totalKm >= entry.getKey() && !existing.contains(entry.getValue())) { + if (previousKm < entry.getKey() && + currentKm >= entry.getKey() && + !existing.contains(entry.getValue())) { achievements.add(awardAchievement( userId, entry.getValue(), @@ -167,7 +173,8 @@ public class AchievementService { String.format("Reached %.0f kilometers total distance!", entry.getKey()), "🏃", "#ffff00", - null, + activity.getId(), + activity.getEndedAt(), Map.of("distance_km", entry.getKey()) )); } @@ -179,11 +186,11 @@ public class AchievementService { /** * Check activity count milestone achievements. */ - private List checkActivityCountMilestones(UUID userId, Set existing) { + private List checkActivityCountMilestones(UUID userId, Activity activity, + ActivityProgress progress, + Set existing) { List achievements = new ArrayList<>(); - long activityCount = activityRepository.countByUserId(userId); - Map milestones = Map.of( 10L, Achievement.AchievementType.ACTIVITIES_10, 50L, Achievement.AchievementType.ACTIVITIES_50, @@ -193,7 +200,9 @@ public class AchievementService { ); for (Map.Entry entry : milestones.entrySet()) { - if (activityCount >= entry.getKey() && !existing.contains(entry.getValue())) { + if (progress.previousActivityCount() < entry.getKey() && + progress.currentActivityCount() >= entry.getKey() && + !existing.contains(entry.getValue())) { achievements.add(awardAchievement( userId, entry.getValue(), @@ -201,7 +210,8 @@ public class AchievementService { String.format("Completed %d activities!", entry.getKey()), "💪", "#ff6600", - null, + activity.getId(), + activity.getEndedAt(), Map.of("activity_count", entry.getKey()) )); } @@ -213,10 +223,13 @@ public class AchievementService { /** * Check streak achievements (consecutive days). */ - private List checkStreakAchievements(UUID userId, Set existing) { + private List checkStreakAchievements(UUID userId, Activity activity, + ActivityProgress progress, + Set existing) { List achievements = new ArrayList<>(); - int currentStreak = calculateCurrentStreak(userId); + int previousStreak = calculateStreak(progress.previousActivityDates(), activity.getEndedAt().toLocalDate()); + int currentStreak = calculateStreak(progress.currentActivityDates(), activity.getEndedAt().toLocalDate()); Map streakMilestones = Map.of( 7, Achievement.AchievementType.STREAK_7_DAYS, @@ -226,7 +239,9 @@ public class AchievementService { ); for (Map.Entry entry : streakMilestones.entrySet()) { - if (currentStreak >= entry.getKey() && !existing.contains(entry.getValue())) { + if (previousStreak < entry.getKey() && + currentStreak >= entry.getKey() && + !existing.contains(entry.getValue())) { achievements.add(awardAchievement( userId, entry.getValue(), @@ -234,7 +249,8 @@ public class AchievementService { String.format("Worked out %d days in a row!", entry.getKey()), "🔥", "#ff1493", - null, + activity.getId(), + activity.getEndedAt(), Map.of("streak_days", entry.getKey()) )); } @@ -247,6 +263,7 @@ public class AchievementService { * Check time-based achievements (early bird, night owl, weekend warrior). */ private List checkTimeBasedAchievements(UUID userId, Activity activity, + ActivityProgress progress, Set existing) { List achievements = new ArrayList<>(); @@ -254,8 +271,9 @@ public class AchievementService { // Early bird (before 6am) if (startTime.isBefore(LocalTime.of(6, 0)) && !existing.contains(Achievement.AchievementType.EARLY_BIRD)) { - long earlyActivities = activityRepository.countByUserIdAndStartTimeBefore(userId, LocalTime.of(6, 0)); - if (earlyActivities >= 5) { + long previousEarlyActivities = progress.previousActivitiesStartingBefore(LocalTime.of(6, 0)); + long currentEarlyActivities = progress.currentActivitiesStartingBefore(LocalTime.of(6, 0)); + if (previousEarlyActivities < 5 && currentEarlyActivities >= 5) { achievements.add(awardAchievement( userId, Achievement.AchievementType.EARLY_BIRD, @@ -264,15 +282,17 @@ public class AchievementService { "🌅", "#ccff00", activity.getId(), - Map.of("early_activities", earlyActivities) + activity.getEndedAt(), + Map.of("early_activities", currentEarlyActivities) )); } } // Night owl (after 10pm) if (startTime.isAfter(LocalTime.of(22, 0)) && !existing.contains(Achievement.AchievementType.NIGHT_OWL)) { - long lateActivities = activityRepository.countByUserIdAndStartTimeAfter(userId, LocalTime.of(22, 0)); - if (lateActivities >= 5) { + long previousLateActivities = progress.previousActivitiesStartingAfter(LocalTime.of(22, 0)); + long currentLateActivities = progress.currentActivitiesStartingAfter(LocalTime.of(22, 0)); + if (previousLateActivities < 5 && currentLateActivities >= 5) { achievements.add(awardAchievement( userId, Achievement.AchievementType.NIGHT_OWL, @@ -281,7 +301,8 @@ public class AchievementService { "🦉", "#9370db", activity.getId(), - Map.of("late_activities", lateActivities) + activity.getEndedAt(), + Map.of("late_activities", currentLateActivities) )); } } @@ -293,12 +314,14 @@ public class AchievementService { * Check elevation achievements. */ private List checkElevationAchievements(UUID userId, Activity activity, + ActivityProgress progress, Set existing) { List achievements = new ArrayList<>(); // Single activity elevation if (activity.getElevationGain() != null && activity.getElevationGain().compareTo(BigDecimal.valueOf(1000)) >= 0 && + !progress.previousHasElevationGainAtLeast(BigDecimal.valueOf(1000)) && !existing.contains(Achievement.AchievementType.MOUNTAINEER_1000M)) { achievements.add(awardAchievement( @@ -309,40 +332,45 @@ public class AchievementService { "⛰️", "#8b4513", activity.getId(), + activity.getEndedAt(), Map.of("elevation_gain", activity.getElevationGain()) )); } // Total elevation milestones - BigDecimal totalElevation = activityRepository.sumElevationGainByUserId(userId); - if (totalElevation != null) { - double totalM = totalElevation.doubleValue(); + double previousElevation = progress.previousElevationMeters().doubleValue(); + double currentElevation = progress.currentElevationMeters().doubleValue(); - if (totalM >= 5000 && !existing.contains(Achievement.AchievementType.MOUNTAINEER_5000M)) { - achievements.add(awardAchievement( - userId, - Achievement.AchievementType.MOUNTAINEER_5000M, - "Mountain Conqueror", - "Climbed 5000m total elevation!", - "🏔️", - "#4169e1", - null, - Map.of("total_elevation", totalM) - )); - } + if (previousElevation < 5000 && + currentElevation >= 5000 && + !existing.contains(Achievement.AchievementType.MOUNTAINEER_5000M)) { + achievements.add(awardAchievement( + userId, + Achievement.AchievementType.MOUNTAINEER_5000M, + "Mountain Conqueror", + "Climbed 5000m total elevation!", + "🏔️", + "#4169e1", + activity.getId(), + activity.getEndedAt(), + Map.of("total_elevation", currentElevation) + )); + } - if (totalM >= 10000 && !existing.contains(Achievement.AchievementType.MOUNTAINEER_10000M)) { - achievements.add(awardAchievement( - userId, - Achievement.AchievementType.MOUNTAINEER_10000M, - "Summit Master", - "Climbed 10000m total elevation!", - "🗻", - "#1e90ff", - null, - Map.of("total_elevation", totalM) - )); - } + if (previousElevation < 10000 && + currentElevation >= 10000 && + !existing.contains(Achievement.AchievementType.MOUNTAINEER_10000M)) { + achievements.add(awardAchievement( + userId, + Achievement.AchievementType.MOUNTAINEER_10000M, + "Summit Master", + "Climbed 10000m total elevation!", + "🗻", + "#1e90ff", + activity.getId(), + activity.getEndedAt(), + Map.of("total_elevation", currentElevation) + )); } return achievements; @@ -351,12 +379,17 @@ public class AchievementService { /** * Check variety achievements. */ - private List checkVarietyAchievements(UUID userId, Set existing) { + private List checkVarietyAchievements(UUID userId, Activity activity, + ActivityProgress progress, + Set existing) { List achievements = new ArrayList<>(); - long distinctActivityTypes = activityRepository.countDistinctActivityTypesByUserId(userId); + long previousDistinctActivityTypes = progress.previousDistinctActivityTypes(); + long currentDistinctActivityTypes = progress.currentDistinctActivityTypes(); - if (distinctActivityTypes >= 3 && !existing.contains(Achievement.AchievementType.VARIETY_SEEKER)) { + if (previousDistinctActivityTypes < 3 && + currentDistinctActivityTypes >= 3 && + !existing.contains(Achievement.AchievementType.VARIETY_SEEKER)) { achievements.add(awardAchievement( userId, Achievement.AchievementType.VARIETY_SEEKER, @@ -364,8 +397,9 @@ public class AchievementService { "Tried 3+ different activity types!", "🌈", "#ff69b4", - null, - Map.of("activity_types", distinctActivityTypes) + activity.getId(), + activity.getEndedAt(), + Map.of("activity_types", currentDistinctActivityTypes) )); } @@ -376,6 +410,7 @@ public class AchievementService { * Check speed achievements. */ private List checkSpeedAchievements(UUID userId, Activity activity, + ActivityProgress progress, Set existing) { List achievements = new ArrayList<>(); @@ -383,7 +418,9 @@ public class AchievementService { // maxSpeed is already in km/h from FitParser double maxSpeedKmh = activity.getMetrics().getMaxSpeed().doubleValue(); - if (maxSpeedKmh >= 40 && !existing.contains(Achievement.AchievementType.SPEED_DEMON)) { + if (maxSpeedKmh >= 40 && + !progress.previousHasMaxSpeedAtLeast(BigDecimal.valueOf(40)) && + !existing.contains(Achievement.AchievementType.SPEED_DEMON)) { achievements.add(awardAchievement( userId, Achievement.AchievementType.SPEED_DEMON, @@ -392,6 +429,7 @@ public class AchievementService { "⚡", "#ffd700", activity.getId(), + activity.getEndedAt(), Map.of("max_speed_kmh", maxSpeedKmh) )); } @@ -400,70 +438,13 @@ public class AchievementService { return achievements; } - /** - * Calculate current activity streak (consecutive days). - * - *

Loads all activity timestamps for the user in the last 366 days in a single - * query, deduplicates them to a {@code Set} in Java, and walks the - * resulting set in memory. Previously this method issued one {@code SELECT EXISTS} - * query per day (up to 365 round-trips per activity upload), which was the single - * biggest performance hot spot in the achievement evaluation path. - * - *

Java-side date deduplication is intentional: Hibernate 6 + Spring Data 3 do - * not reliably convert SQL date scalar projections to {@code List}. - * The result set is small (a few hundred timestamps at most) so the cost of - * Java-side distinct is negligible. - * - *

The streak / rest-day logic is preserved bug-for-bug from the previous - * implementation: a missing day after a streak has started is silently skipped - * (the original loop did the same). Fixing the rest-day semantics is out of - * scope for this performance change. - */ - private int calculateCurrentStreak(UUID userId) { - LocalDate today = LocalDate.now(); - // 366 to safely cover the lookback window even if today's activity is in the - // future relative to the cutoff (timezone edge cases). - LocalDateTime since = today.minusDays(366).atStartOfDay(); - Set activityDates = new HashSet<>(); - for (LocalDateTime ts : activityRepository.findActivityStartTimestampsSince(userId, since)) { - activityDates.add(ts.toLocalDate()); - } - - if (activityDates.isEmpty()) { - return 0; - } - - LocalDate checkDate = today; - int streak = 0; - - // Walk backwards from today using the in-memory set instead of per-day queries. - for (int i = 0; i < 365; i++) { - boolean hasActivity = activityDates.contains(checkDate); - - if (hasActivity) { - streak++; - checkDate = checkDate.minusDays(1); - } else { - // Allow one rest day if we already have a streak (preserving original - // behaviour, including the latent "infinite consecutive rest days - // allowed once a streak has started" quirk in the original loop). - if (streak > 0 && i > 0) { - checkDate = checkDate.minusDays(1); - continue; - } - break; - } - } - - return streak; - } - /** * Award an achievement to a user. */ private Achievement awardAchievement(UUID userId, Achievement.AchievementType achievementType, String name, String description, String icon, String color, - UUID activityId, Map metadata) { + UUID activityId, LocalDateTime earnedAt, + Map metadata) { Achievement achievement = Achievement.builder() .userId(userId) .achievementType(achievementType) @@ -471,7 +452,7 @@ public class AchievementService { .description(description) .badgeIcon(icon) .badgeColor(color) - .earnedAt(LocalDateTime.now()) + .earnedAt(earnedAt != null ? earnedAt : LocalDateTime.now()) .activityId(activityId) .metadata(metadata) .build(); @@ -510,4 +491,170 @@ public class AchievementService { public long getAchievementCount(UUID userId) { return achievementRepository.countByUserId(userId); } + + private int calculateStreak(Set activityDates, LocalDate anchorDate) { + if (activityDates.isEmpty() || !activityDates.contains(anchorDate)) { + return 0; + } + + LocalDate checkDate = anchorDate; + int streak = 0; + + for (int i = 0; i < 365; i++) { + boolean hasActivity = activityDates.contains(checkDate); + + if (hasActivity) { + streak++; + checkDate = checkDate.minusDays(1); + } else { + if (streak > 0 && i > 0) { + checkDate = checkDate.minusDays(1); + continue; + } + break; + } + } + + return streak; + } + + private record ActivityProgress( + List previousActivities, + List currentActivities + ) { + private static ActivityProgress fromHistory(List activityHistory, Activity currentActivity) { + int currentIndex = -1; + for (int i = 0; i < activityHistory.size(); i++) { + if (Objects.equals(activityHistory.get(i).getId(), currentActivity.getId())) { + currentIndex = i; + break; + } + } + + if (currentIndex < 0) { + throw new IllegalStateException("Current activity missing from chronological history: " + currentActivity.getId()); + } + + return new ActivityProgress( + List.copyOf(activityHistory.subList(0, currentIndex)), + List.copyOf(activityHistory.subList(0, currentIndex + 1)) + ); + } + + long previousActivityCount() { + return previousActivities.size(); + } + + long currentActivityCount() { + return currentActivities.size(); + } + + long previousActivityTypeCount(Activity.ActivityType type) { + return previousActivities.stream().filter(activity -> activity.getActivityType() == type).count(); + } + + long currentActivityTypeCount(Activity.ActivityType type) { + return currentActivities.stream().filter(activity -> activity.getActivityType() == type).count(); + } + + BigDecimal previousDistanceMeters() { + return sumDistance(previousActivities); + } + + BigDecimal currentDistanceMeters() { + return sumDistance(currentActivities); + } + + BigDecimal previousElevationMeters() { + return sumElevation(previousActivities); + } + + BigDecimal currentElevationMeters() { + return sumElevation(currentActivities); + } + + boolean previousHasElevationGainAtLeast(BigDecimal threshold) { + return previousActivities.stream() + .map(Activity::getElevationGain) + .filter(Objects::nonNull) + .anyMatch(elevation -> elevation.compareTo(threshold) >= 0); + } + + long previousDistinctActivityTypes() { + return previousActivities.stream().map(Activity::getActivityType).distinct().count(); + } + + long currentDistinctActivityTypes() { + return currentActivities.stream().map(Activity::getActivityType).distinct().count(); + } + + long previousActivitiesStartingBefore(LocalTime time) { + return previousActivities.stream() + .filter(activity -> activity.getStartedAt() != null) + .filter(activity -> activity.getStartedAt().toLocalTime().isBefore(time)) + .count(); + } + + long currentActivitiesStartingBefore(LocalTime time) { + return currentActivities.stream() + .filter(activity -> activity.getStartedAt() != null) + .filter(activity -> activity.getStartedAt().toLocalTime().isBefore(time)) + .count(); + } + + long previousActivitiesStartingAfter(LocalTime time) { + return previousActivities.stream() + .filter(activity -> activity.getStartedAt() != null) + .filter(activity -> activity.getStartedAt().toLocalTime().isAfter(time)) + .count(); + } + + long currentActivitiesStartingAfter(LocalTime time) { + return currentActivities.stream() + .filter(activity -> activity.getStartedAt() != null) + .filter(activity -> activity.getStartedAt().toLocalTime().isAfter(time)) + .count(); + } + + Set previousActivityDates() { + return collectDates(previousActivities); + } + + Set currentActivityDates() { + return collectDates(currentActivities); + } + + boolean previousHasMaxSpeedAtLeast(BigDecimal thresholdKmh) { + return previousActivities.stream() + .map(Activity::getMetrics) + .filter(Objects::nonNull) + .map(metrics -> metrics.getMaxSpeed()) + .filter(Objects::nonNull) + .anyMatch(speed -> speed.compareTo(thresholdKmh) >= 0); + } + + private static BigDecimal sumDistance(List activities) { + return activities.stream() + .map(Activity::getTotalDistance) + .filter(Objects::nonNull) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + private static BigDecimal sumElevation(List activities) { + return activities.stream() + .map(Activity::getElevationGain) + .filter(Objects::nonNull) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + private static Set collectDates(List activities) { + Set dates = new HashSet<>(); + for (Activity activity : activities) { + if (activity.getStartedAt() != null) { + dates.add(activity.getStartedAt().toLocalDate()); + } + } + return dates; + } + } } diff --git a/src/test/java/net/javahippie/fitpub/service/AchievementServiceTest.java b/src/test/java/net/javahippie/fitpub/service/AchievementServiceTest.java index e9cd220..27d6ac4 100644 --- a/src/test/java/net/javahippie/fitpub/service/AchievementServiceTest.java +++ b/src/test/java/net/javahippie/fitpub/service/AchievementServiceTest.java @@ -14,14 +14,14 @@ import net.javahippie.fitpub.repository.AchievementRepository; import net.javahippie.fitpub.repository.ActivityRepository; import java.math.BigDecimal; +import java.time.LocalDate; import java.time.LocalDateTime; -import java.time.LocalTime; +import java.util.ArrayList; import java.util.List; import java.util.UUID; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; /** @@ -54,14 +54,7 @@ class AchievementServiceTest { void testCheckAndAwardAchievements_FirstActivity() { // Given Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO); - - when(activityRepository.countByUserId(userId)).thenReturn(1L); - when(activityRepository.countByUserIdAndActivityType(userId, Activity.ActivityType.RUN)).thenReturn(1L); - when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(5000)); - when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L); - // Streak source: today has one activity (1-day streak — not enough to trigger any streak achievement) - lenient().when(activityRepository.findActivityStartTimestampsSince(any(), any())) - .thenReturn(List.of(java.time.LocalDateTime.now())); + stubHistory(activity); when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); // When @@ -82,12 +75,11 @@ class AchievementServiceTest { @DisplayName("Should award first run achievement") void testCheckAndAwardAchievements_FirstRun() { // Given + Activity firstRide = createActivity(Activity.ActivityType.RIDE, 10000L, BigDecimal.ZERO); + firstRide.setStartedAt(testTime.minusDays(1)); Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO); - when(activityRepository.countByUserId(userId)).thenReturn(10L); // Not first overall - when(activityRepository.countByUserIdAndActivityType(userId, Activity.ActivityType.RUN)).thenReturn(1L); - when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(50000)); - when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(2L); + stubHistory(firstRide, activity); when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); // When @@ -102,13 +94,12 @@ class AchievementServiceTest { @Test @DisplayName("Should award distance milestone achievements") void testCheckAndAwardAchievements_DistanceMilestone() { - // Given - User has completed 10+ km total + // Given - Current activity crosses 10 km total + Activity previous = createActivity(Activity.ActivityType.RUN, 7000L, BigDecimal.ZERO); + previous.setStartedAt(testTime.minusDays(1)); Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO); - when(activityRepository.countByUserId(userId)).thenReturn(5L); - when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(3L); - when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(12000)); // 12 km - when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L); + stubHistory(previous, activity); when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); // When @@ -126,13 +117,17 @@ class AchievementServiceTest { @Test @DisplayName("Should award activity count milestone") void testCheckAndAwardAchievements_ActivityCount() { - // Given - User has 10 activities + // Given - Current activity is the 10th activity + List history = new ArrayList<>(); + for (int i = 0; i < 9; i++) { + Activity previous = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO); + previous.setStartedAt(testTime.minusDays(10 - i)); + history.add(previous); + } Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO); + history.add(activity); - when(activityRepository.countByUserId(userId)).thenReturn(10L); - when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(5L); - when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(50000)); - when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L); + stubHistory(history); when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); // When @@ -147,15 +142,18 @@ class AchievementServiceTest { @Test @DisplayName("Should award early bird achievement") void testCheckAndAwardAchievements_EarlyBird() { - // Given - Activity before 6am, and user has 5+ early activities + // Given - Activity before 6am is the 5th early activity + List history = new ArrayList<>(); + for (int i = 0; i < 4; i++) { + Activity previous = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO); + previous.setStartedAt(LocalDateTime.of(2025, 11, 20 + i, 5, 30)); + history.add(previous); + } Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO); activity.setStartedAt(LocalDateTime.of(2025, 12, 1, 5, 30)); // 5:30 AM - when(activityRepository.countByUserId(userId)).thenReturn(10L); - when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(5L); - when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(50000)); - when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L); - when(activityRepository.countByUserIdAndStartTimeBefore(eq(userId), eq(LocalTime.of(6, 0)))).thenReturn(5L); + history.add(activity); + stubHistory(history); when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); // When @@ -170,15 +168,18 @@ class AchievementServiceTest { @Test @DisplayName("Should award night owl achievement") void testCheckAndAwardAchievements_NightOwl() { - // Given - Activity after 10pm, and user has 5+ late activities + // Given - Activity after 10pm is the 5th late activity + List history = new ArrayList<>(); + for (int i = 0; i < 4; i++) { + Activity previous = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO); + previous.setStartedAt(LocalDateTime.of(2025, 11, 20 + i, 23, 0)); + history.add(previous); + } Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO); activity.setStartedAt(LocalDateTime.of(2025, 12, 1, 23, 0)); // 11:00 PM - when(activityRepository.countByUserId(userId)).thenReturn(10L); - when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(5L); - when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(50000)); - when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L); - when(activityRepository.countByUserIdAndStartTimeAfter(eq(userId), eq(LocalTime.of(22, 0)))).thenReturn(5L); + history.add(activity); + stubHistory(history); when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); // When @@ -196,11 +197,7 @@ class AchievementServiceTest { // Given - Activity with 1000m+ elevation gain Activity activity = createActivity(Activity.ActivityType.HIKE, 10000L, BigDecimal.valueOf(1200)); - when(activityRepository.countByUserId(userId)).thenReturn(5L); - when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(3L); - when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(50000)); - when(activityRepository.sumElevationGainByUserId(userId)).thenReturn(BigDecimal.valueOf(1200)); - when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L); + stubHistory(activity); when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); // When @@ -215,14 +212,12 @@ class AchievementServiceTest { @Test @DisplayName("Should award total elevation milestones") void testCheckAndAwardAchievements_TotalElevation() { - // Given - User has 5000m+ total elevation + // Given - Current activity crosses 5000m total elevation + Activity previous = createActivity(Activity.ActivityType.HIKE, 10000L, BigDecimal.valueOf(4500)); + previous.setStartedAt(testTime.minusDays(1)); Activity activity = createActivity(Activity.ActivityType.HIKE, 10000L, BigDecimal.valueOf(500)); - when(activityRepository.countByUserId(userId)).thenReturn(20L); - when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(10L); - when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(200000)); - when(activityRepository.sumElevationGainByUserId(userId)).thenReturn(BigDecimal.valueOf(6000)); // 6000m total - when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(2L); + stubHistory(previous, activity); when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); // When @@ -240,13 +235,14 @@ class AchievementServiceTest { @Test @DisplayName("Should award variety seeker achievement") void testCheckAndAwardAchievements_VarietySeeker() { - // Given - User has tried 3+ different activity types + // Given - Current activity introduces the 3rd distinct activity type + Activity run = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO); + run.setStartedAt(testTime.minusDays(2)); + Activity ride = createActivity(Activity.ActivityType.RIDE, 20000L, BigDecimal.ZERO); + ride.setStartedAt(testTime.minusDays(1)); Activity activity = createActivity(Activity.ActivityType.SWIM, 2000L, BigDecimal.ZERO); - when(activityRepository.countByUserId(userId)).thenReturn(15L); - when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(5L); - when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(30000)); - when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(3L); + stubHistory(run, ride, activity); when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); // When @@ -267,10 +263,7 @@ class AchievementServiceTest { metrics.setMaxSpeed(BigDecimal.valueOf(45.0)); // 45 km/h (realistic for cycling) activity.setMetrics(metrics); - when(activityRepository.countByUserId(userId)).thenReturn(10L); - when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(5L); - when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(200000)); - when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L); + stubHistory(activity); when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); // When @@ -285,19 +278,18 @@ class AchievementServiceTest { @Test @DisplayName("Should award 7-day streak achievement") void testCheckAndAwardAchievements_7DayStreak() { - // Given - User has 7+ consecutive days of activities + // Given - Current activity completes a 7-day streak + List history = new ArrayList<>(); + LocalDate anchorDate = testTime.toLocalDate(); + for (int i = 6; i >= 1; i--) { + Activity previous = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO); + previous.setStartedAt(anchorDate.minusDays(i).atTime(10, 0)); + history.add(previous); + } Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO); + history.add(activity); - when(activityRepository.countByUserId(userId)).thenReturn(20L); - when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(10L); - when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(100000)); - when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L); - // Streak source: 8 consecutive days of activity ending today, as raw timestamps - java.time.LocalDateTime now = java.time.LocalDateTime.now(); - when(activityRepository.findActivityStartTimestampsSince(any(), any())).thenReturn(List.of( - now, now.minusDays(1), now.minusDays(2), now.minusDays(3), - now.minusDays(4), now.minusDays(5), now.minusDays(6), now.minusDays(7) - )); + stubHistory(history); when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); // When @@ -314,8 +306,7 @@ class AchievementServiceTest { void testCheckAndAwardAchievements_AlreadyEarned() { // Given - User already has every achievement Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO); - - when(activityRepository.countByUserId(userId)).thenReturn(10L); + stubHistory(activity); // Simulate "user already has all achievements" by returning one of every type from the // preload query that checkAndAwardAchievements uses to populate the in-memory set. List allEarned = new java.util.ArrayList<>(); @@ -359,11 +350,7 @@ class AchievementServiceTest { metrics.setMaxSpeed(BigDecimal.valueOf(16.0)); // 57.6 km/h (unrealistic for run, but for testing) activity.setMetrics(metrics); - when(activityRepository.countByUserId(userId)).thenReturn(1L); // First activity - when(activityRepository.countByUserIdAndActivityType(userId, Activity.ActivityType.RUN)).thenReturn(1L); - when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(5000)); - when(activityRepository.sumElevationGainByUserId(userId)).thenReturn(BigDecimal.valueOf(1100)); - when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L); + stubHistory(activity); when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); // When @@ -380,6 +367,30 @@ class AchievementServiceTest { )); } + @Test + @DisplayName("Should use activity end time as earnedAt for historical milestone") + void testCheckAndAwardAchievements_UsesActivityEndTimeForEarnedAt() { + Activity previous = createActivity(Activity.ActivityType.RUN, 9000L, BigDecimal.ZERO); + previous.setStartedAt(LocalDateTime.of(2025, 11, 30, 10, 0)); + previous.setEndedAt(LocalDateTime.of(2025, 11, 30, 11, 0)); + Activity activity = createActivity(Activity.ActivityType.RUN, 2000L, BigDecimal.ZERO); + activity.setStartedAt(LocalDateTime.of(2025, 12, 1, 7, 15)); + activity.setEndedAt(LocalDateTime.of(2025, 12, 1, 8, 5)); + + stubHistory(previous, activity); + when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + List achievements = achievementService.checkAndAwardAchievements(activity); + + Achievement distanceAchievement = achievements.stream() + .filter(a -> a.getAchievementType() == Achievement.AchievementType.DISTANCE_10K) + .findFirst() + .orElseThrow(); + + assertEquals(activity.getEndedAt(), distanceAchievement.getEarnedAt()); + assertEquals(activity.getId(), distanceAchievement.getActivityId()); + } + @Test @DisplayName("Should get user achievements") void testGetUserAchievements() { @@ -421,6 +432,7 @@ class AchievementServiceTest { .userId(userId) .activityType(activityType) .startedAt(testTime) + .endedAt(testTime.plusHours(1)) .totalDistance(BigDecimal.valueOf(distanceMeters)) .totalDurationSeconds(3600L) .elevationGain(elevationGain) @@ -439,4 +451,12 @@ class AchievementServiceTest { .earnedAt(testTime) .build(); } + + private void stubHistory(Activity... activities) { + stubHistory(List.of(activities)); + } + + private void stubHistory(List activities) { + when(activityRepository.findByUserIdOrderByStartedAtAsc(userId)).thenReturn(activities); + } }