achievements = new ArrayList<>();
@@ -383,7 +443,9 @@ public class AchievementService {
// maxSpeed is already in km/h from FitParser
double maxSpeedKmh = activity.getMetrics().getMaxSpeed().doubleValue();
- if (maxSpeedKmh >= 40 && !existing.contains(Achievement.AchievementType.SPEED_DEMON)) {
+ if (maxSpeedKmh >= 40 &&
+ !progress.previousHasMaxSpeedAtLeast(BigDecimal.valueOf(40)) &&
+ !existing.contains(Achievement.AchievementType.SPEED_DEMON)) {
achievements.add(awardAchievement(
userId,
Achievement.AchievementType.SPEED_DEMON,
@@ -392,6 +454,7 @@ public class AchievementService {
"⚡",
"#ffd700",
activity.getId(),
+ activity.getEndedAt(),
Map.of("max_speed_kmh", maxSpeedKmh)
));
}
@@ -400,70 +463,13 @@ public class AchievementService {
return achievements;
}
- /**
- * Calculate current activity streak (consecutive days).
- *
- * Loads all activity timestamps for the user in the last 366 days in a single
- * query, deduplicates them to a {@code Set} in Java, and walks the
- * resulting set in memory. Previously this method issued one {@code SELECT EXISTS}
- * query per day (up to 365 round-trips per activity upload), which was the single
- * biggest performance hot spot in the achievement evaluation path.
- *
- * Java-side date deduplication is intentional: Hibernate 6 + Spring Data 3 do
- * not reliably convert SQL date scalar projections to {@code List}.
- * The result set is small (a few hundred timestamps at most) so the cost of
- * Java-side distinct is negligible.
- *
- * The streak / rest-day logic is preserved bug-for-bug from the previous
- * implementation: a missing day after a streak has started is silently skipped
- * (the original loop did the same). Fixing the rest-day semantics is out of
- * scope for this performance change.
- */
- private int calculateCurrentStreak(UUID userId) {
- LocalDate today = LocalDate.now();
- // 366 to safely cover the lookback window even if today's activity is in the
- // future relative to the cutoff (timezone edge cases).
- LocalDateTime since = today.minusDays(366).atStartOfDay();
- Set activityDates = new HashSet<>();
- for (LocalDateTime ts : activityRepository.findActivityStartTimestampsSince(userId, since)) {
- activityDates.add(ts.toLocalDate());
- }
-
- if (activityDates.isEmpty()) {
- return 0;
- }
-
- LocalDate checkDate = today;
- int streak = 0;
-
- // Walk backwards from today using the in-memory set instead of per-day queries.
- for (int i = 0; i < 365; i++) {
- boolean hasActivity = activityDates.contains(checkDate);
-
- if (hasActivity) {
- streak++;
- checkDate = checkDate.minusDays(1);
- } else {
- // Allow one rest day if we already have a streak (preserving original
- // behaviour, including the latent "infinite consecutive rest days
- // allowed once a streak has started" quirk in the original loop).
- if (streak > 0 && i > 0) {
- checkDate = checkDate.minusDays(1);
- continue;
- }
- break;
- }
- }
-
- return streak;
- }
-
/**
* Award an achievement to a user.
*/
private Achievement awardAchievement(UUID userId, Achievement.AchievementType achievementType,
String name, String description, String icon, String color,
- UUID activityId, Map metadata) {
+ UUID activityId, LocalDateTime earnedAt,
+ Map metadata) {
Achievement achievement = Achievement.builder()
.userId(userId)
.achievementType(achievementType)
@@ -471,7 +477,7 @@ public class AchievementService {
.description(description)
.badgeIcon(icon)
.badgeColor(color)
- .earnedAt(LocalDateTime.now())
+ .earnedAt(earnedAt != null ? earnedAt : LocalDateTime.now())
.activityId(activityId)
.metadata(metadata)
.build();
@@ -510,4 +516,170 @@ public class AchievementService {
public long getAchievementCount(UUID userId) {
return achievementRepository.countByUserId(userId);
}
+
+ private int calculateStreak(Set activityDates, LocalDate anchorDate) {
+ if (activityDates.isEmpty() || !activityDates.contains(anchorDate)) {
+ return 0;
+ }
+
+ LocalDate checkDate = anchorDate;
+ int streak = 0;
+
+ for (int i = 0; i < 365; i++) {
+ boolean hasActivity = activityDates.contains(checkDate);
+
+ if (hasActivity) {
+ streak++;
+ checkDate = checkDate.minusDays(1);
+ } else {
+ if (streak > 0 && i > 0) {
+ checkDate = checkDate.minusDays(1);
+ continue;
+ }
+ break;
+ }
+ }
+
+ return streak;
+ }
+
+ private record ActivityProgress(
+ List previousActivities,
+ List currentActivities
+ ) {
+ private static ActivityProgress fromHistory(List activityHistory, Activity currentActivity) {
+ int currentIndex = -1;
+ for (int i = 0; i < activityHistory.size(); i++) {
+ if (Objects.equals(activityHistory.get(i).getId(), currentActivity.getId())) {
+ currentIndex = i;
+ break;
+ }
+ }
+
+ if (currentIndex < 0) {
+ throw new IllegalStateException("Current activity missing from chronological history: " + currentActivity.getId());
+ }
+
+ return new ActivityProgress(
+ List.copyOf(activityHistory.subList(0, currentIndex)),
+ List.copyOf(activityHistory.subList(0, currentIndex + 1))
+ );
+ }
+
+ long previousActivityCount() {
+ return previousActivities.size();
+ }
+
+ long currentActivityCount() {
+ return currentActivities.size();
+ }
+
+ long previousActivityTypeCount(Activity.ActivityType type) {
+ return previousActivities.stream().filter(activity -> activity.getActivityType() == type).count();
+ }
+
+ long currentActivityTypeCount(Activity.ActivityType type) {
+ return currentActivities.stream().filter(activity -> activity.getActivityType() == type).count();
+ }
+
+ BigDecimal previousDistanceMeters() {
+ return sumDistance(previousActivities);
+ }
+
+ BigDecimal currentDistanceMeters() {
+ return sumDistance(currentActivities);
+ }
+
+ BigDecimal previousElevationMeters() {
+ return sumElevation(previousActivities);
+ }
+
+ BigDecimal currentElevationMeters() {
+ return sumElevation(currentActivities);
+ }
+
+ boolean previousHasElevationGainAtLeast(BigDecimal threshold) {
+ return previousActivities.stream()
+ .map(Activity::getElevationGain)
+ .filter(Objects::nonNull)
+ .anyMatch(elevation -> elevation.compareTo(threshold) >= 0);
+ }
+
+ long previousDistinctActivityTypes() {
+ return previousActivities.stream().map(Activity::getActivityType).distinct().count();
+ }
+
+ long currentDistinctActivityTypes() {
+ return currentActivities.stream().map(Activity::getActivityType).distinct().count();
+ }
+
+ long previousActivitiesStartingBefore(LocalTime time) {
+ return previousActivities.stream()
+ .filter(activity -> activity.getStartedAt() != null)
+ .filter(activity -> activity.getStartedAt().toLocalTime().isBefore(time))
+ .count();
+ }
+
+ long currentActivitiesStartingBefore(LocalTime time) {
+ return currentActivities.stream()
+ .filter(activity -> activity.getStartedAt() != null)
+ .filter(activity -> activity.getStartedAt().toLocalTime().isBefore(time))
+ .count();
+ }
+
+ long previousActivitiesStartingAfter(LocalTime time) {
+ return previousActivities.stream()
+ .filter(activity -> activity.getStartedAt() != null)
+ .filter(activity -> activity.getStartedAt().toLocalTime().isAfter(time))
+ .count();
+ }
+
+ long currentActivitiesStartingAfter(LocalTime time) {
+ return currentActivities.stream()
+ .filter(activity -> activity.getStartedAt() != null)
+ .filter(activity -> activity.getStartedAt().toLocalTime().isAfter(time))
+ .count();
+ }
+
+ Set previousActivityDates() {
+ return collectDates(previousActivities);
+ }
+
+ Set currentActivityDates() {
+ return collectDates(currentActivities);
+ }
+
+ boolean previousHasMaxSpeedAtLeast(BigDecimal thresholdKmh) {
+ return previousActivities.stream()
+ .map(Activity::getMetrics)
+ .filter(Objects::nonNull)
+ .map(metrics -> metrics.getMaxSpeed())
+ .filter(Objects::nonNull)
+ .anyMatch(speed -> speed.compareTo(thresholdKmh) >= 0);
+ }
+
+ private static BigDecimal sumDistance(List activities) {
+ return activities.stream()
+ .map(Activity::getTotalDistance)
+ .filter(Objects::nonNull)
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
+ }
+
+ private static BigDecimal sumElevation(List activities) {
+ return activities.stream()
+ .map(Activity::getElevationGain)
+ .filter(Objects::nonNull)
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
+ }
+
+ private static Set collectDates(List activities) {
+ Set dates = new HashSet<>();
+ for (Activity activity : activities) {
+ if (activity.getStartedAt() != null) {
+ dates.add(activity.getStartedAt().toLocalDate());
+ }
+ }
+ return dates;
+ }
+ }
}
diff --git a/src/main/java/net/javahippie/fitpub/service/ActivitySummaryService.java b/src/main/java/net/javahippie/fitpub/service/ActivitySummaryService.java
index d7ff249..77dc568 100644
--- a/src/main/java/net/javahippie/fitpub/service/ActivitySummaryService.java
+++ b/src/main/java/net/javahippie/fitpub/service/ActivitySummaryService.java
@@ -182,7 +182,7 @@ public class ActivitySummaryService {
startDateTime,
endDateTime
);
- long achievementsEarned = achievementRepository.countByUserIdAndDateRange(
+ long achievementsEarned = achievementRepository.countByUserIdAndActivityStartedDateRange(
userId,
startDateTime,
endDateTime
diff --git a/src/main/java/net/javahippie/fitpub/service/BatchImportService.java b/src/main/java/net/javahippie/fitpub/service/BatchImportService.java
index ea477cd..b33b10a 100644
--- a/src/main/java/net/javahippie/fitpub/service/BatchImportService.java
+++ b/src/main/java/net/javahippie/fitpub/service/BatchImportService.java
@@ -344,11 +344,9 @@ public class BatchImportService {
personalRecordService.checkAndUpdatePersonalRecords(activity);
}
- // Recalculate achievements for each activity
+ // Recalculate achievements from the full chronological activity history
log.debug("Recalculating achievements...");
- for (Activity activity : activities) {
- achievementService.checkAndAwardAchievements(activity);
- }
+ achievementService.rebuildAchievementsForUser(job.getUserId());
// Recalculate training load for each activity
log.debug("Recalculating training load...");
diff --git a/src/main/java/net/javahippie/fitpub/service/FitFileService.java b/src/main/java/net/javahippie/fitpub/service/FitFileService.java
index bb7086e..39c8b76 100644
--- a/src/main/java/net/javahippie/fitpub/service/FitFileService.java
+++ b/src/main/java/net/javahippie/fitpub/service/FitFileService.java
@@ -319,6 +319,13 @@ public class FitFileService {
return activityRepository.findByIdAndUserId(activityId, userId)
.map(activity -> {
activityRepository.delete(activity);
+ achievementService.rebuildAchievementsForUser(userId);
+ if (activity.getStartedAt() != null) {
+ java.time.LocalDate activityDate = activity.getStartedAt().toLocalDate();
+ activitySummaryService.updateWeeklySummary(userId, activityDate);
+ activitySummaryService.updateMonthlySummary(userId, activityDate);
+ activitySummaryService.updateYearlySummary(userId, activityDate);
+ }
log.info("Deleted activity {} for user {}", activityId, userId);
return true;
})
diff --git a/src/main/resources/templates/analytics/achievements.html b/src/main/resources/templates/analytics/achievements.html
index 1187c57..cd4e122 100644
--- a/src/main/resources/templates/analytics/achievements.html
+++ b/src/main/resources/templates/analytics/achievements.html
@@ -8,15 +8,20 @@
-
-
-
- Achievements
-
-
- Back to Dashboard
-
-
+
+
@@ -100,6 +105,35 @@
}
}
+ async function rebuildAchievements() {
+ const rebuildBtn = document.getElementById('rebuild-achievements-btn');
+ const originalContent = rebuildBtn.innerHTML;
+
+ rebuildBtn.disabled = true;
+ rebuildBtn.innerHTML = '
Recalculating...';
+
+ try {
+ const response = await FitPubAuth.authenticatedFetch('/api/analytics/achievements/rebuild', {
+ method: 'POST'
+ });
+
+ const result = await response.json();
+
+ if (!response.ok) {
+ throw new Error(result.message || 'Failed to recalculate achievements');
+ }
+
+ FitPub.showAlert('success', result.message || 'Achievements recalculated successfully');
+ await loadAchievements();
+ } catch (error) {
+ console.error('Error rebuilding achievements:', error);
+ FitPub.showAlert('danger', error.message || 'Failed to recalculate achievements');
+ } finally {
+ rebuildBtn.disabled = false;
+ rebuildBtn.innerHTML = originalContent;
+ }
+ }
+
function updateStats(achievements) {
// Earned count
document.getElementById('earned-count').textContent = achievements.length;
@@ -108,6 +142,8 @@
if (achievements.length > 0) {
const latest = new Date(achievements[0].earnedAt);
document.getElementById('latest-date').textContent = latest.toLocaleDateString();
+ } else {
+ document.getElementById('latest-date').textContent = '-';
}
// Completion percentage
@@ -217,6 +253,7 @@
window.location.href = '/auth/login';
return;
}
+ document.getElementById('rebuild-achievements-btn').addEventListener('click', rebuildAchievements);
loadAchievements();
});
diff --git a/src/test/java/net/javahippie/fitpub/service/AchievementServiceTest.java b/src/test/java/net/javahippie/fitpub/service/AchievementServiceTest.java
index e9cd220..a38e05b 100644
--- a/src/test/java/net/javahippie/fitpub/service/AchievementServiceTest.java
+++ b/src/test/java/net/javahippie/fitpub/service/AchievementServiceTest.java
@@ -4,6 +4,7 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InOrder;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@@ -14,14 +15,14 @@ import net.javahippie.fitpub.repository.AchievementRepository;
import net.javahippie.fitpub.repository.ActivityRepository;
import java.math.BigDecimal;
+import java.time.LocalDate;
import java.time.LocalDateTime;
-import java.time.LocalTime;
+import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
/**
@@ -54,14 +55,7 @@ class AchievementServiceTest {
void testCheckAndAwardAchievements_FirstActivity() {
// Given
Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO);
-
- when(activityRepository.countByUserId(userId)).thenReturn(1L);
- when(activityRepository.countByUserIdAndActivityType(userId, Activity.ActivityType.RUN)).thenReturn(1L);
- when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(5000));
- when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L);
- // Streak source: today has one activity (1-day streak — not enough to trigger any streak achievement)
- lenient().when(activityRepository.findActivityStartTimestampsSince(any(), any()))
- .thenReturn(List.of(java.time.LocalDateTime.now()));
+ stubHistory(activity);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
@@ -82,12 +76,11 @@ class AchievementServiceTest {
@DisplayName("Should award first run achievement")
void testCheckAndAwardAchievements_FirstRun() {
// Given
+ Activity firstRide = createActivity(Activity.ActivityType.RIDE, 10000L, BigDecimal.ZERO);
+ firstRide.setStartedAt(testTime.minusDays(1));
Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO);
- when(activityRepository.countByUserId(userId)).thenReturn(10L); // Not first overall
- when(activityRepository.countByUserIdAndActivityType(userId, Activity.ActivityType.RUN)).thenReturn(1L);
- when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(50000));
- when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(2L);
+ stubHistory(firstRide, activity);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
@@ -102,13 +95,12 @@ class AchievementServiceTest {
@Test
@DisplayName("Should award distance milestone achievements")
void testCheckAndAwardAchievements_DistanceMilestone() {
- // Given - User has completed 10+ km total
+ // Given - Current activity crosses 10 km total
+ Activity previous = createActivity(Activity.ActivityType.RUN, 7000L, BigDecimal.ZERO);
+ previous.setStartedAt(testTime.minusDays(1));
Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO);
- when(activityRepository.countByUserId(userId)).thenReturn(5L);
- when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(3L);
- when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(12000)); // 12 km
- when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L);
+ stubHistory(previous, activity);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
@@ -126,13 +118,17 @@ class AchievementServiceTest {
@Test
@DisplayName("Should award activity count milestone")
void testCheckAndAwardAchievements_ActivityCount() {
- // Given - User has 10 activities
+ // Given - Current activity is the 10th activity
+ List
history = new ArrayList<>();
+ for (int i = 0; i < 9; i++) {
+ Activity previous = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO);
+ previous.setStartedAt(testTime.minusDays(10 - i));
+ history.add(previous);
+ }
Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO);
+ history.add(activity);
- when(activityRepository.countByUserId(userId)).thenReturn(10L);
- when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(5L);
- when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(50000));
- when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L);
+ stubHistory(history);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
@@ -147,15 +143,18 @@ class AchievementServiceTest {
@Test
@DisplayName("Should award early bird achievement")
void testCheckAndAwardAchievements_EarlyBird() {
- // Given - Activity before 6am, and user has 5+ early activities
+ // Given - Activity before 6am is the 5th early activity
+ List history = new ArrayList<>();
+ for (int i = 0; i < 4; i++) {
+ Activity previous = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO);
+ previous.setStartedAt(LocalDateTime.of(2025, 11, 20 + i, 5, 30));
+ history.add(previous);
+ }
Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO);
activity.setStartedAt(LocalDateTime.of(2025, 12, 1, 5, 30)); // 5:30 AM
- when(activityRepository.countByUserId(userId)).thenReturn(10L);
- when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(5L);
- when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(50000));
- when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L);
- when(activityRepository.countByUserIdAndStartTimeBefore(eq(userId), eq(LocalTime.of(6, 0)))).thenReturn(5L);
+ history.add(activity);
+ stubHistory(history);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
@@ -170,15 +169,18 @@ class AchievementServiceTest {
@Test
@DisplayName("Should award night owl achievement")
void testCheckAndAwardAchievements_NightOwl() {
- // Given - Activity after 10pm, and user has 5+ late activities
+ // Given - Activity after 10pm is the 5th late activity
+ List history = new ArrayList<>();
+ for (int i = 0; i < 4; i++) {
+ Activity previous = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO);
+ previous.setStartedAt(LocalDateTime.of(2025, 11, 20 + i, 23, 0));
+ history.add(previous);
+ }
Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO);
activity.setStartedAt(LocalDateTime.of(2025, 12, 1, 23, 0)); // 11:00 PM
- when(activityRepository.countByUserId(userId)).thenReturn(10L);
- when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(5L);
- when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(50000));
- when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L);
- when(activityRepository.countByUserIdAndStartTimeAfter(eq(userId), eq(LocalTime.of(22, 0)))).thenReturn(5L);
+ history.add(activity);
+ stubHistory(history);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
@@ -196,11 +198,7 @@ class AchievementServiceTest {
// Given - Activity with 1000m+ elevation gain
Activity activity = createActivity(Activity.ActivityType.HIKE, 10000L, BigDecimal.valueOf(1200));
- when(activityRepository.countByUserId(userId)).thenReturn(5L);
- when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(3L);
- when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(50000));
- when(activityRepository.sumElevationGainByUserId(userId)).thenReturn(BigDecimal.valueOf(1200));
- when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L);
+ stubHistory(activity);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
@@ -215,14 +213,12 @@ class AchievementServiceTest {
@Test
@DisplayName("Should award total elevation milestones")
void testCheckAndAwardAchievements_TotalElevation() {
- // Given - User has 5000m+ total elevation
+ // Given - Current activity crosses 5000m total elevation
+ Activity previous = createActivity(Activity.ActivityType.HIKE, 10000L, BigDecimal.valueOf(4500));
+ previous.setStartedAt(testTime.minusDays(1));
Activity activity = createActivity(Activity.ActivityType.HIKE, 10000L, BigDecimal.valueOf(500));
- when(activityRepository.countByUserId(userId)).thenReturn(20L);
- when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(10L);
- when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(200000));
- when(activityRepository.sumElevationGainByUserId(userId)).thenReturn(BigDecimal.valueOf(6000)); // 6000m total
- when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(2L);
+ stubHistory(previous, activity);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
@@ -240,13 +236,14 @@ class AchievementServiceTest {
@Test
@DisplayName("Should award variety seeker achievement")
void testCheckAndAwardAchievements_VarietySeeker() {
- // Given - User has tried 3+ different activity types
+ // Given - Current activity introduces the 3rd distinct activity type
+ Activity run = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO);
+ run.setStartedAt(testTime.minusDays(2));
+ Activity ride = createActivity(Activity.ActivityType.RIDE, 20000L, BigDecimal.ZERO);
+ ride.setStartedAt(testTime.minusDays(1));
Activity activity = createActivity(Activity.ActivityType.SWIM, 2000L, BigDecimal.ZERO);
- when(activityRepository.countByUserId(userId)).thenReturn(15L);
- when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(5L);
- when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(30000));
- when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(3L);
+ stubHistory(run, ride, activity);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
@@ -267,10 +264,7 @@ class AchievementServiceTest {
metrics.setMaxSpeed(BigDecimal.valueOf(45.0)); // 45 km/h (realistic for cycling)
activity.setMetrics(metrics);
- when(activityRepository.countByUserId(userId)).thenReturn(10L);
- when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(5L);
- when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(200000));
- when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L);
+ stubHistory(activity);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
@@ -285,19 +279,18 @@ class AchievementServiceTest {
@Test
@DisplayName("Should award 7-day streak achievement")
void testCheckAndAwardAchievements_7DayStreak() {
- // Given - User has 7+ consecutive days of activities
+ // Given - Current activity completes a 7-day streak
+ List history = new ArrayList<>();
+ LocalDate anchorDate = testTime.toLocalDate();
+ for (int i = 6; i >= 1; i--) {
+ Activity previous = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO);
+ previous.setStartedAt(anchorDate.minusDays(i).atTime(10, 0));
+ history.add(previous);
+ }
Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO);
+ history.add(activity);
- when(activityRepository.countByUserId(userId)).thenReturn(20L);
- when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(10L);
- when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(100000));
- when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L);
- // Streak source: 8 consecutive days of activity ending today, as raw timestamps
- java.time.LocalDateTime now = java.time.LocalDateTime.now();
- when(activityRepository.findActivityStartTimestampsSince(any(), any())).thenReturn(List.of(
- now, now.minusDays(1), now.minusDays(2), now.minusDays(3),
- now.minusDays(4), now.minusDays(5), now.minusDays(6), now.minusDays(7)
- ));
+ stubHistory(history);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
@@ -314,8 +307,7 @@ class AchievementServiceTest {
void testCheckAndAwardAchievements_AlreadyEarned() {
// Given - User already has every achievement
Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO);
-
- when(activityRepository.countByUserId(userId)).thenReturn(10L);
+ stubHistory(activity);
// Simulate "user already has all achievements" by returning one of every type from the
// preload query that checkAndAwardAchievements uses to populate the in-memory set.
List allEarned = new java.util.ArrayList<>();
@@ -359,11 +351,7 @@ class AchievementServiceTest {
metrics.setMaxSpeed(BigDecimal.valueOf(16.0)); // 57.6 km/h (unrealistic for run, but for testing)
activity.setMetrics(metrics);
- when(activityRepository.countByUserId(userId)).thenReturn(1L); // First activity
- when(activityRepository.countByUserIdAndActivityType(userId, Activity.ActivityType.RUN)).thenReturn(1L);
- when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(5000));
- when(activityRepository.sumElevationGainByUserId(userId)).thenReturn(BigDecimal.valueOf(1100));
- when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L);
+ stubHistory(activity);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
@@ -380,6 +368,52 @@ class AchievementServiceTest {
));
}
+ @Test
+ @DisplayName("Should use activity end time as earnedAt for historical milestone")
+ void testCheckAndAwardAchievements_UsesActivityEndTimeForEarnedAt() {
+ Activity previous = createActivity(Activity.ActivityType.RUN, 9000L, BigDecimal.ZERO);
+ previous.setStartedAt(LocalDateTime.of(2025, 11, 30, 10, 0));
+ previous.setEndedAt(LocalDateTime.of(2025, 11, 30, 11, 0));
+ Activity activity = createActivity(Activity.ActivityType.RUN, 2000L, BigDecimal.ZERO);
+ activity.setStartedAt(LocalDateTime.of(2025, 12, 1, 7, 15));
+ activity.setEndedAt(LocalDateTime.of(2025, 12, 1, 8, 5));
+
+ stubHistory(previous, activity);
+ when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
+
+ List achievements = achievementService.checkAndAwardAchievements(activity);
+
+ Achievement distanceAchievement = achievements.stream()
+ .filter(a -> a.getAchievementType() == Achievement.AchievementType.DISTANCE_10K)
+ .findFirst()
+ .orElseThrow();
+
+ assertEquals(activity.getEndedAt(), distanceAchievement.getEarnedAt());
+ assertEquals(activity.getId(), distanceAchievement.getActivityId());
+ }
+
+ @Test
+ @DisplayName("Should rebuild achievements by deleting existing rows and replaying history")
+ void testRebuildAchievementsForUser() {
+ Activity previous = createActivity(Activity.ActivityType.RUN, 9000L, BigDecimal.ZERO);
+ previous.setStartedAt(LocalDateTime.of(2025, 11, 30, 10, 0));
+ previous.setEndedAt(LocalDateTime.of(2025, 11, 30, 11, 0));
+ Activity activity = createActivity(Activity.ActivityType.RUN, 2000L, BigDecimal.ZERO);
+ activity.setStartedAt(LocalDateTime.of(2025, 12, 1, 7, 15));
+ activity.setEndedAt(LocalDateTime.of(2025, 12, 1, 8, 5));
+
+ stubHistory(previous, activity);
+ when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
+
+ List rebuilt = achievementService.rebuildAchievementsForUser(userId);
+
+ assertTrue(rebuilt.stream().anyMatch(a -> a.getAchievementType() == Achievement.AchievementType.DISTANCE_10K));
+
+ InOrder inOrder = inOrder(achievementRepository);
+ inOrder.verify(achievementRepository).deleteByUserId(userId);
+ inOrder.verify(achievementRepository, atLeastOnce()).save(any(Achievement.class));
+ }
+
@Test
@DisplayName("Should get user achievements")
void testGetUserAchievements() {
@@ -421,6 +455,7 @@ class AchievementServiceTest {
.userId(userId)
.activityType(activityType)
.startedAt(testTime)
+ .endedAt(testTime.plusHours(1))
.totalDistance(BigDecimal.valueOf(distanceMeters))
.totalDurationSeconds(3600L)
.elevationGain(elevationGain)
@@ -439,4 +474,12 @@ class AchievementServiceTest {
.earnedAt(testTime)
.build();
}
+
+ private void stubHistory(Activity... activities) {
+ stubHistory(List.of(activities));
+ }
+
+ private void stubHistory(List activities) {
+ when(activityRepository.findByUserIdOrderByStartedAtAsc(userId)).thenReturn(activities);
+ }
}
diff --git a/src/test/java/net/javahippie/fitpub/service/ActivitySummaryServiceTest.java b/src/test/java/net/javahippie/fitpub/service/ActivitySummaryServiceTest.java
index 37d5681..5f19254 100644
--- a/src/test/java/net/javahippie/fitpub/service/ActivitySummaryServiceTest.java
+++ b/src/test/java/net/javahippie/fitpub/service/ActivitySummaryServiceTest.java
@@ -9,8 +9,13 @@ import net.javahippie.fitpub.repository.PersonalRecordRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.dao.DataIntegrityViolationException;
+import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
@@ -19,37 +24,39 @@ import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.argThat;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+@ExtendWith(MockitoExtension.class)
class ActivitySummaryServiceTest {
+ @Mock
private ActivitySummaryRepository activitySummaryRepository;
+
+ @Mock
private ActivityRepository activityRepository;
+
+ @Mock
private PersonalRecordRepository personalRecordRepository;
+
+ @Mock
private AchievementRepository achievementRepository;
- private ActivitySummaryService service;
+
+ @InjectMocks
+ private ActivitySummaryService activitySummaryService;
+
+ private UUID userId;
@BeforeEach
void setUp() {
- activitySummaryRepository = mock(ActivitySummaryRepository.class);
- activityRepository = mock(ActivityRepository.class);
- personalRecordRepository = mock(PersonalRecordRepository.class);
- achievementRepository = mock(AchievementRepository.class);
- service = new ActivitySummaryService(
- activitySummaryRepository,
- activityRepository,
- personalRecordRepository,
- achievementRepository
- );
+ userId = UUID.randomUUID();
}
@Test
@DisplayName("Should retry summary save as update when concurrent insert hits unique constraint")
void shouldRetrySummarySaveAfterConcurrentInsert() {
- UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
LocalDate date = LocalDate.of(2025, 10, 8);
LocalDate weekStart = LocalDate.of(2025, 10, 6);
LocalDate weekEnd = LocalDate.of(2025, 10, 12);
@@ -62,9 +69,10 @@ class ActivitySummaryServiceTest {
.periodEnd(weekEnd)
.build();
- //noinspection unchecked
when(activitySummaryRepository.findByUserIdAndPeriodTypeAndPeriodStart(
- userId, ActivitySummary.PeriodType.WEEK, weekStart
+ userId,
+ ActivitySummary.PeriodType.WEEK,
+ weekStart
)).thenReturn(Optional.empty(), Optional.of(existingSummary));
when(activityRepository.findByUserIdAndStartedAtBetweenOrderByStartedAtDesc(
@@ -81,16 +89,59 @@ class ActivitySummaryServiceTest {
));
when(personalRecordRepository.countByUserIdAndDateRange(any(), any(), any())).thenReturn(0L);
- when(achievementRepository.countByUserIdAndDateRange(any(), any(), any())).thenReturn(0L);
-
+ when(achievementRepository.countByUserIdAndActivityStartedDateRange(any(), any(), any())).thenReturn(0L);
when(activitySummaryRepository.save(any(ActivitySummary.class)))
.thenThrow(new DataIntegrityViolationException("duplicate"))
.thenReturn(existingSummary);
- service.updateWeeklySummary(userId, date);
+ activitySummaryService.updateWeeklySummary(userId, date);
verify(activitySummaryRepository, times(2))
.findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.WEEK, weekStart);
verify(activitySummaryRepository, times(2)).save(any(ActivitySummary.class));
}
+
+ @Test
+ @DisplayName("Should count achievements in summaries by triggering activity start date")
+ void shouldCountAchievementsByActivityStartDate() {
+ LocalDate weekDate = LocalDate.of(2025, 12, 3);
+ LocalDate weekStart = LocalDate.of(2025, 12, 1);
+ LocalDateTime startDateTime = weekStart.atStartOfDay();
+ LocalDateTime endDateTime = weekStart.plusDays(7).atStartOfDay();
+
+ Activity activity = Activity.builder()
+ .id(UUID.randomUUID())
+ .userId(userId)
+ .activityType(Activity.ActivityType.RUN)
+ .startedAt(LocalDateTime.of(2025, 12, 3, 23, 30))
+ .endedAt(LocalDateTime.of(2025, 12, 4, 0, 15))
+ .totalDistance(BigDecimal.valueOf(5000))
+ .totalDurationSeconds(2700L)
+ .elevationGain(BigDecimal.valueOf(120))
+ .build();
+
+ when(activitySummaryRepository.findByUserIdAndPeriodTypeAndPeriodStart(
+ userId,
+ ActivitySummary.PeriodType.WEEK,
+ weekStart
+ )).thenReturn(Optional.empty());
+ when(activityRepository.findByUserIdAndStartedAtBetweenOrderByStartedAtDesc(
+ userId,
+ startDateTime,
+ endDateTime
+ )).thenReturn(List.of(activity));
+ when(personalRecordRepository.countByUserIdAndDateRange(userId, startDateTime, endDateTime)).thenReturn(0L);
+ when(achievementRepository.countByUserIdAndActivityStartedDateRange(userId, startDateTime, endDateTime))
+ .thenReturn(1L);
+ when(activitySummaryRepository.save(any(ActivitySummary.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ activitySummaryService.updateWeeklySummary(userId, weekDate);
+
+ verify(achievementRepository).countByUserIdAndActivityStartedDateRange(userId, startDateTime, endDateTime);
+ verify(activitySummaryRepository).save(argThat(summary ->
+ summary.getAchievementsEarned() == 1 &&
+ summary.getActivityCount() == 1
+ ));
+ }
}
diff --git a/src/test/java/net/javahippie/fitpub/service/FitFileServiceTest.java b/src/test/java/net/javahippie/fitpub/service/FitFileServiceTest.java
index 3ad4b11..0ab3ac1 100644
--- a/src/test/java/net/javahippie/fitpub/service/FitFileServiceTest.java
+++ b/src/test/java/net/javahippie/fitpub/service/FitFileServiceTest.java
@@ -224,9 +224,11 @@ class FitFileServiceTest {
void testDeleteActivity() {
// Arrange
UUID activityId = UUID.randomUUID();
+ LocalDateTime startedAt = LocalDateTime.of(2025, 12, 3, 10, 0);
Activity activity = Activity.builder()
.id(activityId)
.userId(testUserId)
+ .startedAt(startedAt)
.build();
when(activityRepository.findByIdAndUserId(activityId, testUserId))
@@ -238,6 +240,10 @@ class FitFileServiceTest {
// Assert
assertTrue(result);
verify(activityRepository).delete(activity);
+ verify(achievementService).rebuildAchievementsForUser(testUserId);
+ verify(activitySummaryService).updateWeeklySummary(testUserId, startedAt.toLocalDate());
+ verify(activitySummaryService).updateMonthlySummary(testUserId, startedAt.toLocalDate());
+ verify(activitySummaryService).updateYearlySummary(testUserId, startedAt.toLocalDate());
}
@Test
@@ -254,6 +260,10 @@ class FitFileServiceTest {
// Assert
assertFalse(result);
verify(activityRepository, never()).delete(any());
+ verify(achievementService, never()).rebuildAchievementsForUser(any());
+ verify(activitySummaryService, never()).updateWeeklySummary(any(), any());
+ verify(activitySummaryService, never()).updateMonthlySummary(any(), any());
+ verify(activitySummaryService, never()).updateYearlySummary(any(), any());
}
@Test