Dashboard

This commit is contained in:
2026-04-14 09:14:46 +02:00
parent 1184abcf69
commit f770b22d31
7 changed files with 118 additions and 91 deletions

View File

@@ -25,8 +25,8 @@ public class GoalMetricPeriodTotalDAO extends AbstractDAO<GoalMetricPeriodTotal>
final long athleteId,
final GoalMetricEnum metric,
final GoalPeriodEnum period,
final int periodYear,
final int periodIndex) {
final int year,
final int calendarWeek) {
final CriteriaBuilder cb = em.getCriteriaBuilder();
final CriteriaQuery<GoalMetricPeriodTotal> cq = cb.createQuery(GoalMetricPeriodTotal.class);
final Root<GoalMetricPeriodTotal> root = cq.from(GoalMetricPeriodTotal.class);
@@ -34,8 +34,8 @@ public class GoalMetricPeriodTotalDAO extends AbstractDAO<GoalMetricPeriodTotal>
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_.periodYear), periodYear),
cb.equal(root.get(GoalMetricPeriodTotal_.periodIndex), periodIndex)
cb.equal(root.get(GoalMetricPeriodTotal_.year), year),
cb.equal(root.get(GoalMetricPeriodTotal_.calendarWeek), calendarWeek)
);
return em.createQuery(cq).getResultStream().findFirst().orElse(null);
}
@@ -51,41 +51,23 @@ public class GoalMetricPeriodTotalDAO extends AbstractDAO<GoalMetricPeriodTotal>
cb.equal(root.get(GoalMetricPeriodTotal_.metric), GoalMetricEnum.DISTANCE)
)
.orderBy(
cb.desc(root.get(GoalMetricPeriodTotal_.periodYear)),
cb.desc(root.get(GoalMetricPeriodTotal_.periodIndex))
cb.desc(root.get(GoalMetricPeriodTotal_.year)),
cb.desc(root.get(GoalMetricPeriodTotal_.calendarWeek))
);
return em.createQuery(cq).setMaxResults(1).getResultStream().findFirst().orElse(null);
}
public List<GoalMetricPeriodTotal> findByAthleteForDashboard(
final long athleteId,
final int weekYear,
final int weekIndex,
final int monthYear,
final int monthIndex,
final int calendarYear) {
final int year,
final int calendarWeek) {
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.or(
cb.and(
cb.equal(root.get(GoalMetricPeriodTotal_.period), GoalPeriodEnum.WEEKLY),
cb.equal(root.get(GoalMetricPeriodTotal_.periodYear), weekYear),
cb.equal(root.get(GoalMetricPeriodTotal_.periodIndex), weekIndex)
),
cb.and(
cb.equal(root.get(GoalMetricPeriodTotal_.period), GoalPeriodEnum.MONTHLY),
cb.equal(root.get(GoalMetricPeriodTotal_.periodYear), monthYear),
cb.equal(root.get(GoalMetricPeriodTotal_.periodIndex), monthIndex)
),
cb.and(
cb.equal(root.get(GoalMetricPeriodTotal_.period), GoalPeriodEnum.YEARLY),
cb.equal(root.get(GoalMetricPeriodTotal_.periodYear), calendarYear),
cb.equal(root.get(GoalMetricPeriodTotal_.periodIndex), 1)
)
)
cb.equal(root.get(GoalMetricPeriodTotal_.year), year),
cb.equal(root.get(GoalMetricPeriodTotal_.calendarWeek), calendarWeek)
);
return em.createQuery(cq).getResultList();
}
@@ -104,8 +86,8 @@ public class GoalMetricPeriodTotalDAO extends AbstractDAO<GoalMetricPeriodTotal>
cb.equal(root.get(GoalMetricPeriodTotal_.period), period)
)
.orderBy(
cb.asc(root.get(GoalMetricPeriodTotal_.periodYear)),
cb.asc(root.get(GoalMetricPeriodTotal_.periodIndex))
cb.asc(root.get(GoalMetricPeriodTotal_.year)),
cb.asc(root.get(GoalMetricPeriodTotal_.calendarWeek))
);
return em.createQuery(cq).getResultList();
}

View File

