Mail support

This commit is contained in:
2026-04-25 22:14:33 +02:00
parent f8813cb80c
commit 0d7b563220
22 changed files with 510 additions and 91 deletions

48
.idea/compiler.xml generated
View File

@@ -61,6 +61,52 @@
<entry name="$USER_HOME$/.m2/peak-repo/org/antlr/antlr4-runtime/4.13.2/antlr4-runtime-4.13.2.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/net/bytebuddy/byte-buddy/1.17.8/byte-buddy-1.17.8.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/jboss/logging/jboss-logging/3.6.1.Final/jboss-logging-3.6.1.Final.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/projectlombok/lombok/1.18.42/lombok-1.18.42.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/mapstruct/mapstruct-processor/1.6.3/mapstruct-processor-1.6.3.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/errorprone/error_prone_core/2.37.0/error_prone_core-2.37.0.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/errorprone/error_prone_annotation/2.37.0/error_prone_annotation-2.37.0.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/errorprone/error_prone_check_api/2.37.0/error_prone_check_api-2.37.0.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/io/github/java-diff-utils/java-diff-utils/4.12/java-diff-utils-4.12.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/github/kevinstern/software-and-algorithms/1.0/software-and-algorithms-1.0.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/github/ben-manes/caffeine/caffeine/3.0.5/caffeine-3.0.5.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/googlejavaformat/google-java-format/1.24.0/google-java-format-1.24.0.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/pcollections/pcollections/4.0.1/pcollections-4.0.1.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/guava/guava/32.1.1-jre/guava-32.1.1-jre.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/guava/failureaccess/1.0.1/failureaccess-1.0.1.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/guava/listenablefuture/9999.0-empty-to-avoid-conflict-with-guava/listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/code/findbugs/jsr305/3.0.2/jsr305-3.0.2.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/checkerframework/checker-qual/3.33.0/checker-qual-3.33.0.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/j2objc/j2objc-annotations/2.8/j2objc-annotations-2.8.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/auto/auto-common/1.2.2/auto-common-1.2.2.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/io/github/eisop/dataflow-errorprone/3.41.0-eisop1/dataflow-errorprone-3.41.0-eisop1.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/auto/value/auto-value-annotations/1.9/auto-value-annotations-1.9.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/errorprone/error_prone_annotations/2.37.0/error_prone_annotations-2.37.0.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/protobuf/protobuf-java/3.25.5/protobuf-java-3.25.5.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/auto/service/auto-service-annotations/1.0.1/auto-service-annotations-1.0.1.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/jspecify/jspecify/1.0.0/jspecify-1.0.0.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/javax/inject/javax.inject/1/javax.inject-1.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/uber/nullaway/nullaway/0.13.1/nullaway-0.13.1.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/checkerframework/dataflow-nullaway/3.53.0/dataflow-nullaway-3.53.0.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/hibernate/orm/hibernate-processor/7.2.4.Final/hibernate-processor-7.2.4.Final.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/hibernate/orm/hibernate-core/7.2.4.Final/hibernate-core-7.2.4.Final.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/jakarta/persistence/jakarta.persistence-api/3.2.0/jakarta.persistence-api-3.2.0.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/jakarta/transaction/jakarta.transaction-api/2.0.1/jakarta.transaction-api-2.0.1.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/fasterxml/classmate/1.7.1/classmate-1.7.1.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/jakarta/inject/jakarta.inject-api/2.0.1/jakarta.inject-api-2.0.1.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/hibernate/models/hibernate-models/1.0.1/hibernate-models-1.0.1.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/io/smallrye/jandex/3.3.2/jandex-3.3.2.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/jakarta/xml/bind/jakarta.xml.bind-api/4.0.4/jakarta.xml.bind-api-4.0.4.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/jakarta/activation/jakarta.activation-api/2.1.4/jakarta.activation-api-2.1.4.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/glassfish/jaxb/jaxb-runtime/4.0.6/jaxb-runtime-4.0.6.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/glassfish/jaxb/jaxb-core/4.0.6/jaxb-core-4.0.6.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/eclipse/angus/angus-activation/2.0.3/angus-activation-2.0.3.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/glassfish/jaxb/txw2/4.0.6/txw2-4.0.6.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/sun/istack/istack-commons-runtime/4.1.2/istack-commons-runtime-4.1.2.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/jakarta/validation/jakarta.validation-api/3.1.1/jakarta.validation-api-3.1.1.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/jakarta/annotation/jakarta.annotation-api/3.0.0/jakarta.annotation-api-3.0.0.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/antlr/antlr4-runtime/4.13.2/antlr4-runtime-4.13.2.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/net/bytebuddy/byte-buddy/1.17.8/byte-buddy-1.17.8.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/jboss/logging/jboss-logging/3.6.1.Final/jboss-logging-3.6.1.Final.jar" />
</processorPath>
<module name="peakup-backend" />
</profile>
@@ -71,7 +117,7 @@
</component>
<component name="JavacSettings">
<option name="ADDITIONAL_OPTIONS_OVERRIDE">
<module name="peakup-backend" options="-XDcompilePolicy=simple --should-stop=ifError=FLOW &quot;-Xplugin:ErrorProne -Xep:NullAway:ERROR -XepOpt:NullAway:AnnotatedPackages=com.peakup&#10; -Xep:MissingSummary:OFF -Xep:MissingOverride:ERROR -Xep:DefaultCharset:ERROR&#10; -Xep:StringCaseLocaleUsage:ERROR -Xep:MathAbsoluteRandom:ERROR&#10; -Xep:UnnecessaryDefaultInEnumSwitch:ERROR -Xep:OperatorPrecedence:ERROR&#10; -XepOpt:NullAway:TreatGeneratedAsUnannotated=true&#10; -XepOpt:NullAway:CheckOptionalEmptiness=true&quot; -Xpkginfo:always -J--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED -J--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED -J--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED -J--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED -J--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED --add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED" />
<module name="peakup-backend" options="-parameters -XDcompilePolicy=simple --should-stop=ifError=FLOW &quot;-Xplugin:ErrorProne -Xep:NullAway:ERROR -XepOpt:NullAway:AnnotatedPackages=com.peakup&#10; -Xep:MissingSummary:OFF -Xep:MissingOverride:ERROR -Xep:DefaultCharset:ERROR&#10; -Xep:StringCaseLocaleUsage:ERROR -Xep:MathAbsoluteRandom:ERROR&#10; -Xep:UnnecessaryDefaultInEnumSwitch:ERROR -Xep:OperatorPrecedence:ERROR&#10; -XepOpt:NullAway:TreatGeneratedAsUnannotated=true&#10; -XepOpt:NullAway:CheckOptionalEmptiness=true&quot; -Xpkginfo:always -J--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED -J--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED -J--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED -J--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED -J--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED --add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED" />
</option>
</component>
</project>

