Merge branch '24-fix-achievement-date' into sattelgeschichten

This commit is contained in:
Marcus Fihlon 2026-04-29 10:43:47 +02:00
commit 851ece0edd
Signed by: McPringle
GPG key ID: C6B7F469EE363E1F
13 changed files with 614 additions and 243 deletions

6
.idea/bld.xml generated Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="BldConfiguration">
<events />
</component>
</project>

View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourcePerFileMappings">
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/2564811a-81f9-4d83-b1b1-04cb2763e3fa/console_1.sql" value="2564811a-81f9-4d83-b1b1-04cb2763e3fa" />
</component>
</project>

View file

@ -133,6 +133,25 @@ public class AnalyticsController {
return ResponseEntity.ok(achievements); return ResponseEntity.ok(achievements);
} }
/**
* Rebuild achievements for the authenticated user.
*/
@PostMapping("/achievements/rebuild")
public ResponseEntity<RebuildResponse> 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. * Get training load for a date range.
*/ */
@ -276,4 +295,6 @@ public class AnalyticsController {
case UNKNOWN -> "Not enough data to calculate form status."; case UNKNOWN -> "Not enough data to calculate form status.";
}; };
} }
private record RebuildResponse(String message) {}
} }

View file

@ -2,6 +2,7 @@ package net.javahippie.fitpub.repository;
import net.javahippie.fitpub.model.entity.Achievement; import net.javahippie.fitpub.model.entity.Achievement;
import org.springframework.data.jpa.repository.JpaRepository; 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.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@ -40,6 +41,12 @@ public interface AchievementRepository extends JpaRepository<Achievement, UUID>
*/ */
long countByUserId(UUID userId); 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. * Get count of achievements earned by a user in a date range.
*/ */
@ -53,6 +60,23 @@ public interface AchievementRepository extends JpaRepository<Achievement, UUID>
@Param("endDate") LocalDateTime endDate @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. * Find recent achievements for a user.
*/ */

View file

