Dashboard

This commit is contained in:
2026-04-13 08:08:41 +02:00
parent f5acffb89b
commit 7692f479d6
7 changed files with 106 additions and 20 deletions

6
.idea/data_source_mapping.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourcePerFileMappings">
<file url="file://$PROJECT_DIR$/peakup-backend/src/main/resources/db/migration/V40__rename_goal_metric_aggregate_table.sql" value="146146a8-fc52-45aa-b5e3-48b7a37a3a5f" />
</component>
</project>

1
.idea/sqldialects.xml generated
View File

@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/peakup-backend/src/main/resources/db/migration/V40__rename_goal_metric_aggregate_table.sql" dialect="PostgreSQL" />
<file url="file://$PROJECT_DIR$/peakup-backend/src/test/resources/skripts.sql" dialect="GenericSQL" />
<file url="PROJECT" dialect="PostgreSQL" />
</component>

View File

@@ -23,6 +23,20 @@ public class IntervalsActivityDAO extends AbstractDAO<IntervalsActivity> {
return IntervalsActivity.class;
}
public @Nullable IntervalsActivity getEarliestActivity(final Long athleteId) {
final CriteriaBuilder cb = em.getCriteriaBuilder();
final CriteriaQuery<IntervalsActivity> cq = cb.createQuery(IntervalsActivity.class);
final Root<IntervalsActivity> root = cq.from(IntervalsActivity.class);
cq.select(root);
cq.where(cb.equal(root.get(AbstractActivity_.athleteId), athleteId));
cq.orderBy(cb.asc(root.get(AbstractActivity_.startTime)));
return em.createQuery(cq)
.setMaxResults(1)
.getResultStream()
.findFirst()
.orElse(null);
}
public @Nullable IntervalsActivity getLatestActivity(final Long athleteId) {
final CriteriaBuilder cb = em.getCriteriaBuilder();
final CriteriaQuery<IntervalsActivity> cq = cb.createQuery(IntervalsActivity.class);

View File

@@ -3,6 +3,7 @@ package com.peakup.activity.service;
import com.peakup.activity.dao.IntervalsActivityDAO;
import com.peakup.activity.model.IntervalsActivity;
import com.peakup.common.service.AbstractService;
import jakarta.annotation.Nullable;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
@@ -30,6 +31,11 @@ public class IntervalsActivityService extends AbstractService<IntervalsActivity,
return super.getById(id);
}
@Transactional
public @Nullable IntervalsActivity getEarliestActivity(final Long athleteId) {
return intervalsActivityDAO.getEarliestActivity(athleteId);
}
@Transactional
public List<IntervalsActivity> getAllByAthleteId(final Long athleteId) {
return intervalsActivityDAO.getAllByAthleteId(athleteId);

View File

@@ -40,6 +40,23 @@ public class GoalMetricPeriodTotalDAO extends AbstractDAO<GoalMetricPeriodTotal>
return em.createQuery(cq).getResultStream().findFirst().orElse(null);
}
public @Nullable GoalMetricPeriodTotal findLastWeeklyByAthlete(final long athleteId) {
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_.period), GoalPeriodEnum.WEEKLY),
cb.equal(root.get(GoalMetricPeriodTotal_.metric), GoalMetricEnum.DISTANCE)
)
.orderBy(
cb.desc(root.get(GoalMetricPeriodTotal_.periodYear)),
cb.desc(root.get(GoalMetricPeriodTotal_.periodIndex))
);
return em.createQuery(cq).setMaxResults(1).getResultStream().findFirst().orElse(null);
}
public List<GoalMetricPeriodTotal> findByAthleteAndMetricAndPeriod(
final long athleteId,
final GoalMetricEnum metric,

View File

@@ -75,11 +75,38 @@ public class GoalMetricPeriodTotalService extends AbstractService<GoalMetricPeri
return intervalsActivityService.getByAthleteIdBetween(athleteId, weekStart.toString(), weekEnd.toString());
}
@Transactional
public void recalculateUpToCurrentWeek(final long athleteId) {
final LocalDate today = LocalDate.now(ZoneOffset.UTC);
final GoalMetricPeriodTotal lastWeekly = goalMetricAggregateDAO.findLastWeeklyByAthlete(athleteId);
final LocalDate startDate;
if (lastWeekly != null) {
startDate = LocalDate.of(lastWeekly.getPeriodYear(), 1, 4)
.with(WeekFields.ISO.weekOfWeekBasedYear(), lastWeekly.getPeriodIndex())
.with(WeekFields.ISO.dayOfWeek(), 1);
} else {
final IntervalsActivity earliestActivity = intervalsActivityService.getEarliestActivity(athleteId);
if (earliestActivity == null) {
return;
}
startDate = earliestActivity.getStartTime().atZone(ZoneOffset.UTC).toLocalDate()
.with(WeekFields.ISO.dayOfWeek(), 1);
}
LocalDate weekStart = startDate;
while (!weekStart.isAfter(today)) {
recalculateForAthlete(athleteId,
weekStart.get(WeekFields.ISO.weekBasedYear()),
weekStart.get(WeekFields.ISO.weekOfWeekBasedYear()));
weekStart = weekStart.plusWeeks(1);
}
}
private void recalculateMetricForWeek(
final long athleteId,
final GoalMetricEnum metric,
final List<IntervalsActivity> weekActivities) {
// WEEKLY: always recompute fresh from this week's activities
// 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();
@@ -89,26 +116,29 @@ public class GoalMetricPeriodTotalService extends AbstractService<GoalMetricPeri
weeklySums.merge(key, metric.extractValue(activity), Double::sum);
}
for (final Map.Entry<PeriodKey, Double> entry : weeklySums.entrySet()) {
upsertAggregate(athleteId, metric, GoalPeriodEnum.WEEKLY,
entry.getKey().year(), entry.getKey().index(), entry.getValue());
}
final int weekYear = entry.getKey().year();
final int weekIndex = entry.getKey().index();
final double newWeeklyValue = entry.getValue();
// MONTHLY, YEARLY: reload existing aggregate (base from before this week) and add this week's contribution.
// Activities are grouped by period key — if the week spans a month or year boundary, each side is
// handled as a separate key and its own aggregate is updated independently.
for (final GoalPeriodEnum period : List.of(GoalPeriodEnum.MONTHLY, GoalPeriodEnum.YEARLY)) {
final Map<PeriodKey, Double> periodSums = new HashMap<>();
for (final IntervalsActivity activity : weekActivities) {
final LocalDate date = activity.getStartTime().atZone(ZoneOffset.UTC).toLocalDate();
final PeriodKey key = new PeriodKey(period.getPeriodYear(date), period.getPeriodIndex(date));
periodSums.merge(key, metric.extractValue(activity), Double::sum);
}
for (final Map.Entry<PeriodKey, Double> entry : periodSums.entrySet()) {
final GoalMetricPeriodTotal existing = goalMetricAggregateDAO.findByKey(
athleteId, metric, period, entry.getKey().year(), entry.getKey().index());
final double baseValue = existing != null ? existing.getAggregatedValue() : 0.0;
upsertAggregate(athleteId, metric, period,
entry.getKey().year(), entry.getKey().index(), baseValue + 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);
for (final GoalPeriodEnum period : List.of(GoalPeriodEnum.MONTHLY, GoalPeriodEnum.YEARLY)) {
final int periodYear = period.getPeriodYear(weekStartDate);
final int periodIndex = period.getPeriodIndex(weekStartDate);
final GoalMetricPeriodTotal existing = goalMetricAggregateDAO.findByKey(
athleteId, metric, period, periodYear, periodIndex);
final double baseValue = existing != null ? existing.getAggregatedValue() : 0.0;
upsertAggregate(athleteId, metric, period, periodYear, periodIndex, baseValue + weeklyDelta);
}
}
}
}