View File

@@ -69,6 +69,10 @@
<groupId>io.quarkus</groupId>
<artifactId>quarkus-mailer</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-qute</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-arc</artifactId>
@@ -207,6 +211,7 @@
<showDeprecation>true</showDeprecation>
<!--fork>true</fork-->
<forceLegacyJavacApi>true</forceLegacyJavacApi>
<parameters>true</parameters>
<compilerArgs>
<arg>-XDcompilePolicy=simple</arg>
<arg>--should-stop=ifError=FLOW</arg>

View File

@@ -3,6 +3,8 @@ package com.peakup.activity.service;
import com.peakup.activity.dao.IntervalsActivityDAO;
import com.peakup.activity.model.IntervalsActivity;
import com.peakup.common.service.AbstractService;
import com.peakup.common.util.NullableUtils;
import com.peakup.goal.model.GoalMetricEnum;
import jakarta.annotation.Nullable;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@@ -60,6 +62,15 @@ public class IntervalsActivityService extends AbstractService<IntervalsActivity,
return intervalsActivityDAO.getByAthleteIdAndDay(athleteId, dayStart, dayEnd);
}
public double extractMetricValue(final IntervalsActivity activity, final GoalMetricEnum metric) {
return switch (metric) {
case DISTANCE -> NullableUtils.defaultValue(activity.getDistanceM());
case TSS -> NullableUtils.defaultValue(activity.getTrainingLoad());
case ELEVATION -> NullableUtils.defaultValue(activity.getTotalElevationGainM());
case TIME -> NullableUtils.defaultValue(activity.getMovingTimeS());
};
}
@Transactional
public IntervalsActivity linkWorkout(final long activityId, final long workoutId) {
final IntervalsActivity activity = intervalsActivityDAO.getById(activityId);

View File

@@ -1,4 +1,4 @@
package com.peakup.common;
package com.peakup.common.util;
import jakarta.annotation.Nullable;

View File

@@ -0,0 +1,38 @@
package com.peakup.common.util;
public class UnitConversionUtils {
private UnitConversionUtils() {
}
public static double metersToKilometers(final long meters) {
return meters / 1000.0;
}
public static double metersToKilometers(final double meters) {
return meters / 1000.0;
}
public static long kilometersToMeters(final double kilometers) {
return Math.round(kilometers * 1000.0);
}
public static double metersPerSecondToKilometersPerHour(final double metersPerSecond) {
return metersPerSecond * 3.6;
}
public static double kilometersPerHourToMetersPerSecond(final double kilometersPerHour) {
return kilometersPerHour / 3.6;
}
// returns [hours, minutes, seconds]
public static int[] secondsToHoursMinutesSeconds(final long totalSeconds) {
final long hours = totalSeconds / 3600;
final long minutes = (totalSeconds % 3600) / 60;
final long seconds = totalSeconds % 60;
return new int[]{(int) hours, (int) minutes, (int) seconds};
}
public static long hoursMinutesSecondsToSeconds(final int hours, final int minutes, final int seconds) {
return (long) hours * 3600 + (long) minutes * 60 + seconds;
}
}

View File

@@ -0,0 +1,2 @@
@com.peakup.common.annotation.NonNull
package com.peakup.common.util;

View File

@@ -3,15 +3,15 @@ package com.peakup.connector.intervals.mapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.peakup.activity.model.ActivityAchievement;
import com.peakup.activity.model.ActivityTypeEnum;
import com.peakup.activity.model.ActivityAttachment;
import com.peakup.activity.model.ActivityMapStreamPoint;
import com.peakup.activity.model.ActivityStreamPoint;
import com.peakup.activity.model.ActivityTypeEnum;
import com.peakup.activity.model.ActivityZone;
import com.peakup.activity.model.IgnorePart;
import com.peakup.activity.model.IntervalsActivity;
import com.peakup.common.ImportException;
import com.peakup.common.NullableUtils;
import com.peakup.common.util.NullableUtils;
import com.peakup.connector.intervals.client.IntervalsClient;
import jakarta.annotation.Nullable;
import jakarta.inject.Inject;

View File

@@ -3,7 +3,7 @@ package com.peakup.connector.strava.mapper;
import com.peakup.activity.model.ActivityTypeEnum;
import com.peakup.activity.model.IntervalsImportStatusEnum;
import com.peakup.activity.model.StravaActivity;
import com.peakup.common.NullableUtils;
import com.peakup.common.util.NullableUtils;
import com.peakup.connector.strava.client.StravaClient;
import org.mapstruct.AfterMapping;
import org.mapstruct.Context;

View File

@@ -1,34 +1,13 @@
package com.peakup.goal.model;
import com.peakup.activity.model.IntervalsActivity;
import com.peakup.common.dao.AbstractEnumConverter;
import com.peakup.common.dao.PersistableEnum;
public enum GoalMetricEnum implements PersistableEnum<GoalMetricEnum> {
DISTANCE(1, "km") {
@Override
public double extractValue(final IntervalsActivity activity) {
return activity.getDistanceM();
}
},
TSS(2, "") {
@Override
public double extractValue(final IntervalsActivity activity) {
return activity.getTrainingLoad();
}
},
ELEVATION(3, "m") {
@Override
public double extractValue(final IntervalsActivity activity) {
return activity.getTotalElevationGainM();
}
},
TIME(4, "s") {
@Override
public double extractValue(final IntervalsActivity activity) {
return activity.getMovingTimeS();
}
};
DISTANCE(1, "km"),
TSS(2, ""),
ELEVATION(3, "m"),
TIME(4, "s");
private final int key;
@SuppressWarnings({"FieldCanBeLocal", "UnusedVariable", "unused"})
@@ -39,8 +18,6 @@ public enum GoalMetricEnum implements PersistableEnum<GoalMetricEnum> {
this.unit = unit;
}
public abstract double extractValue(IntervalsActivity activity);
@Override
public Integer getKey() {
return key;

View File

@@ -134,7 +134,7 @@ public class GoalMetricPeriodTotalService extends AbstractService<GoalMetricPeri
final PeriodKey key = new PeriodKey(
GoalPeriodEnum.WEEKLY.getPeriodYear(date),
GoalPeriodEnum.WEEKLY.getPeriodIndex(date));
weeklySums.merge(key, metric.extractValue(activity), Double::sum);
weeklySums.merge(key, intervalsActivityService.extractMetricValue(activity, metric), Double::sum);
}
if (weeklySums.isEmpty()) {
final boolean weeklyExists = goalMetricAggregateDAO.findByKey(

View File

@@ -4,6 +4,7 @@ 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.common.util.UnitConversionUtils;
import com.peakup.goal.dao.GoalAchievementNotificationDAO;
import com.peakup.goal.dao.MilestoneNotificationDAO;
import com.peakup.goal.model.Goal;
@@ -14,9 +15,12 @@ import com.peakup.goal.model.MilestoneNotification;
import com.peakup.goal.model.MilestoneNotificationTypeEnum;
import io.quarkus.mailer.Mail;
import io.quarkus.mailer.Mailer;
import io.quarkus.qute.CheckedTemplate;
import io.quarkus.qute.TemplateInstance;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.time.LocalDate;
import java.time.ZoneOffset;
@@ -28,43 +32,63 @@ 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;
@ConfigProperty(name = "peakup.notifications.tss-high-threshold", defaultValue = "70.0")
double tssHighThreshold;
@ConfigProperty(name = "peakup.notifications.tss-very-high-threshold", defaultValue = "150.0")
double tssVeryHighThreshold;
@ConfigProperty(name = "peakup.notifications.goal-progress-threshold", defaultValue = "0.7")
double goalProgressThreshold;
@ConfigProperty(name = "peakup.notifications.activities-milestone-count", defaultValue = "6")
int activitiesMilestoneCount;
@Inject
@SuppressWarnings("NullAway.Init")
GoalService goalService;
@Inject
@SuppressWarnings("NullAway.Init")
GoalMetricPeriodTotalService goalMetricPeriodTotalService;
@Inject
@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;
@ConfigProperty(name = "peakup.notifications.enabled", defaultValue = "false")
@SuppressWarnings("NullAway.Init")
boolean notificationsEnabled;
private static double getAggregatedValueConsiderUnits(final GoalMetricPeriodTotal total) {
return total.getMetric().isTransformKMToMeter() ?
total.getAggregatedValue() * 1000 :
total.getAggregatedValue();
private static ActivitySummary toActivitySummary(final IntervalsActivity activity) {
final String distance = String.format("%.1f km", UnitConversionUtils.metersToKilometers(activity.getDistanceM()));
final String duration = formatDuration(activity.getMovingTimeS());
final String power = String.format("%.0f W", activity.getAveragePowerWatt());
final String tss = String.format("%.0f TSS", activity.getTrainingLoad());
return new ActivitySummary(activity.getName(), distance, duration, power, tss);
}
private static String formatDuration(final long seconds) {
final long hours = seconds / 3600;
final long minutes = (seconds % 3600) / 60;
if (hours > 0) {
return String.format("%d:%02d h", hours, minutes);
}
return String.format("%d min", minutes);
}
private void sendOrLog(final String to, final String subject, final TemplateInstance templateInstance) {
final String body = templateInstance.render();
if (notificationsEnabled) {
mailer.send(Mail.withHtml(to, subject, body));
} else {
log.info("Notification suppressed (notifications disabled) – to={} subject={} body={}", to, subject, body);
}
}
@Transactional
@@ -108,21 +132,18 @@ public class GoalNotificationService {
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));
sendOrLog(athlete.getEmail(), subject, Templates.weekFirstActivity(athlete.getFirstName(), toActivitySummary(weekActivities.get(0))));
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(
if (activityCount > activitiesMilestoneCount && !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));
final String subject = "peakUp – 🔥 " + activityCount + "+ activities this week!";
final List<ActivitySummary> activitySummaries = weekActivities.stream()
.map(GoalNotificationService::toActivitySummary)
.toList();
sendOrLog(athlete.getEmail(), subject, Templates.weeklyActivities(athlete.getFirstName(), activityCount, activitySummaries));
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);
}
@@ -138,27 +159,22 @@ public class GoalNotificationService {
continue;
}
final double tss = activity.getTrainingLoad();
final String tssFormatted = String.format("%.0f", tss);
if (tss > TSS_HIGH_THRESHOLD && !milestoneNotificationDAO.existsByKey(
if (tss > tssHighThreshold && !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));
final String subject = "peakUp – ⚡ high-load activity (TSS > " + tssHighThreshold + ")";
sendOrLog(athlete.getEmail(), subject, Templates.activityTssHigh(athlete.getFirstName(), activity.getName(), tssFormatted));
saveMilestone(athleteId, MilestoneNotificationTypeEnum.ACTIVITY_TSS_HIGH, activityId, 0, 0);
log.info("TSS>70 notification sent for athleteId={} activityId={} tss={}", athleteId, activityId, tss);
log.info("TSS> {} notification sent for athleteId={} activityId={} tss={}", tssHighThreshold, athleteId, activityId, tss);
}
if (tss > TSS_VERY_HIGH_THRESHOLD && !milestoneNotificationDAO.existsByKey(
if (tss > tssVeryHighThreshold && !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));
final String subject = "peakUp – 🔥 very high-load activity (TSS > " + tssVeryHighThreshold + ")";
sendOrLog(athlete.getEmail(), subject, Templates.activityTssVeryHigh(athlete.getFirstName(), activity.getName(), tssFormatted));
saveMilestone(athleteId, MilestoneNotificationTypeEnum.ACTIVITY_TSS_VERY_HIGH, activityId, 0, 0);
log.info("TSS>150 notification sent for athleteId={} activityId={} tss={}", athleteId, activityId, tss);
log.info("TSS>{} notification sent for athleteId={} activityId={} tss={}", tssVeryHighThreshold, athleteId, activityId, tss);
}
}
}
@@ -201,12 +217,12 @@ public class GoalNotificationService {
}
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 String metricLabel = goal.getMetric().name().toLowerCase(Locale.ROOT);
final String subject = String.format("peakUp – 🏆 %s %s goal achieved!", periodLabel, metricLabel);
sendOrLog(athlete.getEmail(), subject, Templates.goalAchievement(
athlete.getFirstName(), periodLabel, metricLabel,
String.format("%.2f", goal.getTargetValue()),
String.format("%.2f", total.getAggregatedValue())));
final GoalAchievementNotification notification = new GoalAchievementNotification();
notification.setAthleteId(athleteId);
@@ -257,23 +273,23 @@ public class GoalNotificationService {
return;
}
final double aggregatedValue = getAggregatedValueConsiderUnits(total);
if (aggregatedValue < GOAL_PROGRESS_THRESHOLD * goal.getTargetValue()
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 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));
final String metricLabel = goal.getMetric().name().toLowerCase(Locale.ROOT);
final String subject = String.format("peakUp – 📈 %d%% of your %s %s goal reached!", percentage, periodLabel, metricLabel);
sendOrLog(athlete.getEmail(), subject, Templates.goalPercentage(
athlete.getFirstName(), percentage, periodLabel, metricLabel,
String.format("%.2f", goal.getTargetValue()),
String.format("%.2f", aggregatedValue)));
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);
log.info("Goal {}% notification sent for athleteId={} goalId={} period={} year={} periodIndex={}",
percentage, athleteId, goalId, goal.getPeriod(), notificationYear, periodIndex);
}
private void saveMilestone(
@@ -290,4 +306,22 @@ public class GoalNotificationService {
milestone.setPeriodIndex(periodIndex);
milestoneNotificationDAO.insert(milestone);
}
public record ActivitySummary(String name, String distance, String duration, String power, String tss) {
}
@CheckedTemplate(basePath = "GoalNotifications")
static class Templates {
static native TemplateInstance weekFirstActivity(String firstName, ActivitySummary activity);
static native TemplateInstance weeklyActivities(String firstName, int activityCount, List<ActivitySummary> activities);
static native TemplateInstance activityTssHigh(String firstName, String activityName, String tss);
static native TemplateInstance activityTssVeryHigh(String firstName, String activityName, String tss);
static native TemplateInstance goalAchievement(String firstName, String periodLabel, String metricLabel, String targetValue, String achievedValue);
static native TemplateInstance goalPercentage(String firstName, int percentage, String periodLabel, String metricLabel, String targetValue, String currentValue);
}
}

