This commit is contained in:
2026-04-22 11:58:54 +02:00
parent 783637eaf8
commit e72b6ecc05
9 changed files with 541 additions and 39 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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