@@ -10,7 +10,7 @@ import lombok.EqualsAndHashCode;
@Entity
@Table(name = "goal_metric_period_total", uniqueConstraints = @UniqueConstraint(
columnNames = {"athlete_id", "metric", "period", "period_year", "period_index"}))
columnNames = {"athlete_id", "metric", "period", "year", "calendar_week"}))
@Data
@EqualsAndHashCode(callSuper = true)
public class GoalMetricPeriodTotal extends AbstractTimestampedEntity {
@@ -24,11 +24,11 @@ public class GoalMetricPeriodTotal extends AbstractTimestampedEntity {
@Column(nullable = false)
private GoalPeriodEnum period;
@Column(name = "period_year", nullable = false)
private int periodYear;
@Column(name = "year", nullable = false)
private int year;
@Column(name = "period_index", nullable = false)
private int periodIndex;
@Column(name = "calendar_week", nullable = false)
private int calendarWeek;
@Column(nullable = false)
private double aggregatedValue;

View File

@@ -4,8 +4,8 @@ import com.peakup.activity.model.IntervalsActivity;
import com.peakup.activity.service.IntervalsActivityService;
import com.peakup.common.service.AbstractService;
import com.peakup.goal.dao.GoalMetricPeriodTotalDAO;
import com.peakup.goal.model.GoalMetricPeriodTotal;
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;
@@ -30,6 +30,14 @@ public class GoalMetricPeriodTotalService extends AbstractService<GoalMetricPeri
@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;
@@ -39,14 +47,14 @@ public class GoalMetricPeriodTotalService extends AbstractService<GoalMetricPeri
public void recalculateForAthlete(final long athleteId, final int year, final int calendarWeek) {
final List<IntervalsActivity> weekActivities = fetchWeekActivities(athleteId, year, calendarWeek);
for (final GoalMetricEnum metric : GoalMetricEnum.values()) {
recalculateMetricForWeek(athleteId, metric, weekActivities);
recalculateMetricForWeek(athleteId, metric, weekActivities, year, calendarWeek);
}
}
@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);
recalculateMetricForWeek(athleteId, metric, weekActivities, year, calendarWeek);
}
@Transactional
@@ -54,9 +62,9 @@ public class GoalMetricPeriodTotalService extends AbstractService<GoalMetricPeri
final long athleteId,
final GoalMetricEnum metric,
final GoalPeriodEnum period,
final int periodYear,
final int periodIndex) {
return goalMetricAggregateDAO.findByKey(athleteId, metric, period, periodYear, periodIndex);
final int year,
final int calendarWeek) {
return goalMetricAggregateDAO.findByKey(athleteId, metric, period, year, calendarWeek);
}
@Transactional
@@ -69,15 +77,7 @@ public class GoalMetricPeriodTotalService extends AbstractService<GoalMetricPeri
@Transactional
public List<GoalMetricPeriodTotal> getByAthleteAndWeek(final long athleteId, final int year, final int calendarWeek) {
final LocalDate monday = LocalDate.of(year, 1, 4)
.with(WeekFields.ISO.weekOfWeekBasedYear(), calendarWeek)
.with(WeekFields.ISO.dayOfWeek(), 1);
return goalMetricAggregateDAO.findByAthleteForDashboard(
athleteId,
year, calendarWeek,
monday.getYear(), monday.getMonthValue(),
monday.getYear()
);
return goalMetricAggregateDAO.findByAthleteForDashboard(athleteId, year, calendarWeek);
}
private List<IntervalsActivity> fetchWeekActivities(final long athleteId, final int year, final int calendarWeek) {
@@ -104,11 +104,12 @@ public class GoalMetricPeriodTotalService extends AbstractService<GoalMetricPeri
}
@Transactional
@Nullable LocalDate resolveStartDate(final long athleteId) {
@Nullable
LocalDate resolveStartDate(final long athleteId) {
final GoalMetricPeriodTotal lastWeekly = goalMetricAggregateDAO.findLastWeeklyByAthlete(athleteId);
if (lastWeekly != null) {
return LocalDate.of(lastWeekly.getPeriodYear(), 1, 4)
.with(WeekFields.ISO.weekOfWeekBasedYear(), lastWeekly.getPeriodIndex())
return LocalDate.of(lastWeekly.getYear(), 1, 4)
.with(WeekFields.ISO.weekOfWeekBasedYear(), lastWeekly.getCalendarWeek())
.with(WeekFields.ISO.dayOfWeek(), 1);
}
final IntervalsActivity earliestActivity = intervalsActivityService.getEarliestActivity(athleteId);
@@ -122,7 +123,9 @@ public class GoalMetricPeriodTotalService extends AbstractService<GoalMetricPeri
private void recalculateMetricForWeek(
final long athleteId,
final GoalMetricEnum metric,
final List<IntervalsActivity> weekActivities) {
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<>();
@@ -133,29 +136,59 @@ public class GoalMetricPeriodTotalService extends AbstractService<GoalMetricPeri
GoalPeriodEnum.WEEKLY.getPeriodIndex(date));
weeklySums.merge(key, metric.extractValue(activity), Double::sum);
}
for (final Map.Entry<PeriodKey, Double> entry : weeklySums.entrySet()) {
final int weekYear = entry.getKey().year();
final int weekIndex = entry.getKey().index();
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)
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);
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);
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);
}
}
}
@@ -165,10 +198,10 @@ public class GoalMetricPeriodTotalService extends AbstractService<GoalMetricPeri
final long athleteId,
final GoalMetricEnum metric,
final GoalPeriodEnum period,
final int periodYear,
final int periodIndex,
final int year,
final int calendarWeek,
final double value) {
final GoalMetricPeriodTotal existing = goalMetricAggregateDAO.findByKey(athleteId, metric, period, periodYear, periodIndex);
final GoalMetricPeriodTotal existing = goalMetricAggregateDAO.findByKey(athleteId, metric, period, year, calendarWeek);
if (existing != null) {
existing.setAggregatedValue(value);
} else {
@@ -176,13 +209,13 @@ public class GoalMetricPeriodTotalService extends AbstractService<GoalMetricPeri
aggregate.setAthleteId(athleteId);
aggregate.setMetric(metric);
aggregate.setPeriod(period);
aggregate.setPeriodYear(periodYear);
aggregate.setPeriodIndex(periodIndex);
aggregate.setYear(year);
aggregate.setCalendarWeek(calendarWeek);
aggregate.setAggregatedValue(value);
insert(aggregate);
}
}
private record PeriodKey(int year, int index) {
private record PeriodKey(int year, int calendarWeek) {
}
}