View File

@@ -1,4 +1,5 @@
quarkus.datasource.jdbc.url=jdbc:postgresql://10.8.11.116:5432/peakup
peakup.notifications.enabled=true
quarkus.log.file.enable=true
quarkus.log.file.path=/opt/peakup/logs/peakup.log
quarkus.log.file.level=INFO

View File

@@ -20,6 +20,11 @@ strava-api/mp-rest/url=https://www.strava.com
# intervals.icu
intervals-api/mp-rest/url=https://intervals.icu
peakup.stream-import.delay-ms=1000
peakup.notifications.enabled=false
peakup.notifications.tss-high-threshold=70.0
peakup.notifications.tss-very-high-threshold=150.0
peakup.notifications.goal-progress-threshold=0.7
peakup.notifications.activities-milestone-count=6
# mailer
quarkus.mailer.from=peakup@peakup.app
quarkus.mailer.host=${SMTP_HOST:smtp.gmail.com}

View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>peakUp – High-load activity</title>
</head>
<body style="font-family: system-ui, -apple-system, sans-serif; color:#111; max-width:600px; margin:0 auto; padding:32px; background:#FFFFFF;">
<div style="margin-bottom:32px;">
</div>
<h1 style="font-size:28px; font-weight:700; margin:0 0 16px 0; color:#111;">
⚡ High-load activity!
</h1>
<p style="font-size:18px; line-height:1.5; color:#333; margin-bottom:32px;">
Well done, <strong>{firstName}</strong>!<br>
That was a serious effort – your body felt it.
</p>
<div style="background:#FFF; border:2px solid #F97316; border-radius:16px; padding:24px; margin-bottom:32px;">
<div style="font-size:15px; color:#666; margin-bottom:4px;">{activityName}</div>
<div style="font-size:28px; font-weight:700; color:#111; margin-bottom:4px;">{tss} TSS</div>
<div style="font-size:15px; color:#666;">High training load</div>
<div style="height:4px; background:linear-gradient(to right, #EF4444, #F97316); border-radius:9999px; margin:20px 0;"></div>
</div>
<p style="font-size:17px; line-height:1.6; color:#444; margin-bottom:32px;">
Make sure to get enough rest and fuel up well.<br>
Quality recovery is what turns hard sessions into real gains.
</p>
<a href="http://peakup/dashboard"
style="display:inline-block; background:linear-gradient(to right, #EF4444, #F97316); color:white; font-weight:600; padding:16px 32px; border-radius:9999px; text-decoration:none; font-size:17px;">
Back to Dashboard →
</a>
<p style="margin-top:48px; font-size:13px; color:#888; text-align:center;">
peakUp • Your path to new personal bests
</p>
</body>
</html>

