Intervals icu activity
This commit is contained in:
2
.idea/compiler.xml
generated
2
.idea/compiler.xml
generated
@@ -124,7 +124,7 @@
|
||||
</component>
|
||||
<component name="JavacSettings">
|
||||
<option name="ADDITIONAL_OPTIONS_OVERRIDE">
|
||||
<module name="peakup-backend" options="-XDcompilePolicy=simple -XDshould-stop.ifError=FLOW "-Xplugin:ErrorProne -Xep:NullAway:ERROR -XepOpt:NullAway:AnnotatedPackages=com.peakup -XepOpt:NullAway:TreatGeneratedAsUnannotated=true -Xep:MissingSummary:OFF" -Xpkginfo:always" />
|
||||
<module name="peakup-backend" options="-XDcompilePolicy=simple -XDshould-stop.ifError=FLOW "-Xplugin:ErrorProne -Xep:NullAway:ERROR -XepOpt:NullAway:AnnotatedPackages=com.peakup -XepOpt:NullAway:TreatGeneratedAsUnannotated=true -Xep:MissingSummary:OFF" -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.parser=ALL-UNNAMED -J--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED -J--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED -J--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED -J--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED -J--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED" />
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
7
.idea/sqldialects.xml
generated
Normal file
7
.idea/sqldialects.xml
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="SqlDialectMappings">
|
||||
<file url="file://$PROJECT_DIR$/peakup-backend/src/test/resources/skripts.sql" dialect="GenericSQL" />
|
||||
<file url="PROJECT" dialect="PostgreSQL" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -150,14 +150,22 @@
|
||||
<configuration>
|
||||
<release>${maven.compiler.release}</release>
|
||||
<showWarnings>true</showWarnings>
|
||||
<fork>true</fork>
|
||||
<compilerArgs>
|
||||
<arg>-XDcompilePolicy=simple</arg>
|
||||
<arg>-XDshould-stop.ifError=FLOW</arg>
|
||||
<arg>-Xplugin:ErrorProne -Xep:NullAway:ERROR -XepOpt:NullAway:AnnotatedPackages=com.peakup
|
||||
-XepOpt:NullAway:TreatGeneratedAsUnannotated=true
|
||||
-Xep:MissingSummary:OFF
|
||||
</arg>
|
||||
<arg>-Xplugin:ErrorProne -Xep:NullAway:ERROR -XepOpt:NullAway:AnnotatedPackages=com.peakup -XepOpt:NullAway:TreatGeneratedAsUnannotated=true -Xep:MissingSummary:OFF</arg>
|
||||
<arg>-Xpkginfo:always</arg>
|
||||
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED</arg>
|
||||
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED</arg>
|
||||
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED</arg>
|
||||
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED</arg>
|
||||
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED</arg>
|
||||
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED</arg>
|
||||
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED</arg>
|
||||
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED</arg>
|
||||
<arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED</arg>
|
||||
<arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED</arg>
|
||||
</compilerArgs>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
|
||||
@@ -18,6 +18,20 @@ public class IntervalsActivityDAO extends AbstractDAO<IntervalsActivity> {
|
||||
return IntervalsActivity.class;
|
||||
}
|
||||
|
||||
public @Nullable IntervalsActivity getLatestActivity(Long athleteId) {
|
||||
CriteriaBuilder cb = em.getCriteriaBuilder();
|
||||
CriteriaQuery<IntervalsActivity> cq = cb.createQuery(IntervalsActivity.class);
|
||||
Root<IntervalsActivity> root = cq.from(IntervalsActivity.class);
|
||||
cq.select(root);
|
||||
cq.where(cb.equal(root.get(AbstractActivity_.athleteId), athleteId));
|
||||
cq.orderBy(cb.desc(root.get(AbstractActivity_.startTime)));
|
||||
return em.createQuery(cq)
|
||||
.setMaxResults(1)
|
||||
.getResultStream()
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
public @Nullable IntervalsActivity findByOriginId(String intervalsId) {
|
||||
CriteriaBuilder cb = em.getCriteriaBuilder();
|
||||
CriteriaQuery<IntervalsActivity> cq = cb.createQuery(IntervalsActivity.class);
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
package com.peakup.activity.importer;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.peakup.activity.connector.IntervalsActivityConnector;
|
||||
import com.peakup.activity.dao.IntervalsActivityDAO;
|
||||
import com.peakup.activity.dao.StravaActivityDAO;
|
||||
import com.peakup.activity.model.IntervalsActivity;
|
||||
import com.peakup.activity.model.IntervalsEnrichmentStatusEnum;
|
||||
import com.peakup.activity.model.StravaActivity;
|
||||
import com.peakup.connector.intervals.IntervalsClient;
|
||||
import com.peakup.dao.AbstractDAO;
|
||||
import jakarta.annotation.Nullable;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.transaction.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static com.peakup.common.NullableUtils.defaultValue;
|
||||
|
||||
@@ -32,11 +29,11 @@ public class IntervalsActivityImporter
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
StravaActivityDAO stravaActivityDAO;
|
||||
IntervalsActivityConnector intervalsActivityConnector;
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
IntervalsActivityConnector intervalsActivityConnector;
|
||||
ObjectMapper objectMapper;
|
||||
|
||||
@Override
|
||||
protected AbstractDAO<IntervalsActivity> getDAO() {
|
||||
@@ -54,90 +51,79 @@ public class IntervalsActivityImporter
|
||||
a.setAthleteId(athleteId);
|
||||
a.originId = defaultValue(src.id);
|
||||
a.stravaOriginId = defaultValue(src.external_id);
|
||||
if (src.start_date != null) {
|
||||
a.startTime = Instant.parse(src.start_date);
|
||||
}
|
||||
a.type = defaultValue(src.type);
|
||||
a.sportType = defaultValue(src.type);
|
||||
a.timezone = defaultValue(src.timezone);
|
||||
a.name = defaultValue(src.name);
|
||||
a.description = src.description;
|
||||
a.trainer = src.trainer;
|
||||
a.commute = src.commute;
|
||||
a.race = src.race;
|
||||
a.gearId = defaultValue(src.gear_id);
|
||||
a.totalAscentM = defaultValue(src.icu_total_ascent);
|
||||
a.totalDescentM = defaultValue(src.icu_total_descent);
|
||||
a.minAltitudeM = defaultValue(src.icu_min_altitude);
|
||||
a.maxAltitudeM = defaultValue(src.icu_max_altitude);
|
||||
a.trainingLoad = defaultValue(src.icu_training_load);
|
||||
a.gearId = src.gear != null ? defaultValue(src.gear.id) : "";
|
||||
a.distanceM = defaultValue(src.distance);
|
||||
a.movingTimeS = src.moving_time != null ? src.moving_time : 0L;
|
||||
a.elapsedTimeS = src.elapsed_time != null ? src.elapsed_time : 0L;
|
||||
a.averageSpeedMps = defaultValue(src.average_speed);
|
||||
a.maxSpeedMps = defaultValue(src.max_speed);
|
||||
a.averageHeartrateBpm = (double) defaultValue(src.average_heartrate);
|
||||
a.maxHeartrateBPM = src.max_heartrate != null ? src.max_heartrate.doubleValue() : 0.0;
|
||||
a.averageCadenceRPM = defaultValue(src.average_cadence);
|
||||
a.calories = (double) defaultValue(src.calories);
|
||||
a.averagePowerWatt = src.icu_average_watts != null ? src.icu_average_watts.doubleValue() : 0.0;
|
||||
a.normalizedPowerWatt = src.icu_weighted_avg_watts != null ? src.icu_weighted_avg_watts.doubleValue() : 0.0;
|
||||
a.totalWorkKJ = src.icu_joules != null ? src.icu_joules / 1000.0 : 0.0;
|
||||
a.totalElevationGainM = defaultValue(src.total_elevation_gain);
|
||||
a.totalAscentM = defaultValue(src.total_elevation_gain);
|
||||
a.totalDescentM = defaultValue(src.total_elevation_loss);
|
||||
a.minAltitudeM = defaultValue(src.min_altitude);
|
||||
a.maxAltitudeM = defaultValue(src.max_altitude);
|
||||
a.trainingLoad = (double) defaultValue(src.icu_training_load);
|
||||
a.intensityFactor = defaultValue(src.icu_intensity);
|
||||
a.efficiencyFactor = defaultValue(src.icu_ef);
|
||||
a.averageWattsKg = defaultValue(src.icu_average_watts_kg);
|
||||
a.efficiencyFactor = defaultValue(src.icu_efficiency_factor);
|
||||
a.ftp = src.icu_ftp;
|
||||
a.atl = defaultValue(src.icu_atl);
|
||||
a.ctl = defaultValue(src.icu_ctl);
|
||||
a.form = defaultValue(src.icu_form);
|
||||
a.rampRate = defaultValue(src.icu_ramprate);
|
||||
a.loadRatio = defaultValue(src.icu_load_ratio);
|
||||
a.hrrPercent = defaultValue(src.hrr);
|
||||
a.variabilityIndex = defaultValue(src.variability_index);
|
||||
if (src.icu_hrr != null) {
|
||||
a.hrrBpmDrop = src.icu_hrr.hrr != null ? src.icu_hrr.hrr.doubleValue() : 0.0;
|
||||
a.hrrStartBpm = src.icu_hrr.start_bpm;
|
||||
a.hrrEndBpm = src.icu_hrr.end_bpm;
|
||||
a.hrrStartTimeSec = src.icu_hrr.start_time;
|
||||
a.hrrEndTimeSec = src.icu_hrr.end_time;
|
||||
a.hrrAverageWatts = src.icu_hrr.average_watts;
|
||||
}
|
||||
if (src.icu_zone_times != null) {
|
||||
try {
|
||||
a.icuZoneTimes = objectMapper.writeValueAsString(src.icu_zone_times);
|
||||
} catch (JsonProcessingException e) {
|
||||
log.warn("Failed to serialize icu_zone_times for activity {}", src.id, e);
|
||||
}
|
||||
}
|
||||
a.variabilityIndex = defaultValue(src.icu_variability_index);
|
||||
a.feel = src.feel;
|
||||
a.perceivedExertion = defaultValue(src.perceived_exertion);
|
||||
a.sufferScore = src.suffer_score;
|
||||
a.averageStrideLengthM = defaultValue(src.average_stride_length);
|
||||
a.averageVerticalOscillationMm = defaultValue(src.average_vertical_oscillation);
|
||||
a.averageGroundContactTimeMs = defaultValue(src.average_ground_contact_time);
|
||||
a.averageVerticalRatio = defaultValue(src.average_vertical_ratio);
|
||||
a.numSteps = src.total_steps;
|
||||
a.averageStrideLengthM = defaultValue(src.average_stride);
|
||||
a.averageTempC = defaultValue(src.average_temp);
|
||||
return a;
|
||||
}
|
||||
|
||||
public void importActivities(Long athleteId, String intervalsAthleteId) {
|
||||
List<StravaActivity> pending = stravaActivityDAO.getPendingEnrichment(athleteId);
|
||||
if (pending.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String oldest = LocalDate.ofInstant(pending.get(0).startTime, ZoneOffset.UTC)
|
||||
.minusDays(1).toString();
|
||||
String newest = LocalDate.ofInstant(pending.get(pending.size() - 1).startTime, ZoneOffset.UTC)
|
||||
.plusDays(1).toString();
|
||||
|
||||
List<IntervalsClient.IntervalsActivity> raws =
|
||||
IntervalsActivity latest = intervalsActivityDAO.getLatestActivity(athleteId);
|
||||
String oldest = latest != null
|
||||
? LocalDate.ofInstant(latest.startTime, ZoneOffset.UTC).minusDays(2).toString()
|
||||
: "2010-01-01";
|
||||
String newest = LocalDate.now(ZoneOffset.UTC).toString();
|
||||
List<IntervalsClient.IntervalsActivity> allRaws =
|
||||
intervalsActivityConnector.fetchForRange(intervalsAthleteId, oldest, newest);
|
||||
|
||||
log.info("Enriching {} pending activities for athleteId {} using {} intervals.icu activities.",
|
||||
pending.size(), athleteId, raws.size());
|
||||
|
||||
int count = 0;
|
||||
for (IntervalsClient.IntervalsActivity raw : raws) {
|
||||
if (findExisting(raw) == null) {
|
||||
intervalsActivityDAO.insert(map(raw, athleteId));
|
||||
count++;
|
||||
}
|
||||
if (raw.external_id != null) {
|
||||
updateStravaMatchStatus(raw.external_id);
|
||||
}
|
||||
}
|
||||
markUnmatched(athleteId, raws);
|
||||
log.info("Imported {} IntervalsActivities for athleteId {}", count, athleteId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
protected void updateStravaMatchStatus(String stravaId) {
|
||||
StravaActivity sa = stravaActivityDAO.findByOriginId(stravaId);
|
||||
if (sa != null) {
|
||||
sa.intervalsEnrichmentStatus = IntervalsEnrichmentStatusEnum.ENRICHED;
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
protected void markUnmatched(Long athleteId, List<IntervalsClient.IntervalsActivity> fetched) {
|
||||
Set<String> matchedStravaIds = new HashSet<>();
|
||||
for (IntervalsClient.IntervalsActivity a : fetched) {
|
||||
if (a.external_id != null) {
|
||||
matchedStravaIds.add(a.external_id);
|
||||
}
|
||||
}
|
||||
for (StravaActivity sa : stravaActivityDAO.getPendingEnrichment(athleteId)) {
|
||||
if (!matchedStravaIds.contains(sa.originId)) {
|
||||
sa.intervalsEnrichmentStatus = IntervalsEnrichmentStatusEnum.NOT_FOUND;
|
||||
}
|
||||
}
|
||||
allRaws.stream()
|
||||
.filter(a -> a.start_date == null)
|
||||
.forEach(a -> log.warn("Skipping intervals.icu activity without start_date: id={}, name={}", a.id, a.name));
|
||||
List<IntervalsClient.IntervalsActivity> raws = allRaws.stream()
|
||||
.filter(a -> a.start_date != null)
|
||||
.toList();
|
||||
int count = persist(athleteId, raws);
|
||||
log.info("Imported {} intervals.icu activities for athleteId {}", count, athleteId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
package com.peakup.activity.model;
|
||||
|
||||
import com.peakup.model.AbstractTimestampedEntity;
|
||||
import jakarta.annotation.Nullable;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
@Entity
|
||||
@Table(name = "intervals_activity")
|
||||
@@ -53,9 +54,6 @@ public class IntervalsActivity extends AbstractActivity {
|
||||
@Column
|
||||
public Double efficiencyFactor;
|
||||
|
||||
@Column(name = "average_watts_kg")
|
||||
public Double averageWattsKg;
|
||||
|
||||
@Column
|
||||
public @Nullable Integer ftp;
|
||||
|
||||
@@ -66,16 +64,26 @@ public class IntervalsActivity extends AbstractActivity {
|
||||
public Double ctl;
|
||||
|
||||
@Column
|
||||
public Double form;
|
||||
public @Nullable Double hrrBpmDrop;
|
||||
|
||||
@Column
|
||||
public Double rampRate;
|
||||
public @Nullable Integer hrrStartBpm;
|
||||
|
||||
@Column
|
||||
public Double loadRatio;
|
||||
public @Nullable Integer hrrEndBpm;
|
||||
|
||||
@Column(name = "hrr_percent")
|
||||
public Double hrrPercent;
|
||||
@Column(name = "hrr_start_time_s")
|
||||
public @Nullable Integer hrrStartTimeSec;
|
||||
|
||||
@Column(name = "hrr_end_time_s")
|
||||
public @Nullable Integer hrrEndTimeSec;
|
||||
|
||||
@Column
|
||||
public @Nullable Integer hrrAverageWatts;
|
||||
|
||||
@Column(columnDefinition = "jsonb")
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
public @Nullable String icuZoneTimes;
|
||||
|
||||
@Column
|
||||
public Double variabilityIndex;
|
||||
@@ -86,24 +94,9 @@ public class IntervalsActivity extends AbstractActivity {
|
||||
@Column
|
||||
public Double perceivedExertion;
|
||||
|
||||
@Column
|
||||
public @Nullable Integer sufferScore;
|
||||
|
||||
@Column(name = "average_stride_length_m")
|
||||
public Double averageStrideLengthM;
|
||||
|
||||
@Column(name = "average_vertical_oscillation_mm")
|
||||
public Double averageVerticalOscillationMm;
|
||||
|
||||
@Column(name = "average_ground_contact_time_ms")
|
||||
public Double averageGroundContactTimeMs;
|
||||
|
||||
@Column
|
||||
public Double averageVerticalRatio;
|
||||
|
||||
@Column
|
||||
public @Nullable Long numSteps;
|
||||
|
||||
@Column(name = "average_temp_c")
|
||||
public Double averageTempC;
|
||||
|
||||
|
||||
@@ -25,53 +25,67 @@ public interface IntervalsClient {
|
||||
@QueryParam("newest") String newest // YYYY-MM-DD
|
||||
);
|
||||
|
||||
@GET
|
||||
@Path("/activity/{id}")
|
||||
IntervalsActivity getActivity(@PathParam("id") String id);
|
||||
|
||||
class IntervalsActivity {
|
||||
|
||||
public @Nullable String id; // intervals.icu activity id, e.g. "a12345678"
|
||||
public @Nullable String external_id; // Strava activity id — used for matching
|
||||
public @Nullable String start_date; // ISO-8601, e.g. "2024-03-01T09:00:00Z"
|
||||
|
||||
// Metadata
|
||||
public @Nullable String type;
|
||||
public @Nullable String timezone;
|
||||
public @Nullable String name;
|
||||
public @Nullable String description;
|
||||
public @Nullable Boolean trainer;
|
||||
public @Nullable Boolean commute;
|
||||
public @Nullable Boolean race;
|
||||
public @Nullable String gear_id;
|
||||
public @Nullable StravaGear gear;
|
||||
public @Nullable Double distance;
|
||||
public @Nullable Long moving_time;
|
||||
public @Nullable Long elapsed_time;
|
||||
public @Nullable Double average_speed;
|
||||
public @Nullable Double max_speed;
|
||||
public @Nullable Integer average_heartrate;
|
||||
public @Nullable Integer max_heartrate;
|
||||
public @Nullable Double average_cadence;
|
||||
public @Nullable Integer calories;
|
||||
public @Nullable Integer icu_average_watts;
|
||||
public @Nullable Integer icu_weighted_avg_watts;
|
||||
public @Nullable Integer icu_joules;
|
||||
|
||||
// Elevation detail
|
||||
public @Nullable Double icu_total_ascent;
|
||||
public @Nullable Double icu_total_descent;
|
||||
public @Nullable Double icu_min_altitude;
|
||||
public @Nullable Double icu_max_altitude;
|
||||
public @Nullable Double total_elevation_gain;
|
||||
public @Nullable Double total_elevation_loss;
|
||||
public @Nullable Double min_altitude;
|
||||
public @Nullable Double max_altitude;
|
||||
|
||||
// Training load
|
||||
public @Nullable Double icu_training_load;
|
||||
public @Nullable Integer icu_training_load;
|
||||
public @Nullable Double icu_intensity; // Intensity Factor (IF)
|
||||
public @Nullable Double icu_ef; // Efficiency Factor
|
||||
public @Nullable Double icu_average_watts_kg;
|
||||
public @Nullable Double icu_efficiency_factor; // Efficiency Factor
|
||||
public @Nullable Integer icu_ftp;
|
||||
|
||||
// Fitness state (ATL / CTL / TSB)
|
||||
public @Nullable Double icu_atl;
|
||||
public @Nullable Double icu_ctl;
|
||||
public @Nullable Double icu_form;
|
||||
public @Nullable Double icu_ramprate;
|
||||
public @Nullable Double icu_load_ratio;
|
||||
|
||||
// HR metrics
|
||||
public @Nullable Double hrr; // heart rate reserve %
|
||||
public @Nullable Double variability_index; // NP / AP
|
||||
public @Nullable IcuHrr icu_hrr;
|
||||
public @Nullable Double icu_variability_index; // NP / AP
|
||||
|
||||
// Subjective
|
||||
public @Nullable Integer feel; // 1–5
|
||||
public @Nullable Double perceived_exertion; // RPE 1–10
|
||||
public @Nullable Integer suffer_score;
|
||||
|
||||
// Power zone time-in-zone
|
||||
public @Nullable java.util.List<IcuZoneTime> icu_zone_times;
|
||||
|
||||
// Running dynamics
|
||||
public @Nullable Double average_stride_length; // meters
|
||||
public @Nullable Double average_vertical_oscillation; // mm
|
||||
public @Nullable Double average_ground_contact_time; // ms
|
||||
public @Nullable Double average_vertical_ratio;
|
||||
public @Nullable Long total_steps;
|
||||
public @Nullable Double average_stride; // meters
|
||||
|
||||
// Environment
|
||||
public @Nullable Double average_temp; // °C
|
||||
@@ -80,5 +94,32 @@ public interface IntervalsClient {
|
||||
private IntervalsActivity() {
|
||||
// jackson
|
||||
}
|
||||
|
||||
public static class StravaGear {
|
||||
public @Nullable String id;
|
||||
|
||||
@SuppressWarnings({"NullAway.Init", "unused"})
|
||||
private StravaGear() {}
|
||||
}
|
||||
|
||||
public static class IcuHrr {
|
||||
public @Nullable Integer start_time; // elapsed seconds at recovery start
|
||||
public @Nullable Integer end_time; // elapsed seconds at recovery end
|
||||
public @Nullable Integer start_bpm; // HR at start of recovery window
|
||||
public @Nullable Integer end_bpm; // HR at end of recovery window
|
||||
public @Nullable Integer average_watts; // power during recovery
|
||||
public @Nullable Integer hrr; // BPM drop over the recovery window
|
||||
|
||||
@SuppressWarnings({"NullAway.Init", "unused"})
|
||||
private IcuHrr() {}
|
||||
}
|
||||
|
||||
public static class IcuZoneTime {
|
||||
public @Nullable String id; // e.g. "Z1", "Z2", "SS"
|
||||
public @Nullable Integer secs; // seconds spent in zone
|
||||
|
||||
@SuppressWarnings({"NullAway.Init", "unused"})
|
||||
private IcuZoneTime() {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
package com.peakup.connector.intervals.scheduler;
|
||||
|
||||
import com.peakup.activity.importer.IntervalsActivityImporter;
|
||||
import com.peakup.connector.intervals.IntervalsContext;
|
||||
import com.peakup.connector.intervals.model.IntervalsConnector;
|
||||
import com.peakup.connector.intervals.service.IntervalsConnectorService;
|
||||
import io.quarkus.scheduler.Scheduled;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@lombok.extern.slf4j.Slf4j
|
||||
@ApplicationScoped
|
||||
public class IntervalsEnrichmentScheduler {
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
IntervalsConnectorService intervalsConnectorService;
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
IntervalsActivityImporter intervalsActivityImporter;
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
IntervalsContext intervalsContext;
|
||||
|
||||
@Scheduled(every = "10m")
|
||||
void importActivities() {
|
||||
List<IntervalsConnector> connectors = intervalsConnectorService.getIntervalsConnectors();
|
||||
for (IntervalsConnector connector : connectors) {
|
||||
try {
|
||||
intervalsContext.setAthleteId(connector.getAthleteId());
|
||||
intervalsActivityImporter.importActivities(
|
||||
connector.getAthleteId(),
|
||||
connector.getIntervalsAthleteId()
|
||||
);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to import intervals activities for athleteId {}.", connector.getAthleteId(), e);
|
||||
} finally {
|
||||
intervalsContext.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
@com.peakup.common.annotation.NonNull
|
||||
package com.peakup.connector.intervals.scheduler;
|
||||
@@ -1,32 +0,0 @@
|
||||
package com.peakup.connector.strava.scheduler;
|
||||
|
||||
import com.peakup.activity.importer.StravaActivityImporter;
|
||||
import com.peakup.connector.strava.model.StravaConnector;
|
||||
import com.peakup.connector.strava.service.StravaConnectorService;
|
||||
import io.quarkus.scheduler.Scheduled;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@lombok.extern.slf4j.Slf4j
|
||||
@ApplicationScoped
|
||||
public class StravaScheduler {
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
StravaConnectorService stravaConnectorService;
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
StravaActivityImporter stravaActivityImporter;
|
||||
|
||||
@Scheduled(every = "5m")
|
||||
void importActivities() {
|
||||
List<StravaConnector> stravaConnectors = stravaConnectorService.getStravaConnectors();
|
||||
for (StravaConnector stravaConnector : stravaConnectors) {
|
||||
stravaActivityImporter.importActivities(stravaConnector.getAthleteId());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
@com.peakup.common.annotation.NonNull
|
||||
package com.peakup.connector.strava.scheduler;
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.peakup.scheduler;
|
||||
|
||||
import com.peakup.activity.importer.IntervalsActivityImporter;
|
||||
import com.peakup.activity.importer.StravaActivityImporter;
|
||||
import com.peakup.connector.intervals.IntervalsContext;
|
||||
import com.peakup.connector.intervals.model.IntervalsConnector;
|
||||
import com.peakup.connector.intervals.service.IntervalsConnectorService;
|
||||
import com.peakup.connector.strava.model.StravaConnector;
|
||||
import com.peakup.connector.strava.service.StravaConnectorService;
|
||||
import io.quarkus.scheduler.Scheduled;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
@lombok.extern.slf4j.Slf4j
|
||||
@ApplicationScoped
|
||||
public class ActivityImportScheduler {
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
StravaConnectorService stravaConnectorService;
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
StravaActivityImporter stravaActivityImporter;
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
IntervalsConnectorService intervalsConnectorService;
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
IntervalsActivityImporter intervalsActivityImporter;
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
IntervalsContext intervalsContext;
|
||||
|
||||
private final AtomicBoolean running = new AtomicBoolean(false);
|
||||
|
||||
@Scheduled(every = "5m")
|
||||
void importActivities() {
|
||||
if (!running.compareAndSet(false, true)) {
|
||||
log.warn("Import already running, skipping this trigger.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
for (StravaConnector connector : stravaConnectorService.getStravaConnectors()) {
|
||||
try {
|
||||
stravaActivityImporter.importActivities(connector.getAthleteId());
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to import Strava activities for athleteId {}.", connector.getAthleteId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
for (IntervalsConnector connector : intervalsConnectorService.getIntervalsConnectors()) {
|
||||
try {
|
||||
intervalsContext.setAthleteId(connector.getAthleteId());
|
||||
intervalsActivityImporter.importActivities(connector.getAthleteId(), connector.getIntervalsAthleteId());
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to import Intervals activities for athleteId {}.", connector.getAthleteId(), e);
|
||||
} finally {
|
||||
intervalsContext.clear();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
running.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
@com.peakup.common.annotation.NonNull
|
||||
package com.peakup.scheduler;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- V7: Widen origin ID columns on intervals_activity — external IDs can exceed VARCHAR(50)
|
||||
ALTER TABLE intervals_activity
|
||||
ALTER COLUMN strava_origin_id TYPE TEXT,
|
||||
ALTER COLUMN origin_id TYPE TEXT;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- V8: Expand icu_hrr from scalar to full nested object fields; add icu_zone_times JSONB
|
||||
ALTER TABLE intervals_activity
|
||||
RENAME COLUMN hrr_percent TO hrr_bpm_drop;
|
||||
|
||||
ALTER TABLE intervals_activity
|
||||
ADD COLUMN hrr_start_bpm INTEGER,
|
||||
ADD COLUMN hrr_end_bpm INTEGER,
|
||||
ADD COLUMN hrr_start_time_s INTEGER,
|
||||
ADD COLUMN hrr_end_time_s INTEGER,
|
||||
ADD COLUMN hrr_average_watts INTEGER,
|
||||
ADD COLUMN icu_zone_times JSONB;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- V9: Drop phantom columns that never had API data
|
||||
ALTER TABLE intervals_activity
|
||||
DROP COLUMN average_watts_kg,
|
||||
DROP COLUMN form,
|
||||
DROP COLUMN ramp_rate,
|
||||
DROP COLUMN load_ratio,
|
||||
DROP COLUMN suffer_score,
|
||||
DROP COLUMN average_vertical_oscillation_mm,
|
||||
DROP COLUMN average_ground_contact_time_ms,
|
||||
DROP COLUMN average_vertical_ratio,
|
||||
DROP COLUMN num_steps;
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.peakup.activity.importer;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.peakup.activity.model.IntervalsActivity;
|
||||
import com.peakup.connector.intervals.IntervalsClient;
|
||||
import com.peakup.connector.intervals.IntervalsContext;
|
||||
import com.peakup.connector.intervals.service.IntervalsConnectorService;
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import jakarta.inject.Inject;
|
||||
import org.eclipse.microprofile.rest.client.inject.RestClient;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import static com.peakup.common.NullableUtils.defaultValue;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@QuarkusTest
|
||||
class IntervalsActivityMappingTest {
|
||||
|
||||
private static final String INTERVALS_ATHLETE_ID = "i157824";
|
||||
private static final List<String> ORIGIN_IDS = List.of(
|
||||
"i132106734", "i130231052", "i112771483", "i120252407", "i94213198"
|
||||
);
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
IntervalsContext intervalsContext;
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
IntervalsActivityImporter importer;
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
IntervalsConnectorService connectorService;
|
||||
|
||||
@Inject
|
||||
@RestClient
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
IntervalsClient intervalsClient;
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
ObjectMapper objectMapper;
|
||||
|
||||
private long athleteId;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
athleteId = connectorService.getIntervalsConnectors().stream()
|
||||
.filter(c -> INTERVALS_ATHLETE_ID.equals(c.getIntervalsAthleteId()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalStateException("No intervals connector found for " + INTERVALS_ATHLETE_ID))
|
||||
.getAthleteId();
|
||||
intervalsContext.setAthleteId(athleteId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAllFieldsMappedCorrectly() {
|
||||
for (String originId : ORIGIN_IDS) {
|
||||
IntervalsClient.IntervalsActivity raw = intervalsClient.getActivity(originId);
|
||||
IntervalsActivity mapped = importer.map(raw, athleteId);
|
||||
assertMapping(raw, mapped, "[" + originId + "] ");
|
||||
}
|
||||
}
|
||||
|
||||
private void assertMapping(IntervalsClient.IntervalsActivity raw, IntervalsActivity entity, String ctx) {
|
||||
// --- AbstractActivity fields ---
|
||||
assertEquals(defaultValue(raw.id), entity.originId, ctx + "originId");
|
||||
assertEquals(defaultValue(raw.external_id), entity.stravaOriginId, ctx + "stravaOriginId");
|
||||
if (raw.start_date != null) {
|
||||
assertEquals(Instant.parse(raw.start_date), entity.startTime, ctx + "startTime");
|
||||
}
|
||||
assertEquals(defaultValue(raw.type), entity.type, ctx + "type");
|
||||
assertEquals(defaultValue(raw.type), entity.sportType, ctx + "sportType");
|
||||
assertEquals(defaultValue(raw.timezone), entity.timezone, ctx + "timezone");
|
||||
assertEquals(defaultValue(raw.name), entity.name, ctx + "name");
|
||||
assertEquals(defaultValue(raw.distance), entity.distanceM, 0.001, ctx + "distanceM");
|
||||
assertEquals(raw.moving_time != null ? raw.moving_time : 0L, entity.movingTimeS, ctx + "movingTimeS");
|
||||
assertEquals(raw.elapsed_time != null ? raw.elapsed_time : 0L, entity.elapsedTimeS, ctx + "elapsedTimeS");
|
||||
assertEquals(defaultValue(raw.average_speed), entity.averageSpeedMps, 0.001, ctx + "averageSpeedMps");
|
||||
assertEquals(defaultValue(raw.max_speed), entity.maxSpeedMps, 0.001, ctx + "maxSpeedMps");
|
||||
assertEquals(defaultValue(raw.average_heartrate), entity.averageHeartrateBpm, 0.001, ctx + "averageHeartrateBpm");
|
||||
assertEquals(raw.max_heartrate != null ? raw.max_heartrate.doubleValue() : 0.0, entity.maxHeartrateBPM, 0.001, ctx + "maxHeartrateBPM");
|
||||
assertEquals(defaultValue(raw.average_cadence), entity.averageCadenceRPM, 0.001, ctx + "averageCadenceRPM");
|
||||
assertEquals(defaultValue(raw.calories), entity.calories, 0.001, ctx + "calories");
|
||||
assertEquals(raw.icu_average_watts != null ? raw.icu_average_watts.doubleValue() : 0.0, entity.averagePowerWatt, 0.001, ctx + "averagePowerWatt");
|
||||
assertEquals(raw.icu_weighted_avg_watts != null ? raw.icu_weighted_avg_watts.doubleValue() : 0.0, entity.normalizedPowerWatt, 0.001, ctx + "normalizedPowerWatt");
|
||||
assertEquals(raw.icu_joules != null ? raw.icu_joules / 1000.0 : 0.0, entity.totalWorkKJ, 0.001, ctx + "totalWorkKJ");
|
||||
assertEquals(defaultValue(raw.total_elevation_gain), entity.totalElevationGainM, 0.001, ctx + "totalElevationGainM");
|
||||
|
||||
// --- IntervalsActivity-specific fields ---
|
||||
assertEquals(raw.description, entity.description, ctx + "description");
|
||||
assertEquals(raw.trainer, entity.trainer, ctx + "trainer");
|
||||
assertEquals(raw.commute, entity.commute, ctx + "commute");
|
||||
assertEquals(raw.race, entity.race, ctx + "race");
|
||||
assertEquals(raw.gear != null ? defaultValue(raw.gear.id) : "", entity.gearId, ctx + "gearId");
|
||||
assertEquals(defaultValue(raw.total_elevation_gain), entity.totalAscentM, 0.001, ctx + "totalAscentM");
|
||||
assertEquals(defaultValue(raw.total_elevation_loss), entity.totalDescentM, 0.001, ctx + "totalDescentM");
|
||||
assertEquals(defaultValue(raw.min_altitude), entity.minAltitudeM, 0.001, ctx + "minAltitudeM");
|
||||
assertEquals(defaultValue(raw.max_altitude), entity.maxAltitudeM, 0.001, ctx + "maxAltitudeM");
|
||||
assertEquals(defaultValue(raw.icu_training_load), entity.trainingLoad, 0.001, ctx + "trainingLoad");
|
||||
assertEquals(defaultValue(raw.icu_intensity), entity.intensityFactor, 0.001, ctx + "intensityFactor");
|
||||
assertEquals(defaultValue(raw.icu_efficiency_factor), entity.efficiencyFactor, 0.001, ctx + "efficiencyFactor");
|
||||
assertEquals(raw.icu_ftp, entity.ftp, ctx + "ftp");
|
||||
assertEquals(defaultValue(raw.icu_atl), entity.atl, 0.001, ctx + "atl");
|
||||
assertEquals(defaultValue(raw.icu_ctl), entity.ctl, 0.001, ctx + "ctl");
|
||||
assertEquals(defaultValue(raw.icu_variability_index), entity.variabilityIndex, 0.001, ctx + "variabilityIndex");
|
||||
assertEquals(raw.feel, entity.feel, ctx + "feel");
|
||||
assertEquals(defaultValue(raw.perceived_exertion), entity.perceivedExertion, 0.001, ctx + "perceivedExertion");
|
||||
assertEquals(defaultValue(raw.average_stride), entity.averageStrideLengthM, 0.001, ctx + "averageStrideLengthM");
|
||||
assertEquals(defaultValue(raw.average_temp), entity.averageTempC, 0.001, ctx + "averageTempC");
|
||||
|
||||
// --- HRR block ---
|
||||
if (raw.icu_hrr != null) {
|
||||
assertEquals(raw.icu_hrr.hrr != null ? raw.icu_hrr.hrr.doubleValue() : 0.0, defaultValue(entity.hrrBpmDrop), 0.001, ctx + "hrrBpmDrop");
|
||||
assertEquals(raw.icu_hrr.start_bpm, entity.hrrStartBpm, ctx + "hrrStartBpm");
|
||||
assertEquals(raw.icu_hrr.end_bpm, entity.hrrEndBpm, ctx + "hrrEndBpm");
|
||||
assertEquals(raw.icu_hrr.start_time, entity.hrrStartTimeSec, ctx + "hrrStartTimeSec");
|
||||
assertEquals(raw.icu_hrr.end_time, entity.hrrEndTimeSec, ctx + "hrrEndTimeSec");
|
||||
assertEquals(raw.icu_hrr.average_watts, entity.hrrAverageWatts, ctx + "hrrAverageWatts");
|
||||
}
|
||||
|
||||
// --- Zone times (JSON) ---
|
||||
if (raw.icu_zone_times != null) {
|
||||
assertNotNull(entity.icuZoneTimes, ctx + "icuZoneTimes should not be null");
|
||||
try {
|
||||
assertEquals(
|
||||
objectMapper.readTree(objectMapper.writeValueAsString(raw.icu_zone_times)),
|
||||
objectMapper.readTree(entity.icuZoneTimes),
|
||||
ctx + "icuZoneTimes JSON content"
|
||||
);
|
||||
} catch (JsonProcessingException e) {
|
||||
fail(ctx + "icuZoneTimes JSON parse failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Athlete and Strava Connector seed data for Ingo and Mirja Schnabel
|
||||
-- Generated from local database on 2026-03-08
|
||||
|
||||
-- Athletes
|
||||
INSERT INTO athlete (id, first_name, last_name, weight, sex, created_at, modified_at)
|
||||
VALUES (90, 'Ingo', 'Schnabel', 93, NULL, '2025-12-29 18:40:14.968397+00', '2025-12-29 18:40:14.968397+00'),
|
||||
(91, 'Mirja', 'Schnabel', 50, NULL, '2026-01-17 16:44:40.548585+00', '2026-01-17 16:44:40.548585+00');
|
||||
|
||||
-- Strava Connectors
|
||||
INSERT INTO strava_connector (id, athlete_id, strava_athlete_id, access_token, refresh_token, expires_at, client_id,
|
||||
client_secret, created_at, modified_at)
|
||||
VALUES (91, 90, '36523216', '12c757d282015eb26c21117c3a9a561b038ad2cb', '685ae77fcc95c4349231d9cd2657c4dc5ebb2388',
|
||||
1773023973, '116054', '59459bc900769ab3bb20d0ece6f3552b51d969ea', '2025-12-29 18:40:31.367635+00',
|
||||
'2026-03-08 20:39:33.608005+00'),
|
||||
(1530, 91, '108631588', 'f6ae8f13bc259339a417089c5d39e134d3fafb01', '4cce30a1b809a35180415c2c047bedaa2c2b2ddc',
|
||||
1773023974, '196530', '9c251d9add6a46b4af26d371108bfb84d42f2ec8', '2026-01-17 16:44:42.889363+00',
|
||||
'2026-03-08 20:39:35.183066+00');
|
||||
|
||||
-- Intervals Connectors
|
||||
INSERT INTO intervals_connector (id, athlete_id, intervals_athlete_id, api_key, created_at, modified_at)
|
||||
VALUES (nextval('global_id_seq'), 90, 'use athlet', '', now(), now()),
|
||||
(nextval('global_id_seq'), 91, 'i310361', '7er8wec6ziuaitp6nrw6wteqf', now(), now());
|
||||
|
||||
1
resources/intervals.icu.json
Normal file
1
resources/intervals.icu.json
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user