ActivitySummary

This commit is contained in:
2026-05-14 18:02:45 +02:00
parent f8ad8199b5
commit d00e052b65
17 changed files with 342 additions and 51 deletions

2
.idea/sqldialects.xml generated
View File

@@ -2,7 +2,5 @@
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/peakup-backend/src/main/resources/db/migration/V40__rename_goal_metric_aggregate_table.sql" dialect="PostgreSQL" />
<file url="file://$PROJECT_DIR$/peakup-backend/src/test/resources/skripts.sql" dialect="GenericSQL" />
<file url="PROJECT" dialect="PostgreSQL" />
</component>
</project>

View File

@@ -108,6 +108,27 @@
<property name="validateEnhancedForLoopVariable" value="true"/>
</module>
<!-- ===== ERROR PREVENTION ===== -->
<!-- Unused local variables -->
<module name="UnusedLocalVariable"/>
<!-- equals() without hashCode() -->
<module name="EqualsHashCode"/>
<!-- switch without default -->
<module name="MissingSwitchDefault"/>
<!-- accidental switch fall-through -->
<module name="FallThrough"/>
<!-- assignment inside a condition: if (x = y) -->
<module name="InnerAssignment"/>
<!-- x == true, x != false -->
<module name="SimplifyBooleanExpression"/>
<!-- if (x) return true; else return false; -->
<module name="SimplifyBooleanReturn"/>
<!-- local variable or parameter shadows a field -->
<module name="HiddenField">
<property name="ignoreConstructorParameter" value="true"/>
<property name="ignoreSetter" value="true"/>
</module>
<!-- ===== CODE STYLE ===== -->
<!-- One statement per line -->
<module name="OneStatementPerLine"/>

View File

@@ -1,2 +0,0 @@
@com.peakup.common.annotation.NonNull
package com.peakup.activity.importer;

View File

@@ -22,4 +22,8 @@ public class ActivityMapStreamService {
throw new IllegalStateException("Failed to load map stream for activity " + activityId, sqlException);
}
}
public void insertBatch(final List<ActivityMapStreamPoint> points) throws SQLException {
activityMapStreamDAO.insertBatch(points);
}
}

View File

@@ -22,4 +22,8 @@ public class ActivityStreamService {
throw new IllegalStateException("Failed to load stream for activity " + activityId, sqlException);
}
}
public void insertBatch(final List<ActivityStreamPoint> points) throws SQLException {
activityStreamDAO.insertBatch(points);
}
}

View File