View File

@@ -0,0 +1,12 @@
-- V41: Rename period_year/period_index to year/calendar_week.
-- Both columns now always represent the ISO snapshot calendar week, regardless of period type.
-- Existing rows have mismatched semantics (period_index was month number / 1 for YEARLY),
-- so the table is truncated; recalculation will rebuild it from activities.
ALTER TABLE goal_metric_period_total RENAME COLUMN period_year TO year;
ALTER TABLE goal_metric_period_total RENAME COLUMN period_index TO calendar_week;
ALTER TABLE goal_metric_period_total
RENAME CONSTRAINT uq_goal_metric_period_total TO uq_goal_metric_period_total_snapshot;
TRUNCATE TABLE goal_metric_period_total;

View File

@@ -53,12 +53,12 @@ class GoalMetricPeriodTotalServiceTest extends AbstractPeakUpTest {
assertEquals(1000.0, weekly.getAggregatedValue());
final GoalMetricPeriodTotal monthly = goalMetricPeriodTotalService.getAggregate(
ATHLETE_ID, GoalMetricEnum.DISTANCE, GoalPeriodEnum.MONTHLY, today.getYear(), today.getMonthValue());
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, today.getYear(), 1);
ATHLETE_ID, GoalMetricEnum.DISTANCE, GoalPeriodEnum.YEARLY, weekYear, weekIndex);
assertNotNull(yearly);
assertEquals(1000.0, yearly.getAggregatedValue());
}

View File

@@ -105,11 +105,11 @@ function formatGoalValue(metric: Metric, value: number): string {
function buildActualsFromTotals(
totals: GoalMetricPeriodTotalDo[],
period: GoalPeriodEnum,
periodYear: number,
periodIndex: number,
year: number,
calendarWeek: number,
): Actuals {
const relevant = totals.filter(
(t) => t.period === period && t.periodYear === periodYear && t.periodIndex === periodIndex,
(t) => t.period === period && t.year === year && t.calendarWeek === calendarWeek,
)
const result: Actuals = {DISTANCE: 0, TSS: 0, ELEVATION: 0, TIME: 0}
for (const t of relevant) {
@@ -360,8 +360,8 @@ export function Dashboard({athleteId}: { athleteId: number }) {
// ── Actuals ─────────────────────────────────────────────────────────────────
const weekActuals = buildActualsFromTotals(goalMetricPeriodTotals, 'WEEKLY', year, week)
const monthActuals = buildActualsFromTotals(goalMetricPeriodTotals, 'MONTHLY', wStart.getFullYear(), wStart.getMonth() + 1)
const yearActuals = buildActualsFromTotals(goalMetricPeriodTotals, 'YEARLY', wStart.getFullYear(), 1)
const monthActuals = buildActualsFromTotals(goalMetricPeriodTotals, 'MONTHLY', year, week)
const yearActuals = buildActualsFromTotals(goalMetricPeriodTotals, 'YEARLY', year, week)
const weeklyGoals = goals.filter((g) => g.period === 'WEEKLY')
const monthlyGoals = goals.filter((g) => g.period === 'MONTHLY')

View File

@@ -8,7 +8,7 @@ export interface GoalMetricPeriodTotalDo extends AbstractTimestampedEntityDo {
athleteId: number;
metric: GoalMetricEnum;
period: GoalPeriodEnum;
periodYear: number;
periodIndex: number;
year: number;
calendarWeek: number;
aggregatedValue: number;
}