View File

@@ -7,6 +7,7 @@ import com.peakup.connector.intervals.model.IntervalsConnector;
import com.peakup.connector.intervals.service.IntervalsConnectorService;
import com.peakup.connector.strava.model.StravaConnector;
import com.peakup.connector.strava.service.StravaConnectorService;
import com.peakup.goal.service.GoalMetricPeriodTotalService;
import io.quarkus.scheduler.Scheduled;
import io.smallrye.common.annotation.RunOnVirtualThread;
import jakarta.enterprise.context.ApplicationScoped;
@@ -34,6 +35,9 @@ public class ActivityImportScheduler {
@Inject
@SuppressWarnings("NullAway.Init")
IntervalsContext intervalsContext;
@Inject
@SuppressWarnings("NullAway.Init")
GoalMetricPeriodTotalService goalMetricPeriodTotalService;
@Scheduled(every = "20m")
@RunOnVirtualThread
@@ -74,6 +78,14 @@ public class ActivityImportScheduler {
intervalsContext.clear();
}
}
for (final IntervalsConnector connector : intervalsConnectorService.getIntervalsConnectors()) {
try {
goalMetricPeriodTotalService.recalculateUpToCurrentWeek(connector.getAthleteId());
} catch (final Exception e) {
log.error("Failed to recalculate goal metric period totals for athleteId {}.", connector.getAthleteId(), e);
}
}
} finally {
running.set(false);
}