fix(analytics): rebuild stale current period summaries on read

Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>
This commit is contained in:
Marcus Fihlon 2026-04-29 17:20:41 +02:00
parent 88ac213214
commit 6110deba21
Signed by: McPringle
GPG key ID: C6B7F469EE363E1F
3 changed files with 150 additions and 11 deletions

View file

@ -284,6 +284,23 @@ public class AnalyticsController {
return ResponseEntity.ok(summary);
}
/**
* Get current year summary.
*/
@GetMapping("/summaries/current-year")
public ResponseEntity<ActivitySummary> getCurrentYearSummary(
@AuthenticationPrincipal UserDetails userDetails) {
UUID userId = getUserId(userDetails);
ActivitySummary summary = activitySummaryService.getCurrentYearSummary(userId);
if (summary == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(summary);
}
/**
* Get form status description.
*/

View file

@ -268,6 +268,22 @@ public class ActivitySummaryService {
);
}
/**
* Get current year summary.
*/
@Transactional
public ActivitySummary getCurrentYearSummary(UUID userId) {
LocalDate yearStart = LocalDate.now().with(TemporalAdjusters.firstDayOfYear());
LocalDate yearEndExclusive = yearStart.plusYears(1);
return getOrBuildCurrentSummary(
userId,
ActivitySummary.PeriodType.YEAR,
yearStart,
yearEndExclusive,
() -> updateYearlySummary(userId, yearStart)
);
}
private ActivitySummary getOrBuildCurrentSummary(
UUID userId,
ActivitySummary.PeriodType periodType,
@ -275,16 +291,6 @@ public class ActivitySummaryService {
LocalDate periodEndExclusive,
Runnable rebuildAction
) {
ActivitySummary existingSummary = activitySummaryRepository.findByUserIdAndPeriodTypeAndPeriodStart(
userId,
periodType,
periodStart
).orElse(null);
if (existingSummary != null) {
return existingSummary;
}
boolean hasActivitiesInPeriod = activityRepository.existsByUserIdAndStartedAtBetween(
userId,
periodStart.atStartOfDay(),
@ -292,7 +298,11 @@ public class ActivitySummaryService {
);
if (!hasActivitiesInPeriod) {
return null;
return activitySummaryRepository.findByUserIdAndPeriodTypeAndPeriodStart(
userId,
periodType,
periodStart
).orElse(null);
}
rebuildAction.run();

View file

@ -215,4 +215,116 @@ class ActivitySummaryServiceTest {
assertNull(result);
verify(activityRepository).existsByUserIdAndStartedAtBetween(userId, startDateTime, endDateTime);
}
@Test
@DisplayName("Should rebuild stale current month summary when activities exist")
void getCurrentMonthSummary_RebuildsExistingStaleSummary() {
LocalDate monthStart = LocalDate.now().withDayOfMonth(1);
LocalDateTime startDateTime = monthStart.atStartOfDay();
LocalDateTime endDateTime = monthStart.plusMonths(1).atStartOfDay();
Activity activity = Activity.builder()
.id(UUID.randomUUID())
.userId(userId)
.activityType(Activity.ActivityType.RUN)
.startedAt(startDateTime.plusDays(3).plusHours(6))
.endedAt(startDateTime.plusDays(3).plusHours(7))
.totalDistance(BigDecimal.valueOf(12000))
.totalDurationSeconds(4200L)
.elevationGain(BigDecimal.valueOf(200))
.build();
ActivitySummary rebuiltSummary = ActivitySummary.builder()
.userId(userId)
.periodType(ActivitySummary.PeriodType.MONTH)
.periodStart(monthStart)
.periodEnd(monthStart.plusMonths(1).minusDays(1))
.activityCount(1)
.build();
when(activityRepository.existsByUserIdAndStartedAtBetween(userId, startDateTime, endDateTime))
.thenReturn(true);
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(0L);
when(activitySummaryRepository.findByUserIdAndPeriodTypeAndPeriodStart(
userId,
ActivitySummary.PeriodType.MONTH,
monthStart
)).thenReturn(
Optional.of(ActivitySummary.builder()
.userId(userId)
.periodType(ActivitySummary.PeriodType.MONTH)
.periodStart(monthStart)
.periodEnd(monthStart.plusMonths(1).minusDays(1))
.activityCount(0)
.build()),
Optional.of(rebuiltSummary)
);
when(activitySummaryRepository.save(any(ActivitySummary.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
ActivitySummary result = activitySummaryService.getCurrentMonthSummary(userId);
assertNotNull(result);
assertEquals(1, result.getActivityCount());
verify(activitySummaryRepository).save(any(ActivitySummary.class));
}
@Test
@DisplayName("Should rebuild stale current year summary when activities exist")
void getCurrentYearSummary_RebuildsExistingStaleSummary() {
LocalDate yearStart = LocalDate.now().withDayOfYear(1);
LocalDateTime startDateTime = yearStart.atStartOfDay();
LocalDateTime endDateTime = yearStart.plusYears(1).atStartOfDay();
Activity activity = Activity.builder()
.id(UUID.randomUUID())
.userId(userId)
.activityType(Activity.ActivityType.RIDE)
.startedAt(startDateTime.plusDays(20).plusHours(9))
.endedAt(startDateTime.plusDays(20).plusHours(11))
.totalDistance(BigDecimal.valueOf(65000))
.totalDurationSeconds(7200L)
.elevationGain(BigDecimal.valueOf(900))
.build();
ActivitySummary rebuiltSummary = ActivitySummary.builder()
.userId(userId)
.periodType(ActivitySummary.PeriodType.YEAR)
.periodStart(yearStart)
.periodEnd(yearStart.plusYears(1).minusDays(1))
.activityCount(1)
.build();
when(activityRepository.existsByUserIdAndStartedAtBetween(userId, startDateTime, endDateTime))
.thenReturn(true);
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(0L);
when(activitySummaryRepository.findByUserIdAndPeriodTypeAndPeriodStart(
userId,
ActivitySummary.PeriodType.YEAR,
yearStart
)).thenReturn(
Optional.of(ActivitySummary.builder()
.userId(userId)
.periodType(ActivitySummary.PeriodType.YEAR)
.periodStart(yearStart)
.periodEnd(yearStart.plusYears(1).minusDays(1))
.activityCount(0)
.build()),
Optional.of(rebuiltSummary)
);
when(activitySummaryRepository.save(any(ActivitySummary.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
ActivitySummary result = activitySummaryService.getCurrentYearSummary(userId);
assertNotNull(result);
assertEquals(1, result.getActivityCount());
verify(activitySummaryRepository).save(any(ActivitySummary.class));
}
}