View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>peakUp – Very high-load activity</title>
</head>
<body style="font-family: system-ui, -apple-system, sans-serif; color:#111; max-width:600px; margin:0 auto; padding:32px; background:#FFFFFF;">
<div style="margin-bottom:32px;">
</div>
<h1 style="font-size:28px; font-weight:700; margin:0 0 16px 0; color:#111;">
🔥 Exceptional effort!
</h1>
<p style="font-size:18px; line-height:1.5; color:#333; margin-bottom:32px;">
Wow, <strong>{firstName}</strong>!<br>
That session was on another level entirely.
</p>
<div style="background:#FFF; border:2px solid #F97316; border-radius:16px; padding:24px; margin-bottom:32px;">
<div style="font-size:15px; color:#666; margin-bottom:4px;">{activityName}</div>
<div style="font-size:28px; font-weight:700; color:#111; margin-bottom:4px;">{tss} TSS</div>
<div style="font-size:15px; color:#666;">Very high training load</div>
<div style="height:4px; background:linear-gradient(to right, #EF4444, #F97316); border-radius:9999px; margin:20px 0;"></div>
</div>
<p style="font-size:17px; line-height:1.6; color:#444; margin-bottom:32px;">
Take your recovery seriously – sleep, nutrition, easy days.<br>
You've done the hard work, now let your body adapt.
</p>
<a href="http://peakup/dashboard"
style="display:inline-block; background:linear-gradient(to right, #EF4444, #F97316); color:white; font-weight:600; padding:16px 32px; border-radius:9999px; text-decoration:none; font-size:17px;">
Back to Dashboard →
</a>
<p style="margin-top:48px; font-size:13px; color:#888; text-align:center;">
peakUp • Your path to new personal bests
</p>
</body>
</html>

