fix(analytics): use activity end time for achievement earnedAt #24

Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>
This commit is contained in:
Marcus Fihlon 2026-04-29 09:31:30 +02:00
parent 9e529f8b99
commit 4d16e8c685
Signed by: McPringle
GPG key ID: C6B7F469EE363E1F
3 changed files with 380 additions and 205 deletions

View file

@ -31,6 +31,14 @@ public interface ActivityRepository extends JpaRepository<Activity, UUID> {
*/ */
List<Activity> findByUserIdOrderByStartedAtDesc(UUID userId); List<Activity> findByUserIdOrderByStartedAtDesc(UUID userId);
/**
* Find all activities for a specific user in chronological order.
*
* @param userId the user ID
* @return list of activities
*/
List<Activity> findByUserIdOrderByStartedAtAsc(UUID userId);
/** /**
* Find all activities for a user within a date range. * Find all activities for a user within a date range.
* *

View file

@ -43,11 +43,13 @@ public class AchievementService {
public List<Achievement> checkAndAwardAchievements(Activity activity) { public List<Achievement> checkAndAwardAchievements(Activity activity) {
List<Achievement> newAchievements = new ArrayList<>(); List<Achievement> newAchievements = new ArrayList<>();
if (activity.getUserId() == null) { if (activity.getUserId() == null || activity.getStartedAt() == null || activity.getEndedAt() == null) {
return newAchievements; return newAchievements;
} }
UUID userId = activity.getUserId(); UUID userId = activity.getUserId();
List<Activity> 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 // 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 // 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 // Check first activity achievements
newAchievements.addAll(checkFirstActivityAchievements(userId, activity, existing)); newAchievements.addAll(checkFirstActivityAchievements(userId, activity, progress, existing));
// Check distance milestones // Check distance milestones
newAchievements.addAll(checkDistanceMilestones(userId, existing)); newAchievements.addAll(checkDistanceMilestones(userId, activity, progress, existing));
// Check activity count milestones // Check activity count milestones
newAchievements.addAll(checkActivityCountMilestones(userId, existing)); newAchievements.addAll(checkActivityCountMilestones(userId, activity, progress, existing));
// Check streak achievements // Check streak achievements
newAchievements.addAll(checkStreakAchievements(userId, existing)); newAchievements.addAll(checkStreakAchievements(userId, activity, progress, existing));
// Check time-based achievements // Check time-based achievements
newAchievements.addAll(checkTimeBasedAchievements(userId, activity, existing)); newAchievements.addAll(checkTimeBasedAchievements(userId, activity, progress, existing));
// Check elevation achievements // Check elevation achievements
newAchievements.addAll(checkElevationAchievements(userId, activity, existing)); newAchievements.addAll(checkElevationAchievements(userId, activity, progress, existing));
// Check variety achievements // Check variety achievements
newAchievements.addAll(checkVarietyAchievements(userId, existing)); newAchievements.addAll(checkVarietyAchievements(userId, activity, progress, existing));
// Check speed achievements // Check speed achievements
newAchievements.addAll(checkSpeedAchievements(userId, activity, existing)); newAchievements.addAll(checkSpeedAchievements(userId, activity, progress, existing));
return newAchievements; return newAchievements;
} }
@ -88,12 +90,14 @@ public class AchievementService {
* Check first activity achievements. * Check first activity achievements.
*/ */
private List<Achievement> checkFirstActivityAchievements(UUID userId, Activity activity, private List<Achievement> checkFirstActivityAchievements(UUID userId, Activity activity,
ActivityProgress progress,
Set<Achievement.AchievementType> existing) { Set<Achievement.AchievementType> existing) {
List<Achievement> achievements = new ArrayList<>(); List<Achievement> achievements = new ArrayList<>();
// First activity overall // First activity overall
long totalActivities = activityRepository.countByUserId(userId); if (progress.previousActivityCount() == 0 &&
if (totalActivities == 1 && !existing.contains(Achievement.AchievementType.FIRST_ACTIVITY)) { progress.currentActivityCount() == 1 &&
!existing.contains(Achievement.AchievementType.FIRST_ACTIVITY)) {
achievements.add(awardAchievement( achievements.add(awardAchievement(
userId, userId,
Achievement.AchievementType.FIRST_ACTIVITY, Achievement.AchievementType.FIRST_ACTIVITY,
@ -102,15 +106,17 @@ public class AchievementService {
"🎉", "🎉",
"#ff00ff", "#ff00ff",
activity.getId(), activity.getId(),
activity.getEndedAt(),
null null
)); ));
} }
// First activity by type // First activity by type
String activityType = activity.getActivityType().name(); 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) { Achievement.AchievementType achievementType = switch (activityType) {
case "RUN" -> Achievement.AchievementType.FIRST_RUN; case "RUN" -> Achievement.AchievementType.FIRST_RUN;
case "RIDE" -> Achievement.AchievementType.FIRST_RIDE; case "RIDE" -> Achievement.AchievementType.FIRST_RIDE;
@ -127,6 +133,7 @@ public class AchievementService {
getActivityEmoji(activityType), getActivityEmoji(activityType),
"#00ffff", "#00ffff",
activity.getId(), activity.getId(),
activity.getEndedAt(),
null null
)); ));
} }
@ -138,16 +145,13 @@ public class AchievementService {
/** /**
* Check distance milestone achievements. * Check distance milestone achievements.
*/ */
private List<Achievement> checkDistanceMilestones(UUID userId, Set<Achievement.AchievementType> existing) { private List<Achievement> checkDistanceMilestones(UUID userId, Activity activity,
ActivityProgress progress,
Set<Achievement.AchievementType> existing) {
List<Achievement> achievements = new ArrayList<>(); List<Achievement> achievements = new ArrayList<>();
// Calculate total distance double previousKm = progress.previousDistanceMeters().doubleValue() / 1000.0;
BigDecimal totalDistance = activityRepository.sumDistanceByUserId(userId); double currentKm = progress.currentDistanceMeters().doubleValue() / 1000.0;
if (totalDistance == null) {
return achievements;
}
double totalKm = totalDistance.doubleValue() / 1000.0;
// Check milestones // Check milestones
Map<Double, Achievement.AchievementType> milestones = Map.of( Map<Double, Achievement.AchievementType> milestones = Map.of(
@ -159,7 +163,9 @@ public class AchievementService {
); );
for (Map.Entry<Double, Achievement.AchievementType> entry : milestones.entrySet()) { for (Map.Entry<Double, Achievement.AchievementType> 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( achievements.add(awardAchievement(
userId, userId,
entry.getValue(), entry.getValue(),
@ -167,7 +173,8 @@ public class AchievementService {
String.format("Reached %.0f kilometers total distance!", entry.getKey()), String.format("Reached %.0f kilometers total distance!", entry.getKey()),
"🏃", "🏃",
"#ffff00", "#ffff00",
null, activity.getId(),
activity.getEndedAt(),
Map.of("distance_km", entry.getKey()) Map.of("distance_km", entry.getKey())
)); ));
} }
@ -179,11 +186,11 @@ public class AchievementService {
/** /**
* Check activity count milestone achievements. * Check activity count milestone achievements.
*/ */
private List<Achievement> checkActivityCountMilestones(UUID userId, Set<Achievement.AchievementType> existing) { private List<Achievement> checkActivityCountMilestones(UUID userId, Activity activity,
ActivityProgress progress,
Set<Achievement.AchievementType> existing) {
List<Achievement> achievements = new ArrayList<>(); List<Achievement> achievements = new ArrayList<>();
long activityCount = activityRepository.countByUserId(userId);
Map<Long, Achievement.AchievementType> milestones = Map.of( Map<Long, Achievement.AchievementType> milestones = Map.of(
10L, Achievement.AchievementType.ACTIVITIES_10, 10L, Achievement.AchievementType.ACTIVITIES_10,
50L, Achievement.AchievementType.ACTIVITIES_50, 50L, Achievement.AchievementType.ACTIVITIES_50,
@ -193,7 +200,9 @@ public class AchievementService {
); );
for (Map.Entry<Long, Achievement.AchievementType> entry : milestones.entrySet()) { for (Map.Entry<Long, Achievement.AchievementType> 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( achievements.add(awardAchievement(
userId, userId,
entry.getValue(), entry.getValue(),
@ -201,7 +210,8 @@ public class AchievementService {
String.format("Completed %d activities!", entry.getKey()), String.format("Completed %d activities!", entry.getKey()),
"💪", "💪",
"#ff6600", "#ff6600",
null, activity.getId(),
activity.getEndedAt(),
Map.of("activity_count", entry.getKey()) Map.of("activity_count", entry.getKey())
)); ));
} }
@ -213,10 +223,13 @@ public class AchievementService {
/** /**
* Check streak achievements (consecutive days). * Check streak achievements (consecutive days).
*/ */
private List<Achievement> checkStreakAchievements(UUID userId, Set<Achievement.AchievementType> existing) { private List<Achievement> checkStreakAchievements(UUID userId, Activity activity,
ActivityProgress progress,
Set<Achievement.AchievementType> existing) {
List<Achievement> achievements = new ArrayList<>(); List<Achievement> achievements = new ArrayList<>();
int currentStreak = calculateCurrentStreak(userId); int previousStreak = calculateStreak(progress.previousActivityDates(), activity.getEndedAt().toLocalDate());
int currentStreak = calculateStreak(progress.currentActivityDates(), activity.getEndedAt().toLocalDate());
Map<Integer, Achievement.AchievementType> streakMilestones = Map.of( Map<Integer, Achievement.AchievementType> streakMilestones = Map.of(
7, Achievement.AchievementType.STREAK_7_DAYS, 7, Achievement.AchievementType.STREAK_7_DAYS,
@ -226,7 +239,9 @@ public class AchievementService {
); );
for (Map.Entry<Integer, Achievement.AchievementType> entry : streakMilestones.entrySet()) { for (Map.Entry<Integer, Achievement.AchievementType> 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( achievements.add(awardAchievement(
userId, userId,
entry.getValue(), entry.getValue(),
@ -234,7 +249,8 @@ public class AchievementService {
String.format("Worked out %d days in a row!", entry.getKey()), String.format("Worked out %d days in a row!", entry.getKey()),
"🔥", "🔥",
"#ff1493", "#ff1493",
null, activity.getId(),
activity.getEndedAt(),
Map.of("streak_days", entry.getKey()) Map.of("streak_days", entry.getKey())
)); ));
} }
@ -247,6 +263,7 @@ public class AchievementService {
* Check time-based achievements (early bird, night owl, weekend warrior). * Check time-based achievements (early bird, night owl, weekend warrior).
*/ */
private List<Achievement> checkTimeBasedAchievements(UUID userId, Activity activity, private List<Achievement> checkTimeBasedAchievements(UUID userId, Activity activity,
ActivityProgress progress,
Set<Achievement.AchievementType> existing) { Set<Achievement.AchievementType> existing) {
List<Achievement> achievements = new ArrayList<>(); List<Achievement> achievements = new ArrayList<>();
@ -254,8 +271,9 @@ public class AchievementService {
// Early bird (before 6am) // Early bird (before 6am)
if (startTime.isBefore(LocalTime.of(6, 0)) && !existing.contains(Achievement.AchievementType.EARLY_BIRD)) { if (startTime.isBefore(LocalTime.of(6, 0)) && !existing.contains(Achievement.AchievementType.EARLY_BIRD)) {
long earlyActivities = activityRepository.countByUserIdAndStartTimeBefore(userId, LocalTime.of(6, 0)); long previousEarlyActivities = progress.previousActivitiesStartingBefore(LocalTime.of(6, 0));
if (earlyActivities >= 5) { long currentEarlyActivities = progress.currentActivitiesStartingBefore(LocalTime.of(6, 0));
if (previousEarlyActivities < 5 && currentEarlyActivities >= 5) {
achievements.add(awardAchievement( achievements.add(awardAchievement(
userId, userId,
Achievement.AchievementType.EARLY_BIRD, Achievement.AchievementType.EARLY_BIRD,
@ -264,15 +282,17 @@ public class AchievementService {
"🌅", "🌅",
"#ccff00", "#ccff00",
activity.getId(), activity.getId(),
Map.of("early_activities", earlyActivities) activity.getEndedAt(),
Map.of("early_activities", currentEarlyActivities)
)); ));
} }
} }
// Night owl (after 10pm) // Night owl (after 10pm)
if (startTime.isAfter(LocalTime.of(22, 0)) && !existing.contains(Achievement.AchievementType.NIGHT_OWL)) { if (startTime.isAfter(LocalTime.of(22, 0)) && !existing.contains(Achievement.AchievementType.NIGHT_OWL)) {
long lateActivities = activityRepository.countByUserIdAndStartTimeAfter(userId, LocalTime.of(22, 0)); long previousLateActivities = progress.previousActivitiesStartingAfter(LocalTime.of(22, 0));
if (lateActivities >= 5) { long currentLateActivities = progress.currentActivitiesStartingAfter(LocalTime.of(22, 0));
if (previousLateActivities < 5 && currentLateActivities >= 5) {
achievements.add(awardAchievement( achievements.add(awardAchievement(
userId, userId,
Achievement.AchievementType.NIGHT_OWL, Achievement.AchievementType.NIGHT_OWL,
@ -281,7 +301,8 @@ public class AchievementService {
"🦉", "🦉",
"#9370db", "#9370db",
activity.getId(), activity.getId(),
Map.of("late_activities", lateActivities) activity.getEndedAt(),
Map.of("late_activities", currentLateActivities)
)); ));
} }
} }
@ -293,12 +314,14 @@ public class AchievementService {
* Check elevation achievements. * Check elevation achievements.
*/ */
private List<Achievement> checkElevationAchievements(UUID userId, Activity activity, private List<Achievement> checkElevationAchievements(UUID userId, Activity activity,
ActivityProgress progress,
Set<Achievement.AchievementType> existing) { Set<Achievement.AchievementType> existing) {
List<Achievement> achievements = new ArrayList<>(); List<Achievement> achievements = new ArrayList<>();
// Single activity elevation // Single activity elevation
if (activity.getElevationGain() != null && if (activity.getElevationGain() != null &&
activity.getElevationGain().compareTo(BigDecimal.valueOf(1000)) >= 0 && activity.getElevationGain().compareTo(BigDecimal.valueOf(1000)) >= 0 &&
!progress.previousHasElevationGainAtLeast(BigDecimal.valueOf(1000)) &&
!existing.contains(Achievement.AchievementType.MOUNTAINEER_1000M)) { !existing.contains(Achievement.AchievementType.MOUNTAINEER_1000M)) {
achievements.add(awardAchievement( achievements.add(awardAchievement(
@ -309,40 +332,45 @@ public class AchievementService {
"⛰️", "⛰️",
"#8b4513", "#8b4513",
activity.getId(), activity.getId(),
activity.getEndedAt(),
Map.of("elevation_gain", activity.getElevationGain()) Map.of("elevation_gain", activity.getElevationGain())
)); ));
} }
// Total elevation milestones // Total elevation milestones
BigDecimal totalElevation = activityRepository.sumElevationGainByUserId(userId); double previousElevation = progress.previousElevationMeters().doubleValue();
if (totalElevation != null) { double currentElevation = progress.currentElevationMeters().doubleValue();
double totalM = totalElevation.doubleValue();
if (totalM >= 5000 && !existing.contains(Achievement.AchievementType.MOUNTAINEER_5000M)) { if (previousElevation < 5000 &&
achievements.add(awardAchievement( currentElevation >= 5000 &&
userId, !existing.contains(Achievement.AchievementType.MOUNTAINEER_5000M)) {
Achievement.AchievementType.MOUNTAINEER_5000M, achievements.add(awardAchievement(
"Mountain Conqueror", userId,
"Climbed 5000m total elevation!", Achievement.AchievementType.MOUNTAINEER_5000M,
"🏔️", "Mountain Conqueror",
"#4169e1", "Climbed 5000m total elevation!",
null, "🏔️",
Map.of("total_elevation", totalM) "#4169e1",
)); activity.getId(),
} activity.getEndedAt(),
Map.of("total_elevation", currentElevation)
));
}
if (totalM >= 10000 && !existing.contains(Achievement.AchievementType.MOUNTAINEER_10000M)) { if (previousElevation < 10000 &&
achievements.add(awardAchievement( currentElevation >= 10000 &&
userId, !existing.contains(Achievement.AchievementType.MOUNTAINEER_10000M)) {
Achievement.AchievementType.MOUNTAINEER_10000M, achievements.add(awardAchievement(
"Summit Master", userId,
"Climbed 10000m total elevation!", Achievement.AchievementType.MOUNTAINEER_10000M,
"🗻", "Summit Master",
"#1e90ff", "Climbed 10000m total elevation!",
null, "🗻",
Map.of("total_elevation", totalM) "#1e90ff",
)); activity.getId(),
} activity.getEndedAt(),
Map.of("total_elevation", currentElevation)
));
} }
return achievements; return achievements;
@ -351,12 +379,17 @@ public class AchievementService {
/** /**
* Check variety achievements. * Check variety achievements.
*/ */
private List<Achievement> checkVarietyAchievements(UUID userId, Set<Achievement.AchievementType> existing) { private List<Achievement> checkVarietyAchievements(UUID userId, Activity activity,
ActivityProgress progress,
Set<Achievement.AchievementType> existing) {
List<Achievement> achievements = new ArrayList<>(); List<Achievement> 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( achievements.add(awardAchievement(
userId, userId,
Achievement.AchievementType.VARIETY_SEEKER, Achievement.AchievementType.VARIETY_SEEKER,
@ -364,8 +397,9 @@ public class AchievementService {
"Tried 3+ different activity types!", "Tried 3+ different activity types!",
"🌈", "🌈",
"#ff69b4", "#ff69b4",
null, activity.getId(),
Map.of("activity_types", distinctActivityTypes) activity.getEndedAt(),
Map.of("activity_types", currentDistinctActivityTypes)
)); ));
} }
@ -376,6 +410,7 @@ public class AchievementService {
* Check speed achievements. * Check speed achievements.
*/ */
private List<Achievement> checkSpeedAchievements(UUID userId, Activity activity, private List<Achievement> checkSpeedAchievements(UUID userId, Activity activity,
ActivityProgress progress,
Set<Achievement.AchievementType> existing) { Set<Achievement.AchievementType> existing) {
List<Achievement> achievements = new ArrayList<>(); List<Achievement> achievements = new ArrayList<>();
@ -383,7 +418,9 @@ public class AchievementService {
// maxSpeed is already in km/h from FitParser // maxSpeed is already in km/h from FitParser
double maxSpeedKmh = activity.getMetrics().getMaxSpeed().doubleValue(); 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( achievements.add(awardAchievement(
userId, userId,
Achievement.AchievementType.SPEED_DEMON, Achievement.AchievementType.SPEED_DEMON,
@ -392,6 +429,7 @@ public class AchievementService {
"", "",
"#ffd700", "#ffd700",
activity.getId(), activity.getId(),
activity.getEndedAt(),
Map.of("max_speed_kmh", maxSpeedKmh) Map.of("max_speed_kmh", maxSpeedKmh)
)); ));
} }
@ -400,70 +438,13 @@ public class AchievementService {
return achievements; return achievements;
} }
/**
* Calculate current activity streak (consecutive days).
*
* <p>Loads all activity timestamps for the user in the last 366 days in a single
* query, deduplicates them to a {@code Set<LocalDate>} 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.
*
* <p>Java-side date deduplication is intentional: Hibernate 6 + Spring Data 3 do
* not reliably convert SQL date scalar projections to {@code List<LocalDate>}.
* The result set is small (a few hundred timestamps at most) so the cost of
* Java-side distinct is negligible.
*
* <p>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<LocalDate> 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. * Award an achievement to a user.
*/ */
private Achievement awardAchievement(UUID userId, Achievement.AchievementType achievementType, private Achievement awardAchievement(UUID userId, Achievement.AchievementType achievementType,
String name, String description, String icon, String color, String name, String description, String icon, String color,
UUID activityId, Map<String, Object> metadata) { UUID activityId, LocalDateTime earnedAt,
Map<String, Object> metadata) {
Achievement achievement = Achievement.builder() Achievement achievement = Achievement.builder()
.userId(userId) .userId(userId)
.achievementType(achievementType) .achievementType(achievementType)
@ -471,7 +452,7 @@ public class AchievementService {
.description(description) .description(description)
.badgeIcon(icon) .badgeIcon(icon)
.badgeColor(color) .badgeColor(color)
.earnedAt(LocalDateTime.now()) .earnedAt(earnedAt != null ? earnedAt : LocalDateTime.now())
.activityId(activityId) .activityId(activityId)
.metadata(metadata) .metadata(metadata)
.build(); .build();
@ -510,4 +491,170 @@ public class AchievementService {
public long getAchievementCount(UUID userId) { public long getAchievementCount(UUID userId) {
return achievementRepository.countByUserId(userId); return achievementRepository.countByUserId(userId);
} }
private int calculateStreak(Set<LocalDate> 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<Activity> previousActivities,
List<Activity> currentActivities
) {
private static ActivityProgress fromHistory(List<Activity> 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<LocalDate> previousActivityDates() {
return collectDates(previousActivities);
}
Set<LocalDate> 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<Activity> activities) {
return activities.stream()
.map(Activity::getTotalDistance)
.filter(Objects::nonNull)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
private static BigDecimal sumElevation(List<Activity> activities) {
return activities.stream()
.map(Activity::getElevationGain)
.filter(Objects::nonNull)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
private static Set<LocalDate> collectDates(List<Activity> activities) {
Set<LocalDate> dates = new HashSet<>();
for (Activity activity : activities) {
if (activity.getStartedAt() != null) {
dates.add(activity.getStartedAt().toLocalDate());
}
}
return dates;
}
}
} }

View file

@ -14,14 +14,14 @@ import net.javahippie.fitpub.repository.AchievementRepository;
import net.javahippie.fitpub.repository.ActivityRepository; import net.javahippie.fitpub.repository.ActivityRepository;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.LocalTime; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
/** /**
@ -54,14 +54,7 @@ class AchievementServiceTest {
void testCheckAndAwardAchievements_FirstActivity() { void testCheckAndAwardAchievements_FirstActivity() {
// Given // Given
Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO); Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO);
stubHistory(activity);
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()));
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When // When
@ -82,12 +75,11 @@ class AchievementServiceTest {
@DisplayName("Should award first run achievement") @DisplayName("Should award first run achievement")
void testCheckAndAwardAchievements_FirstRun() { void testCheckAndAwardAchievements_FirstRun() {
// Given // Given
Activity firstRide = createActivity(Activity.ActivityType.RIDE, 10000L, BigDecimal.ZERO);
firstRide.setStartedAt(testTime.minusDays(1));
Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO); Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO);
when(activityRepository.countByUserId(userId)).thenReturn(10L); // Not first overall stubHistory(firstRide, activity);
when(activityRepository.countByUserIdAndActivityType(userId, Activity.ActivityType.RUN)).thenReturn(1L);
when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(50000));
when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(2L);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When // When
@ -102,13 +94,12 @@ class AchievementServiceTest {
@Test @Test
@DisplayName("Should award distance milestone achievements") @DisplayName("Should award distance milestone achievements")
void testCheckAndAwardAchievements_DistanceMilestone() { 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); Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO);
when(activityRepository.countByUserId(userId)).thenReturn(5L); stubHistory(previous, activity);
when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(3L);
when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(12000)); // 12 km
when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When // When
@ -126,13 +117,17 @@ class AchievementServiceTest {
@Test @Test
@DisplayName("Should award activity count milestone") @DisplayName("Should award activity count milestone")
void testCheckAndAwardAchievements_ActivityCount() { void testCheckAndAwardAchievements_ActivityCount() {
// Given - User has 10 activities // Given - Current activity is the 10th activity
List<Activity> 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); Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO);
history.add(activity);
when(activityRepository.countByUserId(userId)).thenReturn(10L); stubHistory(history);
when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(5L);
when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(50000));
when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When // When
@ -147,15 +142,18 @@ class AchievementServiceTest {
@Test @Test
@DisplayName("Should award early bird achievement") @DisplayName("Should award early bird achievement")
void testCheckAndAwardAchievements_EarlyBird() { void testCheckAndAwardAchievements_EarlyBird() {
// Given - Activity before 6am, and user has 5+ early activities // Given - Activity before 6am is the 5th early activity
List<Activity> 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 activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO);
activity.setStartedAt(LocalDateTime.of(2025, 12, 1, 5, 30)); // 5:30 AM activity.setStartedAt(LocalDateTime.of(2025, 12, 1, 5, 30)); // 5:30 AM
when(activityRepository.countByUserId(userId)).thenReturn(10L); history.add(activity);
when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(5L); stubHistory(history);
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);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When // When
@ -170,15 +168,18 @@ class AchievementServiceTest {
@Test @Test
@DisplayName("Should award night owl achievement") @DisplayName("Should award night owl achievement")
void testCheckAndAwardAchievements_NightOwl() { void testCheckAndAwardAchievements_NightOwl() {
// Given - Activity after 10pm, and user has 5+ late activities // Given - Activity after 10pm is the 5th late activity
List<Activity> 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 activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO);
activity.setStartedAt(LocalDateTime.of(2025, 12, 1, 23, 0)); // 11:00 PM activity.setStartedAt(LocalDateTime.of(2025, 12, 1, 23, 0)); // 11:00 PM
when(activityRepository.countByUserId(userId)).thenReturn(10L); history.add(activity);
when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(5L); stubHistory(history);
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);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When // When
@ -196,11 +197,7 @@ class AchievementServiceTest {
// Given - Activity with 1000m+ elevation gain // Given - Activity with 1000m+ elevation gain
Activity activity = createActivity(Activity.ActivityType.HIKE, 10000L, BigDecimal.valueOf(1200)); Activity activity = createActivity(Activity.ActivityType.HIKE, 10000L, BigDecimal.valueOf(1200));
when(activityRepository.countByUserId(userId)).thenReturn(5L); stubHistory(activity);
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);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When // When
@ -215,14 +212,12 @@ class AchievementServiceTest {
@Test @Test
@DisplayName("Should award total elevation milestones") @DisplayName("Should award total elevation milestones")
void testCheckAndAwardAchievements_TotalElevation() { 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)); Activity activity = createActivity(Activity.ActivityType.HIKE, 10000L, BigDecimal.valueOf(500));
when(activityRepository.countByUserId(userId)).thenReturn(20L); stubHistory(previous, activity);
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);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When // When
@ -240,13 +235,14 @@ class AchievementServiceTest {
@Test @Test
@DisplayName("Should award variety seeker achievement") @DisplayName("Should award variety seeker achievement")
void testCheckAndAwardAchievements_VarietySeeker() { 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); Activity activity = createActivity(Activity.ActivityType.SWIM, 2000L, BigDecimal.ZERO);
when(activityRepository.countByUserId(userId)).thenReturn(15L); stubHistory(run, ride, activity);
when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(5L);
when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(30000));
when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(3L);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When // When
@ -267,10 +263,7 @@ class AchievementServiceTest {
metrics.setMaxSpeed(BigDecimal.valueOf(45.0)); // 45 km/h (realistic for cycling) metrics.setMaxSpeed(BigDecimal.valueOf(45.0)); // 45 km/h (realistic for cycling)
activity.setMetrics(metrics); activity.setMetrics(metrics);
when(activityRepository.countByUserId(userId)).thenReturn(10L); stubHistory(activity);
when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(5L);
when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(200000));
when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When // When
@ -285,19 +278,18 @@ class AchievementServiceTest {
@Test @Test
@DisplayName("Should award 7-day streak achievement") @DisplayName("Should award 7-day streak achievement")
void testCheckAndAwardAchievements_7DayStreak() { void testCheckAndAwardAchievements_7DayStreak() {
// Given - User has 7+ consecutive days of activities // Given - Current activity completes a 7-day streak
List<Activity> 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); Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO);
history.add(activity);
when(activityRepository.countByUserId(userId)).thenReturn(20L); stubHistory(history);
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)
));
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When // When
@ -314,8 +306,7 @@ class AchievementServiceTest {
void testCheckAndAwardAchievements_AlreadyEarned() { void testCheckAndAwardAchievements_AlreadyEarned() {
// Given - User already has every achievement // Given - User already has every achievement
Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO); Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO);
stubHistory(activity);
when(activityRepository.countByUserId(userId)).thenReturn(10L);
// Simulate "user already has all achievements" by returning one of every type from the // 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. // preload query that checkAndAwardAchievements uses to populate the in-memory set.
List<Achievement> allEarned = new java.util.ArrayList<>(); List<Achievement> 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) metrics.setMaxSpeed(BigDecimal.valueOf(16.0)); // 57.6 km/h (unrealistic for run, but for testing)
activity.setMetrics(metrics); activity.setMetrics(metrics);
when(activityRepository.countByUserId(userId)).thenReturn(1L); // First activity stubHistory(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);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When // 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<Achievement> 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 @Test
@DisplayName("Should get user achievements") @DisplayName("Should get user achievements")
void testGetUserAchievements() { void testGetUserAchievements() {
@ -421,6 +432,7 @@ class AchievementServiceTest {
.userId(userId) .userId(userId)
.activityType(activityType) .activityType(activityType)
.startedAt(testTime) .startedAt(testTime)
.endedAt(testTime.plusHours(1))
.totalDistance(BigDecimal.valueOf(distanceMeters)) .totalDistance(BigDecimal.valueOf(distanceMeters))
.totalDurationSeconds(3600L) .totalDurationSeconds(3600L)
.elevationGain(elevationGain) .elevationGain(elevationGain)
@ -439,4 +451,12 @@ class AchievementServiceTest {
.earnedAt(testTime) .earnedAt(testTime)
.build(); .build();
} }
private void stubHistory(Activity... activities) {
stubHistory(List.of(activities));
}
private void stubHistory(List<Activity> activities) {
when(activityRepository.findByUserIdOrderByStartedAtAsc(userId)).thenReturn(activities);
}
} }