GoalMetric
This commit is contained in:
@@ -98,6 +98,19 @@ public class IntervalsActivityDAO extends AbstractDAO<IntervalsActivity> {
|
||||
.getResultList();
|
||||
}
|
||||
|
||||
public List<IntervalsActivity> getByAthleteIdAfterId(final Long athleteId, final Long lastActivityId) {
|
||||
final CriteriaBuilder cb = em.getCriteriaBuilder();
|
||||
final CriteriaQuery<IntervalsActivity> cq = cb.createQuery(IntervalsActivity.class);
|
||||
final Root<IntervalsActivity> root = cq.from(IntervalsActivity.class);
|
||||
cq.select(root)
|
||||
.where(
|
||||
cb.equal(root.get(AbstractActivity_.athleteId), athleteId),
|
||||
cb.greaterThan(root.get(AbstractEntity_.id), lastActivityId)
|
||||
)
|
||||
.orderBy(cb.asc(root.get(AbstractActivity_.startTime)));
|
||||
return em.createQuery(cq).getResultList();
|
||||
}
|
||||
|
||||
public List<IntervalsActivity> getByAthleteIdBetween(final Long athleteId, final Instant from, final Instant to) {
|
||||
final CriteriaBuilder cb = em.getCriteriaBuilder();
|
||||
final CriteriaQuery<IntervalsActivity> cq = cb.createQuery(IntervalsActivity.class);
|
||||
|
||||
@@ -42,6 +42,11 @@ public class IntervalsActivityService extends AbstractService<IntervalsActivity,
|
||||
return intervalsActivityDAO.getByAthleteId(athleteId, page, size);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public List<IntervalsActivity> getByAthleteIdAfterId(final Long athleteId, final Long lastActivityId) {
|
||||
return intervalsActivityDAO.getByAthleteIdAfterId(athleteId, lastActivityId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public List<IntervalsActivity> getByAthleteIdBetween(final Long athleteId, final LocalDate from, final LocalDate to) {
|
||||
final Instant fromInstant = from.atStartOfDay(ZoneOffset.UTC).toInstant();
|
||||
|
||||
@@ -5,7 +5,7 @@ import com.peakup.activity.model.ActivityStreamPoint;
|
||||
import com.peakup.activity.model.ActivitySummary;
|
||||
import com.peakup.common.model.AbstractDataObject;
|
||||
import com.peakup.goal.model.Goal;
|
||||
import com.peakup.goal.model.GoalMetricPeriodTotal;
|
||||
import com.peakup.goal.model.GoalMetricTotals;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
@@ -22,7 +22,7 @@ public class Dashboard extends AbstractDataObject {
|
||||
private int year;
|
||||
private List<ActivitySummary> activities;
|
||||
private List<Goal> goals;
|
||||
private List<GoalMetricPeriodTotal> goalMetricPeriodTotals;
|
||||
private List<GoalMetricTotals> goalMetricTotals;
|
||||
private Map<Long, List<ActivityMapStreamPoint>> weekActivityMapStreams;
|
||||
private Map<Long, List<ActivityStreamPoint>> weekActivityStreams;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import com.peakup.activity.service.ActivityMapStreamService;
|
||||
import com.peakup.activity.service.ActivityStreamService;
|
||||
import com.peakup.activity.service.ActivitySummaryService;
|
||||
import com.peakup.dashboard.model.Dashboard;
|
||||
import com.peakup.goal.model.GoalMetricEnum;
|
||||
import com.peakup.goal.model.GoalMetricTotals;
|
||||
import com.peakup.goal.service.GoalMetricPeriodTotalService;
|
||||
import com.peakup.goal.service.GoalService;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
@@ -60,12 +62,24 @@ public class DashboardService {
|
||||
final Map<Long, List<ActivityStreamPoint>> weekActivityStreams =
|
||||
resolveWeekActivityStreams(activitySummaries, selectedWeekMonday);
|
||||
|
||||
final LocalDate weekMonday = selectedWeekMonday;
|
||||
final int calendarYear = weekMonday.getYear();
|
||||
final int calendarMonth = weekMonday.getMonthValue();
|
||||
final List<GoalMetricTotals> metricTotals = java.util.Arrays.stream(GoalMetricEnum.values())
|
||||
.map(metric -> new GoalMetricTotals(
|
||||
metric,
|
||||
goalMetricPeriodTotalService.getWeeklyTotal(athleteId, metric, year, calendarWeek),
|
||||
goalMetricPeriodTotalService.getMonthlyTotal(athleteId, metric, calendarYear, calendarMonth),
|
||||
goalMetricPeriodTotalService.getYearlyTotal(athleteId, metric, calendarYear)
|
||||
))
|
||||
.toList();
|
||||
|
||||
return new Dashboard(
|
||||
calendarWeek,
|
||||
year,
|
||||
activitySummaries,
|
||||
goalService.getByAthleteId(athleteId),
|
||||
goalMetricPeriodTotalService.getByAthleteAndWeek(athleteId, year, calendarWeek),
|
||||
metricTotals,
|
||||
weekActivityMapStreams,
|
||||
weekActivityStreams
|
||||
);
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.peakup.goal;
|
||||
|
||||
import com.peakup.common.AbstractResource;
|
||||
import com.peakup.goal.model.Goal;
|
||||
import com.peakup.goal.service.GoalMetricPeriodTotalService;
|
||||
import com.peakup.goal.service.GoalService;
|
||||
import jakarta.annotation.Nullable;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
@@ -35,10 +34,6 @@ public class GoalResource extends AbstractResource {
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
GoalService goalService;
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
GoalMetricPeriodTotalService goalMetricPeriodTotalService;
|
||||
|
||||
@GET
|
||||
public RestResponse<List<Goal>> getByAthlete(@QueryParam("athleteId") @Nullable final Long athleteId) {
|
||||
if (athleteId == null) {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package com.peakup.goal.dao;
|
||||
|
||||
import com.peakup.common.dao.AbstractDAO;
|
||||
import com.peakup.goal.model.GoalMetricEnum;
|
||||
import com.peakup.goal.model.GoalMetricPeriodTotal;
|
||||
import com.peakup.goal.model.GoalMetricPeriodTotal_;
|
||||
import com.peakup.goal.model.GoalMetricEnum;
|
||||
import com.peakup.goal.model.GoalPeriodEnum;
|
||||
import jakarta.annotation.Nullable;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.persistence.criteria.CriteriaBuilder;
|
||||
@@ -12,8 +11,6 @@ import jakarta.persistence.criteria.CriteriaDelete;
|
||||
import jakarta.persistence.criteria.CriteriaQuery;
|
||||
import jakarta.persistence.criteria.Root;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@ApplicationScoped
|
||||
public class GoalMetricPeriodTotalDAO extends AbstractDAO<GoalMetricPeriodTotal> {
|
||||
|
||||
@@ -25,8 +22,8 @@ public class GoalMetricPeriodTotalDAO extends AbstractDAO<GoalMetricPeriodTotal>
|
||||
public @Nullable GoalMetricPeriodTotal findByKey(
|
||||
final long athleteId,
|
||||
final GoalMetricEnum metric,
|
||||
final GoalPeriodEnum period,
|
||||
final int year,
|
||||
final int month,
|
||||
final int calendarWeek) {
|
||||
final CriteriaBuilder cb = em.getCriteriaBuilder();
|
||||
final CriteriaQuery<GoalMetricPeriodTotal> cq = cb.createQuery(GoalMetricPeriodTotal.class);
|
||||
@@ -34,43 +31,63 @@ public class GoalMetricPeriodTotalDAO extends AbstractDAO<GoalMetricPeriodTotal>
|
||||
cq.select(root).where(
|
||||
cb.equal(root.get(GoalMetricPeriodTotal_.athleteId), athleteId),
|
||||
cb.equal(root.get(GoalMetricPeriodTotal_.metric), metric),
|
||||
cb.equal(root.get(GoalMetricPeriodTotal_.period), period),
|
||||
cb.equal(root.get(GoalMetricPeriodTotal_.year), year),
|
||||
cb.equal(root.get(GoalMetricPeriodTotal_.month), month),
|
||||
cb.equal(root.get(GoalMetricPeriodTotal_.calendarWeek), calendarWeek)
|
||||
);
|
||||
return em.createQuery(cq).getResultStream().findFirst().orElse(null);
|
||||
}
|
||||
|
||||
public @Nullable GoalMetricPeriodTotal findLastWeeklyByAthlete(final long athleteId) {
|
||||
public @Nullable Long findMaxActivityId(final long athleteId, final GoalMetricEnum metric) {
|
||||
final CriteriaBuilder cb = em.getCriteriaBuilder();
|
||||
final CriteriaQuery<GoalMetricPeriodTotal> cq = cb.createQuery(GoalMetricPeriodTotal.class);
|
||||
final CriteriaQuery<Long> cq = cb.createQuery(Long.class);
|
||||
final Root<GoalMetricPeriodTotal> root = cq.from(GoalMetricPeriodTotal.class);
|
||||
cq.select(root)
|
||||
.where(
|
||||
cb.equal(root.get(GoalMetricPeriodTotal_.athleteId), athleteId),
|
||||
cb.equal(root.get(GoalMetricPeriodTotal_.period), GoalPeriodEnum.WEEKLY),
|
||||
cb.equal(root.get(GoalMetricPeriodTotal_.metric), GoalMetricEnum.DISTANCE)
|
||||
)
|
||||
.orderBy(
|
||||
cb.desc(root.get(GoalMetricPeriodTotal_.year)),
|
||||
cb.desc(root.get(GoalMetricPeriodTotal_.calendarWeek))
|
||||
);
|
||||
return em.createQuery(cq).setMaxResults(1).getResultStream().findFirst().orElse(null);
|
||||
cq.select(cb.max(root.get(GoalMetricPeriodTotal_.lastActivityId))).where(
|
||||
cb.equal(root.get(GoalMetricPeriodTotal_.athleteId), athleteId),
|
||||
cb.equal(root.get(GoalMetricPeriodTotal_.metric), metric)
|
||||
);
|
||||
return em.createQuery(cq).getSingleResult();
|
||||
}
|
||||
|
||||
public List<GoalMetricPeriodTotal> findByAthleteForDashboard(
|
||||
final long athleteId,
|
||||
final int year,
|
||||
final int calendarWeek) {
|
||||
public double sumByWeek(final long athleteId, final GoalMetricEnum metric, final int isoWeekYear, final int calendarWeek) {
|
||||
final CriteriaBuilder cb = em.getCriteriaBuilder();
|
||||
final CriteriaQuery<GoalMetricPeriodTotal> cq = cb.createQuery(GoalMetricPeriodTotal.class);
|
||||
final CriteriaQuery<Double> cq = cb.createQuery(Double.class);
|
||||
final Root<GoalMetricPeriodTotal> root = cq.from(GoalMetricPeriodTotal.class);
|
||||
cq.select(root).where(
|
||||
cq.select(cb.sum(root.get(GoalMetricPeriodTotal_.aggregatedValue))).where(
|
||||
cb.equal(root.get(GoalMetricPeriodTotal_.athleteId), athleteId),
|
||||
cb.equal(root.get(GoalMetricPeriodTotal_.year), year),
|
||||
cb.equal(root.get(GoalMetricPeriodTotal_.metric), metric),
|
||||
cb.equal(root.get(GoalMetricPeriodTotal_.isoWeekYear), isoWeekYear),
|
||||
cb.equal(root.get(GoalMetricPeriodTotal_.calendarWeek), calendarWeek)
|
||||
);
|
||||
return em.createQuery(cq).getResultList();
|
||||
final Double result = em.createQuery(cq).getSingleResult();
|
||||
return result != null ? result : 0.0;
|
||||
}
|
||||
|
||||
public double sumByMonth(final long athleteId, final GoalMetricEnum metric, final int year, final int month) {
|
||||
final CriteriaBuilder cb = em.getCriteriaBuilder();
|
||||
final CriteriaQuery<Double> cq = cb.createQuery(Double.class);
|
||||
final Root<GoalMetricPeriodTotal> root = cq.from(GoalMetricPeriodTotal.class);
|
||||
cq.select(cb.sum(root.get(GoalMetricPeriodTotal_.aggregatedValue))).where(
|
||||
cb.equal(root.get(GoalMetricPeriodTotal_.athleteId), athleteId),
|
||||
cb.equal(root.get(GoalMetricPeriodTotal_.metric), metric),
|
||||
cb.equal(root.get(GoalMetricPeriodTotal_.year), year),
|
||||
cb.equal(root.get(GoalMetricPeriodTotal_.month), month)
|
||||
);
|
||||
final Double result = em.createQuery(cq).getSingleResult();
|
||||
return result != null ? result : 0.0;
|
||||
}
|
||||
|
||||
public double sumByYear(final long athleteId, final GoalMetricEnum metric, final int year) {
|
||||
final CriteriaBuilder cb = em.getCriteriaBuilder();
|
||||
final CriteriaQuery<Double> cq = cb.createQuery(Double.class);
|
||||
final Root<GoalMetricPeriodTotal> root = cq.from(GoalMetricPeriodTotal.class);
|
||||
cq.select(cb.sum(root.get(GoalMetricPeriodTotal_.aggregatedValue))).where(
|
||||
cb.equal(root.get(GoalMetricPeriodTotal_.athleteId), athleteId),
|
||||
cb.equal(root.get(GoalMetricPeriodTotal_.metric), metric),
|
||||
cb.equal(root.get(GoalMetricPeriodTotal_.year), year)
|
||||
);
|
||||
final Double result = em.createQuery(cq).getSingleResult();
|
||||
return result != null ? result : 0.0;
|
||||
}
|
||||
|
||||
public void deleteByAthleteId(final long athleteId) {
|
||||
@@ -80,24 +97,4 @@ public class GoalMetricPeriodTotalDAO extends AbstractDAO<GoalMetricPeriodTotal>
|
||||
cd.where(cb.equal(root.get(GoalMetricPeriodTotal_.athleteId), athleteId));
|
||||
em.createQuery(cd).executeUpdate();
|
||||
}
|
||||
|
||||
public List<GoalMetricPeriodTotal> findByAthleteAndMetricAndPeriod(
|
||||
final long athleteId,
|
||||
final GoalMetricEnum metric,
|
||||
final GoalPeriodEnum period) {
|
||||
final CriteriaBuilder cb = em.getCriteriaBuilder();
|
||||
final CriteriaQuery<GoalMetricPeriodTotal> cq = cb.createQuery(GoalMetricPeriodTotal.class);
|
||||
final Root<GoalMetricPeriodTotal> root = cq.from(GoalMetricPeriodTotal.class);
|
||||
cq.select(root)
|
||||
.where(
|
||||
cb.equal(root.get(GoalMetricPeriodTotal_.athleteId), athleteId),
|
||||
cb.equal(root.get(GoalMetricPeriodTotal_.metric), metric),
|
||||
cb.equal(root.get(GoalMetricPeriodTotal_.period), period)
|
||||
)
|
||||
.orderBy(
|
||||
cb.asc(root.get(GoalMetricPeriodTotal_.year)),
|
||||
cb.asc(root.get(GoalMetricPeriodTotal_.calendarWeek))
|
||||
);
|
||||
return em.createQuery(cq).getResultList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.peakup.goal.model;
|
||||
|
||||
import com.peakup.common.model.AbstractTimestampedEntity;
|
||||
import jakarta.annotation.Nullable;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Table;
|
||||
@@ -10,7 +11,8 @@ import lombok.EqualsAndHashCode;
|
||||
|
||||
@Entity
|
||||
@Table(name = "goal_metric_period_total", uniqueConstraints = @UniqueConstraint(
|
||||
columnNames = {"athlete_id", "metric", "period", "year", "calendar_week"}))
|
||||
name = "uq_goal_metric_period_total",
|
||||
columnNames = {"athlete_id", "metric", "year", "month", "calendar_week"}))
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class GoalMetricPeriodTotal extends AbstractTimestampedEntity {
|
||||
@@ -21,18 +23,28 @@ public class GoalMetricPeriodTotal extends AbstractTimestampedEntity {
|
||||
@Column(name = "metric", nullable = false)
|
||||
private GoalMetricEnum metric;
|
||||
|
||||
@Column(name = "period", nullable = false)
|
||||
private GoalPeriodEnum period;
|
||||
|
||||
// calendar year of the activity date
|
||||
@Column(name = "year", nullable = false)
|
||||
private int year;
|
||||
|
||||
// calendar month of the activity date (1–12)
|
||||
@Column(name = "month", nullable = false)
|
||||
private int month;
|
||||
|
||||
// ISO week number (1–52/53); combined with iso_week_year for weekly queries
|
||||
@Column(name = "calendar_week", nullable = false)
|
||||
private int calendarWeek;
|
||||
|
||||
// ISO week-based year; equals year except for cross-year boundary weeks
|
||||
@Column(name = "iso_week_year", nullable = false)
|
||||
private int isoWeekYear;
|
||||
|
||||
@Column(name = "aggregated_value", nullable = false)
|
||||
private double aggregatedValue;
|
||||
|
||||
@Column(name = "last_activity_id")
|
||||
private @Nullable Long lastActivityId;
|
||||
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
public GoalMetricPeriodTotal() {
|
||||
// hibernate
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.peakup.goal.model;
|
||||
|
||||
public record GoalMetricTotals(
|
||||
GoalMetricEnum metric,
|
||||
double weeklyTotal,
|
||||
double monthlyTotal,
|
||||
double yearlyTotal) {
|
||||
}
|
||||
@@ -6,216 +6,96 @@ import com.peakup.common.service.AbstractService;
|
||||
import com.peakup.goal.dao.GoalMetricPeriodTotalDAO;
|
||||
import com.peakup.goal.model.GoalMetricEnum;
|
||||
import com.peakup.goal.model.GoalMetricPeriodTotal;
|
||||
import com.peakup.goal.model.GoalPeriodEnum;
|
||||
import jakarta.annotation.Nullable;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.transaction.Transactional;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.temporal.WeekFields;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@ApplicationScoped
|
||||
@Slf4j
|
||||
public class GoalMetricPeriodTotalService extends AbstractService<GoalMetricPeriodTotal, GoalMetricPeriodTotalDAO> {
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
GoalMetricPeriodTotalDAO goalMetricAggregateDAO;
|
||||
GoalMetricPeriodTotalDAO goalMetricPeriodTotalDAO;
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
IntervalsActivityService intervalsActivityService;
|
||||
|
||||
private static double snapshotValue(final @Nullable GoalMetricPeriodTotal snapshot) {
|
||||
return snapshot != null ? snapshot.getAggregatedValue() : 0.0;
|
||||
}
|
||||
|
||||
private static boolean sameMonth(final LocalDate a, final LocalDate b) {
|
||||
return a.getYear() == b.getYear() && a.getMonthValue() == b.getMonthValue();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected GoalMetricPeriodTotalDAO getDAO() {
|
||||
return goalMetricAggregateDAO;
|
||||
return goalMetricPeriodTotalDAO;
|
||||
}
|
||||
|
||||
@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);
|
||||
@Transactional
|
||||
public void recalculateIncremental(final long athleteId) {
|
||||
for (final GoalMetricEnum metric : GoalMetricEnum.values()) {
|
||||
recalculateMetricForWeek(athleteId, metric, weekActivities, year, calendarWeek);
|
||||
recalculateMetricIncremental(athleteId, metric);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void recalculateForAthlete(final long athleteId, final GoalMetricEnum metric, final int year, final int calendarWeek) {
|
||||
final List<IntervalsActivity> weekActivities = fetchWeekActivities(athleteId, year, calendarWeek);
|
||||
recalculateMetricForWeek(athleteId, metric, weekActivities, year, calendarWeek);
|
||||
}
|
||||
private void recalculateMetricIncremental(final long athleteId, final GoalMetricEnum metric) {
|
||||
final @Nullable Long lastActivityId = goalMetricPeriodTotalDAO.findMaxActivityId(athleteId, metric);
|
||||
|
||||
@Transactional
|
||||
public @Nullable GoalMetricPeriodTotal getAggregate(
|
||||
final long athleteId,
|
||||
final GoalMetricEnum metric,
|
||||
final GoalPeriodEnum period,
|
||||
final int year,
|
||||
final int calendarWeek) {
|
||||
return goalMetricAggregateDAO.findByKey(athleteId, metric, period, year, calendarWeek);
|
||||
}
|
||||
final List<IntervalsActivity> activities = lastActivityId == null
|
||||
? intervalsActivityService.getAllByAthleteId(athleteId)
|
||||
: intervalsActivityService.getByAthleteIdAfterId(athleteId, lastActivityId);
|
||||
|
||||
@Transactional
|
||||
public List<GoalMetricPeriodTotal> getAggregates(
|
||||
final long athleteId,
|
||||
final GoalMetricEnum metric,
|
||||
final GoalPeriodEnum period) {
|
||||
return goalMetricAggregateDAO.findByAthleteAndMetricAndPeriod(athleteId, metric, period);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public List<GoalMetricPeriodTotal> getByAthleteAndWeek(final long athleteId, final int year, final int calendarWeek) {
|
||||
return goalMetricAggregateDAO.findByAthleteForDashboard(athleteId, year, calendarWeek);
|
||||
}
|
||||
|
||||
private List<IntervalsActivity> fetchWeekActivities(final long athleteId, final int year, final int calendarWeek) {
|
||||
final LocalDate weekStart = LocalDate.of(year, 1, 4)
|
||||
.with(WeekFields.ISO.weekOfWeekBasedYear(), calendarWeek)
|
||||
.with(WeekFields.ISO.dayOfWeek(), 1);
|
||||
final LocalDate weekEnd = weekStart.plusDays(6);
|
||||
return intervalsActivityService.getByAthleteIdBetween(athleteId, weekStart, weekEnd);
|
||||
}
|
||||
|
||||
public void recalculateUpToCurrentWeek(final long athleteId) {
|
||||
final LocalDate today = LocalDate.now(ZoneOffset.UTC);
|
||||
final LocalDate startDate = resolveStartDate(athleteId);
|
||||
if (startDate == null) {
|
||||
if (activities.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
LocalDate weekStart = startDate;
|
||||
while (!weekStart.isAfter(today)) {
|
||||
recalculateForAthlete(athleteId,
|
||||
weekStart.get(WeekFields.ISO.weekBasedYear()),
|
||||
weekStart.get(WeekFields.ISO.weekOfWeekBasedYear()));
|
||||
weekStart = weekStart.plusWeeks(1);
|
||||
|
||||
for (final IntervalsActivity activity : activities) {
|
||||
final LocalDate date = activity.getStartTime().atZone(ZoneOffset.UTC).toLocalDate();
|
||||
final int year = date.getYear();
|
||||
final int month = date.getMonthValue();
|
||||
final int calendarWeek = date.get(WeekFields.ISO.weekOfWeekBasedYear());
|
||||
final int isoWeekYear = date.get(WeekFields.ISO.weekBasedYear());
|
||||
final double value = intervalsActivityService.extractMetricValue(activity, metric);
|
||||
|
||||
final GoalMetricPeriodTotal existing = goalMetricPeriodTotalDAO.findByKey(athleteId, metric, year, month, calendarWeek);
|
||||
if (existing != null) {
|
||||
existing.setAggregatedValue(existing.getAggregatedValue() + value);
|
||||
existing.setLastActivityId(activity.getId());
|
||||
} else {
|
||||
final GoalMetricPeriodTotal row = new GoalMetricPeriodTotal();
|
||||
row.setAthleteId(athleteId);
|
||||
row.setMetric(metric);
|
||||
row.setYear(year);
|
||||
row.setMonth(month);
|
||||
row.setCalendarWeek(calendarWeek);
|
||||
row.setIsoWeekYear(isoWeekYear);
|
||||
row.setAggregatedValue(value);
|
||||
row.setLastActivityId(activity.getId());
|
||||
insert(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Nullable
|
||||
LocalDate resolveStartDate(final long athleteId) {
|
||||
final GoalMetricPeriodTotal lastWeekly = goalMetricAggregateDAO.findLastWeeklyByAthlete(athleteId);
|
||||
if (lastWeekly != null) {
|
||||
return LocalDate.of(lastWeekly.getYear(), 1, 4)
|
||||
.with(WeekFields.ISO.weekOfWeekBasedYear(), lastWeekly.getCalendarWeek())
|
||||
.with(WeekFields.ISO.dayOfWeek(), 1);
|
||||
}
|
||||
final IntervalsActivity earliestActivity = intervalsActivityService.getEarliestActivity(athleteId);
|
||||
if (earliestActivity == null) {
|
||||
return null;
|
||||
}
|
||||
return earliestActivity.getStartTime().atZone(ZoneOffset.UTC).toLocalDate()
|
||||
.with(WeekFields.ISO.dayOfWeek(), 1);
|
||||
public double getWeeklyTotal(final long athleteId, final GoalMetricEnum metric, final int isoWeekYear, final int calendarWeek) {
|
||||
return goalMetricPeriodTotalDAO.sumByWeek(athleteId, metric, isoWeekYear, calendarWeek);
|
||||
}
|
||||
|
||||
private void recalculateMetricForWeek(
|
||||
final long athleteId,
|
||||
final GoalMetricEnum metric,
|
||||
final List<IntervalsActivity> weekActivities,
|
||||
final int targetYear,
|
||||
final int targetCalendarWeek) {
|
||||
// WEEKLY: recompute fresh from activities; apply delta to MONTHLY/YEARLY to keep them idempotent.
|
||||
// MONTHLY/YEARLY are attributed to the week's Monday — a slight simplification at month boundaries.
|
||||
final Map<PeriodKey, Double> weeklySums = new HashMap<>();
|
||||
for (final IntervalsActivity activity : weekActivities) {
|
||||
final LocalDate date = activity.getStartTime().atZone(ZoneOffset.UTC).toLocalDate();
|
||||
final PeriodKey key = new PeriodKey(
|
||||
GoalPeriodEnum.WEEKLY.getPeriodYear(date),
|
||||
GoalPeriodEnum.WEEKLY.getPeriodIndex(date));
|
||||
weeklySums.merge(key, intervalsActivityService.extractMetricValue(activity, metric), Double::sum);
|
||||
}
|
||||
if (weeklySums.isEmpty()) {
|
||||
final boolean weeklyExists = goalMetricAggregateDAO.findByKey(
|
||||
athleteId, metric, GoalPeriodEnum.WEEKLY, targetYear, targetCalendarWeek) != null;
|
||||
if (!weeklyExists) {
|
||||
final LocalDate weekStartDate = LocalDate.of(targetYear, 1, 4)
|
||||
.with(WeekFields.ISO.weekOfWeekBasedYear(), targetCalendarWeek)
|
||||
.with(WeekFields.ISO.dayOfWeek(), 1);
|
||||
final LocalDate prevMonday = weekStartDate.minusWeeks(1);
|
||||
final int prevYear = prevMonday.get(WeekFields.ISO.weekBasedYear());
|
||||
final int prevCalendarWeek = prevMonday.get(WeekFields.ISO.weekOfWeekBasedYear());
|
||||
|
||||
upsertAggregate(athleteId, metric, GoalPeriodEnum.WEEKLY, targetYear, targetCalendarWeek, 0.0);
|
||||
|
||||
final double monthlyBase = sameMonth(prevMonday, weekStartDate)
|
||||
? snapshotValue(goalMetricAggregateDAO.findByKey(athleteId, metric, GoalPeriodEnum.MONTHLY, prevYear, prevCalendarWeek))
|
||||
: 0.0;
|
||||
upsertAggregate(athleteId, metric, GoalPeriodEnum.MONTHLY, targetYear, targetCalendarWeek, monthlyBase);
|
||||
|
||||
final double yearlyBase = prevMonday.getYear() == weekStartDate.getYear()
|
||||
? snapshotValue(goalMetricAggregateDAO.findByKey(athleteId, metric, GoalPeriodEnum.YEARLY, prevYear, prevCalendarWeek))
|
||||
: 0.0;
|
||||
upsertAggregate(athleteId, metric, GoalPeriodEnum.YEARLY, targetYear, targetCalendarWeek, yearlyBase);
|
||||
}
|
||||
} else {
|
||||
for (final Map.Entry<PeriodKey, Double> entry : weeklySums.entrySet()) {
|
||||
final int weekYear = entry.getKey().year();
|
||||
final int weekIndex = entry.getKey().calendarWeek();
|
||||
final double newWeeklyValue = entry.getValue();
|
||||
|
||||
final GoalMetricPeriodTotal previousWeekly = goalMetricAggregateDAO.findByKey(
|
||||
athleteId, metric, GoalPeriodEnum.WEEKLY, weekYear, weekIndex);
|
||||
final double previousWeeklyValue = previousWeekly != null ? previousWeekly.getAggregatedValue() : 0.0;
|
||||
final double weeklyDelta = newWeeklyValue - previousWeeklyValue;
|
||||
|
||||
upsertAggregate(athleteId, metric, GoalPeriodEnum.WEEKLY, weekYear, weekIndex, newWeeklyValue);
|
||||
|
||||
if (weeklyDelta != 0.0) {
|
||||
final LocalDate weekStartDate = LocalDate.of(weekYear, 1, 4)
|
||||
.with(WeekFields.ISO.weekOfWeekBasedYear(), weekIndex)
|
||||
.with(WeekFields.ISO.dayOfWeek(), 1);
|
||||
final LocalDate prevMonday = weekStartDate.minusWeeks(1);
|
||||
final int prevYear = prevMonday.get(WeekFields.ISO.weekBasedYear());
|
||||
final int prevCalendarWeek = prevMonday.get(WeekFields.ISO.weekOfWeekBasedYear());
|
||||
|
||||
final double monthlyBase = sameMonth(prevMonday, weekStartDate)
|
||||
? snapshotValue(goalMetricAggregateDAO.findByKey(athleteId, metric, GoalPeriodEnum.MONTHLY, prevYear, prevCalendarWeek))
|
||||
: 0.0;
|
||||
upsertAggregate(athleteId, metric, GoalPeriodEnum.MONTHLY, weekYear, weekIndex, monthlyBase + weeklyDelta);
|
||||
|
||||
final double yearlyBase = prevMonday.getYear() == weekStartDate.getYear()
|
||||
? snapshotValue(goalMetricAggregateDAO.findByKey(athleteId, metric, GoalPeriodEnum.YEARLY, prevYear, prevCalendarWeek))
|
||||
: 0.0;
|
||||
upsertAggregate(athleteId, metric, GoalPeriodEnum.YEARLY, weekYear, weekIndex, yearlyBase + weeklyDelta);
|
||||
}
|
||||
}
|
||||
}
|
||||
@Transactional
|
||||
public double getMonthlyTotal(final long athleteId, final GoalMetricEnum metric, final int year, final int month) {
|
||||
return goalMetricPeriodTotalDAO.sumByMonth(athleteId, metric, year, month);
|
||||
}
|
||||
|
||||
private void upsertAggregate(
|
||||
final long athleteId,
|
||||
final GoalMetricEnum metric,
|
||||
final GoalPeriodEnum period,
|
||||
final int year,
|
||||
final int calendarWeek,
|
||||
final double value) {
|
||||
final GoalMetricPeriodTotal existing = goalMetricAggregateDAO.findByKey(athleteId, metric, period, year, calendarWeek);
|
||||
if (existing != null) {
|
||||
existing.setAggregatedValue(value);
|
||||
} else {
|
||||
final GoalMetricPeriodTotal aggregate = new GoalMetricPeriodTotal();
|
||||
aggregate.setAthleteId(athleteId);
|
||||
aggregate.setMetric(metric);
|
||||
aggregate.setPeriod(period);
|
||||
aggregate.setYear(year);
|
||||
aggregate.setCalendarWeek(calendarWeek);
|
||||
aggregate.setAggregatedValue(value);
|
||||
insert(aggregate);
|
||||
}
|
||||
@Transactional
|
||||
public double getYearlyTotal(final long athleteId, final GoalMetricEnum metric, final int year) {
|
||||
return goalMetricPeriodTotalDAO.sumByYear(athleteId, metric, year);
|
||||
}
|
||||
|
||||
private record PeriodKey(int year, int calendarWeek) {
|
||||
@Transactional
|
||||
public void deleteByAthleteId(final long athleteId) {
|
||||
goalMetricPeriodTotalDAO.deleteByAthleteId(athleteId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import com.peakup.goal.dao.GoalAchievementNotificationDAO;
|
||||
import com.peakup.goal.dao.MilestoneNotificationDAO;
|
||||
import com.peakup.goal.model.Goal;
|
||||
import com.peakup.goal.model.GoalAchievementNotification;
|
||||
import com.peakup.goal.model.GoalMetricPeriodTotal;
|
||||
import com.peakup.goal.model.GoalPeriodEnum;
|
||||
import com.peakup.goal.model.MilestoneNotification;
|
||||
import com.peakup.goal.model.MilestoneNotificationTypeEnum;
|
||||
@@ -184,35 +183,36 @@ public class GoalNotificationService {
|
||||
final long athleteId,
|
||||
final Athlete athlete,
|
||||
final LocalDate today,
|
||||
final int currentYear,
|
||||
final int currentIsoWeekYear,
|
||||
final int currentCalendarWeek) {
|
||||
final int notificationYear;
|
||||
final int periodIndex;
|
||||
|
||||
if (goal.getPeriod() == GoalPeriodEnum.WEEKLY) {
|
||||
notificationYear = currentYear;
|
||||
periodIndex = currentCalendarWeek;
|
||||
} else if (goal.getPeriod() == GoalPeriodEnum.MONTHLY) {
|
||||
notificationYear = today.getYear();
|
||||
periodIndex = today.getMonthValue();
|
||||
} else {
|
||||
notificationYear = today.getYear();
|
||||
periodIndex = 1;
|
||||
}
|
||||
|
||||
final Long goalId = goal.getId();
|
||||
if (goalId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final int notificationYear;
|
||||
final int periodIndex;
|
||||
final double aggregatedValue;
|
||||
|
||||
if (goal.getPeriod() == GoalPeriodEnum.WEEKLY) {
|
||||
notificationYear = currentIsoWeekYear;
|
||||
periodIndex = currentCalendarWeek;
|
||||
aggregatedValue = goalMetricPeriodTotalService.getWeeklyTotal(athleteId, goal.getMetric(), currentIsoWeekYear, currentCalendarWeek);
|
||||
} else if (goal.getPeriod() == GoalPeriodEnum.MONTHLY) {
|
||||
notificationYear = today.getYear();
|
||||
periodIndex = today.getMonthValue();
|
||||
aggregatedValue = goalMetricPeriodTotalService.getMonthlyTotal(athleteId, goal.getMetric(), today.getYear(), today.getMonthValue());
|
||||
} else {
|
||||
notificationYear = today.getYear();
|
||||
periodIndex = 1;
|
||||
aggregatedValue = goalMetricPeriodTotalService.getYearlyTotal(athleteId, goal.getMetric(), today.getYear());
|
||||
}
|
||||
|
||||
if (goalAchievementNotificationDAO.existsByKey(athleteId, goalId, notificationYear, periodIndex)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final GoalMetricPeriodTotal total = goalMetricPeriodTotalService.getAggregate(
|
||||
athleteId, goal.getMetric(), goal.getPeriod(), currentYear, currentCalendarWeek);
|
||||
|
||||
if (total == null || total.getAggregatedValue() < goal.getTargetValue()) {
|
||||
if (aggregatedValue < goal.getTargetValue()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -222,7 +222,7 @@ public class GoalNotificationService {
|
||||
sendOrLog(athlete.getEmail(), subject, Templates.goalAchievement(
|
||||
athlete.getFirstName(), periodLabel, metricLabel,
|
||||
String.format("%.2f", goal.getTargetValue()),
|
||||
String.format("%.2f", total.getAggregatedValue())));
|
||||
String.format("%.2f", aggregatedValue)));
|
||||
|
||||
final GoalAchievementNotification notification = new GoalAchievementNotification();
|
||||
notification.setAthleteId(athleteId);
|
||||
@@ -240,7 +240,7 @@ public class GoalNotificationService {
|
||||
final long athleteId,
|
||||
final Athlete athlete,
|
||||
final LocalDate today,
|
||||
final int currentYear,
|
||||
final int currentIsoWeekYear,
|
||||
final int currentCalendarWeek) {
|
||||
final Long goalId = goal.getId();
|
||||
if (goalId == null) {
|
||||
@@ -249,16 +249,20 @@ public class GoalNotificationService {
|
||||
|
||||
final int notificationYear;
|
||||
final int periodIndex;
|
||||
final double aggregatedValue;
|
||||
|
||||
if (goal.getPeriod() == GoalPeriodEnum.WEEKLY) {
|
||||
notificationYear = currentYear;
|
||||
notificationYear = currentIsoWeekYear;
|
||||
periodIndex = currentCalendarWeek;
|
||||
aggregatedValue = goalMetricPeriodTotalService.getWeeklyTotal(athleteId, goal.getMetric(), currentIsoWeekYear, currentCalendarWeek);
|
||||
} else if (goal.getPeriod() == GoalPeriodEnum.MONTHLY) {
|
||||
notificationYear = today.getYear();
|
||||
periodIndex = today.getMonthValue();
|
||||
aggregatedValue = goalMetricPeriodTotalService.getMonthlyTotal(athleteId, goal.getMetric(), today.getYear(), today.getMonthValue());
|
||||
} else {
|
||||
notificationYear = today.getYear();
|
||||
periodIndex = 1;
|
||||
aggregatedValue = goalMetricPeriodTotalService.getYearlyTotal(athleteId, goal.getMetric(), today.getYear());
|
||||
}
|
||||
|
||||
if (milestoneNotificationDAO.existsByKey(
|
||||
@@ -266,18 +270,11 @@ public class GoalNotificationService {
|
||||
return;
|
||||
}
|
||||
|
||||
final GoalMetricPeriodTotal total = goalMetricPeriodTotalService.getAggregate(
|
||||
athleteId, goal.getMetric(), goal.getPeriod(), currentYear, currentCalendarWeek);
|
||||
|
||||
if (total == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final double aggregatedValue = total.getAggregatedValue();
|
||||
if (aggregatedValue < goalProgressThreshold * goal.getTargetValue()
|
||||
|| aggregatedValue >= goal.getTargetValue()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final int percentage = (int) (goalProgressThreshold * 100);
|
||||
final String periodLabel = goal.getPeriod().name().toLowerCase(Locale.ROOT);
|
||||
final String metricLabel = goal.getMetric().name().toLowerCase(Locale.ROOT);
|
||||
|
||||
@@ -91,15 +91,17 @@ public class Scheduler {
|
||||
|
||||
for (final IntervalsConnector connector : intervalsConnectorService.getIntervalsConnectors()) {
|
||||
@Nullable final ScheduledTask task = scheduledTaskService.findByAthleteIdAndTaskType(connector.getAthleteId(), TaskTypeEnum.RECALCULATE_METRIC).orElse(null);
|
||||
if (task == null) {
|
||||
continue;
|
||||
if (task != null) {
|
||||
goalMetricPeriodTotalService.deleteByAthleteId(connector.getAthleteId());
|
||||
}
|
||||
try {
|
||||
goalMetricPeriodTotalService.recalculateUpToCurrentWeek(connector.getAthleteId());
|
||||
goalMetricPeriodTotalService.recalculateIncremental(connector.getAthleteId());
|
||||
} catch (final Exception e) {
|
||||
log.error("Failed to recalculate goal metric period totals for athleteId {}.", connector.getAthleteId(), e);
|
||||
} finally {
|
||||
scheduledTaskService.delete(task);
|
||||
if (task != null) {
|
||||
scheduledTaskService.delete(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (final IntervalsConnector connector : intervalsConnectorService.getIntervalsConnectors()) {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE goal_metric_period_total
|
||||
ADD COLUMN last_processed_start_time TIMESTAMPTZ NULL,
|
||||
ADD COLUMN last_activity_id BIGINT NULL;
|
||||
@@ -0,0 +1,12 @@
|
||||
DELETE FROM goal_metric_period_total;
|
||||
|
||||
ALTER TABLE goal_metric_period_total
|
||||
DROP CONSTRAINT uq_goal_metric_period_total_snapshot,
|
||||
DROP COLUMN period,
|
||||
DROP COLUMN last_processed_start_time,
|
||||
ADD COLUMN month INT NOT NULL,
|
||||
ADD COLUMN iso_week_year INT NOT NULL;
|
||||
|
||||
ALTER TABLE goal_metric_period_total
|
||||
ADD CONSTRAINT uq_goal_metric_period_total
|
||||
UNIQUE (athlete_id, metric, year, month, calendar_week);
|
||||
@@ -3,8 +3,6 @@ package com.peakup.goal;
|
||||
import com.peakup.AbstractPeakUpTest;
|
||||
import com.peakup.PostgresTestProfile;
|
||||
import com.peakup.goal.model.GoalMetricEnum;
|
||||
import com.peakup.goal.model.GoalMetricPeriodTotal;
|
||||
import com.peakup.goal.model.GoalPeriodEnum;
|
||||
import com.peakup.goal.service.GoalMetricPeriodTotalService;
|
||||
import io.agroal.api.AgroalDataSource;
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
@@ -18,11 +16,8 @@ import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.temporal.WeekFields;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
@QuarkusTest
|
||||
@TestProfile(PostgresTestProfile.class)
|
||||
@@ -39,287 +34,151 @@ class GoalMetricPeriodTotalServiceTest extends AbstractPeakUpTest {
|
||||
AgroalDataSource dataSource;
|
||||
|
||||
@Test
|
||||
void recalculateCreatesAggregatesForAllPeriods() throws Exception {
|
||||
void recalculateCreatesWeeklyMonthlyAndYearlyTotals() throws Exception {
|
||||
insertActivity(1000.0, 100.0, 50.0, 3600L);
|
||||
|
||||
goalMetricPeriodTotalService.recalculateIncremental(ATHLETE_ID);
|
||||
|
||||
final LocalDate today = LocalDate.now(ZoneOffset.UTC);
|
||||
final int weekYear = today.get(WeekFields.ISO.weekBasedYear());
|
||||
final int weekIndex = today.get(WeekFields.ISO.weekOfWeekBasedYear());
|
||||
final int isoWeekYear = today.get(WeekFields.ISO.weekBasedYear());
|
||||
final int calendarWeek = today.get(WeekFields.ISO.weekOfWeekBasedYear());
|
||||
|
||||
goalMetricPeriodTotalService.recalculateForAthlete(ATHLETE_ID, weekYear, weekIndex);
|
||||
|
||||
final GoalMetricPeriodTotal weekly = goalMetricPeriodTotalService.getAggregate(
|
||||
ATHLETE_ID, GoalMetricEnum.DISTANCE, GoalPeriodEnum.WEEKLY, weekYear, weekIndex);
|
||||
assertNotNull(weekly);
|
||||
assertEquals(1000.0, weekly.getAggregatedValue());
|
||||
|
||||
final GoalMetricPeriodTotal monthly = goalMetricPeriodTotalService.getAggregate(
|
||||
ATHLETE_ID, GoalMetricEnum.DISTANCE, GoalPeriodEnum.MONTHLY, weekYear, weekIndex);
|
||||
assertNotNull(monthly);
|
||||
assertEquals(1000.0, monthly.getAggregatedValue());
|
||||
|
||||
final GoalMetricPeriodTotal yearly = goalMetricPeriodTotalService.getAggregate(
|
||||
ATHLETE_ID, GoalMetricEnum.DISTANCE, GoalPeriodEnum.YEARLY, weekYear, weekIndex);
|
||||
assertNotNull(yearly);
|
||||
assertEquals(1000.0, yearly.getAggregatedValue());
|
||||
assertEquals(1000.0, goalMetricPeriodTotalService.getWeeklyTotal(ATHLETE_ID, GoalMetricEnum.DISTANCE, isoWeekYear, calendarWeek));
|
||||
assertEquals(1000.0, goalMetricPeriodTotalService.getMonthlyTotal(ATHLETE_ID, GoalMetricEnum.DISTANCE, today.getYear(), today.getMonthValue()));
|
||||
assertEquals(1000.0, goalMetricPeriodTotalService.getYearlyTotal(ATHLETE_ID, GoalMetricEnum.DISTANCE, today.getYear()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void recalculateSumsMultipleActivities() throws Exception {
|
||||
void recalculateSumsMultipleActivitiesInSameWeek() throws Exception {
|
||||
insertActivity(1000.0, 100.0, 50.0, 3600L);
|
||||
insertActivity(500.0, 50.0, 25.0, 1800L);
|
||||
|
||||
goalMetricPeriodTotalService.recalculateIncremental(ATHLETE_ID);
|
||||
|
||||
final LocalDate today = LocalDate.now(ZoneOffset.UTC);
|
||||
goalMetricPeriodTotalService.recalculateForAthlete(ATHLETE_ID,
|
||||
today.get(WeekFields.ISO.weekBasedYear()), today.get(WeekFields.ISO.weekOfWeekBasedYear()));
|
||||
final GoalMetricPeriodTotal weekly = goalMetricPeriodTotalService.getAggregate(
|
||||
ATHLETE_ID, GoalMetricEnum.DISTANCE, GoalPeriodEnum.WEEKLY,
|
||||
today.get(WeekFields.ISO.weekBasedYear()), today.get(WeekFields.ISO.weekOfWeekBasedYear()));
|
||||
assertNotNull(weekly);
|
||||
assertEquals(1500.0, weekly.getAggregatedValue());
|
||||
assertEquals(1500.0, goalMetricPeriodTotalService.getWeeklyTotal(
|
||||
ATHLETE_ID, GoalMetricEnum.DISTANCE,
|
||||
today.get(WeekFields.ISO.weekBasedYear()),
|
||||
today.get(WeekFields.ISO.weekOfWeekBasedYear())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void recalculateIsIdempotent() throws Exception {
|
||||
insertActivity(1000.0, 100.0, 50.0, 3600L);
|
||||
|
||||
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, weekYear, weekIndex);
|
||||
goalMetricPeriodTotalService.recalculateForAthlete(ATHLETE_ID, weekYear, weekIndex);
|
||||
goalMetricPeriodTotalService.recalculateIncremental(ATHLETE_ID);
|
||||
goalMetricPeriodTotalService.recalculateIncremental(ATHLETE_ID);
|
||||
|
||||
final List<GoalMetricPeriodTotal> totals = goalMetricPeriodTotalService.getAggregates(
|
||||
ATHLETE_ID, GoalMetricEnum.DISTANCE, GoalPeriodEnum.WEEKLY);
|
||||
assertEquals(1, totals.size());
|
||||
assertEquals(1000.0, totals.get(0).getAggregatedValue());
|
||||
final LocalDate today = LocalDate.now(ZoneOffset.UTC);
|
||||
assertEquals(1000.0, goalMetricPeriodTotalService.getWeeklyTotal(
|
||||
ATHLETE_ID, GoalMetricEnum.DISTANCE,
|
||||
today.get(WeekFields.ISO.weekBasedYear()),
|
||||
today.get(WeekFields.ISO.weekOfWeekBasedYear())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void recalculateSingleMetricDoesNotAffectOtherMetrics() throws Exception {
|
||||
void eachMetricIsCalculatedIndependently() throws Exception {
|
||||
insertActivity(1000.0, 80.0, 200.0, 7200L);
|
||||
|
||||
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.TSS, weekYear, weekIndex);
|
||||
|
||||
final GoalMetricPeriodTotal tssTotal = goalMetricPeriodTotalService.getAggregate(
|
||||
ATHLETE_ID, GoalMetricEnum.TSS, GoalPeriodEnum.WEEKLY, weekYear, weekIndex);
|
||||
assertNotNull(tssTotal);
|
||||
assertEquals(80.0, tssTotal.getAggregatedValue());
|
||||
|
||||
assertNull(goalMetricPeriodTotalService.getAggregate(
|
||||
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);
|
||||
goalMetricPeriodTotalService.recalculateIncremental(ATHLETE_ID);
|
||||
|
||||
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 int isoWeekYear = today.get(WeekFields.ISO.weekBasedYear());
|
||||
final int calendarWeek = today.get(WeekFields.ISO.weekOfWeekBasedYear());
|
||||
|
||||
final GoalMetricPeriodTotal weekly = goalMetricPeriodTotalService.getAggregate(
|
||||
ATHLETE_ID, GoalMetricEnum.ELEVATION, GoalPeriodEnum.WEEKLY, weekYear, weekIndex);
|
||||
assertNotNull(weekly);
|
||||
assertEquals(350.0, weekly.getAggregatedValue());
|
||||
assertEquals(1000.0, goalMetricPeriodTotalService.getWeeklyTotal(ATHLETE_ID, GoalMetricEnum.DISTANCE, isoWeekYear, calendarWeek));
|
||||
assertEquals(80.0, goalMetricPeriodTotalService.getWeeklyTotal(ATHLETE_ID, GoalMetricEnum.TSS, isoWeekYear, calendarWeek));
|
||||
assertEquals(200.0, goalMetricPeriodTotalService.getWeeklyTotal(ATHLETE_ID, GoalMetricEnum.ELEVATION, isoWeekYear, calendarWeek));
|
||||
assertEquals(7200.0, goalMetricPeriodTotalService.getWeeklyTotal(ATHLETE_ID, GoalMetricEnum.TIME, isoWeekYear, calendarWeek));
|
||||
}
|
||||
|
||||
// ── Monthly totals ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void monthlyTotalSumsAcrossWeeks() throws Exception {
|
||||
// W10-2025 (Mon 2025-03-03) and W11-2025 (Mon 2025-03-10) — both March.
|
||||
insertActivityOnDate(LocalDate.of(2025, 3, 3), 1000.0);
|
||||
goalMetricPeriodTotalService.recalculateIncremental(ATHLETE_ID);
|
||||
|
||||
insertActivityOnDate(LocalDate.of(2025, 3, 10), 500.0);
|
||||
goalMetricPeriodTotalService.recalculateIncremental(ATHLETE_ID);
|
||||
|
||||
assertEquals(1500.0, goalMetricPeriodTotalService.getMonthlyTotal(ATHLETE_ID, GoalMetricEnum.DISTANCE, 2025, 3));
|
||||
}
|
||||
|
||||
@Test
|
||||
void timeMetricIsExtractedCorrectly() throws Exception {
|
||||
insertActivity(0.0, 0.0, 0.0, 5400L);
|
||||
void monthlyTotalResetsAtMonthBoundary() throws Exception {
|
||||
insertActivityOnDate(LocalDate.of(2025, 1, 27), 1000.0); // January
|
||||
goalMetricPeriodTotalService.recalculateIncremental(ATHLETE_ID);
|
||||
|
||||
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);
|
||||
insertActivityOnDate(LocalDate.of(2025, 2, 3), 500.0); // February
|
||||
goalMetricPeriodTotalService.recalculateIncremental(ATHLETE_ID);
|
||||
|
||||
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");
|
||||
assertEquals(1000.0, goalMetricPeriodTotalService.getMonthlyTotal(ATHLETE_ID, GoalMetricEnum.DISTANCE, 2025, 1));
|
||||
assertEquals(500.0, goalMetricPeriodTotalService.getMonthlyTotal(ATHLETE_ID, GoalMetricEnum.DISTANCE, 2025, 2));
|
||||
}
|
||||
|
||||
@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());
|
||||
void midWeekMonthBoundaryProducesTwoRows() throws Exception {
|
||||
// April 1 2025 is a Tuesday; ISO W14-2025 spans March 31 – April 6.
|
||||
insertActivityOnDate(LocalDate.of(2025, 3, 31), 800.0); // March — W14
|
||||
insertActivityOnDate(LocalDate.of(2025, 4, 3), 600.0); // April — W14
|
||||
goalMetricPeriodTotalService.recalculateIncremental(ATHLETE_ID);
|
||||
|
||||
// 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);
|
||||
assertEquals(800.0, goalMetricPeriodTotalService.getMonthlyTotal(ATHLETE_ID, GoalMetricEnum.DISTANCE, 2025, 3));
|
||||
assertEquals(600.0, goalMetricPeriodTotalService.getMonthlyTotal(ATHLETE_ID, GoalMetricEnum.DISTANCE, 2025, 4));
|
||||
|
||||
// Now call the full recalculate — it must find the lastWeekly and not start from scratch
|
||||
goalMetricPeriodTotalService.recalculateUpToCurrentWeek(ATHLETE_ID);
|
||||
// Weekly total spans both months
|
||||
assertEquals(1400.0, goalMetricPeriodTotalService.getWeeklyTotal(ATHLETE_ID, GoalMetricEnum.DISTANCE, 2025, 14));
|
||||
}
|
||||
|
||||
final GoalMetricPeriodTotal weekly = goalMetricPeriodTotalService.getAggregate(
|
||||
ATHLETE_ID, GoalMetricEnum.DISTANCE, GoalPeriodEnum.WEEKLY, currentWeekYear, currentWeekIndex);
|
||||
assertNotNull(weekly);
|
||||
assertEquals(800.0, weekly.getAggregatedValue());
|
||||
// ── Yearly totals ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void yearlyTotalSumsAcrossMonths() throws Exception {
|
||||
insertActivityOnDate(LocalDate.of(2025, 1, 6), 1000.0);
|
||||
insertActivityOnDate(LocalDate.of(2025, 6, 2), 2000.0);
|
||||
goalMetricPeriodTotalService.recalculateIncremental(ATHLETE_ID);
|
||||
|
||||
assertEquals(3000.0, goalMetricPeriodTotalService.getYearlyTotal(ATHLETE_ID, GoalMetricEnum.DISTANCE, 2025));
|
||||
}
|
||||
|
||||
@Test
|
||||
void yearlyTotalResetsAtYearBoundary() throws Exception {
|
||||
insertActivityOnDate(LocalDate.of(2024, 12, 15), 1000.0);
|
||||
insertActivityOnDate(LocalDate.of(2025, 1, 6), 500.0);
|
||||
goalMetricPeriodTotalService.recalculateIncremental(ATHLETE_ID);
|
||||
|
||||
assertEquals(1000.0, goalMetricPeriodTotalService.getYearlyTotal(ATHLETE_ID, GoalMetricEnum.DISTANCE, 2024));
|
||||
assertEquals(500.0, goalMetricPeriodTotalService.getYearlyTotal(ATHLETE_ID, GoalMetricEnum.DISTANCE, 2025));
|
||||
}
|
||||
|
||||
// ── Historical backfill ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void historicalActivityIsAddedToCorrectRow() throws Exception {
|
||||
// W11 processed first, then W10 inserted (higher id, earlier startTime).
|
||||
insertActivityOnDate(LocalDate.of(2025, 3, 10), 1000.0); // W11
|
||||
goalMetricPeriodTotalService.recalculateIncremental(ATHLETE_ID);
|
||||
|
||||
insertActivityOnDate(LocalDate.of(2025, 3, 3), 800.0); // W10 — historical
|
||||
goalMetricPeriodTotalService.recalculateIncremental(ATHLETE_ID);
|
||||
|
||||
assertEquals(800.0, goalMetricPeriodTotalService.getWeeklyTotal(ATHLETE_ID, GoalMetricEnum.DISTANCE, 2025, 10));
|
||||
assertEquals(1000.0, goalMetricPeriodTotalService.getWeeklyTotal(ATHLETE_ID, GoalMetricEnum.DISTANCE, 2025, 11));
|
||||
assertEquals(1800.0, goalMetricPeriodTotalService.getMonthlyTotal(ATHLETE_ID, GoalMetricEnum.DISTANCE, 2025, 3));
|
||||
}
|
||||
|
||||
// ── No activities ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void recalculateIncrementalIsNoOpWhenNoActivitiesExist() {
|
||||
goalMetricPeriodTotalService.recalculateIncremental(ATHLETE_ID);
|
||||
|
||||
assertEquals(0.0, goalMetricPeriodTotalService.getWeeklyTotal(
|
||||
ATHLETE_ID, GoalMetricEnum.DISTANCE,
|
||||
LocalDate.now(ZoneOffset.UTC).get(WeekFields.ISO.weekBasedYear()),
|
||||
LocalDate.now(ZoneOffset.UTC).get(WeekFields.ISO.weekOfWeekBasedYear())));
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
@@ -339,20 +198,15 @@ class GoalMetricPeriodTotalServiceTest extends AbstractPeakUpTest {
|
||||
}
|
||||
}
|
||||
|
||||
private void insertActivityOnDate(
|
||||
final LocalDate date,
|
||||
final double distanceM,
|
||||
final double trainingLoad,
|
||||
final double elevationM,
|
||||
final long movingTimeS) throws Exception {
|
||||
private void insertActivityOnDate(final LocalDate date, final double distanceM) 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));
|
||||
VALUES (%d, '%s', %f, 0.0, 0.0, 0, now(), now())
|
||||
""".formatted(ATHLETE_ID, startTime, distanceM));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,6 @@ import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.Statement;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.temporal.WeekFields;
|
||||
|
||||
@Tag("real-mail")
|
||||
@QuarkusTest
|
||||
@@ -43,10 +40,7 @@ class GoalNotificationRealMailTest extends AbstractPeakUpTest {
|
||||
insertGoal(GoalPeriodEnum.WEEKLY, GoalMetricEnum.DISTANCE, 1000.0);
|
||||
insertActivity(1500.0);
|
||||
|
||||
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, weekYear, weekIndex);
|
||||
goalMetricPeriodTotalService.recalculateIncremental(ATHLETE_ID);
|
||||
|
||||
goalNotificationService.checkAndNotify(ATHLETE_ID);
|
||||
}
|
||||
|
||||
@@ -16,9 +16,6 @@ import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.Statement;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.temporal.WeekFields;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
@@ -278,11 +275,7 @@ class GoalNotificationServiceTest extends AbstractPeakUpTest {
|
||||
}
|
||||
|
||||
private void recalculateCurrentWeek() {
|
||||
final LocalDate today = LocalDate.now(ZoneOffset.UTC);
|
||||
goalMetricPeriodTotalService.recalculateForAthlete(
|
||||
ATHLETE_ID,
|
||||
today.get(WeekFields.ISO.weekBasedYear()),
|
||||
today.get(WeekFields.ISO.weekOfWeekBasedYear()));
|
||||
goalMetricPeriodTotalService.recalculateIncremental(ATHLETE_ID);
|
||||
}
|
||||
|
||||
private void insertGoal(final GoalPeriodEnum period, final GoalMetricEnum metric, final double targetValue)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { AbstractDataObjectDo } from './AbstractDataObjectDo';
|
||||
import type { ActivitySummaryDo } from './ActivitySummaryDo';
|
||||
import type { GoalDo } from './GoalDo';
|
||||
import type { GoalMetricPeriodTotalDo } from './GoalMetricPeriodTotalDo';
|
||||
import type { GoalMetricTotalsDo } from './GoalMetricTotalsDo';
|
||||
import type { ActivityMapStreamPointDo } from './ActivityMapStreamPointDo';
|
||||
import type { ActivityStreamPointDo } from './ActivityStreamPointDo';
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface DashboardDo extends AbstractDataObjectDo {
|
||||
year: number;
|
||||
activities: ActivitySummaryDo[];
|
||||
goals: GoalDo[];
|
||||
goalMetricPeriodTotals: GoalMetricPeriodTotalDo[];
|
||||
goalMetricTotals: GoalMetricTotalsDo[];
|
||||
weekActivityMapStreams: Record<string, ActivityMapStreamPointDo[]>;
|
||||
weekActivityStreams: Record<string, ActivityStreamPointDo[]>;
|
||||
}
|
||||
|
||||
@@ -11,4 +11,5 @@ export interface GoalMetricPeriodTotalDo extends AbstractTimestampedEntityDo {
|
||||
year: number;
|
||||
calendarWeek: number;
|
||||
aggregatedValue: number;
|
||||
lastProcessedStartTime: string | null;
|
||||
}
|
||||
|
||||
10
peakup-frontend/src/types/GoalMetricTotalsDo.ts
Normal file
10
peakup-frontend/src/types/GoalMetricTotalsDo.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// GENERATED — do not edit manually.
|
||||
|
||||
import type { GoalMetricEnum } from './GoalMetricEnum';
|
||||
|
||||
export interface GoalMetricTotalsDo {
|
||||
metric: GoalMetricEnum;
|
||||
weeklyTotal: number;
|
||||
monthlyTotal: number;
|
||||
yearlyTotal: number;
|
||||
}
|
||||
Reference in New Issue
Block a user