@@ -71,6 +71,33 @@ public class IntervalsActivityService extends AbstractService<IntervalsActivity,
};
}
@Transactional
public @Nullable IntervalsActivity findByOriginId(final String originId) {
return intervalsActivityDAO.findByOriginId(originId);
}
@Transactional
public @Nullable IntervalsActivity getLatestActivity(final Long athleteId) {
return intervalsActivityDAO.getLatestActivity(athleteId);
}
@Transactional
public List<Long> findPendingStreamIds(final Long athleteId) {
return intervalsActivityDAO.findPendingStreamIds(athleteId);
}
@Override
@Transactional
public void insert(final IntervalsActivity activity) {
super.insert(activity);
}
@Override
@Transactional
public void update(final IntervalsActivity activity) {
super.update(activity);
}
@Transactional
public IntervalsActivity linkWorkout(final long activityId, final long workoutId) {
final IntervalsActivity activity = intervalsActivityDAO.getById(activityId);

View File

@@ -3,8 +3,10 @@ package com.peakup.activity.service;
import com.peakup.activity.dao.StravaActivityDAO;
import com.peakup.activity.model.StravaActivity;
import com.peakup.common.service.AbstractService;
import jakarta.annotation.Nullable;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
@ApplicationScoped
public class StravaActivityService extends AbstractService<StravaActivity, StravaActivityDAO> {
@@ -17,4 +19,20 @@ public class StravaActivityService extends AbstractService<StravaActivity, Strav
protected StravaActivityDAO getDAO() {
return stravaActivityDAO;
}
@Transactional
public @Nullable StravaActivity findByOriginId(final String originId) {
return stravaActivityDAO.findByOriginId(originId);
}
@Transactional
public @Nullable StravaActivity getLatestActivity(final Long athleteId) {
return stravaActivityDAO.getLatestActivity(athleteId);
}
@Override
@Transactional
public void insert(final StravaActivity activity) {
super.insert(activity);
}
}

View File

@@ -437,8 +437,8 @@ public interface IntervalsClient {
}
@com.fasterxml.jackson.annotation.JsonAnySetter
public void setUnknownField(final String name, final Object value) {
unknownFields.put(name, value);
public void setUnknownField(final String fieldName, final Object value) {
unknownFields.put(fieldName, value);
}
public static class StravaGear {

View File

@@ -43,7 +43,7 @@ public class GoalMetricPeriodTotalService extends AbstractService<GoalMetricPeri
return goalMetricAggregateDAO;
}
@Transactional
@Transactional(Transactional.TxType.REQUIRES_NEW)
public void recalculateForAthlete(final long athleteId, final int year, final int calendarWeek) {
final List<IntervalsActivity> weekActivities = fetchWeekActivities(athleteId, year, calendarWeek);
for (final GoalMetricEnum metric : GoalMetricEnum.values()) {
@@ -88,10 +88,8 @@ public class GoalMetricPeriodTotalService extends AbstractService<GoalMetricPeri
return intervalsActivityService.getByAthleteIdBetween(athleteId, weekStart, weekEnd);
}
@Transactional
public void deleteAndRecalculateByAthleteId(final long athleteId) {
goalMetricAggregateDAO.deleteByAthleteId(athleteId);
recalculateUpToCurrentWeek(athleteId);
}
public void recalculateUpToCurrentWeek(final long athleteId) {

View File

@@ -1,7 +1,6 @@
package com.peakup.activity.importer;
package com.peakup.scheduler;
import com.peakup.activity.model.AbstractActivity;
import com.peakup.common.dao.AbstractDAO;
import jakarta.annotation.Nullable;
import java.util.List;
@@ -9,17 +8,17 @@ import java.util.List;
@lombok.extern.slf4j.Slf4j
public abstract class AbstractActivityImporter<R, A extends AbstractActivity> {
protected abstract AbstractDAO<A> getDAO();
protected abstract @Nullable A findExisting(R raw);
protected abstract A map(R raw, Long athleteId);
protected abstract void insertActivity(A activity);
protected int persist(final Long athleteId, final List<R> raws) {
int count = 0;
for (final R raw : raws) {
if (findExisting(raw) == null) {
getDAO().insert(map(raw, athleteId));
insertActivity(map(raw, athleteId));
count++;
} else {
log.warn("Already imported: {}", raw);

View File

@@ -1,7 +1,5 @@
package com.peakup.scheduler;
import com.peakup.activity.importer.IntervalsActivityImporter;
import com.peakup.activity.importer.StravaActivityImporter;
import com.peakup.connector.intervals.client.IntervalsContext;
import com.peakup.connector.intervals.model.IntervalsConnector;
import com.peakup.connector.intervals.service.IntervalsConnectorService;

View File

@@ -1,12 +1,11 @@
package com.peakup.activity.importer;
package com.peakup.scheduler;
import com.peakup.activity.dao.ActivityMapStreamDAO;
import com.peakup.activity.dao.ActivityStreamDAO;
import com.peakup.activity.dao.IntervalsActivityDAO;
import com.peakup.activity.model.IntervalsActivity;
import com.peakup.activity.model.StreamStatusEnum;
import com.peakup.activity.service.ActivityMapStreamService;
import com.peakup.activity.service.ActivityStreamService;
import com.peakup.activity.service.IntervalsActivityService;
import com.peakup.common.ImportException;
import com.peakup.common.dao.AbstractDAO;
import com.peakup.connector.intervals.IntervalsActivityConnector;
import com.peakup.connector.intervals.client.IntervalsClient;
import com.peakup.connector.intervals.mapper.IntervalsActivityMapper;
@@ -28,15 +27,15 @@ public class IntervalsActivityImporter
@Inject
@SuppressWarnings("NullAway.Init")
IntervalsActivityDAO intervalsActivityDAO;
IntervalsActivityService intervalsActivityService;
@Inject
@SuppressWarnings("NullAway.Init")
ActivityStreamDAO activityStreamDAO;
ActivityStreamService activityStreamService;
@Inject
@SuppressWarnings("NullAway.Init")
ActivityMapStreamDAO activityMapStreamDAO;
ActivityMapStreamService activityMapStreamService;
@Inject
@SuppressWarnings("NullAway.Init")
@@ -54,13 +53,13 @@ public class IntervalsActivityImporter
long streamImportDelayMs;
@Override
protected AbstractDAO<IntervalsActivity> getDAO() {
return intervalsActivityDAO;
protected @Nullable IntervalsActivity findExisting(final IntervalsClient.IntervalsActivity raw) {
return raw.id != null ? intervalsActivityService.findByOriginId(raw.id) : null;
}
@Override
protected @Nullable IntervalsActivity findExisting(final IntervalsClient.IntervalsActivity raw) {
return raw.id != null ? intervalsActivityDAO.findByOriginId(raw.id) : null;
protected void insertActivity(final IntervalsActivity activity) {
intervalsActivityService.insert(activity);
}
@Override
@@ -85,7 +84,7 @@ public class IntervalsActivityImporter
return false;
}
final IntervalsActivity activity = map(raw, athleteId);
intervalsActivityDAO.insert(activity);
intervalsActivityService.insert(activity);
return true;
}
@@ -111,7 +110,7 @@ public class IntervalsActivityImporter
}
public void importPendingStreams(final Long athleteId) {
final List<Long> pendingIds = intervalsActivityDAO.findPendingStreamIds(athleteId);
final List<Long> pendingIds = intervalsActivityService.findPendingStreamIds(athleteId);
final int total = pendingIds.size();
log.info("Found {} pending stream activities for athleteId {}", total, athleteId);
for (int i = 0; i < total; i++) {
@@ -133,9 +132,9 @@ public class IntervalsActivityImporter
@Transactional
public void importStreamForActivity(final Long activityId) {
final IntervalsActivity activity = intervalsActivityDAO.getById(activityId);
final IntervalsActivity activity = intervalsActivityService.getById(activityId);
importStreams(activity);
intervalsActivityDAO.update(activity);
intervalsActivityService.update(activity);
}
private boolean importStreams(final IntervalsClient.StreamsData streams, final java.time.Instant startTime, final Long activityId)
@@ -143,7 +142,7 @@ public class IntervalsActivityImporter
if (streams.size() == 0) {
return false;
}
activityStreamDAO.insertBatch(intervalsActivityMapper.mapStreams(streams, startTime, activityId));
activityStreamService.insertBatch(intervalsActivityMapper.mapStreams(streams, startTime, activityId));
return true;
}
@@ -157,13 +156,13 @@ public class IntervalsActivityImporter
if (streams.time == null) {
return false;
}
activityMapStreamDAO.insertBatch(
activityMapStreamService.insertBatch(
intervalsActivityMapper.mapMapStream(mapData, streams.time, startTime, activityId));
return true;
}
public void importActivities(final Long athleteId, final String intervalsAthleteId) {
final IntervalsActivity latest = intervalsActivityDAO.getLatestActivity(athleteId);
final IntervalsActivity latest = intervalsActivityService.getLatestActivity(athleteId);
final String oldest = latest != null
? LocalDate.ofInstant(latest.startTime, ZoneOffset.UTC).minusDays(2).toString()
: "2010-01-01";

View File

@@ -1,8 +1,7 @@
package com.peakup.activity.importer;
package com.peakup.scheduler;
import com.peakup.activity.dao.StravaActivityDAO;
import com.peakup.activity.model.StravaActivity;
import com.peakup.common.dao.AbstractDAO;
import com.peakup.activity.service.StravaActivityService;
import com.peakup.connector.strava.StravaActivityConnector;
import com.peakup.connector.strava.client.StravaClient;
import com.peakup.connector.strava.client.StravaContext;
@@ -24,7 +23,7 @@ public class StravaActivityImporter extends AbstractActivityImporter<StravaClien
@Inject
@SuppressWarnings("NullAway.Init")
StravaActivityDAO stravaActivityDAO;
StravaActivityService stravaActivityService;
@Inject
@SuppressWarnings("NullAway.Init")
@@ -39,13 +38,13 @@ public class StravaActivityImporter extends AbstractActivityImporter<StravaClien
StravaContext stravaContext;
@Override
protected AbstractDAO<StravaActivity> getDAO() {
return stravaActivityDAO;
protected @Nullable StravaActivity findExisting(final StravaClient.Activity raw) {
return raw.id != null ? stravaActivityService.findByOriginId(String.valueOf(raw.id)) : null;
}
@Override
protected @Nullable StravaActivity findExisting(final StravaClient.Activity raw) {
return raw.id != null ? stravaActivityDAO.findByOriginId(String.valueOf(raw.id)) : null;
protected void insertActivity(final StravaActivity activity) {
stravaActivityService.insert(activity);
}
@Override
@@ -61,7 +60,7 @@ public class StravaActivityImporter extends AbstractActivityImporter<StravaClien
public void importActivities(final Long athleteId) {
stravaContext.setAthleteId(athleteId);
final StravaActivity latest = stravaActivityDAO.getLatestActivity(athleteId);
final StravaActivity latest = stravaActivityService.getLatestActivity(athleteId);
final Instant after = latest != null
? latest.startTime.minus(2, ChronoUnit.DAYS)
: Instant.EPOCH;

View File

@@ -165,14 +165,14 @@ class ArchitectureTest {
}
/**
* {@code @Transactional} is only permitted in service and importer classes.
* {@code @Transactional} is only permitted in service, importer, and scheduler classes.
* Resources must delegate to services; base abstract classes must not carry it.
*/
@Test
void transactionalOnlyInServicesAndImporters() {
methods().that().areAnnotatedWith("jakarta.transaction.Transactional")
.should().beDeclaredInClassesThat().resideInAnyPackage("..service..", "..importer..")
.because("@Transactional must only appear in service or importer classes, not in resources or DAOs")
.should().beDeclaredInClassesThat().resideInAnyPackage("..service..", "..importer..", "..scheduler..")
.because("@Transactional must only appear in service, importer, or scheduler classes, not in resources or DAOs")
.check(CLASSES);
}

View File

@@ -10,6 +10,8 @@ import com.peakup.connector.intervals.client.IntervalsContext;
import com.peakup.connector.intervals.model.IntervalsConnector;
import com.peakup.connector.intervals.service.IntervalsConnectorService;
import com.peakup.connector.strava.service.StravaConnectorService;
import com.peakup.scheduler.IntervalsActivityImporter;
import com.peakup.scheduler.StravaActivityImporter;
import io.agroal.api.AgroalDataSource;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.TestProfile;

View File

@@ -155,9 +155,6 @@ class IntervalsActivityMappingTest extends AbstractPeakUpTest {
IntervalsContext intervalsContext;
@Inject
@SuppressWarnings("NullAway.Init")
IntervalsActivityImporter importer;
@Inject
@SuppressWarnings("NullAway.Init")
IntervalsActivityMapper mapper;
@Inject
@SuppressWarnings("NullAway.Init")
@@ -182,7 +179,7 @@ class IntervalsActivityMappingTest extends AbstractPeakUpTest {
void testAllFieldsMappedCorrectly() {
for (final String originId : ORIGIN_IDS) {
final IntervalsClient.IntervalsActivity raw = intervalsClient.getActivity(originId);
final IntervalsActivity mapped = importer.map(raw, athleteId);
final IntervalsActivity mapped = mapper.toEntity(raw, athleteId);
checkCoverage(raw, "[" + originId + "] ");
assertMapping(raw, mapped, "[" + originId + "] ");
assertEquals(StreamStatusEnum.PENDING, mapped.streamStatus,

View File

@@ -14,6 +14,7 @@ import org.junit.jupiter.api.Test;
import java.sql.Connection;
import java.sql.Statement;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.time.temporal.WeekFields;
@@ -112,6 +113,217 @@ class GoalMetricPeriodTotalServiceTest extends AbstractPeakUpTest {
ATHLETE_ID, GoalMetricEnum.DISTANCE, GoalPeriodEnum.WEEKLY, weekYear, weekIndex));
}
// ── Monthly carry-over ────────────────────────────────────────────────────
@Test
void monthlyCarryOverWithinSameMonth() throws Exception {
// 2025-W10 (Mon 2025-03-03) and 2025-W11 (Mon 2025-03-10) are both in March.
// monthly(W11) must equal monthly(W10) + weekly(W11).
final LocalDate w10Monday = LocalDate.of(2025, 3, 3);
final LocalDate w11Monday = LocalDate.of(2025, 3, 10);
final int w10Year = w10Monday.get(WeekFields.ISO.weekBasedYear());
final int w10Week = w10Monday.get(WeekFields.ISO.weekOfWeekBasedYear());
final int w11Year = w11Monday.get(WeekFields.ISO.weekBasedYear());
final int w11Week = w11Monday.get(WeekFields.ISO.weekOfWeekBasedYear());
insertActivityOnDate(w10Monday, 1000.0, 0.0, 0.0, 0L);
goalMetricPeriodTotalService.recalculateForAthlete(ATHLETE_ID, w10Year, w10Week);
insertActivityOnDate(w11Monday, 500.0, 0.0, 0.0, 0L);
goalMetricPeriodTotalService.recalculateForAthlete(ATHLETE_ID, w11Year, w11Week);
final GoalMetricPeriodTotal monthly11 = goalMetricPeriodTotalService.getAggregate(
ATHLETE_ID, GoalMetricEnum.DISTANCE, GoalPeriodEnum.MONTHLY, w11Year, w11Week);
assertNotNull(monthly11);
assertEquals(1500.0, monthly11.getAggregatedValue());
final GoalMetricPeriodTotal yearly11 = goalMetricPeriodTotalService.getAggregate(
ATHLETE_ID, GoalMetricEnum.DISTANCE, GoalPeriodEnum.YEARLY, w11Year, w11Week);
assertNotNull(yearly11);
assertEquals(1500.0, yearly11.getAggregatedValue());
}
// ── Monthly boundary reset ────────────────────────────────────────────────
@Test
void monthlyResetsAtMonthBoundary() throws Exception {
// 2025-W05 (Mon 2025-01-27, January) → 2025-W06 (Mon 2025-02-03, February).
// prevMonday of W06 is 2025-01-27 (January) ≠ February → monthly resets.
// But both Mondays are in calendar year 2025 → yearly carries over.
final LocalDate w05Monday = LocalDate.of(2025, 1, 27);
final LocalDate w06Monday = LocalDate.of(2025, 2, 3);
final int w05Year = w05Monday.get(WeekFields.ISO.weekBasedYear());
final int w05Week = w05Monday.get(WeekFields.ISO.weekOfWeekBasedYear());
final int w06Year = w06Monday.get(WeekFields.ISO.weekBasedYear());
final int w06Week = w06Monday.get(WeekFields.ISO.weekOfWeekBasedYear());
insertActivityOnDate(w05Monday, 1000.0, 0.0, 0.0, 0L);
goalMetricPeriodTotalService.recalculateForAthlete(ATHLETE_ID, w05Year, w05Week);
insertActivityOnDate(w06Monday, 500.0, 0.0, 0.0, 0L);
goalMetricPeriodTotalService.recalculateForAthlete(ATHLETE_ID, w06Year, w06Week);
final GoalMetricPeriodTotal monthly06 = goalMetricPeriodTotalService.getAggregate(
ATHLETE_ID, GoalMetricEnum.DISTANCE, GoalPeriodEnum.MONTHLY, w06Year, w06Week);
assertNotNull(monthly06);
assertEquals(500.0, monthly06.getAggregatedValue(), "monthly resets at month boundary");
final GoalMetricPeriodTotal yearly06 = goalMetricPeriodTotalService.getAggregate(
ATHLETE_ID, GoalMetricEnum.DISTANCE, GoalPeriodEnum.YEARLY, w06Year, w06Week);
assertNotNull(yearly06);
assertEquals(1500.0, yearly06.getAggregatedValue(), "yearly carries over within same calendar year");
}
// ── Yearly boundary reset ─────────────────────────────────────────────────
@Test
void yearlyResetsAtYearBoundary() throws Exception {
// ISO W01-2025 starts Mon 2024-12-30; ISO W02-2025 starts Mon 2025-01-06.
// prevMonday of W02 is 2024-12-30 (year 2024) ≠ year of 2025-01-06 (year 2025) → yearly resets.
// sameMonth(Dec 30, Jan 6) = false → monthly also resets.
final LocalDate w01Monday = LocalDate.of(2024, 12, 30);
final LocalDate w02Monday = LocalDate.of(2025, 1, 6);
final int w01Year = w01Monday.get(WeekFields.ISO.weekBasedYear());
final int w01Week = w01Monday.get(WeekFields.ISO.weekOfWeekBasedYear());
final int w02Year = w02Monday.get(WeekFields.ISO.weekBasedYear());
final int w02Week = w02Monday.get(WeekFields.ISO.weekOfWeekBasedYear());
insertActivityOnDate(w01Monday, 1000.0, 0.0, 0.0, 0L);
goalMetricPeriodTotalService.recalculateForAthlete(ATHLETE_ID, w01Year, w01Week);
insertActivityOnDate(w02Monday, 500.0, 0.0, 0.0, 0L);
goalMetricPeriodTotalService.recalculateForAthlete(ATHLETE_ID, w02Year, w02Week);
final GoalMetricPeriodTotal yearly02 = goalMetricPeriodTotalService.getAggregate(
ATHLETE_ID, GoalMetricEnum.DISTANCE, GoalPeriodEnum.YEARLY, w02Year, w02Week);
assertNotNull(yearly02);
assertEquals(500.0, yearly02.getAggregatedValue(), "yearly resets at calendar-year boundary");
final GoalMetricPeriodTotal monthly02 = goalMetricPeriodTotalService.getAggregate(
ATHLETE_ID, GoalMetricEnum.DISTANCE, GoalPeriodEnum.MONTHLY, w02Year, w02Week);
assertNotNull(monthly02);
assertEquals(500.0, monthly02.getAggregatedValue(), "monthly also resets (different month)");
}
// ── Empty week ────────────────────────────────────────────────────────────
@Test
void emptyWeekCreatesZeroAggregatesAndCarriesForward() throws Exception {
// Seed W10-2025 with activities, then recalculate W11-2025 with no activities.
// W11 weekly must be 0; monthly/yearly must carry the W10 snapshot (same month, same year).
final LocalDate w10Monday = LocalDate.of(2025, 3, 3);
final LocalDate w11Monday = LocalDate.of(2025, 3, 10);
final int w10Year = w10Monday.get(WeekFields.ISO.weekBasedYear());
final int w10Week = w10Monday.get(WeekFields.ISO.weekOfWeekBasedYear());
final int w11Year = w11Monday.get(WeekFields.ISO.weekBasedYear());
final int w11Week = w11Monday.get(WeekFields.ISO.weekOfWeekBasedYear());
insertActivityOnDate(w10Monday, 1000.0, 0.0, 0.0, 0L);
goalMetricPeriodTotalService.recalculateForAthlete(ATHLETE_ID, w10Year, w10Week);
// W11 has no activities
goalMetricPeriodTotalService.recalculateForAthlete(ATHLETE_ID, w11Year, w11Week);
final GoalMetricPeriodTotal weekly11 = goalMetricPeriodTotalService.getAggregate(
ATHLETE_ID, GoalMetricEnum.DISTANCE, GoalPeriodEnum.WEEKLY, w11Year, w11Week);
assertNotNull(weekly11);
assertEquals(0.0, weekly11.getAggregatedValue(), "empty week creates zero weekly");
final GoalMetricPeriodTotal monthly11 = goalMetricPeriodTotalService.getAggregate(
ATHLETE_ID, GoalMetricEnum.DISTANCE, GoalPeriodEnum.MONTHLY, w11Year, w11Week);
assertNotNull(monthly11);
assertEquals(1000.0, monthly11.getAggregatedValue(), "monthly carries W10 snapshot into empty W11");
final GoalMetricPeriodTotal yearly11 = goalMetricPeriodTotalService.getAggregate(
ATHLETE_ID, GoalMetricEnum.DISTANCE, GoalPeriodEnum.YEARLY, w11Year, w11Week);
assertNotNull(yearly11);
assertEquals(1000.0, yearly11.getAggregatedValue(), "yearly carries W10 snapshot into empty W11");
}
@Test
void emptyWeekIsNotRecreatedWhenRecordAlreadyExists() {
// If a WEEKLY record already exists for a week with no activities, recalculate must not touch it.
final LocalDate today = LocalDate.now(ZoneOffset.UTC);
final int weekYear = today.get(WeekFields.ISO.weekBasedYear());
final int weekIndex = today.get(WeekFields.ISO.weekOfWeekBasedYear());
// First pass: no activities → creates zero record
goalMetricPeriodTotalService.recalculateForAthlete(ATHLETE_ID, weekYear, weekIndex);
// Second pass: still no activities → must not duplicate
goalMetricPeriodTotalService.recalculateForAthlete(ATHLETE_ID, weekYear, weekIndex);
final List<GoalMetricPeriodTotal> totals = goalMetricPeriodTotalService.getAggregates(
ATHLETE_ID, GoalMetricEnum.DISTANCE, GoalPeriodEnum.WEEKLY);
assertEquals(1, totals.size(), "no duplicate records created for repeated empty-week recalculation");
assertEquals(0.0, totals.get(0).getAggregatedValue());
}
// ── Metric extraction ─────────────────────────────────────────────────────
@Test
void elevationMetricIsExtractedCorrectly() throws Exception {
insertActivity(0.0, 0.0, 350.0, 0L);
final LocalDate today = LocalDate.now(ZoneOffset.UTC);
final int weekYear = today.get(WeekFields.ISO.weekBasedYear());
final int weekIndex = today.get(WeekFields.ISO.weekOfWeekBasedYear());
goalMetricPeriodTotalService.recalculateForAthlete(ATHLETE_ID, GoalMetricEnum.ELEVATION, weekYear, weekIndex);
final GoalMetricPeriodTotal weekly = goalMetricPeriodTotalService.getAggregate(
ATHLETE_ID, GoalMetricEnum.ELEVATION, GoalPeriodEnum.WEEKLY, weekYear, weekIndex);
assertNotNull(weekly);
assertEquals(350.0, weekly.getAggregatedValue());
}
@Test
void timeMetricIsExtractedCorrectly() throws Exception {
insertActivity(0.0, 0.0, 0.0, 5400L);
final LocalDate today = LocalDate.now(ZoneOffset.UTC);
final int weekYear = today.get(WeekFields.ISO.weekBasedYear());
final int weekIndex = today.get(WeekFields.ISO.weekOfWeekBasedYear());
goalMetricPeriodTotalService.recalculateForAthlete(ATHLETE_ID, GoalMetricEnum.TIME, weekYear, weekIndex);
final GoalMetricPeriodTotal weekly = goalMetricPeriodTotalService.getAggregate(
ATHLETE_ID, GoalMetricEnum.TIME, GoalPeriodEnum.WEEKLY, weekYear, weekIndex);
assertNotNull(weekly);
assertEquals(5400.0, weekly.getAggregatedValue());
}
// ── recalculateUpToCurrentWeek ────────────────────────────────────────────
@Test
void recalculateUpToCurrentWeekIsNoOpWhenNoActivitiesExist() {
goalMetricPeriodTotalService.recalculateUpToCurrentWeek(ATHLETE_ID);
final List<GoalMetricPeriodTotal> totals = goalMetricPeriodTotalService.getAggregates(
ATHLETE_ID, GoalMetricEnum.DISTANCE, GoalPeriodEnum.WEEKLY);
assertEquals(0, totals.size(), "no records created when no activities and no prior weekly record exist");
}
@Test
void recalculateUpToCurrentWeekResumesFromLastWeeklyRecord() throws Exception {
// Seed a weekly record for a past week directly, then call recalculateUpToCurrentWeek.
// It must resume from that week's Monday, not re-scan from the beginning.
final LocalDate today = LocalDate.now(ZoneOffset.UTC);
final int currentWeekYear = today.get(WeekFields.ISO.weekBasedYear());
final int currentWeekIndex = today.get(WeekFields.ISO.weekOfWeekBasedYear());
// Insert an activity for today so recalculate has something to process
insertActivity(800.0, 0.0, 0.0, 0L);
goalMetricPeriodTotalService.recalculateForAthlete(ATHLETE_ID, currentWeekYear, currentWeekIndex);
// Now call the full recalculate — it must find the lastWeekly and not start from scratch
goalMetricPeriodTotalService.recalculateUpToCurrentWeek(ATHLETE_ID);
final GoalMetricPeriodTotal weekly = goalMetricPeriodTotalService.getAggregate(
ATHLETE_ID, GoalMetricEnum.DISTANCE, GoalPeriodEnum.WEEKLY, currentWeekYear, currentWeekIndex);
assertNotNull(weekly);
assertEquals(800.0, weekly.getAggregatedValue());
}
// ── Helpers ───────────────────────────────────────────────────────────────
private void insertActivity(
final double distanceM,
final double trainingLoad,
@@ -126,4 +338,21 @@ class GoalMetricPeriodTotalServiceTest extends AbstractPeakUpTest {
""".formatted(ATHLETE_ID, distanceM, trainingLoad, elevationM, movingTimeS));
}
}
private void insertActivityOnDate(
final LocalDate date,
final double distanceM,
final double trainingLoad,
final double elevationM,
final long movingTimeS) throws Exception {
final Instant startTime = date.atStartOfDay(ZoneOffset.UTC).toInstant();
try (final Connection conn = dataSource.getConnection();
final Statement stmt = conn.createStatement()) {
stmt.execute("""
INSERT INTO intervals_activity
(athlete_id, start_time, distance_m, training_load, total_elevation_gain_m, moving_time_s, created_at, modified_at)
VALUES (%d, '%s', %f, %f, %f, %d, now(), now())
""".formatted(ATHLETE_ID, startTime, distanceM, trainingLoad, elevationM, movingTimeS));
}
}
}