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);
/**
* 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.
*

View file

@ -43,11 +43,13 @@ public class AchievementService {
public List<Achievement> checkAndAwardAchievements(Activity activity) {
List<Achievement> newAchievements = new ArrayList<>();
if (activity.getUserId() == null) {
if (activity.getUserId() == null || activity.getStartedAt() == null || activity.getEndedAt() == null) {
return newAchievements;
}
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
// sub-checks below can do an in-memory `contains()` instead of an EXISTS query
@ -58,28 +60,28 @@ public class AchievementService {
}
// Check first activity achievements
newAchievements.addAll(checkFirstActivityAchievements(userId, activity, existing));
newAchievements.addAll(checkFirstActivityAchievements(userId, activity, progress, existing));
// Check distance milestones
newAchievements.addAll(checkDistanceMilestones(userId, existing));
newAchievements.addAll(checkDistanceMilestones(userId, activity, progress, existing));
// Check activity count milestones
newAchievements.addAll(checkActivityCountMilestones(userId, existing));
newAchievements.addAll(checkActivityCountMilestones(userId, activity, progress, existing));
// Check streak achievements
newAchievements.addAll(checkStreakAchievements(userId, existing));
newAchievements.addAll(checkStreakAchievements(userId, activity, progress, existing));
// Check time-based achievements
newAchievements.addAll(checkTimeBasedAchievements(userId, activity, existing));
newAchievements.addAll(checkTimeBasedAchievements(userId, activity, progress, existing));
// Check elevation achievements
newAchievements.addAll(checkElevationAchievements(userId, activity, existing));
newAchievements.addAll(checkElevationAchievements(userId, activity, progress, existing));
// Check variety achievements
newAchievements.addAll(checkVarietyAchievements(userId, existing));
newAchievements.addAll(checkVarietyAchievements(userId, activity, progress, existing));
// Check speed achievements
newAchievements.addAll(checkSpeedAchievements(userId, activity, existing));
newAchievements.addAll(checkSpeedAchievements(userId, activity, progress, existing));
return newAchievements;
}
@ -88,12 +90,14 @@ public class AchievementService {
* Check first activity achievements.
*/
private List<Achievement> checkFirstActivityAchievements(UUID userId, Activity activity,
ActivityProgress progress,
Set<Achievement.AchievementType> existing) {
List<Achievement> achievements = new ArrayList<>();
// First activity overall
long totalActivities = activityRepository.countByUserId(userId);
if (totalActivities == 1 && !existing.contains(Achievement.AchievementType.FIRST_ACTIVITY)) {
if (progress.previousActivityCount() == 0 &&
progress.currentActivityCount() == 1 &&
!existing.contains(Achievement.AchievementType.FIRST_ACTIVITY)) {
achievements.add(awardAchievement(
userId,
Achievement.AchievementType.FIRST_ACTIVITY,
@ -102,15 +106,17 @@ public class AchievementService {
"🎉",
"#ff00ff",
activity.getId(),
activity.getEndedAt(),
null
));
}
// First activity by type
String activityType = activity.getActivityType().name();
long typeCount = activityRepository.countByUserIdAndActivityType(userId, activity.getActivityType());
long previousTypeCount = progress.previousActivityTypeCount(activity.getActivityType());
long currentTypeCount = progress.currentActivityTypeCount(activity.getActivityType());
if (typeCount == 1) {
if (previousTypeCount == 0 && currentTypeCount == 1) {
Achievement.AchievementType achievementType = switch (activityType) {
case "RUN" -> Achievement.AchievementType.FIRST_RUN;
case "RIDE" -> Achievement.AchievementType.FIRST_RIDE;
@ -127,6 +133,7 @@ public class AchievementService {
getActivityEmoji(activityType),
"#00ffff",
activity.getId(),
activity.getEndedAt(),
null
));
}
@ -138,16 +145,13 @@ public class AchievementService {
/**
* Check distance milestone achievements.
*/
private List<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<>();
// 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<Double, Achievement.AchievementType> milestones = Map.of(
@ -159,7 +163,9 @@ public class AchievementService {
);
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(
userId,
entry.getValue(),
@ -167,7 +173,8 @@ public class AchievementService {
String.format("Reached %.0f kilometers total distance!", entry.getKey()),
"🏃",
"#ffff00",
null,
activity.getId(),
activity.getEndedAt(),
Map.of("distance_km", entry.getKey())
));
}
@ -179,11 +186,11 @@ public class AchievementService {
/**
* Check activity count milestone achievements.
*/
private List<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<>();
long activityCount = activityRepository.countByUserId(userId);
Map<Long, Achievement.AchievementType> milestones = Map.of(
10L, Achievement.AchievementType.ACTIVITIES_10,
50L, Achievement.AchievementType.ACTIVITIES_50,
@ -193,7 +200,9 @@ public class AchievementService {
);
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(
userId,
entry.getValue(),
@ -201,7 +210,8 @@ public class AchievementService {
String.format("Completed %d activities!", entry.getKey()),
"💪",
"#ff6600",
null,
activity.getId(),
activity.getEndedAt(),
Map.of("activity_count", entry.getKey())
));
}
@ -213,10 +223,13 @@ public class AchievementService {
/**
* Check streak achievements (consecutive days).
*/
private List<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<>();
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(
7, Achievement.AchievementType.STREAK_7_DAYS,
@ -226,7 +239,9 @@ public class AchievementService {
);
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(
userId,
entry.getValue(),
@ -234,7 +249,8 @@ public class AchievementService {
String.format("Worked out %d days in a row!", entry.getKey()),
"🔥",
"#ff1493",
null,
activity.getId(),
activity.getEndedAt(),
Map.of("streak_days", entry.getKey())
));
}
@ -247,6 +263,7 @@ public class AchievementService {
* Check time-based achievements (early bird, night owl, weekend warrior).
*/
private List<Achievement> checkTimeBasedAchievements(UUID userId, Activity activity,
ActivityProgress progress,
Set<Achievement.AchievementType> existing) {
List<Achievement> achievements = new ArrayList<>();
@ -254,8 +271,9 @@ public class AchievementService {
// Early bird (before 6am)
if (startTime.isBefore(LocalTime.of(6, 0)) && !existing.contains(Achievement.AchievementType.EARLY_BIRD)) {
long earlyActivities = activityRepository.countByUserIdAndStartTimeBefore(userId, LocalTime.of(6, 0));
if (earlyActivities >= 5) {
long previousEarlyActivities = progress.previousActivitiesStartingBefore(LocalTime.of(6, 0));
long currentEarlyActivities = progress.currentActivitiesStartingBefore(LocalTime.of(6, 0));
if (previousEarlyActivities < 5 && currentEarlyActivities >= 5) {
achievements.add(awardAchievement(
userId,
Achievement.AchievementType.EARLY_BIRD,
@ -264,15 +282,17 @@ public class AchievementService {
"🌅",
"#ccff00",
activity.getId(),
Map.of("early_activities", earlyActivities)
activity.getEndedAt(),
Map.of("early_activities", currentEarlyActivities)
));
}
}
// Night owl (after 10pm)
if (startTime.isAfter(LocalTime.of(22, 0)) && !existing.contains(Achievement.AchievementType.NIGHT_OWL)) {
long lateActivities = activityRepository.countByUserIdAndStartTimeAfter(userId, LocalTime.of(22, 0));
if (lateActivities >= 5) {
long previousLateActivities = progress.previousActivitiesStartingAfter(LocalTime.of(22, 0));
long currentLateActivities = progress.currentActivitiesStartingAfter(LocalTime.of(22, 0));
if (previousLateActivities < 5 && currentLateActivities >= 5) {
achievements.add(awardAchievement(
userId,
Achievement.AchievementType.NIGHT_OWL,
@ -281,7 +301,8 @@ public class AchievementService {
"🦉",
"#9370db",
activity.getId(),
Map.of("late_activities", lateActivities)
activity.getEndedAt(),
Map.of("late_activities", currentLateActivities)
));
}
}
@ -293,12 +314,14 @@ public class AchievementService {
* Check elevation achievements.
*/
private List<Achievement> checkElevationAchievements(UUID userId, Activity activity,
ActivityProgress progress,
Set<Achievement.AchievementType> existing) {
List<Achievement> achievements = new ArrayList<>();
// Single activity elevation
if (activity.getElevationGain() != null &&
activity.getElevationGain().compareTo(BigDecimal.valueOf(1000)) >= 0 &&
!progress.previousHasElevationGainAtLeast(BigDecimal.valueOf(1000)) &&
!existing.contains(Achievement.AchievementType.MOUNTAINEER_1000M)) {
achievements.add(awardAchievement(
@ -309,40 +332,45 @@ public class AchievementService {
"⛰️",
"#8b4513",
activity.getId(),
activity.getEndedAt(),
Map.of("elevation_gain", activity.getElevationGain())
));
}
// Total elevation milestones
BigDecimal totalElevation = activityRepository.sumElevationGainByUserId(userId);
if (totalElevation != null) {
double totalM = totalElevation.doubleValue();
double previousElevation = progress.previousElevationMeters().doubleValue();
double currentElevation = progress.currentElevationMeters().doubleValue();
if (totalM >= 5000 && !existing.contains(Achievement.AchievementType.MOUNTAINEER_5000M)) {
achievements.add(awardAchievement(
userId,
Achievement.AchievementType.MOUNTAINEER_5000M,
"Mountain Conqueror",
"Climbed 5000m total elevation!",
"🏔️",
"#4169e1",
null,
Map.of("total_elevation", totalM)
));
}
if (previousElevation < 5000 &&
currentElevation >= 5000 &&
!existing.contains(Achievement.AchievementType.MOUNTAINEER_5000M)) {
achievements.add(awardAchievement(
userId,
Achievement.AchievementType.MOUNTAINEER_5000M,
"Mountain Conqueror",
"Climbed 5000m total elevation!",
"🏔️",
"#4169e1",
activity.getId(),
activity.getEndedAt(),
Map.of("total_elevation", currentElevation)
));
}
if (totalM >= 10000 && !existing.contains(Achievement.AchievementType.MOUNTAINEER_10000M)) {
achievements.add(awardAchievement(
userId,
Achievement.AchievementType.MOUNTAINEER_10000M,
"Summit Master",
"Climbed 10000m total elevation!",
"🗻",
"#1e90ff",
null,
Map.of("total_elevation", totalM)
));
}
if (previousElevation < 10000 &&
currentElevation >= 10000 &&
!existing.contains(Achievement.AchievementType.MOUNTAINEER_10000M)) {
achievements.add(awardAchievement(
userId,
Achievement.AchievementType.MOUNTAINEER_10000M,
"Summit Master",
"Climbed 10000m total elevation!",
"🗻",
"#1e90ff",
activity.getId(),
activity.getEndedAt(),
Map.of("total_elevation", currentElevation)
));
}
return achievements;
@ -351,12 +379,17 @@ public class AchievementService {
/**
* Check variety achievements.
*/
private List<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<>();
long distinctActivityTypes = activityRepository.countDistinctActivityTypesByUserId(userId);
long previousDistinctActivityTypes = progress.previousDistinctActivityTypes();
long currentDistinctActivityTypes = progress.currentDistinctActivityTypes();
if (distinctActivityTypes >= 3 && !existing.contains(Achievement.AchievementType.VARIETY_SEEKER)) {
if (previousDistinctActivityTypes < 3 &&
currentDistinctActivityTypes >= 3 &&
!existing.contains(Achievement.AchievementType.VARIETY_SEEKER)) {
achievements.add(awardAchievement(
userId,
Achievement.AchievementType.VARIETY_SEEKER,
@ -364,8 +397,9 @@ public class AchievementService {
"Tried 3+ different activity types!",
"🌈",
"#ff69b4",
null,
Map.of("activity_types", distinctActivityTypes)
activity.getId(),
activity.getEndedAt(),
Map.of("activity_types", currentDistinctActivityTypes)
));
}
@ -376,6 +410,7 @@ public class AchievementService {
* Check speed achievements.
*/
private List<Achievement> checkSpeedAchievements(UUID userId, Activity activity,
ActivityProgress progress,
Set<Achievement.AchievementType> existing) {
List<Achievement> achievements = new ArrayList<>();
@ -383,7 +418,9 @@ public class AchievementService {
// maxSpeed is already in km/h from FitParser
double maxSpeedKmh = activity.getMetrics().getMaxSpeed().doubleValue();
if (maxSpeedKmh >= 40 && !existing.contains(Achievement.AchievementType.SPEED_DEMON)) {
if (maxSpeedKmh >= 40 &&
!progress.previousHasMaxSpeedAtLeast(BigDecimal.valueOf(40)) &&
!existing.contains(Achievement.AchievementType.SPEED_DEMON)) {
achievements.add(awardAchievement(
userId,
Achievement.AchievementType.SPEED_DEMON,
@ -392,6 +429,7 @@ public class AchievementService {
"",
"#ffd700",
activity.getId(),
activity.getEndedAt(),
Map.of("max_speed_kmh", maxSpeedKmh)
));
}
@ -400,70 +438,13 @@ public class AchievementService {
return achievements;
}
/**
* Calculate current activity streak (consecutive days).
*
* <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.
*/
private Achievement awardAchievement(UUID userId, Achievement.AchievementType achievementType,
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()
.userId(userId)
.achievementType(achievementType)
@ -471,7 +452,7 @@ public class AchievementService {
.description(description)
.badgeIcon(icon)
.badgeColor(color)
.earnedAt(LocalDateTime.now())
.earnedAt(earnedAt != null ? earnedAt : LocalDateTime.now())
.activityId(activityId)
.metadata(metadata)
.build();
@ -510,4 +491,170 @@ public class AchievementService {
public long getAchievementCount(UUID userId) {
return achievementRepository.countByUserId(userId);
}
private int calculateStreak(Set<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 java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
/**
@ -54,14 +54,7 @@ class AchievementServiceTest {
void testCheckAndAwardAchievements_FirstActivity() {
// Given
Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO);
when(activityRepository.countByUserId(userId)).thenReturn(1L);
when(activityRepository.countByUserIdAndActivityType(userId, Activity.ActivityType.RUN)).thenReturn(1L);
when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(5000));
when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L);
// Streak source: today has one activity (1-day streak not enough to trigger any streak achievement)
lenient().when(activityRepository.findActivityStartTimestampsSince(any(), any()))
.thenReturn(List.of(java.time.LocalDateTime.now()));
stubHistory(activity);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
@ -82,12 +75,11 @@ class AchievementServiceTest {
@DisplayName("Should award first run achievement")
void testCheckAndAwardAchievements_FirstRun() {
// Given
Activity firstRide = createActivity(Activity.ActivityType.RIDE, 10000L, BigDecimal.ZERO);
firstRide.setStartedAt(testTime.minusDays(1));
Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO);
when(activityRepository.countByUserId(userId)).thenReturn(10L); // Not first overall
when(activityRepository.countByUserIdAndActivityType(userId, Activity.ActivityType.RUN)).thenReturn(1L);
when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(50000));
when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(2L);
stubHistory(firstRide, activity);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
@ -102,13 +94,12 @@ class AchievementServiceTest {
@Test
@DisplayName("Should award distance milestone achievements")
void testCheckAndAwardAchievements_DistanceMilestone() {
// Given - User has completed 10+ km total
// Given - Current activity crosses 10 km total
Activity previous = createActivity(Activity.ActivityType.RUN, 7000L, BigDecimal.ZERO);
previous.setStartedAt(testTime.minusDays(1));
Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO);
when(activityRepository.countByUserId(userId)).thenReturn(5L);
when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(3L);
when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(12000)); // 12 km
when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L);
stubHistory(previous, activity);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
@ -126,13 +117,17 @@ class AchievementServiceTest {
@Test
@DisplayName("Should award activity count milestone")
void testCheckAndAwardAchievements_ActivityCount() {
// Given - User has 10 activities
// Given - Current activity is the 10th activity
List<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);
history.add(activity);
when(activityRepository.countByUserId(userId)).thenReturn(10L);
when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(5L);
when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(50000));
when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L);
stubHistory(history);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
@ -147,15 +142,18 @@ class AchievementServiceTest {
@Test
@DisplayName("Should award early bird achievement")
void testCheckAndAwardAchievements_EarlyBird() {
// Given - Activity before 6am, and user has 5+ early activities
// Given - Activity before 6am is the 5th early activity
List<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.setStartedAt(LocalDateTime.of(2025, 12, 1, 5, 30)); // 5:30 AM
when(activityRepository.countByUserId(userId)).thenReturn(10L);
when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(5L);
when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(50000));
when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L);
when(activityRepository.countByUserIdAndStartTimeBefore(eq(userId), eq(LocalTime.of(6, 0)))).thenReturn(5L);
history.add(activity);
stubHistory(history);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
@ -170,15 +168,18 @@ class AchievementServiceTest {
@Test
@DisplayName("Should award night owl achievement")
void testCheckAndAwardAchievements_NightOwl() {
// Given - Activity after 10pm, and user has 5+ late activities
// Given - Activity after 10pm is the 5th late activity
List<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.setStartedAt(LocalDateTime.of(2025, 12, 1, 23, 0)); // 11:00 PM
when(activityRepository.countByUserId(userId)).thenReturn(10L);
when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(5L);
when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(50000));
when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L);
when(activityRepository.countByUserIdAndStartTimeAfter(eq(userId), eq(LocalTime.of(22, 0)))).thenReturn(5L);
history.add(activity);
stubHistory(history);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
@ -196,11 +197,7 @@ class AchievementServiceTest {
// Given - Activity with 1000m+ elevation gain
Activity activity = createActivity(Activity.ActivityType.HIKE, 10000L, BigDecimal.valueOf(1200));
when(activityRepository.countByUserId(userId)).thenReturn(5L);
when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(3L);
when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(50000));
when(activityRepository.sumElevationGainByUserId(userId)).thenReturn(BigDecimal.valueOf(1200));
when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L);
stubHistory(activity);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
@ -215,14 +212,12 @@ class AchievementServiceTest {
@Test
@DisplayName("Should award total elevation milestones")
void testCheckAndAwardAchievements_TotalElevation() {
// Given - User has 5000m+ total elevation
// Given - Current activity crosses 5000m total elevation
Activity previous = createActivity(Activity.ActivityType.HIKE, 10000L, BigDecimal.valueOf(4500));
previous.setStartedAt(testTime.minusDays(1));
Activity activity = createActivity(Activity.ActivityType.HIKE, 10000L, BigDecimal.valueOf(500));
when(activityRepository.countByUserId(userId)).thenReturn(20L);
when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(10L);
when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(200000));
when(activityRepository.sumElevationGainByUserId(userId)).thenReturn(BigDecimal.valueOf(6000)); // 6000m total
when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(2L);
stubHistory(previous, activity);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
@ -240,13 +235,14 @@ class AchievementServiceTest {
@Test
@DisplayName("Should award variety seeker achievement")
void testCheckAndAwardAchievements_VarietySeeker() {
// Given - User has tried 3+ different activity types
// Given - Current activity introduces the 3rd distinct activity type
Activity run = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO);
run.setStartedAt(testTime.minusDays(2));
Activity ride = createActivity(Activity.ActivityType.RIDE, 20000L, BigDecimal.ZERO);
ride.setStartedAt(testTime.minusDays(1));
Activity activity = createActivity(Activity.ActivityType.SWIM, 2000L, BigDecimal.ZERO);
when(activityRepository.countByUserId(userId)).thenReturn(15L);
when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(5L);
when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(30000));
when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(3L);
stubHistory(run, ride, activity);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
@ -267,10 +263,7 @@ class AchievementServiceTest {
metrics.setMaxSpeed(BigDecimal.valueOf(45.0)); // 45 km/h (realistic for cycling)
activity.setMetrics(metrics);
when(activityRepository.countByUserId(userId)).thenReturn(10L);
when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(5L);
when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(200000));
when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L);
stubHistory(activity);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
@ -285,19 +278,18 @@ class AchievementServiceTest {
@Test
@DisplayName("Should award 7-day streak achievement")
void testCheckAndAwardAchievements_7DayStreak() {
// Given - User has 7+ consecutive days of activities
// Given - Current activity completes a 7-day streak
List<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);
history.add(activity);
when(activityRepository.countByUserId(userId)).thenReturn(20L);
when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(10L);
when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(100000));
when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L);
// Streak source: 8 consecutive days of activity ending today, as raw timestamps
java.time.LocalDateTime now = java.time.LocalDateTime.now();
when(activityRepository.findActivityStartTimestampsSince(any(), any())).thenReturn(List.of(
now, now.minusDays(1), now.minusDays(2), now.minusDays(3),
now.minusDays(4), now.minusDays(5), now.minusDays(6), now.minusDays(7)
));
stubHistory(history);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
@ -314,8 +306,7 @@ class AchievementServiceTest {
void testCheckAndAwardAchievements_AlreadyEarned() {
// Given - User already has every achievement
Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO);
when(activityRepository.countByUserId(userId)).thenReturn(10L);
stubHistory(activity);
// Simulate "user already has all achievements" by returning one of every type from the
// preload query that checkAndAwardAchievements uses to populate the in-memory set.
List<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)
activity.setMetrics(metrics);
when(activityRepository.countByUserId(userId)).thenReturn(1L); // First activity
when(activityRepository.countByUserIdAndActivityType(userId, Activity.ActivityType.RUN)).thenReturn(1L);
when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(5000));
when(activityRepository.sumElevationGainByUserId(userId)).thenReturn(BigDecimal.valueOf(1100));
when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L);
stubHistory(activity);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
@ -380,6 +367,30 @@ class AchievementServiceTest {
));
}
@Test
@DisplayName("Should use activity end time as earnedAt for historical milestone")
void testCheckAndAwardAchievements_UsesActivityEndTimeForEarnedAt() {
Activity previous = createActivity(Activity.ActivityType.RUN, 9000L, BigDecimal.ZERO);
previous.setStartedAt(LocalDateTime.of(2025, 11, 30, 10, 0));
previous.setEndedAt(LocalDateTime.of(2025, 11, 30, 11, 0));
Activity activity = createActivity(Activity.ActivityType.RUN, 2000L, BigDecimal.ZERO);
activity.setStartedAt(LocalDateTime.of(2025, 12, 1, 7, 15));
activity.setEndedAt(LocalDateTime.of(2025, 12, 1, 8, 5));
stubHistory(previous, activity);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
List<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
@DisplayName("Should get user achievements")
void testGetUserAchievements() {
@ -421,6 +432,7 @@ class AchievementServiceTest {
.userId(userId)
.activityType(activityType)
.startedAt(testTime)
.endedAt(testTime.plusHours(1))
.totalDistance(BigDecimal.valueOf(distanceMeters))
.totalDurationSeconds(3600L)
.elevationGain(elevationGain)
@ -439,4 +451,12 @@ class AchievementServiceTest {
.earnedAt(testTime)
.build();
}
private void stubHistory(Activity... activities) {
stubHistory(List.of(activities));
}
private void stubHistory(List<Activity> activities) {
when(activityRepository.findByUserIdOrderByStartedAtAsc(userId)).thenReturn(activities);
}
}