Mail support
This commit is contained in:
48
.idea/compiler.xml
generated
48
.idea/compiler.xml
generated
@@ -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 "-Xplugin:ErrorProne -Xep:NullAway:ERROR -XepOpt:NullAway:AnnotatedPackages=com.peakup -Xep:MissingSummary:OFF -Xep:MissingOverride:ERROR -Xep:DefaultCharset:ERROR -Xep:StringCaseLocaleUsage:ERROR -Xep:MathAbsoluteRandom:ERROR -Xep:UnnecessaryDefaultInEnumSwitch:ERROR -Xep:OperatorPrecedence:ERROR -XepOpt:NullAway:TreatGeneratedAsUnannotated=true -XepOpt:NullAway:CheckOptionalEmptiness=true" -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 "-Xplugin:ErrorProne -Xep:NullAway:ERROR -XepOpt:NullAway:AnnotatedPackages=com.peakup -Xep:MissingSummary:OFF -Xep:MissingOverride:ERROR -Xep:DefaultCharset:ERROR -Xep:StringCaseLocaleUsage:ERROR -Xep:MathAbsoluteRandom:ERROR -Xep:UnnecessaryDefaultInEnumSwitch:ERROR -Xep:OperatorPrecedence:ERROR -XepOpt:NullAway:TreatGeneratedAsUnannotated=true -XepOpt:NullAway:CheckOptionalEmptiness=true" -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.peakup.common;
|
||||
package com.peakup.common.util;
|
||||
|
||||
import jakarta.annotation.Nullable;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
@com.peakup.common.annotation.NonNull
|
||||
package com.peakup.common.util;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
27
peakup-frontend/src/utils/unitConversion.ts
Normal file
27
peakup-frontend/src/utils/unitConversion.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user