From 4d16e8c68502512659c07797c636232b2e13c676 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Wed, 29 Apr 2026 09:31:30 +0200 Subject: [PATCH 1/6] 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); + } } From 6af484bcf7c71261cde6d48b738e2e892393d987 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Wed, 29 Apr 2026 09:44:28 +0200 Subject: [PATCH 2/6] fix(analytics): rebuild achievements from activity history Signed-off-by: Marcus Fihlon --- .../repository/AchievementRepository.java | 7 ++++++ .../fitpub/service/AchievementService.java | 25 +++++++++++++++++++ .../fitpub/service/BatchImportService.java | 6 ++--- .../service/AchievementServiceTest.java | 23 +++++++++++++++++ 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/main/java/net/javahippie/fitpub/repository/AchievementRepository.java b/src/main/java/net/javahippie/fitpub/repository/AchievementRepository.java index d06471b..130869c 100644 --- a/src/main/java/net/javahippie/fitpub/repository/AchievementRepository.java +++ b/src/main/java/net/javahippie/fitpub/repository/AchievementRepository.java @@ -2,6 +2,7 @@ package net.javahippie.fitpub.repository; import net.javahippie.fitpub.model.entity.Achievement; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -40,6 +41,12 @@ public interface AchievementRepository extends JpaRepository */ long countByUserId(UUID userId); + /** + * Delete all achievements for a user. + */ + @Modifying + void deleteByUserId(UUID userId); + /** * Get count of achievements earned by a user in 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 d52ed2e..702a581 100644 --- a/src/main/java/net/javahippie/fitpub/service/AchievementService.java +++ b/src/main/java/net/javahippie/fitpub/service/AchievementService.java @@ -86,6 +86,31 @@ public class AchievementService { return newAchievements; } + /** + * Rebuild all achievements for a user from chronological activity history. + */ + @Transactional + public List rebuildAchievementsForUser(UUID userId) { + if (userId == null) { + return List.of(); + } + + List activityHistory = activityRepository.findByUserIdOrderByStartedAtAsc(userId); + if (activityHistory.isEmpty()) { + achievementRepository.deleteByUserId(userId); + return List.of(); + } + + achievementRepository.deleteByUserId(userId); + + List rebuiltAchievements = new ArrayList<>(); + for (Activity activity : activityHistory) { + rebuiltAchievements.addAll(checkAndAwardAchievements(activity)); + } + + return rebuiltAchievements; + } + /** * Check first activity achievements. */ diff --git a/src/main/java/net/javahippie/fitpub/service/BatchImportService.java b/src/main/java/net/javahippie/fitpub/service/BatchImportService.java index ea477cd..b33b10a 100644 --- a/src/main/java/net/javahippie/fitpub/service/BatchImportService.java +++ b/src/main/java/net/javahippie/fitpub/service/BatchImportService.java @@ -344,11 +344,9 @@ public class BatchImportService { personalRecordService.checkAndUpdatePersonalRecords(activity); } - // Recalculate achievements for each activity + // Recalculate achievements from the full chronological activity history log.debug("Recalculating achievements..."); - for (Activity activity : activities) { - achievementService.checkAndAwardAchievements(activity); - } + achievementService.rebuildAchievementsForUser(job.getUserId()); // Recalculate training load for each activity log.debug("Recalculating training load..."); diff --git a/src/test/java/net/javahippie/fitpub/service/AchievementServiceTest.java b/src/test/java/net/javahippie/fitpub/service/AchievementServiceTest.java index 27d6ac4..a38e05b 100644 --- a/src/test/java/net/javahippie/fitpub/service/AchievementServiceTest.java +++ b/src/test/java/net/javahippie/fitpub/service/AchievementServiceTest.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -391,6 +392,28 @@ class AchievementServiceTest { assertEquals(activity.getId(), distanceAchievement.getActivityId()); } + @Test + @DisplayName("Should rebuild achievements by deleting existing rows and replaying history") + void testRebuildAchievementsForUser() { + 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 rebuilt = achievementService.rebuildAchievementsForUser(userId); + + assertTrue(rebuilt.stream().anyMatch(a -> a.getAchievementType() == Achievement.AchievementType.DISTANCE_10K)); + + InOrder inOrder = inOrder(achievementRepository); + inOrder.verify(achievementRepository).deleteByUserId(userId); + inOrder.verify(achievementRepository, atLeastOnce()).save(any(Achievement.class)); + } + @Test @DisplayName("Should get user achievements") void testGetUserAchievements() { From 10037de043978e26bb223e8e0c3a24ce50c59b2d Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Wed, 29 Apr 2026 09:54:31 +0200 Subject: [PATCH 3/6] fix(analytics): count summary achievements by activity period Signed-off-by: Marcus Fihlon --- .../repository/AchievementRepository.java | 17 ++++ .../service/ActivitySummaryService.java | 2 +- .../service/ActivitySummaryServiceTest.java | 95 +++++++++++++++++++ 3 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 src/test/java/net/javahippie/fitpub/service/ActivitySummaryServiceTest.java diff --git a/src/main/java/net/javahippie/fitpub/repository/AchievementRepository.java b/src/main/java/net/javahippie/fitpub/repository/AchievementRepository.java index 130869c..2660051 100644 --- a/src/main/java/net/javahippie/fitpub/repository/AchievementRepository.java +++ b/src/main/java/net/javahippie/fitpub/repository/AchievementRepository.java @@ -60,6 +60,23 @@ public interface AchievementRepository extends JpaRepository @Param("endDate") LocalDateTime endDate ); + /** + * Count achievements whose triggering activity started within a date range. + */ + @Query(value = """ + SELECT COUNT(*) + FROM achievements ach + JOIN activities act ON act.id = ach.activity_id + WHERE ach.user_id = :userId + AND act.started_at >= :startDate + AND act.started_at < :endDate + """, nativeQuery = true) + long countByUserIdAndActivityStartedDateRange( + @Param("userId") UUID userId, + @Param("startDate") LocalDateTime startDate, + @Param("endDate") LocalDateTime endDate + ); + /** * Find recent achievements for a user. */ diff --git a/src/main/java/net/javahippie/fitpub/service/ActivitySummaryService.java b/src/main/java/net/javahippie/fitpub/service/ActivitySummaryService.java index 357bc8d..6579e45 100644 --- a/src/main/java/net/javahippie/fitpub/service/ActivitySummaryService.java +++ b/src/main/java/net/javahippie/fitpub/service/ActivitySummaryService.java @@ -172,7 +172,7 @@ public class ActivitySummaryService { startDateTime, endDateTime ); - long achievementsEarned = achievementRepository.countByUserIdAndDateRange( + long achievementsEarned = achievementRepository.countByUserIdAndActivityStartedDateRange( userId, startDateTime, endDateTime diff --git a/src/test/java/net/javahippie/fitpub/service/ActivitySummaryServiceTest.java b/src/test/java/net/javahippie/fitpub/service/ActivitySummaryServiceTest.java new file mode 100644 index 0000000..fdccc74 --- /dev/null +++ b/src/test/java/net/javahippie/fitpub/service/ActivitySummaryServiceTest.java @@ -0,0 +1,95 @@ +package net.javahippie.fitpub.service; + +import net.javahippie.fitpub.model.entity.Activity; +import net.javahippie.fitpub.model.entity.ActivitySummary; +import net.javahippie.fitpub.repository.ActivityRepository; +import net.javahippie.fitpub.repository.ActivitySummaryRepository; +import net.javahippie.fitpub.repository.AchievementRepository; +import net.javahippie.fitpub.repository.PersonalRecordRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ActivitySummaryServiceTest { + + @Mock + private ActivitySummaryRepository activitySummaryRepository; + + @Mock + private ActivityRepository activityRepository; + + @Mock + private PersonalRecordRepository personalRecordRepository; + + @Mock + private AchievementRepository achievementRepository; + + @InjectMocks + private ActivitySummaryService activitySummaryService; + + private UUID userId; + + @BeforeEach + void setUp() { + userId = UUID.randomUUID(); + } + + @Test + @DisplayName("Should count achievements in summaries by triggering activity start date") + void testUpdateWeeklySummary_CountsAchievementsByActivityStartDate() { + LocalDate weekDate = LocalDate.of(2025, 12, 3); + LocalDate weekStart = LocalDate.of(2025, 12, 1); + LocalDateTime startDateTime = weekStart.atStartOfDay(); + LocalDateTime endDateTime = weekStart.plusDays(7).atStartOfDay(); + + Activity activity = Activity.builder() + .id(UUID.randomUUID()) + .userId(userId) + .activityType(Activity.ActivityType.RUN) + .startedAt(LocalDateTime.of(2025, 12, 3, 23, 30)) + .endedAt(LocalDateTime.of(2025, 12, 4, 0, 15)) + .totalDistance(BigDecimal.valueOf(5000)) + .totalDurationSeconds(2700L) + .elevationGain(BigDecimal.valueOf(120)) + .build(); + + when(activitySummaryRepository.findByUserIdAndPeriodTypeAndPeriodStart( + userId, + ActivitySummary.PeriodType.WEEK, + weekStart + )).thenReturn(Optional.empty()); + when(activityRepository.findByUserIdAndStartedAtBetweenOrderByStartedAtDesc( + userId, + startDateTime, + endDateTime + )).thenReturn(List.of(activity)); + when(personalRecordRepository.countByUserIdAndDateRange(userId, startDateTime, endDateTime)).thenReturn(0L); + when(achievementRepository.countByUserIdAndActivityStartedDateRange(userId, startDateTime, endDateTime)).thenReturn(1L); + when(activitySummaryRepository.save(org.mockito.ArgumentMatchers.any(ActivitySummary.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + activitySummaryService.updateWeeklySummary(userId, weekDate); + + verify(achievementRepository).countByUserIdAndActivityStartedDateRange(userId, startDateTime, endDateTime); + verify(activitySummaryRepository).save(org.mockito.ArgumentMatchers.argThat(summary -> + summary.getAchievementsEarned() == 1 && + summary.getActivityCount() == 1 + )); + } +} From 2c567a5e8e0d395939030d6fb740e764cab45369 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Wed, 29 Apr 2026 10:00:09 +0200 Subject: [PATCH 4/6] fix(analytics): rebuild achievements after activity deletion Signed-off-by: Marcus Fihlon --- src/main/java/net/javahippie/fitpub/service/FitFileService.java | 1 + .../java/net/javahippie/fitpub/service/FitFileServiceTest.java | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/main/java/net/javahippie/fitpub/service/FitFileService.java b/src/main/java/net/javahippie/fitpub/service/FitFileService.java index bb7086e..a5b8b01 100644 --- a/src/main/java/net/javahippie/fitpub/service/FitFileService.java +++ b/src/main/java/net/javahippie/fitpub/service/FitFileService.java @@ -319,6 +319,7 @@ public class FitFileService { return activityRepository.findByIdAndUserId(activityId, userId) .map(activity -> { activityRepository.delete(activity); + achievementService.rebuildAchievementsForUser(userId); log.info("Deleted activity {} for user {}", activityId, userId); return true; }) diff --git a/src/test/java/net/javahippie/fitpub/service/FitFileServiceTest.java b/src/test/java/net/javahippie/fitpub/service/FitFileServiceTest.java index 3ad4b11..6f97fb8 100644 --- a/src/test/java/net/javahippie/fitpub/service/FitFileServiceTest.java +++ b/src/test/java/net/javahippie/fitpub/service/FitFileServiceTest.java @@ -238,6 +238,7 @@ class FitFileServiceTest { // Assert assertTrue(result); verify(activityRepository).delete(activity); + verify(achievementService).rebuildAchievementsForUser(testUserId); } @Test @@ -254,6 +255,7 @@ class FitFileServiceTest { // Assert assertFalse(result); verify(activityRepository, never()).delete(any()); + verify(achievementService, never()).rebuildAchievementsForUser(any()); } @Test From 2ac3d82fda82c1629ec1fb3bd3b3370518064e8c Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Wed, 29 Apr 2026 10:07:01 +0200 Subject: [PATCH 5/6] feat(analytics): add manual achievement rebuild action Signed-off-by: Marcus Fihlon --- .../controller/AnalyticsController.java | 21 +++++++ .../templates/analytics/achievements.html | 55 ++++++++++++++++--- 2 files changed, 67 insertions(+), 9 deletions(-) diff --git a/src/main/java/net/javahippie/fitpub/controller/AnalyticsController.java b/src/main/java/net/javahippie/fitpub/controller/AnalyticsController.java index 43b5793..e5c40eb 100644 --- a/src/main/java/net/javahippie/fitpub/controller/AnalyticsController.java +++ b/src/main/java/net/javahippie/fitpub/controller/AnalyticsController.java @@ -133,6 +133,25 @@ public class AnalyticsController { return ResponseEntity.ok(achievements); } + /** + * Rebuild achievements for the authenticated user. + */ + @PostMapping("/achievements/rebuild") + public ResponseEntity rebuildAchievements( + @AuthenticationPrincipal UserDetails userDetails) { + + UUID userId = getUserId(userDetails); + + try { + achievementService.rebuildAchievementsForUser(userId); + return ResponseEntity.ok(new RebuildResponse("Achievements recalculated successfully")); + } catch (Exception e) { + log.error("Failed to rebuild achievements for user {}", userDetails.getUsername(), e); + return ResponseEntity.internalServerError() + .body(new RebuildResponse("Failed to recalculate achievements: " + e.getMessage())); + } + } + /** * Get training load for a date range. */ @@ -276,4 +295,6 @@ public class AnalyticsController { case UNKNOWN -> "Not enough data to calculate form status."; }; } + + private record RebuildResponse(String message) {} } diff --git a/src/main/resources/templates/analytics/achievements.html b/src/main/resources/templates/analytics/achievements.html index 1187c57..cd4e122 100644 --- a/src/main/resources/templates/analytics/achievements.html +++ b/src/main/resources/templates/analytics/achievements.html @@ -8,15 +8,20 @@