View File

@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>peakUp – Goal achieved!</title>
</head>
<body style="font-family: system-ui, -apple-system, sans-serif; color:#111; max-width:600px; margin:0 auto; padding:32px; background:#FFFFFF;">
<div style="margin-bottom:32px;">
</div>
<h1 style="font-size:28px; font-weight:700; margin:0 0 16px 0; color:#111;">
🏆 Goal achieved!
</h1>
<p style="font-size:18px; line-height:1.5; color:#333; margin-bottom:32px;">
Congratulations, <strong>{firstName}</strong>!<br>
You've hit your {periodLabel} {metricLabel} goal – that's real progress.
</p>
<div style="background:#FFF; border:2px solid #F97316; border-radius:16px; padding:24px; margin-bottom:32px;">
<div style="display:flex; justify-content:space-between; margin-bottom:12px;">
<span style="font-size:15px; color:#666;">🎯 Target</span>
<span style="font-size:15px; font-weight:700; color:#111;">{targetValue}</span>
</div>
<div style="display:flex; justify-content:space-between;">
<span style="font-size:15px; color:#666;">✅ Achieved</span>
<span style="font-size:15px; font-weight:700; color:#111;">{achievedValue}</span>
</div>
<div style="height:4px; background:linear-gradient(to right, #EF4444, #F97316); border-radius:9999px; margin:20px 0;"></div>
</div>
<p style="font-size:17px; line-height:1.6; color:#444; margin-bottom:32px;">
This is what consistent training looks like.<br>
Set your next target and keep raising the bar. 🚀
</p>
<a href="http://peakup/dashboard"
style="display:inline-block; background:linear-gradient(to right, #EF4444, #F97316); color:white; font-weight:600; padding:16px 32px; border-radius:9999px; text-decoration:none; font-size:17px;">
Back to Dashboard →
</a>
<p style="margin-top:48px; font-size:13px; color:#888; text-align:center;">
peakUp • Your path to new personal bests
</p>
</body>
</html>

