GoalMetric

This commit is contained in:
2026-05-17 14:07:37 +02:00
parent 9cbab22580
commit bf07a8c64a
19 changed files with 327 additions and 537 deletions

View File

@@ -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);

View File

@@ -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();

View File

@@ -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;
}

View File

@@ -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
);

View File

@@ -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) {

View File

@@ -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();
}
}

View File

@@ -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

View File

@@ -0,0 +1,8 @@
package com.peakup.goal.model;
public record GoalMetricTotals(
GoalMetricEnum metric,
double weeklyTotal,
double monthlyTotal,
double yearlyTotal) {
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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()) {

View File

@@ -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;

View File

@@ -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);

View File

@@ -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));
}
}
}

View File

@@ -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);
}

View File

@@ -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)

View File

@@ -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[]>;
}

View File

@@ -11,4 +11,5 @@ export interface GoalMetricPeriodTotalDo extends AbstractTimestampedEntityDo {
year: number;
calendarWeek: number;
aggregatedValue: number;
lastProcessedStartTime: string | null;
}

View 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;
}