@ -59,6 +59,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,42 +60,69 @@ 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;
} }
/**
* Rebuild all achievements for a user from chronological activity history.
*/
@Transactional
public List<Achievement> rebuildAchievementsForUser(UUID userId) {
if (userId == null) {
return List.of();
}
List<Activity> activityHistory = activityRepository.findByUserIdOrderByStartedAtAsc(userId);
if (activityHistory.isEmpty()) {
achievementRepository.deleteByUserId(userId);
return List.of();
}
achievementRepository.deleteByUserId(userId);
List<Achievement> rebuiltAchievements = new ArrayList<>();
for (Activity activity : activityHistory) {
rebuiltAchievements.addAll(checkAndAwardAchievements(activity));
}
return rebuiltAchievements;
}
/** /**
* 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 +131,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 +158,7 @@ public class AchievementService {
getActivityEmoji(activityType), getActivityEmoji(activityType),
"#00ffff", "#00ffff",
activity.getId(), activity.getId(),
activity.getEndedAt(),
null null
)); ));
} }
@ -138,16 +170,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 +188,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 +198,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 +211,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 +225,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 +235,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 +248,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 +264,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 +274,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 +288,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 +296,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 +307,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 +326,8 @@ public class AchievementService {
"🦉", "🦉",
"#9370db", "#9370db",
activity.getId(), activity.getId(),
Map.of("late_activities", lateActivities) activity.getEndedAt(),
Map.of("late_activities", currentLateActivities)
)); ));
} }
} }
@ -293,12 +339,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 +357,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 +404,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 +422,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 +435,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 +443,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 +454,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 +463,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 +477,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 +516,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

@ -182,7 +182,7 @@ public class ActivitySummaryService {
startDateTime, startDateTime,
endDateTime endDateTime
); );
long achievementsEarned = achievementRepository.countByUserIdAndDateRange( long achievementsEarned = achievementRepository.countByUserIdAndActivityStartedDateRange(
userId, userId,
startDateTime, startDateTime,
endDateTime endDateTime

View file

@ -344,11 +344,9 @@ public class BatchImportService {
personalRecordService.checkAndUpdatePersonalRecords(activity); personalRecordService.checkAndUpdatePersonalRecords(activity);
} }
// Recalculate achievements for each activity // Recalculate achievements from the full chronological activity history
log.debug("Recalculating achievements..."); log.debug("Recalculating achievements...");
for (Activity activity : activities) { achievementService.rebuildAchievementsForUser(job.getUserId());
achievementService.checkAndAwardAchievements(activity);
}
// Recalculate training load for each activity // Recalculate training load for each activity
log.debug("Recalculating training load..."); log.debug("Recalculating training load...");

View file

@ -319,6 +319,13 @@ public class FitFileService {
return activityRepository.findByIdAndUserId(activityId, userId) return activityRepository.findByIdAndUserId(activityId, userId)
.map(activity -> { .map(activity -> {
activityRepository.delete(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); log.info("Deleted activity {} for user {}", activityId, userId);
return true; return true;
}) })

View file

@ -8,15 +8,20 @@
</head> </head>
<body> <body>
<div layout:fragment="content"> <div layout:fragment="content">
<div class="container py-4"> <div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h1> <h1>
<i class="bi bi-award-fill" style="color: var(--accent-orange);"></i> Achievements <i class="bi bi-award-fill" style="color: var(--accent-orange);"></i> Achievements
</h1> </h1>
<a href="/analytics" class="btn btn-outline-primary"> <div class="d-flex gap-2">
<i class="bi bi-arrow-left"></i> Back to Dashboard <button id="rebuild-achievements-btn" type="button" class="btn btn-outline-secondary">
</a> <i class="bi bi-arrow-repeat"></i> Recalculate
</div> </button>
<a href="/analytics" class="btn btn-outline-primary">
<i class="bi bi-arrow-left"></i> Back to Dashboard
</a>
</div>
</div>
<!-- Stats Summary --> <!-- Stats Summary -->
<div class="row g-3 mb-4"> <div class="row g-3 mb-4">
@ -100,6 +105,35 @@
} }
} }
async function rebuildAchievements() {
const rebuildBtn = document.getElementById('rebuild-achievements-btn');
const originalContent = rebuildBtn.innerHTML;
rebuildBtn.disabled = true;
rebuildBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>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) { function updateStats(achievements) {
// Earned count // Earned count
document.getElementById('earned-count').textContent = achievements.length; document.getElementById('earned-count').textContent = achievements.length;
@ -108,6 +142,8 @@
if (achievements.length > 0) { if (achievements.length > 0) {
const latest = new Date(achievements[0].earnedAt); const latest = new Date(achievements[0].earnedAt);
document.getElementById('latest-date').textContent = latest.toLocaleDateString(); document.getElementById('latest-date').textContent = latest.toLocaleDateString();
} else {
document.getElementById('latest-date').textContent = '-';
} }
// Completion percentage // Completion percentage
@ -217,6 +253,7 @@
window.location.href = '/auth/login'; window.location.href = '/auth/login';
return; return;
} }
document.getElementById('rebuild-achievements-btn').addEventListener('click', rebuildAchievements);
loadAchievements(); loadAchievements();
}); });
</script> </script>

View file

@ -4,6 +4,7 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InOrder;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
@ -14,14 +15,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 +55,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 +76,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 +95,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 +118,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 +143,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 +169,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 +198,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 +213,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 +236,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 +264,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 +279,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 +307,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 +351,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 +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<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 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<Achievement> 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 @Test
@DisplayName("Should get user achievements") @DisplayName("Should get user achievements")
void testGetUserAchievements() { void testGetUserAchievements() {
@ -421,6 +455,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 +474,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);
}
} }

View file

@ -9,8 +9,13 @@ import net.javahippie.fitpub.repository.PersonalRecordRepository;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; 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 org.springframework.dao.DataIntegrityViolationException;
import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
@ -19,37 +24,39 @@ import java.util.UUID;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq; 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.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ActivitySummaryServiceTest { class ActivitySummaryServiceTest {
@Mock
private ActivitySummaryRepository activitySummaryRepository; private ActivitySummaryRepository activitySummaryRepository;
@Mock
private ActivityRepository activityRepository; private ActivityRepository activityRepository;
@Mock
private PersonalRecordRepository personalRecordRepository; private PersonalRecordRepository personalRecordRepository;
@Mock
private AchievementRepository achievementRepository; private AchievementRepository achievementRepository;
private ActivitySummaryService service;
@InjectMocks
private ActivitySummaryService activitySummaryService;
private UUID userId;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
activitySummaryRepository = mock(ActivitySummaryRepository.class); userId = UUID.randomUUID();
activityRepository = mock(ActivityRepository.class);
personalRecordRepository = mock(PersonalRecordRepository.class);
achievementRepository = mock(AchievementRepository.class);
service = new ActivitySummaryService(
activitySummaryRepository,
activityRepository,
personalRecordRepository,
achievementRepository
);
} }
@Test @Test
@DisplayName("Should retry summary save as update when concurrent insert hits unique constraint") @DisplayName("Should retry summary save as update when concurrent insert hits unique constraint")
void shouldRetrySummarySaveAfterConcurrentInsert() { void shouldRetrySummarySaveAfterConcurrentInsert() {
UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
LocalDate date = LocalDate.of(2025, 10, 8); LocalDate date = LocalDate.of(2025, 10, 8);
LocalDate weekStart = LocalDate.of(2025, 10, 6); LocalDate weekStart = LocalDate.of(2025, 10, 6);
LocalDate weekEnd = LocalDate.of(2025, 10, 12); LocalDate weekEnd = LocalDate.of(2025, 10, 12);
@ -62,9 +69,10 @@ class ActivitySummaryServiceTest {
.periodEnd(weekEnd) .periodEnd(weekEnd)
.build(); .build();
//noinspection unchecked
when(activitySummaryRepository.findByUserIdAndPeriodTypeAndPeriodStart( when(activitySummaryRepository.findByUserIdAndPeriodTypeAndPeriodStart(
userId, ActivitySummary.PeriodType.WEEK, weekStart userId,
ActivitySummary.PeriodType.WEEK,
weekStart
)).thenReturn(Optional.empty(), Optional.of(existingSummary)); )).thenReturn(Optional.empty(), Optional.of(existingSummary));
when(activityRepository.findByUserIdAndStartedAtBetweenOrderByStartedAtDesc( when(activityRepository.findByUserIdAndStartedAtBetweenOrderByStartedAtDesc(
@ -81,16 +89,59 @@ class ActivitySummaryServiceTest {
)); ));
when(personalRecordRepository.countByUserIdAndDateRange(any(), any(), any())).thenReturn(0L); 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))) when(activitySummaryRepository.save(any(ActivitySummary.class)))
.thenThrow(new DataIntegrityViolationException("duplicate")) .thenThrow(new DataIntegrityViolationException("duplicate"))
.thenReturn(existingSummary); .thenReturn(existingSummary);
service.updateWeeklySummary(userId, date); activitySummaryService.updateWeeklySummary(userId, date);
verify(activitySummaryRepository, times(2)) verify(activitySummaryRepository, times(2))
.findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.WEEK, weekStart); .findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.WEEK, weekStart);
verify(activitySummaryRepository, times(2)).save(any(ActivitySummary.class)); 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
));
}
} }

View file

@ -224,9 +224,11 @@ class FitFileServiceTest {
void testDeleteActivity() { void testDeleteActivity() {
// Arrange // Arrange
UUID activityId = UUID.randomUUID(); UUID activityId = UUID.randomUUID();
LocalDateTime startedAt = LocalDateTime.of(2025, 12, 3, 10, 0);
Activity activity = Activity.builder() Activity activity = Activity.builder()
.id(activityId) .id(activityId)
.userId(testUserId) .userId(testUserId)
.startedAt(startedAt)
.build(); .build();
when(activityRepository.findByIdAndUserId(activityId, testUserId)) when(activityRepository.findByIdAndUserId(activityId, testUserId))
@ -238,6 +240,10 @@ class FitFileServiceTest {
// Assert // Assert
assertTrue(result); assertTrue(result);
verify(activityRepository).delete(activity); 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 @Test
@ -254,6 +260,10 @@ class FitFileServiceTest {
// Assert // Assert
assertFalse(result); assertFalse(result);
verify(activityRepository, never()).delete(any()); 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 @Test