View File

@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>peakUp – Goal progress</title>
</head>
<body style="font-family: system-ui, -apple-system, sans-serif; color:#111; max-width:600px; margin:0 auto; padding:32px; background:#FFFFFF;">
<div style="margin-bottom:32px;">
</div>
<h1 style="font-size:28px; font-weight:700; margin:0 0 16px 0; color:#111;">
📈 {percentage}% of your goal reached!
</h1>
<p style="font-size:18px; line-height:1.5; color:#333; margin-bottom:32px;">
Great progress, <strong>{firstName}</strong>!<br>
You're well on your way to your {periodLabel} {metricLabel} goal.
</p>
<div style="background:#FFF; border:2px solid #F97316; border-radius:16px; padding:24px; margin-bottom:32px;">
<div style="display:flex; justify-content:space-between; margin-bottom:12px;">
<span style="font-size:15px; color:#666;">🎯 Target</span>
<span style="font-size:15px; font-weight:700; color:#111;">{targetValue}</span>
</div>
<div style="display:flex; justify-content:space-between;">
<span style="font-size:15px; color:#666;">📈 Current</span>
<span style="font-size:15px; font-weight:700; color:#111;">{currentValue}</span>
</div>
<div style="height:4px; background:linear-gradient(to right, #EF4444, #F97316); border-radius:9999px; margin:20px 0;"></div>
</div>
<p style="font-size:17px; line-height:1.6; color:#444; margin-bottom:32px;">
Keep the momentum going – you're nearly there.<br>
Every session counts now. 💪
</p>
<a href="http://peakup/dashboard"
style="display:inline-block; background:linear-gradient(to right, #EF4444, #F97316); color:white; font-weight:600; padding:16px 32px; border-radius:9999px; text-decoration:none; font-size:17px;">
Back to Dashboard →
</a>
<p style="margin-top:48px; font-size:13px; color:#888; text-align:center;">
peakUp • Your path to new personal bests
</p>
</body>
</html>

