ActivitySummary
This commit is contained in:
2
.idea/sqldialects.xml
generated
2
.idea/sqldialects.xml
generated
@@ -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>
|
||||
@@ -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"/>
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
@com.peakup.common.annotation.NonNull
|
||||
package com.peakup.activity.importer;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user