-
-
-

- Achievements -

- - Back to Dashboard - -
+
+
+

+ Achievements +

+
+ + + Back to Dashboard + +
+
@@ -100,6 +105,35 @@ } } + async function rebuildAchievements() { + const rebuildBtn = document.getElementById('rebuild-achievements-btn'); + const originalContent = rebuildBtn.innerHTML; + + rebuildBtn.disabled = true; + rebuildBtn.innerHTML = 'Recalculating...'; + + try { + const response = await FitPubAuth.authenticatedFetch('/api/analytics/achievements/rebuild', { + method: 'POST' + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.message || 'Failed to recalculate achievements'); + } + + FitPub.showAlert('success', result.message || 'Achievements recalculated successfully'); + await loadAchievements(); + } catch (error) { + console.error('Error rebuilding achievements:', error); + FitPub.showAlert('danger', error.message || 'Failed to recalculate achievements'); + } finally { + rebuildBtn.disabled = false; + rebuildBtn.innerHTML = originalContent; + } + } + function updateStats(achievements) { // Earned count document.getElementById('earned-count').textContent = achievements.length; @@ -108,6 +142,8 @@ if (achievements.length > 0) { const latest = new Date(achievements[0].earnedAt); document.getElementById('latest-date').textContent = latest.toLocaleDateString(); + } else { + document.getElementById('latest-date').textContent = '-'; } // Completion percentage @@ -217,6 +253,7 @@ window.location.href = '/auth/login'; return; } + document.getElementById('rebuild-achievements-btn').addEventListener('click', rebuildAchievements); loadAchievements(); }); From 689b8e5e82e53c3dd2c1441fadc30fd892de9e5a Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Wed, 29 Apr 2026 10:15:31 +0200 Subject: [PATCH 6/6] fix(analytics): refresh summaries after activity deletion Signed-off-by: Marcus Fihlon --- .../net/javahippie/fitpub/service/FitFileService.java | 6 ++++++ .../net/javahippie/fitpub/service/FitFileServiceTest.java | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/src/main/java/net/javahippie/fitpub/service/FitFileService.java b/src/main/java/net/javahippie/fitpub/service/FitFileService.java index a5b8b01..39c8b76 100644 --- a/src/main/java/net/javahippie/fitpub/service/FitFileService.java +++ b/src/main/java/net/javahippie/fitpub/service/FitFileService.java @@ -320,6 +320,12 @@ public class FitFileService { .map(activity -> { activityRepository.delete(activity); achievementService.rebuildAchievementsForUser(userId); + if (activity.getStartedAt() != null) { + java.time.LocalDate activityDate = activity.getStartedAt().toLocalDate(); + activitySummaryService.updateWeeklySummary(userId, activityDate); + activitySummaryService.updateMonthlySummary(userId, activityDate); + activitySummaryService.updateYearlySummary(userId, activityDate); + } log.info("Deleted activity {} for user {}", activityId, userId); return true; }) diff --git a/src/test/java/net/javahippie/fitpub/service/FitFileServiceTest.java b/src/test/java/net/javahippie/fitpub/service/FitFileServiceTest.java index 6f97fb8..0ab3ac1 100644 --- a/src/test/java/net/javahippie/fitpub/service/FitFileServiceTest.java +++ b/src/test/java/net/javahippie/fitpub/service/FitFileServiceTest.java @@ -224,9 +224,11 @@ class FitFileServiceTest { void testDeleteActivity() { // Arrange UUID activityId = UUID.randomUUID(); + LocalDateTime startedAt = LocalDateTime.of(2025, 12, 3, 10, 0); Activity activity = Activity.builder() .id(activityId) .userId(testUserId) + .startedAt(startedAt) .build(); when(activityRepository.findByIdAndUserId(activityId, testUserId)) @@ -239,6 +241,9 @@ class FitFileServiceTest { assertTrue(result); verify(activityRepository).delete(activity); verify(achievementService).rebuildAchievementsForUser(testUserId); + verify(activitySummaryService).updateWeeklySummary(testUserId, startedAt.toLocalDate()); + verify(activitySummaryService).updateMonthlySummary(testUserId, startedAt.toLocalDate()); + verify(activitySummaryService).updateYearlySummary(testUserId, startedAt.toLocalDate()); } @Test @@ -256,6 +261,9 @@ class FitFileServiceTest { assertFalse(result); verify(activityRepository, never()).delete(any()); verify(achievementService, never()).rebuildAchievementsForUser(any()); + verify(activitySummaryService, never()).updateWeeklySummary(any(), any()); + verify(activitySummaryService, never()).updateMonthlySummary(any(), any()); + verify(activitySummaryService, never()).updateYearlySummary(any(), any()); } @Test