View File

@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>peakUp – First activity of the week</title>
</head>
<body style="font-family: system-ui, -apple-system, sans-serif; color:#111; max-width:600px; margin:0 auto; padding:32px; background:#FFFFFF;">
<div style="margin-bottom:32px;">
</div>
<h1 style="font-size:28px; font-weight:700; margin:0 0 16px 0; color:#111;">
🎉 First activity of the week!
</h1>
<p style="font-size:18px; line-height:1.5; color:#333; margin-bottom:32px;">
Strong start, <strong>{firstName}</strong>!<br>
You've laid the foundation for a great week today.
</p>
<div style="background:#FFF; border:2px solid #F97316; border-radius:16px; padding:24px; margin-bottom:32px;">
<div style="display:flex; align-items:center; gap:16px; margin-bottom:16px;">
<div style="font-size:48px;">🚴‍♂️</div>
<div>
<div style="font-size:15px; color:#666;">{activity.name}</div>
<div style="font-size:28px; font-weight:700; color:#111;">{activity.distance}</div>
<div style="font-size:15px; color:#666;">{activity.duration} • {activity.power} avg • {activity.tss}</div>
</div>
</div>
<div style="height:4px; background:linear-gradient(to right, #EF4444, #F97316); border-radius:9999px; margin:20px 0;"></div>
</div>
<p style="font-size:17px; line-height:1.6; color:#444; margin-bottom:32px;">
Feels good, right?<br>
Keep it up – every session makes you stronger and brings you closer to your goals.
</p>
<a href="http://peakup/dashboard"
style="display:inline-block; background:linear-gradient(to right, #EF4444, #F97316); color:white; font-weight:600; padding:16px 32px; border-radius:9999px; text-decoration:none; font-size:17px;">
Back to Dashboard →
</a>
<p style="margin-top:48px; font-size:13px; color:#888; text-align:center;">
peakUp • Your path to new personal bests
</p>
</body>
</html>

