Intervals icu activity

This commit is contained in:
2026-03-15 13:30:14 +01:00
parent 2cc988ac73
commit f109509cd2
19 changed files with 437 additions and 207 deletions

2
.idea/compiler.xml generated
View File

@@ -124,7 +124,7 @@
</component>
<component name="JavacSettings">
<option name="ADDITIONAL_OPTIONS_OVERRIDE">
<module name="peakup-backend" options="-XDcompilePolicy=simple -XDshould-stop.ifError=FLOW &quot;-Xplugin:ErrorProne -Xep:NullAway:ERROR -XepOpt:NullAway:AnnotatedPackages=com.peakup&#10; -XepOpt:NullAway:TreatGeneratedAsUnannotated=true&#10; -Xep:MissingSummary:OFF&quot; -Xpkginfo:always" />
<module name="peakup-backend" options="-XDcompilePolicy=simple -XDshould-stop.ifError=FLOW &quot;-Xplugin:ErrorProne -Xep:NullAway:ERROR -XepOpt:NullAway:AnnotatedPackages=com.peakup&#10; -XepOpt:NullAway:TreatGeneratedAsUnannotated=true&#10; -Xep:MissingSummary:OFF&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.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
View 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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,2 +0,0 @@
@com.peakup.common.annotation.NonNull
package com.peakup.connector.intervals.scheduler;

View File

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

View File

@@ -1,2 +0,0 @@
@com.peakup.common.annotation.NonNull
package com.peakup.connector.strava.scheduler;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long