diff --git a/.idea/bld.xml b/.idea/bld.xml new file mode 100644 index 0000000..6600cee --- /dev/null +++ b/.idea/bld.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/data_source_mapping.xml b/.idea/data_source_mapping.xml deleted file mode 100644 index ed1c16b..0000000 --- a/.idea/data_source_mapping.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file 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/java/net/javahippie/fitpub/repository/AchievementRepository.java b/src/main/java/net/javahippie/fitpub/repository/AchievementRepository.java index d06471b..2660051 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. */ @@ -53,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/repository/ActivityRepository.java b/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java index 7a8c03d..c68f6d3 100644 --- a/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java +++ b/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java @@ -59,6 +59,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..702a581 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,42 +60,69 @@ 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; } + /** + * 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. */ 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 +131,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 +158,7 @@ public class AchievementService { getActivityEmoji(activityType), "#00ffff", activity.getId(), + activity.getEndedAt(), null )); } @@ -138,16 +170,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 +188,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 +198,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 +211,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 +225,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 +235,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 +248,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 +264,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 +274,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 +288,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 +296,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 +307,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 +326,8 @@ public class AchievementService { "🦉", "#9370db", activity.getId(), - Map.of("late_activities", lateActivities) + activity.getEndedAt(), + Map.of("late_activities", currentLateActivities) )); } } @@ -293,12 +339,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 +357,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 +404,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 +422,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 +435,7 @@ public class AchievementService { * Check speed achievements. */ private List checkSpeedAchievements(UUID userId, Activity activity, + ActivityProgress progress, Set existing) { List achievements = new ArrayList<>(); @@ -383,7 +443,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 +454,7 @@ public class AchievementService { "⚡", "#ffd700", activity.getId(), + activity.getEndedAt(), Map.of("max_speed_kmh", maxSpeedKmh) )); } @@ -400,70 +463,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 +477,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 +516,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/main/java/net/javahippie/fitpub/service/ActivitySummaryService.java b/src/main/java/net/javahippie/fitpub/service/ActivitySummaryService.java index d7ff249..77dc568 100644 --- a/src/main/java/net/javahippie/fitpub/service/ActivitySummaryService.java +++ b/src/main/java/net/javahippie/fitpub/service/ActivitySummaryService.java @@ -182,7 +182,7 @@ public class ActivitySummaryService { startDateTime, endDateTime ); - long achievementsEarned = achievementRepository.countByUserIdAndDateRange( + long achievementsEarned = achievementRepository.countByUserIdAndActivityStartedDateRange( userId, startDateTime, endDateTime 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/main/java/net/javahippie/fitpub/service/FitFileService.java b/src/main/java/net/javahippie/fitpub/service/FitFileService.java index bb7086e..39c8b76 100644 --- a/src/main/java/net/javahippie/fitpub/service/FitFileService.java +++ b/src/main/java/net/javahippie/fitpub/service/FitFileService.java @@ -319,6 +319,13 @@ public class FitFileService { return activityRepository.findByIdAndUserId(activityId, userId) .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/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(); }); diff --git a/src/test/java/net/javahippie/fitpub/service/AchievementServiceTest.java b/src/test/java/net/javahippie/fitpub/service/AchievementServiceTest.java index e9cd220..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; @@ -14,14 +15,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 +55,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 +76,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 +95,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 +118,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 +143,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 +169,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 +198,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 +213,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 +236,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 +264,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 +279,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 +307,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 +351,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 +368,52 @@ 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 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() { @@ -421,6 +455,7 @@ class AchievementServiceTest { .userId(userId) .activityType(activityType) .startedAt(testTime) + .endedAt(testTime.plusHours(1)) .totalDistance(BigDecimal.valueOf(distanceMeters)) .totalDurationSeconds(3600L) .elevationGain(elevationGain) @@ -439,4 +474,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); + } } diff --git a/src/test/java/net/javahippie/fitpub/service/ActivitySummaryServiceTest.java b/src/test/java/net/javahippie/fitpub/service/ActivitySummaryServiceTest.java index 37d5681..5f19254 100644 --- a/src/test/java/net/javahippie/fitpub/service/ActivitySummaryServiceTest.java +++ b/src/test/java/net/javahippie/fitpub/service/ActivitySummaryServiceTest.java @@ -9,8 +9,13 @@ 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 org.springframework.dao.DataIntegrityViolationException; +import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; @@ -19,37 +24,39 @@ import java.util.UUID; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.argThat; import static org.mockito.Mockito.times; 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; - private ActivitySummaryService service; + + @InjectMocks + private ActivitySummaryService activitySummaryService; + + private UUID userId; @BeforeEach void setUp() { - activitySummaryRepository = mock(ActivitySummaryRepository.class); - activityRepository = mock(ActivityRepository.class); - personalRecordRepository = mock(PersonalRecordRepository.class); - achievementRepository = mock(AchievementRepository.class); - service = new ActivitySummaryService( - activitySummaryRepository, - activityRepository, - personalRecordRepository, - achievementRepository - ); + userId = UUID.randomUUID(); } @Test @DisplayName("Should retry summary save as update when concurrent insert hits unique constraint") void shouldRetrySummarySaveAfterConcurrentInsert() { - UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); LocalDate date = LocalDate.of(2025, 10, 8); LocalDate weekStart = LocalDate.of(2025, 10, 6); LocalDate weekEnd = LocalDate.of(2025, 10, 12); @@ -62,9 +69,10 @@ class ActivitySummaryServiceTest { .periodEnd(weekEnd) .build(); - //noinspection unchecked when(activitySummaryRepository.findByUserIdAndPeriodTypeAndPeriodStart( - userId, ActivitySummary.PeriodType.WEEK, weekStart + userId, + ActivitySummary.PeriodType.WEEK, + weekStart )).thenReturn(Optional.empty(), Optional.of(existingSummary)); when(activityRepository.findByUserIdAndStartedAtBetweenOrderByStartedAtDesc( @@ -81,16 +89,59 @@ class ActivitySummaryServiceTest { )); when(personalRecordRepository.countByUserIdAndDateRange(any(), any(), any())).thenReturn(0L); - when(achievementRepository.countByUserIdAndDateRange(any(), any(), any())).thenReturn(0L); - + when(achievementRepository.countByUserIdAndActivityStartedDateRange(any(), any(), any())).thenReturn(0L); when(activitySummaryRepository.save(any(ActivitySummary.class))) .thenThrow(new DataIntegrityViolationException("duplicate")) .thenReturn(existingSummary); - service.updateWeeklySummary(userId, date); + activitySummaryService.updateWeeklySummary(userId, date); verify(activitySummaryRepository, times(2)) .findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.WEEK, weekStart); verify(activitySummaryRepository, times(2)).save(any(ActivitySummary.class)); } + + @Test + @DisplayName("Should count achievements in summaries by triggering activity start date") + void shouldCountAchievementsByActivityStartDate() { + 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(any(ActivitySummary.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + activitySummaryService.updateWeeklySummary(userId, weekDate); + + verify(achievementRepository).countByUserIdAndActivityStartedDateRange(userId, startDateTime, endDateTime); + verify(activitySummaryRepository).save(argThat(summary -> + summary.getAchievementsEarned() == 1 && + summary.getActivityCount() == 1 + )); + } } diff --git a/src/test/java/net/javahippie/fitpub/service/FitFileServiceTest.java b/src/test/java/net/javahippie/fitpub/service/FitFileServiceTest.java index 3ad4b11..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)) @@ -238,6 +240,10 @@ class FitFileServiceTest { // Assert 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 @@ -254,6 +260,10 @@ class FitFileServiceTest { // Assert 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