View File

@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>peakUp – Activities this week</title>
</head>
<body style="font-family: system-ui, -apple-system, sans-serif; color:#111; max-width:600px; margin:0 auto; padding:32px; background:#FFFFFF;">
<div style="margin-bottom:32px;">
</div>
<h1 style="font-size:28px; font-weight:700; margin:0 0 16px 0; color:#111;">
🔥 {activityCount} activities this week!
</h1>
<p style="font-size:18px; line-height:1.5; color:#333; margin-bottom:32px;">
Incredible week, <strong>{firstName}</strong>!<br>
Here's what you crushed this week:
</p>
{#for activity in activities}
<div style="background:#FFF; border:2px solid #F97316; border-radius:16px; padding:24px; margin-bottom:16px;">
<div style="font-size:16px; font-weight:700; color:#111; margin-bottom:8px;">{activity.name}</div>
<div style="display:flex; gap:24px; font-size:14px; color:#666;">
<span>📍 {activity.distance}</span>
<span>⏱ {activity.duration}</span>
<span>⚡ {activity.tss}</span>
</div>
<div style="height:4px; background:linear-gradient(to right, #EF4444, #F97316); border-radius:9999px; margin-top:16px;"></div>
</div>
{/for}
<a href="http://peakup/dashboard"
style="display:inline-block; background:linear-gradient(to right, #EF4444, #F97316); color:white; font-weight:600; padding:16px 32px; border-radius:9999px; text-decoration:none; font-size:17px; margin-top:16px;">
Go to Dashboard →
</a>
<p style="margin-top:48px; font-size:13px; color:#888; text-align:center;">
peakUp • Your path to new personal bests
</p>
</body>
</html>

View File

@@ -24,7 +24,8 @@ public class PostgresTestProfile implements QuarkusTestProfile {
"quarkus.scheduler.enabled", "false",
"quarkus.http.test-port", "0",
"peakup.stream-import.delay-ms", "0",
"quarkus.mailer.mock", "true"
"quarkus.mailer.mock", "true",
"peakup.notifications.enabled", "true"
);
}
}

View File

@@ -26,7 +26,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static com.peakup.common.NullableUtils.defaultValue;
import static com.peakup.common.util.NullableUtils.defaultValue;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;

View File

@@ -0,0 +1,27 @@
export function metersToKilometers(meters: number): number {
return meters / 1000;
}
export function kilometersToMeters(kilometers: number): number {
return Math.round(kilometers * 1000);
}
export function metersPerSecondToKilometersPerHour(metersPerSecond: number): number {
return metersPerSecond * 3.6;
}
export function kilometersPerHourToMetersPerSecond(kilometersPerHour: number): number {
return kilometersPerHour / 3.6;
}
// returns [hours, minutes, seconds]
export function secondsToHoursMinutesSeconds(totalSeconds: number): [number, number, number] {
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return [hours, minutes, seconds];
}
export function hoursMinutesSecondsToSeconds(hours: number, minutes: number, seconds: number): number {
return hours * 3600 + minutes * 60 + seconds;
}