Goals
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
package com.peakup.goal.dao;
|
||||
|
||||
import com.peakup.common.dao.AbstractDAO;
|
||||
import com.peakup.goal.model.MilestoneNotification;
|
||||
import com.peakup.goal.model.MilestoneNotificationTypeEnum;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
@ApplicationScoped
|
||||
public class MilestoneNotificationDAO extends AbstractDAO<MilestoneNotification> {
|
||||
|
||||
@Override
|
||||
protected Class<MilestoneNotification> getEntityClass() {
|
||||
return MilestoneNotification.class;
|
||||
}
|
||||
|
||||
public boolean existsByKey(
|
||||
final long athleteId,
|
||||
final MilestoneNotificationTypeEnum notificationType,
|
||||
final long referenceId,
|
||||
final int year,
|
||||
final int periodIndex) {
|
||||
return em.createQuery(
|
||||
"SELECT COUNT(n) FROM MilestoneNotification n " +
|
||||
"WHERE n.athleteId = :athleteId AND n.notificationType = :type " +
|
||||
"AND n.referenceId = :referenceId AND n.year = :year AND n.periodIndex = :periodIndex",
|
||||
Long.class)
|
||||
.setParameter("athleteId", athleteId)
|
||||
.setParameter("type", notificationType)
|
||||
.setParameter("referenceId", referenceId)
|
||||
.setParameter("year", year)
|
||||
.setParameter("periodIndex", periodIndex)
|
||||
.getSingleResult() > 0;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import com.peakup.common.dao.AbstractEnumConverter;
|
||||
import com.peakup.common.dao.PersistableEnum;
|
||||
|
||||
public enum GoalMetricEnum implements PersistableEnum<GoalMetricEnum> {
|
||||
DISTANCE(1, "m") {
|
||||
DISTANCE(1, "km") {
|
||||
@Override
|
||||
public double extractValue(final IntervalsActivity activity) {
|
||||
return activity.getDistanceM();
|
||||
@@ -46,6 +46,10 @@ public enum GoalMetricEnum implements PersistableEnum<GoalMetricEnum> {
|
||||
return key;
|
||||
}
|
||||
|
||||
public boolean isTransformKMToMeter() {
|
||||
return unit.equals("km");
|
||||
}
|
||||
|
||||
@jakarta.persistence.Converter(autoApply = true)
|
||||
public static class Converter extends AbstractEnumConverter<GoalMetricEnum> {
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.peakup.goal.model;
|
||||
|
||||
import com.peakup.common.model.AbstractTimestampedEntity;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Entity
|
||||
@Table(name = "milestone_notification", uniqueConstraints = @UniqueConstraint(
|
||||
columnNames = {"athlete_id", "notification_type", "reference_id", "year", "period_index"}))
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class MilestoneNotification extends AbstractTimestampedEntity {
|
||||
|
||||
@Column(name = "athlete_id", nullable = false)
|
||||
private long athleteId;
|
||||
|
||||
@Column(name = "notification_type", nullable = false)
|
||||
private MilestoneNotificationTypeEnum notificationType;
|
||||
|
||||
@Column(name = "reference_id", nullable = false)
|
||||
private long referenceId;
|
||||
|
||||
@Column(name = "year", nullable = false)
|
||||
private int year;
|
||||
|
||||
@Column(name = "period_index", nullable = false)
|
||||
private int periodIndex;
|
||||
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
public MilestoneNotification() {
|
||||
// hibernate
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.peakup.goal.model;
|
||||
|
||||
import com.peakup.common.dao.AbstractEnumConverter;
|
||||
import com.peakup.common.dao.PersistableEnum;
|
||||
|
||||
public enum MilestoneNotificationTypeEnum implements PersistableEnum<MilestoneNotificationTypeEnum> {
|
||||
WEEK_FIRST_ACTIVITY(1),
|
||||
WEEK_SIX_PLUS_ACTIVITIES(2),
|
||||
GOAL_SEVENTY_PERCENT(3),
|
||||
ACTIVITY_TSS_HIGH(4),
|
||||
ACTIVITY_TSS_VERY_HIGH(5);
|
||||
|
||||
private final int key;
|
||||
|
||||
MilestoneNotificationTypeEnum(final int key) {
|
||||
this.key = key;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer getKey() {
|
||||
return key;
|
||||
}
|
||||
|
||||
@jakarta.persistence.Converter(autoApply = true)
|
||||
public static class Converter extends AbstractEnumConverter<MilestoneNotificationTypeEnum> {
|
||||
@Override
|
||||
public MilestoneNotificationTypeEnum getEnumByKey(final Integer key) {
|
||||
return PersistableEnum.getEnumByKey(key, MilestoneNotificationTypeEnum.values());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
package com.peakup.goal.service;
|
||||
|
||||
import com.peakup.activity.model.IntervalsActivity;
|
||||
import com.peakup.activity.service.IntervalsActivityService;
|
||||
import com.peakup.athlete.model.Athlete;
|
||||
import com.peakup.athlete.service.AthleteService;
|
||||
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;
|
||||
import io.quarkus.mailer.Mail;
|
||||
import io.quarkus.mailer.Mailer;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
@@ -23,6 +28,11 @@ import java.util.Locale;
|
||||
@ApplicationScoped
|
||||
public class GoalNotificationService {
|
||||
|
||||
private static final double TSS_HIGH_THRESHOLD = 70.0;
|
||||
private static final double TSS_VERY_HIGH_THRESHOLD = 150.0;
|
||||
private static final double GOAL_PROGRESS_THRESHOLD = 0.7;
|
||||
private static final int ACTIVITIES_MILESTONE_COUNT = 6;
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
GoalService goalService;
|
||||
@@ -35,32 +45,128 @@ public class GoalNotificationService {
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
GoalAchievementNotificationDAO goalAchievementNotificationDAO;
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
MilestoneNotificationDAO milestoneNotificationDAO;
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
AthleteService athleteService;
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
IntervalsActivityService intervalsActivityService;
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
Mailer mailer;
|
||||
|
||||
private static double getAggregatedValueConsiderUnits(final GoalMetricPeriodTotal total) {
|
||||
return total.getMetric().isTransformKMToMeter() ?
|
||||
total.getAggregatedValue() * 1000 :
|
||||
total.getAggregatedValue();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void checkAndNotify(final long athleteId) {
|
||||
final LocalDate today = LocalDate.now(ZoneOffset.UTC);
|
||||
final int currentYear = today.get(WeekFields.ISO.weekBasedYear());
|
||||
final int currentCalendarWeek = today.get(WeekFields.ISO.weekOfWeekBasedYear());
|
||||
|
||||
final LocalDate weekStart = LocalDate.of(currentYear, 1, 4)
|
||||
.with(WeekFields.ISO.weekOfWeekBasedYear(), currentCalendarWeek)
|
||||
.with(WeekFields.ISO.dayOfWeek(), 1);
|
||||
final LocalDate weekEnd = weekStart.plusDays(6);
|
||||
final List<IntervalsActivity> weekActivities = intervalsActivityService.getByAthleteIdBetween(
|
||||
athleteId, weekStart.toString(), weekEnd.toString());
|
||||
|
||||
final Athlete athlete = athleteService.getById(athleteId);
|
||||
|
||||
checkWeeklyActivityMilestones(athleteId, athlete, weekActivities, currentYear, currentCalendarWeek);
|
||||
checkActivityTssMilestones(athleteId, athlete, weekActivities);
|
||||
|
||||
final List<Goal> goals = goalService.getByAthleteId(athleteId);
|
||||
for (final Goal goal : goals) {
|
||||
if (goal.getPeriod() != GoalPeriodEnum.WEEKLY && goal.getPeriod() != GoalPeriodEnum.MONTHLY) {
|
||||
if (goal.getPeriod() != GoalPeriodEnum.WEEKLY
|
||||
&& goal.getPeriod() != GoalPeriodEnum.MONTHLY
|
||||
&& goal.getPeriod() != GoalPeriodEnum.YEARLY) {
|
||||
continue;
|
||||
}
|
||||
checkGoal(goal, athleteId, today, currentYear, currentCalendarWeek);
|
||||
checkGoalAchievement(goal, athleteId, athlete, today, currentYear, currentCalendarWeek);
|
||||
checkGoalSeventyPercent(goal, athleteId, athlete, today, currentYear, currentCalendarWeek);
|
||||
}
|
||||
}
|
||||
|
||||
private void checkGoal(
|
||||
private void checkWeeklyActivityMilestones(
|
||||
final long athleteId,
|
||||
final Athlete athlete,
|
||||
final List<IntervalsActivity> weekActivities,
|
||||
final int currentYear,
|
||||
final int currentCalendarWeek) {
|
||||
final int activityCount = weekActivities.size();
|
||||
|
||||
if (activityCount >= 1 && !milestoneNotificationDAO.existsByKey(
|
||||
athleteId, MilestoneNotificationTypeEnum.WEEK_FIRST_ACTIVITY, 0, currentYear, currentCalendarWeek)) {
|
||||
final String subject = "peakUp – 🏃 first activity of the week!";
|
||||
final String body = String.format(
|
||||
"Hi %s! 👋%n%n🎯 You logged your first activity this week. Great start – keep the momentum going! 💪%n%npeakUp",
|
||||
athlete.getFirstName());
|
||||
mailer.send(Mail.withText(athlete.getEmail(), subject, body));
|
||||
saveMilestone(athleteId, MilestoneNotificationTypeEnum.WEEK_FIRST_ACTIVITY, 0, currentYear, currentCalendarWeek);
|
||||
log.info("First-activity-of-week notification sent for athleteId={} week={}/{}", athleteId, currentYear, currentCalendarWeek);
|
||||
}
|
||||
|
||||
if (activityCount > ACTIVITIES_MILESTONE_COUNT && !milestoneNotificationDAO.existsByKey(
|
||||
athleteId, MilestoneNotificationTypeEnum.WEEK_SIX_PLUS_ACTIVITIES, 0, currentYear, currentCalendarWeek)) {
|
||||
final String subject = "peakUp – 🔥 " + ACTIVITIES_MILESTONE_COUNT + "+ activities this week!";
|
||||
final String body = String.format(
|
||||
"Hi %s! 👋%n%n🏆 You have completed %d activities this week. That's an impressive training week! 🔥%n%npeakUp",
|
||||
athlete.getFirstName(), activityCount);
|
||||
mailer.send(Mail.withText(athlete.getEmail(), subject, body));
|
||||
saveMilestone(athleteId, MilestoneNotificationTypeEnum.WEEK_SIX_PLUS_ACTIVITIES, 0, currentYear, currentCalendarWeek);
|
||||
log.info("Six-plus-activities notification sent for athleteId={} week={}/{} count={}", athleteId, currentYear, currentCalendarWeek, activityCount);
|
||||
}
|
||||
}
|
||||
|
||||
private void checkActivityTssMilestones(
|
||||
final long athleteId,
|
||||
final Athlete athlete,
|
||||
final List<IntervalsActivity> weekActivities) {
|
||||
for (final IntervalsActivity activity : weekActivities) {
|
||||
final Long activityId = activity.getId();
|
||||
if (activityId == null) {
|
||||
continue;
|
||||
}
|
||||
final double tss = activity.getTrainingLoad();
|
||||
|
||||
if (tss > TSS_HIGH_THRESHOLD && !milestoneNotificationDAO.existsByKey(
|
||||
athleteId, MilestoneNotificationTypeEnum.ACTIVITY_TSS_HIGH, activityId, 0, 0)) {
|
||||
final String subject = "peakUp – ⚡ high-load activity (TSS > " + TSS_HIGH_THRESHOLD + ")";
|
||||
final String body = String.format(
|
||||
"Hi %s! 👋%n%n💪 Your activity \"%s\" generated a TSS of %.0f. Well done on the hard effort! ⚡%n%npeakUp",
|
||||
athlete.getFirstName(), activity.getName(), tss);
|
||||
mailer.send(Mail.withText(athlete.getEmail(), subject, body));
|
||||
saveMilestone(athleteId, MilestoneNotificationTypeEnum.ACTIVITY_TSS_HIGH, activityId, 0, 0);
|
||||
log.info("TSS>70 notification sent for athleteId={} activityId={} tss={}", athleteId, activityId, tss);
|
||||
}
|
||||
|
||||
if (tss > TSS_VERY_HIGH_THRESHOLD && !milestoneNotificationDAO.existsByKey(
|
||||
athleteId, MilestoneNotificationTypeEnum.ACTIVITY_TSS_VERY_HIGH, activityId, 0, 0)) {
|
||||
final String subject = "peakUp – 🔥 very high-load activity (TSS > " + TSS_VERY_HIGH_THRESHOLD + ")";
|
||||
final String body = String.format(
|
||||
"Hi %s! 👋%n%n🦾 Your activity \"%s\" generated a TSS of %.0f. That's a very demanding session – make sure to recover well! 😴%n%npeakUp",
|
||||
athlete.getFirstName(), activity.getName(), tss);
|
||||
mailer.send(Mail.withText(athlete.getEmail(), subject, body));
|
||||
saveMilestone(athleteId, MilestoneNotificationTypeEnum.ACTIVITY_TSS_VERY_HIGH, activityId, 0, 0);
|
||||
log.info("TSS>150 notification sent for athleteId={} activityId={} tss={}", athleteId, activityId, tss);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void checkGoalAchievement(
|
||||
final Goal goal,
|
||||
final long athleteId,
|
||||
final Athlete athlete,
|
||||
final LocalDate today,
|
||||
final int currentYear,
|
||||
final int currentCalendarWeek) {
|
||||
@@ -70,9 +176,12 @@ public class GoalNotificationService {
|
||||
if (goal.getPeriod() == GoalPeriodEnum.WEEKLY) {
|
||||
notificationYear = currentYear;
|
||||
periodIndex = currentCalendarWeek;
|
||||
} else {
|
||||
} else if (goal.getPeriod() == GoalPeriodEnum.MONTHLY) {
|
||||
notificationYear = today.getYear();
|
||||
periodIndex = today.getMonthValue();
|
||||
} else {
|
||||
notificationYear = today.getYear();
|
||||
periodIndex = 1;
|
||||
}
|
||||
|
||||
final Long goalId = goal.getId();
|
||||
@@ -91,8 +200,13 @@ public class GoalNotificationService {
|
||||
return;
|
||||
}
|
||||
|
||||
final Athlete athlete = athleteService.getById(athleteId);
|
||||
sendGoalAchievementEmail(athlete, goal, total.getAggregatedValue());
|
||||
final String periodLabel = goal.getPeriod().name().toLowerCase(Locale.ROOT);
|
||||
final String subject = String.format("peakUp – 🏆 %s %s goal achieved!", periodLabel, goal.getMetric().name().toLowerCase(Locale.ROOT));
|
||||
final String body = String.format(
|
||||
"Hi %s! 👋%n%n🎉 Congratulations! You have achieved your %s %s goal.%n%n🎯 Target: %.2f%n✅ Achieved: %.2f%n%nKeep it up! 🚀%n%npeakUp",
|
||||
athlete.getFirstName(), periodLabel, goal.getMetric().name().toLowerCase(Locale.ROOT),
|
||||
goal.getTargetValue(), total.getAggregatedValue());
|
||||
mailer.send(Mail.withText(athlete.getEmail(), subject, body));
|
||||
|
||||
final GoalAchievementNotification notification = new GoalAchievementNotification();
|
||||
notification.setAthleteId(athleteId);
|
||||
@@ -105,14 +219,75 @@ public class GoalNotificationService {
|
||||
athleteId, goalId, goal.getPeriod(), notificationYear, periodIndex);
|
||||
}
|
||||
|
||||
private void sendGoalAchievementEmail(final Athlete athlete, final Goal goal, final double achievedValue) {
|
||||
final String periodLabel = goal.getPeriod() == GoalPeriodEnum.WEEKLY ? "weekly" : "monthly";
|
||||
final String subject = String.format("peakUp – %s %s goal achieved!", periodLabel, goal.getMetric().name().toLowerCase(Locale.ROOT));
|
||||
final String body = String.format(
|
||||
"Hi %s,%n%nCongratulations! You have achieved your %s %s goal.%n%nTarget: %.2f%nAchieved: %.2f%n%nKeep it up!%n%npeakUp",
|
||||
athlete.getFirstName(), periodLabel, goal.getMetric().name().toLowerCase(Locale.ROOT),
|
||||
goal.getTargetValue(), achievedValue);
|
||||
private void checkGoalSeventyPercent(
|
||||
final Goal goal,
|
||||
final long athleteId,
|
||||
final Athlete athlete,
|
||||
final LocalDate today,
|
||||
final int currentYear,
|
||||
final int currentCalendarWeek) {
|
||||
final Long goalId = goal.getId();
|
||||
if (goalId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (milestoneNotificationDAO.existsByKey(
|
||||
athleteId, MilestoneNotificationTypeEnum.GOAL_SEVENTY_PERCENT, goalId, notificationYear, periodIndex)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final GoalMetricPeriodTotal total = goalMetricPeriodTotalService.getAggregate(
|
||||
athleteId, goal.getMetric(), goal.getPeriod(), currentYear, currentCalendarWeek);
|
||||
|
||||
if (total == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final double aggregatedValue = getAggregatedValueConsiderUnits(total);
|
||||
if (aggregatedValue < GOAL_PROGRESS_THRESHOLD * goal.getTargetValue()
|
||||
|| aggregatedValue >= goal.getTargetValue()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final String periodLabel = goal.getPeriod().name().toLowerCase(Locale.ROOT);
|
||||
final String subject = String.format("peakUp – 📈 70%% of your %s %s goal reached!", periodLabel, goal.getMetric().name().toLowerCase(Locale.ROOT));
|
||||
final String body = String.format(
|
||||
"Hi %s! 👋%n%n📊 You have reached 70%% of your %s %s goal.%n%n🎯 Target: %.2f%n📈 Current: %.2f%n%nKeep pushing! 💪%n%npeakUp",
|
||||
athlete.getFirstName(), periodLabel, goal.getMetric().name().toLowerCase(Locale.ROOT),
|
||||
goal.getTargetValue(), aggregatedValue);
|
||||
mailer.send(Mail.withText(athlete.getEmail(), subject, body));
|
||||
saveMilestone(athleteId, MilestoneNotificationTypeEnum.GOAL_SEVENTY_PERCENT, goalId, notificationYear, periodIndex);
|
||||
|
||||
log.info("Goal 70% notification sent for athleteId={} goalId={} period={} year={} periodIndex={}",
|
||||
athleteId, goalId, goal.getPeriod(), notificationYear, periodIndex);
|
||||
}
|
||||
|
||||
private void saveMilestone(
|
||||
final long athleteId,
|
||||
final MilestoneNotificationTypeEnum notificationType,
|
||||
final long referenceId,
|
||||
final int year,
|
||||
final int periodIndex) {
|
||||
final MilestoneNotification milestone = new MilestoneNotification();
|
||||
milestone.setAthleteId(athleteId);
|
||||
milestone.setNotificationType(notificationType);
|
||||
milestone.setReferenceId(referenceId);
|
||||
milestone.setYear(year);
|
||||
milestone.setPeriodIndex(periodIndex);
|
||||
milestoneNotificationDAO.insert(milestone);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE milestone_notification
|
||||
(
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
athlete_id BIGINT NOT NULL,
|
||||
notification_type SMALLINT NOT NULL,
|
||||
reference_id BIGINT NOT NULL,
|
||||
year INT NOT NULL,
|
||||
period_index INT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_milestone_notification UNIQUE (athlete_id, notification_type, reference_id, year, period_index)
|
||||
);
|
||||
@@ -28,6 +28,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
class GoalNotificationServiceTest extends AbstractPeakUpTest {
|
||||
|
||||
private static final long ATHLETE_ID = 90L;
|
||||
private static final String ATHLETE_EMAIL = "ingo.schnabel@gmail.com";
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
@@ -50,36 +51,238 @@ class GoalNotificationServiceTest extends AbstractPeakUpTest {
|
||||
mockMailbox.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAndNotify_sendsEmailWhenGoalAchieved() throws Exception {
|
||||
insertGoal(GoalPeriodEnum.WEEKLY, GoalMetricEnum.DISTANCE, 1000.0);
|
||||
insertActivity(1500.0);
|
||||
// --- Goal achievement (100%) ---
|
||||
|
||||
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);
|
||||
@Test
|
||||
void checkAndNotify_sendsAchievementEmailWhenGoalReached() throws Exception {
|
||||
insertGoal(GoalPeriodEnum.WEEKLY, GoalMetricEnum.DISTANCE, 1000.0);
|
||||
insertActivity(1500.0, 0.0);
|
||||
recalculateCurrentWeek();
|
||||
|
||||
goalNotificationService.checkAndNotify(ATHLETE_ID);
|
||||
|
||||
final var messages = mockMailbox.getMessagesSentTo("ingo.schnabel@gmail.com");
|
||||
assertEquals(1, messages.size());
|
||||
assertTrue(messages.get(0).getSubject().contains("weekly distance"));
|
||||
assertTrue(subjectMatches("weekly distance goal achieved"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAndNotify_doesNotSendEmailWhenGoalNotReached() throws Exception {
|
||||
void checkAndNotify_doesNotSendAchievementEmailWhenGoalNotReached() throws Exception {
|
||||
insertGoal(GoalPeriodEnum.WEEKLY, GoalMetricEnum.DISTANCE, 1000.0);
|
||||
insertActivity(500.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);
|
||||
insertActivity(500.0, 0.0);
|
||||
recalculateCurrentWeek();
|
||||
|
||||
goalNotificationService.checkAndNotify(ATHLETE_ID);
|
||||
|
||||
assertTrue(mockMailbox.getMessagesSentTo("ingo.schnabel@gmail.com").isEmpty());
|
||||
assertTrue(noSubjectMatches("weekly distance goal achieved"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAndNotify_doesNotSendAchievementEmailTwice() throws Exception {
|
||||
insertGoal(GoalPeriodEnum.WEEKLY, GoalMetricEnum.DISTANCE, 1000.0);
|
||||
insertActivity(1500.0, 0.0);
|
||||
recalculateCurrentWeek();
|
||||
|
||||
goalNotificationService.checkAndNotify(ATHLETE_ID);
|
||||
mockMailbox.clear();
|
||||
goalNotificationService.checkAndNotify(ATHLETE_ID);
|
||||
|
||||
assertTrue(noSubjectMatches("weekly distance goal achieved"));
|
||||
}
|
||||
|
||||
// --- Goal 70% ---
|
||||
|
||||
@Test
|
||||
void checkAndNotify_sendsSeventyPercentEmailWhenGoalIsSeventyPercentReached() throws Exception {
|
||||
insertGoal(GoalPeriodEnum.WEEKLY, GoalMetricEnum.DISTANCE, 1000.0);
|
||||
insertActivity(750.0, 0.0);
|
||||
recalculateCurrentWeek();
|
||||
|
||||
goalNotificationService.checkAndNotify(ATHLETE_ID);
|
||||
|
||||
assertTrue(subjectMatches("70%"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAndNotify_doesNotSendSeventyPercentEmailWhenBelowSeventyPercent() throws Exception {
|
||||
insertGoal(GoalPeriodEnum.WEEKLY, GoalMetricEnum.DISTANCE, 1000.0);
|
||||
insertActivity(600.0, 0.0);
|
||||
recalculateCurrentWeek();
|
||||
|
||||
goalNotificationService.checkAndNotify(ATHLETE_ID);
|
||||
|
||||
assertTrue(noSubjectMatches("70%"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAndNotify_doesNotSendSeventyPercentEmailWhenGoalFullyAchieved() throws Exception {
|
||||
insertGoal(GoalPeriodEnum.WEEKLY, GoalMetricEnum.DISTANCE, 1000.0);
|
||||
insertActivity(1100.0, 0.0);
|
||||
recalculateCurrentWeek();
|
||||
|
||||
goalNotificationService.checkAndNotify(ATHLETE_ID);
|
||||
|
||||
assertTrue(noSubjectMatches("70%"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAndNotify_doesNotSendSeventyPercentEmailTwice() throws Exception {
|
||||
insertGoal(GoalPeriodEnum.WEEKLY, GoalMetricEnum.DISTANCE, 1000.0);
|
||||
insertActivity(750.0, 0.0);
|
||||
recalculateCurrentWeek();
|
||||
|
||||
goalNotificationService.checkAndNotify(ATHLETE_ID);
|
||||
mockMailbox.clear();
|
||||
goalNotificationService.checkAndNotify(ATHLETE_ID);
|
||||
|
||||
assertTrue(noSubjectMatches("70%"));
|
||||
}
|
||||
|
||||
// --- First activity of week ---
|
||||
|
||||
@Test
|
||||
void checkAndNotify_sendsFirstActivityEmailOnFirstActivityOfWeek() throws Exception {
|
||||
insertActivity(500.0, 0.0);
|
||||
|
||||
goalNotificationService.checkAndNotify(ATHLETE_ID);
|
||||
|
||||
assertTrue(subjectMatches("first activity"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAndNotify_doesNotSendFirstActivityEmailTwice() throws Exception {
|
||||
insertActivity(500.0, 0.0);
|
||||
|
||||
goalNotificationService.checkAndNotify(ATHLETE_ID);
|
||||
mockMailbox.clear();
|
||||
goalNotificationService.checkAndNotify(ATHLETE_ID);
|
||||
|
||||
assertTrue(noSubjectMatches("first activity"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAndNotify_doesNotSendFirstActivityEmailWithNoActivities() throws Exception {
|
||||
goalNotificationService.checkAndNotify(ATHLETE_ID);
|
||||
|
||||
assertTrue(mockMailbox.getMessagesSentTo(ATHLETE_EMAIL).isEmpty());
|
||||
}
|
||||
|
||||
// --- 6+ activities per week ---
|
||||
|
||||
@Test
|
||||
void checkAndNotify_sendsSixPlusActivitiesEmailWhenMoreThanSixActivities() throws Exception {
|
||||
for (int activityIndex = 0; activityIndex < 7; activityIndex++) {
|
||||
insertActivity(500.0, 0.0);
|
||||
}
|
||||
|
||||
goalNotificationService.checkAndNotify(ATHLETE_ID);
|
||||
|
||||
assertTrue(subjectMatches("7+"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAndNotify_doesNotSendSixPlusActivitiesEmailForExactlySixActivities() throws Exception {
|
||||
for (int activityIndex = 0; activityIndex < 6; activityIndex++) {
|
||||
insertActivity(500.0, 0.0);
|
||||
}
|
||||
|
||||
goalNotificationService.checkAndNotify(ATHLETE_ID);
|
||||
|
||||
assertTrue(noSubjectMatches("7+"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAndNotify_doesNotSendSixPlusActivitiesEmailTwice() throws Exception {
|
||||
for (int activityIndex = 0; activityIndex < 7; activityIndex++) {
|
||||
insertActivity(500.0, 0.0);
|
||||
}
|
||||
|
||||
goalNotificationService.checkAndNotify(ATHLETE_ID);
|
||||
mockMailbox.clear();
|
||||
goalNotificationService.checkAndNotify(ATHLETE_ID);
|
||||
|
||||
assertTrue(noSubjectMatches("7+"));
|
||||
}
|
||||
|
||||
// --- TSS > 70 ---
|
||||
|
||||
@Test
|
||||
void checkAndNotify_sendsTssHighEmailWhenActivityExceedsSeventyTss() throws Exception {
|
||||
insertActivity(500.0, 80.0);
|
||||
|
||||
goalNotificationService.checkAndNotify(ATHLETE_ID);
|
||||
|
||||
assertTrue(subjectMatches("TSS > 70"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAndNotify_doesNotSendTssHighEmailWhenTssBelowThreshold() throws Exception {
|
||||
insertActivity(500.0, 50.0);
|
||||
|
||||
goalNotificationService.checkAndNotify(ATHLETE_ID);
|
||||
|
||||
assertTrue(noSubjectMatches("TSS > 70"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAndNotify_doesNotSendTssHighEmailTwice() throws Exception {
|
||||
insertActivity(500.0, 80.0);
|
||||
|
||||
goalNotificationService.checkAndNotify(ATHLETE_ID);
|
||||
mockMailbox.clear();
|
||||
goalNotificationService.checkAndNotify(ATHLETE_ID);
|
||||
|
||||
assertTrue(noSubjectMatches("TSS > 70"));
|
||||
}
|
||||
|
||||
// --- TSS > 150 ---
|
||||
|
||||
@Test
|
||||
void checkAndNotify_sendsBothTssEmailsWhenActivityExceedsHundredFiftyTss() throws Exception {
|
||||
insertActivity(500.0, 160.0);
|
||||
|
||||
goalNotificationService.checkAndNotify(ATHLETE_ID);
|
||||
|
||||
assertTrue(subjectMatches("TSS > 70"));
|
||||
assertTrue(subjectMatches("TSS > 150"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAndNotify_doesNotSendTssVeryHighEmailWhenTssBelowHundredFifty() throws Exception {
|
||||
insertActivity(500.0, 80.0);
|
||||
|
||||
goalNotificationService.checkAndNotify(ATHLETE_ID);
|
||||
|
||||
assertTrue(noSubjectMatches("TSS > 150"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAndNotify_doesNotSendTssVeryHighEmailTwice() throws Exception {
|
||||
insertActivity(500.0, 160.0);
|
||||
|
||||
goalNotificationService.checkAndNotify(ATHLETE_ID);
|
||||
mockMailbox.clear();
|
||||
goalNotificationService.checkAndNotify(ATHLETE_ID);
|
||||
|
||||
assertTrue(noSubjectMatches("TSS > 150"));
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
private boolean subjectMatches(final String fragment) {
|
||||
return mockMailbox.getMessagesSentTo(ATHLETE_EMAIL).stream()
|
||||
.anyMatch(message -> message.getSubject().contains(fragment));
|
||||
}
|
||||
|
||||
private boolean noSubjectMatches(final String fragment) {
|
||||
return mockMailbox.getMessagesSentTo(ATHLETE_EMAIL).stream()
|
||||
.noneMatch(message -> message.getSubject().contains(fragment));
|
||||
}
|
||||
|
||||
private void recalculateCurrentWeek() {
|
||||
final LocalDate today = LocalDate.now(ZoneOffset.UTC);
|
||||
goalMetricPeriodTotalService.recalculateForAthlete(
|
||||
ATHLETE_ID,
|
||||
today.get(WeekFields.ISO.weekBasedYear()),
|
||||
today.get(WeekFields.ISO.weekOfWeekBasedYear()));
|
||||
}
|
||||
|
||||
private void insertGoal(final GoalPeriodEnum period, final GoalMetricEnum metric, final double targetValue)
|
||||
@@ -93,14 +296,14 @@ class GoalNotificationServiceTest extends AbstractPeakUpTest {
|
||||
}
|
||||
}
|
||||
|
||||
private void insertActivity(final double distanceM) throws Exception {
|
||||
private void insertActivity(final double distanceM, final double trainingLoad) throws Exception {
|
||||
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, now(), %f, 0.0, 0.0, 0, now(), now())
|
||||
""".formatted(ATHLETE_ID, distanceM));
|
||||
(athlete_id, start_time, name, distance_m, training_load, total_elevation_gain_m, moving_time_s, created_at, modified_at)
|
||||
VALUES (%d, now(), 'Test Activity', %f, %f, 0.0, 0, now(), now())
|
||||
""".formatted(ATHLETE_ID, distanceM, trainingLoad));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +104,10 @@ const METRIC_LABEL: Record<Metric, string> = {
|
||||
TIME: 'Time',
|
||||
}
|
||||
|
||||
function normalizeMetricValue(metric: Metric, value: number): number {
|
||||
return metric === 'DISTANCE' ? value / 1000 : value
|
||||
}
|
||||
|
||||
function formatGoalValue(metric: Metric, value: number): string {
|
||||
switch (metric) {
|
||||
case 'DISTANCE':
|
||||
@@ -192,9 +196,9 @@ function GoalPeriodColumn({
|
||||
key={goal.metric}
|
||||
label={METRIC_LABEL[goal.metric]}
|
||||
currentDisplay={formatGoalValue(goal.metric, actuals[goal.metric])}
|
||||
targetDisplay={formatGoalValue(goal.metric, goal.targetValue)}
|
||||
targetDisplay={formatGoalValue(goal.metric, normalizeMetricValue(goal.metric, goal.targetValue))}
|
||||
currentRaw={actuals[goal.metric]}
|
||||
targetRaw={goal.targetValue}
|
||||
targetRaw={normalizeMetricValue(goal.metric, goal.targetValue)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -40,6 +40,7 @@ function hhmmToSeconds(value: string): number | null {
|
||||
|
||||
function formatDisplayValue(metric: Metric, targetValue: number): string {
|
||||
if (metric === 'TIME') return secondsToHhmm(targetValue)
|
||||
if (metric === 'DISTANCE') return String(targetValue / 1000)
|
||||
return String(targetValue)
|
||||
}
|
||||
|
||||
@@ -55,6 +56,7 @@ function parseInputValue(metric: Metric, raw: string): ParseResult {
|
||||
}
|
||||
const n = parseFloat(trimmed)
|
||||
if (isNaN(n) || n < 0) return null
|
||||
if (metric === 'DISTANCE') return n === 0 ? 'empty' : Math.round(n * 1000)
|
||||
return n === 0 ? 'empty' : n
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user