Merge branch '24-fix-achievement-date' into sattelgeschichten
This commit is contained in:
commit
851ece0edd
13 changed files with 614 additions and 243 deletions
6
.idea/bld.xml
generated
Normal file
6
.idea/bld.xml
generated
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="BldConfiguration">
|
||||||
|
<events />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/data_source_mapping.xml
generated
6
.idea/data_source_mapping.xml
generated
|
|
@ -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>
|
|
||||||
|
|
@ -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) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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...");
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue