Add streams

This commit is contained in:
2026-03-15 19:20:42 +01:00
parent f109509cd2
commit bc5cba563b
24 changed files with 2230 additions and 44 deletions

2
.idea/dataSources.xml generated
View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="peakup@localhost" uuid="fe9ac26c-536e-42d2-a0d0-b6ae02f4e756">
<data-source source="LOCAL" name="peakup@localhost" uuid="146146a8-fc52-45aa-b5e3-48b7a37a3a5f">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>

View File

@@ -6,8 +6,6 @@ import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.List;
@ApplicationScoped
@@ -22,6 +20,10 @@ public class IntervalsActivityConnector implements ActivityConnector<IntervalsCl
return intervalsClient.getActivities(intervalsAthleteId, oldest, newest);
}
public IntervalsClient.StreamsData getStreams(String activityId) {
return intervalsClient.getActivityStreams(activityId);
}
@Override
public List<IntervalsClient.IntervalsActivity> fetchRawActivities(
@Nullable Instant after, @Nullable Instant before) {

View File

@@ -0,0 +1,28 @@
package com.peakup.activity.dao;
import com.peakup.activity.model.ActivityAchievement;
import com.peakup.activity.model.ActivityAchievement_;
import com.peakup.dao.AbstractDAO;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Root;
import java.util.List;
@ApplicationScoped
public class ActivityAchievementDAO extends AbstractDAO<ActivityAchievement> {
@Override
protected Class<ActivityAchievement> getEntityClass() {
return ActivityAchievement.class;
}
public List<ActivityAchievement> findByActivityId(long activityId) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<ActivityAchievement> cq = cb.createQuery(ActivityAchievement.class);
Root<ActivityAchievement> root = cq.from(ActivityAchievement.class);
cq.where(cb.equal(root.get(ActivityAchievement_.activityId), activityId));
return em.createQuery(cq).getResultList();
}
}

View File

@@ -0,0 +1,70 @@
package com.peakup.activity.dao;
import com.peakup.activity.model.ActivityStreamPoint;
import jakarta.annotation.Nullable;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import javax.sql.DataSource;
import java.sql.*;
import java.util.List;
@lombok.extern.slf4j.Slf4j
@ApplicationScoped
public class ActivityStreamDAO {
@Inject
@SuppressWarnings("NullAway.Init")
DataSource dataSource;
private static final String INSERT_SQL = """
INSERT INTO activity_stream_point
(time, activity_id, elapsed_s, distance_m, altitude_m, heartrate_bpm, watts,
cadence_rpm, velocity_mps, lat, lng, temp_c, grade_smooth, moving, lr_balance, extra)
VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb)
ON CONFLICT (activity_id, time) DO NOTHING
""";
public void insertBatch(List<ActivityStreamPoint> points) throws SQLException {
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(INSERT_SQL)) {
for (ActivityStreamPoint p : points) {
ps.setTimestamp(1, Timestamp.from(p.getTime()));
ps.setLong (2, p.getActivityId());
ps.setInt (3, p.getElapsedS());
setNullable(ps, 4, p.getDistanceM(), Types.DOUBLE);
setNullable(ps, 5, p.getAltitudeM(), Types.DOUBLE);
setNullable(ps, 6, p.getHeartrateBpm(), Types.INTEGER);
setNullable(ps, 7, p.getWatts(), Types.INTEGER);
setNullable(ps, 8, p.getCadenceRpm(), Types.DOUBLE);
setNullable(ps, 9, p.getVelocityMps(), Types.DOUBLE);
setNullable(ps, 10, p.getLat(), Types.DOUBLE);
setNullable(ps, 11, p.getLng(), Types.DOUBLE);
setNullable(ps, 12, p.getTempC(), Types.DOUBLE);
setNullable(ps, 13, p.getGradeSmooth(), Types.DOUBLE);
setNullable(ps, 14, p.getMoving(), Types.BOOLEAN);
setNullable(ps, 15, p.getLrBalance(), Types.DOUBLE);
setNullable(ps, 16, p.getExtra(), Types.VARCHAR);
ps.addBatch();
}
ps.executeBatch();
log.debug("Inserted {} stream points for activity {}",
points.size(), points.isEmpty() ? "?" : points.get(0).getActivityId());
}
}
private void setNullable(PreparedStatement ps, int idx, @Nullable Object val, int sqlType) throws SQLException {
if (val == null) {
ps.setNull(idx, sqlType);
} else if (val instanceof Double d) {
ps.setDouble(idx, d);
} else if (val instanceof Integer i) {
ps.setInt(idx, i);
} else if (val instanceof Boolean b) {
ps.setBoolean(idx, b);
} else {
ps.setString(idx, val.toString());
}
}
}

View File

@@ -3,17 +3,27 @@ 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.ActivityAchievementDAO;
import com.peakup.activity.dao.ActivityStreamDAO;
import com.peakup.activity.dao.IntervalsActivityDAO;
import com.peakup.activity.model.ActivityAchievement;
import com.peakup.activity.model.ActivityAttachment;
import com.peakup.activity.model.ActivityStreamPoint;
import com.peakup.activity.model.ActivityZone;
import com.peakup.activity.model.IgnorePart;
import com.peakup.activity.model.IntervalsActivity;
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.sql.SQLException;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import static com.peakup.common.NullableUtils.defaultValue;
@@ -27,6 +37,14 @@ public class IntervalsActivityImporter
@SuppressWarnings("NullAway.Init")
IntervalsActivityDAO intervalsActivityDAO;
@Inject
@SuppressWarnings("NullAway.Init")
ActivityAchievementDAO activityAchievementDAO;
@Inject
@SuppressWarnings("NullAway.Init")
ActivityStreamDAO activityStreamDAO;
@Inject
@SuppressWarnings("NullAway.Init")
IntervalsActivityConnector intervalsActivityConnector;
@@ -35,6 +53,10 @@ public class IntervalsActivityImporter
@SuppressWarnings("NullAway.Init")
ObjectMapper objectMapper;
@Inject
@SuppressWarnings("NullAway.Init")
IntervalsActivityImporter self; // CDI proxy — enables @Transactional on persistSingle()
@Override
protected AbstractDAO<IntervalsActivity> getDAO() {
return intervalsActivityDAO;
@@ -62,7 +84,14 @@ public class IntervalsActivityImporter
a.trainer = src.trainer;
a.commute = src.commute;
a.race = src.race;
a.gearId = src.gear != null ? defaultValue(src.gear.id) : "";
if (src.gear != null) {
a.gearId = defaultValue(src.gear.id);
a.gearName = src.gear.name;
a.gearDistanceM = src.gear.distance;
a.gearPrimary = src.gear.primary;
} else {
a.gearId = "";
}
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;
@@ -94,21 +123,287 @@ public class IntervalsActivityImporter
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.averageStrideLengthM = defaultValue(src.average_stride);
a.averageTempC = defaultValue(src.average_temp);
a.minTempC = src.min_temp;
a.maxTempC = src.max_temp;
a.avgLrBalance = src.avg_lr_balance;
a.gapMps = src.gap;
a.gapModel = src.gap_model;
a.deviceName = src.device_name;
a.deviceWatts = src.device_watts;
a.powerMeter = src.power_meter;
a.powerMeterSerial = src.power_meter_serial;
a.powerMeterBattery = src.power_meter_battery;
a.crankLengthMm = src.crank_length;
a.powerField = src.power_field;
a.useElevationCorrection = src.use_elevation_correction;
a.carbsUsedG = src.carbs_used;
a.icuDistanceM = src.icu_distance;
a.icuIgnoreTime = src.icu_ignore_time;
a.icuRecordingTimeS = src.icu_recording_time;
a.coastingTimeS = src.coasting_time;
a.icuWarmupTimeS = src.icu_warmup_time;
a.icuCooldownTimeS = src.icu_cooldown_time;
a.subType = src.sub_type;
a.pairedEventId = src.paired_event_id;
a.icuWeightKg = src.icu_weight;
a.lthr = src.lthr;
a.icuRestingHr = src.icu_resting_hr;
a.thresholdPaceMps = src.threshold_pace;
a.icuPowerSpikeThreshold = src.icu_power_spike_threshold;
a.icuSweetSpotMin = src.icu_sweet_spot_min;
a.icuSweetSpotMax = src.icu_sweet_spot_max;
a.trimp = src.trimp;
a.icuWPrimeJ = src.icu_w_prime;
a.pMaxWatt = src.p_max;
a.icuPmCp = src.icu_pm_cp;
a.icuPmWPrimeJ = src.icu_pm_w_prime;
a.icuPmPMaxWatt = src.icu_pm_p_max;
a.icuPmFtp = src.icu_pm_ftp;
a.icuPmFtpSecs = src.icu_pm_ftp_secs;
a.icuPmFtpWatts = src.icu_pm_ftp_watts;
a.icuRollingCp = src.icu_rolling_cp;
a.icuRollingWPrimeJ = src.icu_rolling_w_prime;
a.icuRollingPMaxWatt = src.icu_rolling_p_max;
a.icuRollingFtp = src.icu_rolling_ftp;
a.icuRollingFtpDelta = src.icu_rolling_ftp_delta;
a.ssCp = src.ss_cp;
a.ssWPrimeJ = src.ss_w_prime;
a.ssPMaxWatt = src.ss_p_max;
a.icuIgnorePower = src.icu_ignore_power;
a.icuIgnoreHr = src.icu_ignore_hr;
a.ignoreVelocity = src.ignore_velocity;
a.ignorePace = src.ignore_pace;
a.hasHeartrate = src.has_heartrate;
a.analyzed = src.analyzed;
a.hasWeather = src.has_weather;
a.hasSegments = src.has_segments;
a.fileType = src.file_type;
a.fileSportIndex = src.file_sport_index;
a.icuAthleteId = src.icu_athlete_id;
a.icuCreated = src.created;
a.icuSyncDate = src.icu_sync_date;
a.icuChatId = src.icu_chat_id;
a.startDateLocal = src.start_date_local;
a.icuHrZones = toJson(src.icu_hr_zones);
a.paceZones = toJson(src.pace_zones);
a.icuPowerZones = toJson(src.icu_power_zones);
a.icuTrainingLoadData = toJson(src.icu_training_load_data);
a.intervalSummary = toJson(src.interval_summary);
a.skylineChartBytes = src.skyline_chart_bytes;
if (src.stream_types != null) {
a.streamTypes = src.stream_types.toArray(new String[0]);
}
a.icuZoneTimes = toJson(src.icu_zone_times);
a.icuHrZoneTimes = toJson(src.icu_hr_zone_times);
a.paceZoneTimes = toJson(src.pace_zone_times);
a.gapZoneTimes = toJson(src.gap_zone_times);
a.useGapZoneTimes = src.use_gap_zone_times;
a.tizOrder = src.tiz_order;
a.polarizationIndex = src.polarization_index;
a.decoupling = src.decoupling;
a.icuLapCount = src.icu_lap_count;
a.icuJoulesAboveFtp = src.icu_joules_above_ftp;
a.icuMaxWbalDepletion = src.icu_max_wbal_depletion;
a.icuPowerHrZ2 = src.icu_power_hr_z2;
a.icuPowerHrZ2Mins = src.icu_power_hr_z2_mins;
a.icuCadenceZ2 = src.icu_cadence_z2;
a.icuRpe = src.icu_rpe;
a.icuPowerHr = src.icu_power_hr;
a.icuMedianTimeDelta = src.icu_median_time_delta;
a.p30sExponent = src.p30s_exponent;
a.icuIntervalsEdited = src.icu_intervals_edited;
a.lockIntervals = src.lock_intervals;
a.workoutShiftSecs = src.workout_shift_secs;
a.poolLengthM = src.pool_length;
a.lengths = toJson(src.lengths);
a.powerLoad = src.power_load;
a.hrLoad = src.hr_load;
a.paceLoad = src.pace_load;
a.hrLoadType = src.hr_load_type;
a.paceLoadType = src.pace_load_type;
a.sessionRpe = src.session_rpe;
a.strainScore = src.strain_score;
a.kgLifted = src.kg_lifted;
a.averageAltitudeM = src.average_altitude;
a.averageWeatherTempC = src.average_weather_temp;
a.minWeatherTempC = src.min_weather_temp;
a.maxWeatherTempC = src.max_weather_temp;
a.averageFeelsLikeC = src.average_feels_like;
a.minFeelsLikeC = src.min_feels_like;
a.maxFeelsLikeC = src.max_feels_like;
a.averageWindSpeedMps = src.average_wind_speed;
a.averageWindGustMps = src.average_wind_gust;
a.prevailingWindDeg = src.prevailing_wind_deg;
a.headwindPercent = src.headwind_percent;
a.tailwindPercent = src.tailwind_percent;
a.averageClouds = src.average_clouds;
a.maxRainMm = src.max_rain;
a.maxSnowMm = src.max_snow;
a.carbsIngestedG = src.carbs_ingested;
a.paceMps = src.pace;
a.athleteMaxHr = src.athlete_max_hr;
a.compliance = src.compliance;
a.coachTick = src.coach_tick;
a.activityGroup = src.group;
a.icuSyncError = src.icu_sync_error;
a.icuColor = src.icu_color;
a.source = src.source;
a.oauthClientId = src.oauth_client_id;
a.oauthClientName = src.oauth_client_name;
a.stravaId = src.strava_id;
a.routeId = src.route_id;
a.powerFieldNames = toJson(src.power_field_names);
// recording_stops
if (src.recording_stops != null) {
a.recordingStops = src.recording_stops.stream().mapToInt(Integer::intValue).toArray();
}
// ignore_parts
if (src.ignore_parts != null) {
for (IntervalsClient.IntervalsActivity.Ignore ig : src.ignore_parts) {
IgnorePart part = new IgnorePart();
part.startIndex = ig.start_index != null ? ig.start_index : 0;
part.endIndex = ig.end_index != null ? ig.end_index : 0;
part.power = ig.power;
part.pace = ig.pace;
part.hr = ig.hr;
a.ignoreParts.add(part);
}
}
// tags
if (src.tags != null) {
a.tags.addAll(src.tags);
}
// attachments
if (src.attachments != null) {
for (IntervalsClient.IntervalsActivity.Attachment att : src.attachments) {
ActivityAttachment attachment = new ActivityAttachment();
attachment.attachmentId = att.id;
attachment.filename = att.filename;
attachment.mimetype = att.mimetype;
attachment.url = att.url;
a.attachments.add(attachment);
}
}
// custom_zones
if (src.custom_zones != null) {
for (IntervalsClient.IntervalsActivity.ZoneSet zoneSet : src.custom_zones) {
if (zoneSet.zones == null) continue;
for (IntervalsClient.IntervalsActivity.ZoneInfo zi : zoneSet.zones) {
ActivityZone zone = new ActivityZone();
zone.zoneSetCode = defaultValue(zoneSet.code);
zone.zoneId = defaultValue(zi.id);
zone.rangeStart = defaultValue(zi.start);
zone.rangeEnd = defaultValue(zi.end);
zone.valueStart = defaultValue(zi.start_value);
zone.valueEnd = defaultValue(zi.end_value);
zone.secs = zi.secs != null ? zi.secs : 0;
a.zones.add(zone);
}
}
}
return a;
}
@Override
protected int persist(Long athleteId, List<IntervalsClient.IntervalsActivity> raws) {
int count = 0;
for (IntervalsClient.IntervalsActivity raw : raws) {
if (self.persistSingle(raw, athleteId)) count++;
}
return count;
}
@Transactional
public boolean persistSingle(IntervalsClient.IntervalsActivity raw, Long athleteId) {
if (findExisting(raw) != null) {
log.warn("Already imported: id={}, name={}", raw.id, raw.name);
return false;
}
IntervalsActivity activity = map(raw, athleteId);
intervalsActivityDAO.insert(activity);
persistAchievements(raw, activity);
try {
importStreams(raw, activity.getId());
} catch (Exception e) {
log.warn("Stream import failed for activity {}: {}", raw.id, e.getMessage());
}
return true;
}
private void persistAchievements(IntervalsClient.IntervalsActivity raw, IntervalsActivity activity) {
if (raw.icu_achievements == null || activity.getId() == null) return;
for (IntervalsClient.IntervalsActivity.IcuAchievement src : raw.icu_achievements) {
ActivityAchievement ach = new ActivityAchievement();
ach.activityId = activity.getId();
ach.achievementId = src.id;
ach.type = src.type;
ach.message = src.message;
ach.watts = src.watts;
ach.secs = src.secs;
ach.value = src.value;
ach.distance = src.distance;
ach.pace = src.pace;
activityAchievementDAO.insert(ach);
}
}
private void importStreams(IntervalsClient.IntervalsActivity raw, @Nullable Long activityId)
throws SQLException {
if (raw.id == null || activityId == null || raw.start_date == null) return;
IntervalsClient.StreamsData streams = intervalsActivityConnector.getStreams(raw.id);
if (streams.size() == 0) return;
List<ActivityStreamPoint> points = mapStreams(streams, Instant.parse(raw.start_date), activityId);
activityStreamDAO.insertBatch(points);
}
// package-private for testing
List<ActivityStreamPoint> mapStreams(IntervalsClient.StreamsData streams, Instant startTime, long activityId) {
int n = streams.size();
List<ActivityStreamPoint> points = new ArrayList<>(n);
for (int i = 0; i < n; i++) {
List<Integer> timeList = streams.time;
if (timeList == null) break;
int elapsed = timeList.get(i);
ActivityStreamPoint p = new ActivityStreamPoint(startTime.plusSeconds(elapsed), activityId, elapsed);
if (streams.distance != null) p.setDistanceM(streams.distance.get(i));
if (streams.altitude != null) p.setAltitudeM(streams.altitude.get(i));
if (streams.heartrate != null) p.setHeartrateBpm(streams.heartrate.get(i));
if (streams.watts != null) p.setWatts(streams.watts.get(i));
if (streams.cadence != null) p.setCadenceRpm(streams.cadence.get(i));
if (streams.velocity_smooth != null) p.setVelocityMps(streams.velocity_smooth.get(i));
if (streams.latlng != null) p.setLat(streams.latlng.get(i));
if (streams.temp != null) p.setTempC(streams.temp.get(i));
if (streams.grade_smooth != null) p.setGradeSmooth(streams.grade_smooth.get(i));
if (streams.moving != null) p.setMoving(streams.moving.get(i));
if (streams.left_right_balance != null) p.setLrBalance(streams.left_right_balance.get(i));
if (!streams.unknownStreams.isEmpty()) p.setExtra(toJson(streams.unknownStreams));
points.add(p);
}
return points;
}
private @Nullable String toJson(@Nullable Object obj) {
if (obj == null) return null;
try {
return objectMapper.writeValueAsString(obj);
} catch (JsonProcessingException e) {
throw new RuntimeException("Failed to serialize JSON field", e);
}
}
public void importActivities(Long athleteId, String intervalsAthleteId) {
IntervalsActivity latest = intervalsActivityDAO.getLatestActivity(athleteId);
String oldest = latest != null

View File

@@ -0,0 +1,52 @@
package com.peakup.activity.model;
import com.peakup.model.AbstractDependentEntity;
import jakarta.annotation.Nullable;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Entity
@Table(name = "activity_achievement")
@Data
@EqualsAndHashCode(callSuper = true)
public class ActivityAchievement extends AbstractDependentEntity {
@Column(name = "activity_id")
public long activityId;
@Column(name = "achievement_id")
public @Nullable String achievementId;
@Column
public @Nullable String type;
@Column
public @Nullable String message;
@Column
public @Nullable Integer watts;
@Column
public @Nullable Integer secs;
@Column
public @Nullable Integer value;
@Column
public @Nullable Double distance;
@Column
public @Nullable Double pace;
@SuppressWarnings("NullAway.Init")
public ActivityAchievement() {
}
@Override
public long getFK() {
return getActivityId();
}
}

View File

@@ -0,0 +1,27 @@
package com.peakup.activity.model;
import jakarta.annotation.Nullable;
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import lombok.Data;
@Embeddable
@Data
public class ActivityAttachment {
@Column(name = "attachment_id")
public @Nullable String attachmentId;
@Column
public @Nullable String filename;
@Column
public @Nullable String mimetype;
@Column
public @Nullable String url;
@SuppressWarnings("NullAway.Init")
public ActivityAttachment() {
}
}

View File

@@ -0,0 +1,86 @@
package com.peakup.activity.model;
import jakarta.annotation.Nullable;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import com.peakup.model.AbstractTimescaleEntity;
import java.io.Serializable;
import java.time.Instant;
@Entity
@Table(name = "activity_stream_point")
@IdClass(ActivityStreamPoint.Key.class)
@Data
@EqualsAndHashCode(callSuper = true)
public class ActivityStreamPoint extends AbstractTimescaleEntity {
@Id
@Column(nullable = false)
private long activityId;
@Column(name = "elapsed_s", nullable = false)
private int elapsedS;
@Column(name = "distance_m")
private @Nullable Double distanceM;
@Column(name = "altitude_m")
private @Nullable Double altitudeM;
@Column(name = "heartrate_bpm")
private @Nullable Integer heartrateBpm;
@Column
private @Nullable Integer watts;
@Column(name = "cadence_rpm")
private @Nullable Double cadenceRpm;
@Column(name = "velocity_mps")
private @Nullable Double velocityMps;
@Column
private @Nullable Double lat;
@Column
private @Nullable Double lng;
@Column(name = "temp_c")
private @Nullable Double tempC;
@Column(name = "grade_smooth")
private @Nullable Double gradeSmooth;
@Column
private @Nullable Boolean moving;
@Column(name = "lr_balance")
private @Nullable Double lrBalance;
@Column(columnDefinition = "JSONB")
private @Nullable String extra;
@SuppressWarnings("NullAway.Init")
public ActivityStreamPoint() {
// Hibernate
}
public ActivityStreamPoint(Instant time, long activityId, int elapsedS) {
super(time);
this.activityId = activityId;
this.elapsedS = elapsedS;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@SuppressWarnings("NullAway.Init")
public static class Key implements Serializable {
private long activityId;
private Instant time;
}
}

View File

@@ -0,0 +1,35 @@
package com.peakup.activity.model;
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import lombok.Data;
@Embeddable
@Data
public class ActivityZone {
@Column(name = "zone_set_code")
public String zoneSetCode;
@Column(name = "zone_id")
public String zoneId;
@Column(name = "range_start")
public double rangeStart;
@Column(name = "range_end")
public double rangeEnd;
@Column(name = "value_start")
public double valueStart;
@Column(name = "value_end")
public double valueEnd;
@Column
public int secs;
@SuppressWarnings("NullAway.Init")
public ActivityZone() {
}
}

View File

@@ -0,0 +1,30 @@
package com.peakup.activity.model;
import jakarta.annotation.Nullable;
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import lombok.Data;
@Embeddable
@Data
public class IgnorePart {
@Column(name = "start_index")
public int startIndex;
@Column(name = "end_index")
public int endIndex;
@Column
public @Nullable Boolean power;
@Column
public @Nullable Boolean pace;
@Column
public @Nullable Boolean hr;
@SuppressWarnings("NullAway.Init")
public IgnorePart() {
}
}

View File

@@ -1,14 +1,15 @@
package com.peakup.activity.model;
import jakarta.annotation.Nullable;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import jakarta.persistence.*;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "intervals_activity")
@Data
@@ -33,6 +34,15 @@ public class IntervalsActivity extends AbstractActivity {
@Column
public String gearId;
@Column
public @Nullable String gearName;
@Column(name = "gear_distance_m")
public @Nullable Double gearDistanceM;
@Column
public @Nullable Boolean gearPrimary;
@Column(name = "total_ascent_m")
public Double totalAscentM;
@@ -81,10 +91,6 @@ public class IntervalsActivity extends AbstractActivity {
@Column
public @Nullable Integer hrrAverageWatts;
@Column(columnDefinition = "jsonb")
@JdbcTypeCode(SqlTypes.JSON)
public @Nullable String icuZoneTimes;
@Column
public Double variabilityIndex;
@@ -100,6 +106,446 @@ public class IntervalsActivity extends AbstractActivity {
@Column(name = "average_temp_c")
public Double averageTempC;
@Column(columnDefinition = "integer[]")
@JdbcTypeCode(SqlTypes.ARRAY)
public @Nullable int[] recordingStops;
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(name = "activity_zone", joinColumns = @JoinColumn(name = "activity_id"))
public List<ActivityZone> zones = new ArrayList<>();
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(name = "activity_ignore_part", joinColumns = @JoinColumn(name = "activity_id"))
@OrderColumn(name = "sort_index")
public List<IgnorePart> ignoreParts = new ArrayList<>();
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(name = "activity_tag", joinColumns = @JoinColumn(name = "activity_id"))
@Column(name = "tag")
public List<String> tags = new ArrayList<>();
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(name = "activity_attachment", joinColumns = @JoinColumn(name = "activity_id"))
@OrderColumn(name = "sort_index")
public List<ActivityAttachment> attachments = new ArrayList<>();
// --- Environment ---
@Column(name = "min_temp_c")
public @Nullable Double minTempC;
@Column(name = "max_temp_c")
public @Nullable Double maxTempC;
@Column
public @Nullable Double avgLrBalance;
// --- Grade Adjusted Pace ---
@Column(name = "gap_mps")
public @Nullable Double gapMps;
@Column
public @Nullable String gapModel;
// --- Device / equipment ---
@Column
public @Nullable String deviceName;
@Column
public @Nullable Boolean deviceWatts;
@Column
public @Nullable String powerMeter;
@Column
public @Nullable String powerMeterSerial;
@Column
public @Nullable String powerMeterBattery;
@Column(name = "crank_length_mm")
public @Nullable Double crankLengthMm;
@Column
public @Nullable String powerField;
@Column
public @Nullable Boolean useElevationCorrection;
// --- Nutrition ---
@Column(name = "carbs_used_g")
public @Nullable Double carbsUsedG;
// --- Intervals.icu internal distance ---
@Column(name = "icu_distance_m")
public @Nullable Double icuDistanceM;
// --- Time accounting ---
@Column
public @Nullable Boolean icuIgnoreTime;
@Column(name = "icu_recording_time_s")
public @Nullable Integer icuRecordingTimeS;
@Column(name = "coasting_time_s")
public @Nullable Integer coastingTimeS;
@Column(name = "icu_warmup_time_s")
public @Nullable Integer icuWarmupTimeS;
@Column(name = "icu_cooldown_time_s")
public @Nullable Integer icuCooldownTimeS;
// --- Activity sub-type and pairing ---
@Column
public @Nullable String subType;
@Column
public @Nullable String pairedEventId;
// --- Athlete state at activity time ---
@Column(name = "icu_weight_kg")
public @Nullable Double icuWeightKg;
@Column
public @Nullable Integer lthr;
@Column
public @Nullable Integer icuRestingHr;
@Column(name = "threshold_pace_mps")
public @Nullable Double thresholdPaceMps;
@Column
public @Nullable Integer icuPowerSpikeThreshold;
@Column
public @Nullable Double icuSweetSpotMin;
@Column
public @Nullable Double icuSweetSpotMax;
@Column
public @Nullable Double trimp;
// --- W' and P-max ---
@Column(name = "icu_w_prime_j")
public @Nullable Integer icuWPrimeJ;
@Column(name = "p_max_watt")
public @Nullable Integer pMaxWatt;
// --- Performance model (PM) ---
@Column
public @Nullable Integer icuPmCp;
@Column(name = "icu_pm_w_prime_j")
public @Nullable Integer icuPmWPrimeJ;
@Column(name = "icu_pm_p_max_watt")
public @Nullable Integer icuPmPMaxWatt;
@Column
public @Nullable Integer icuPmFtp;
@Column
public @Nullable Integer icuPmFtpSecs;
@Column
public @Nullable Integer icuPmFtpWatts;
// --- Rolling performance ---
@Column
public @Nullable Integer icuRollingCp;
@Column(name = "icu_rolling_w_prime_j")
public @Nullable Integer icuRollingWPrimeJ;
@Column(name = "icu_rolling_p_max_watt")
public @Nullable Integer icuRollingPMaxWatt;
@Column
public @Nullable Integer icuRollingFtp;
@Column
public @Nullable Integer icuRollingFtpDelta;
// --- Sweet-spot CP model ---
@Column
public @Nullable Integer ssCp;
@Column(name = "ss_w_prime_j")
public @Nullable Integer ssWPrimeJ;
@Column(name = "ss_p_max_watt")
public @Nullable Integer ssPMaxWatt;
// --- Ignore flags ---
@Column
public @Nullable Boolean icuIgnorePower;
@Column
public @Nullable Boolean icuIgnoreHr;
@Column
public @Nullable Boolean ignoreVelocity;
@Column
public @Nullable Boolean ignorePace;
// --- Activity flags ---
@Column
public @Nullable Boolean hasHeartrate;
@Column
public @Nullable String analyzed;
@Column
public @Nullable Boolean hasWeather;
@Column
public @Nullable Boolean hasSegments;
// --- File / sync metadata ---
@Column
public @Nullable String fileType;
@Column
public @Nullable Integer fileSportIndex;
@Column
public @Nullable String icuAthleteId;
@Column
public @Nullable String icuCreated;
@Column
public @Nullable String icuSyncDate;
@Column
public @Nullable Long icuChatId;
// --- Local-timezone start ---
@Column
public @Nullable String startDateLocal;
// --- Zone definitions (JSON) ---
@Column(columnDefinition = "TEXT")
public @Nullable String icuHrZones;
@Column(columnDefinition = "TEXT")
public @Nullable String paceZones;
@Column(columnDefinition = "TEXT")
public @Nullable String icuPowerZones;
// --- Complex nested data (JSON) ---
@Column(columnDefinition = "TEXT")
public @Nullable String icuTrainingLoadData;
@Column(columnDefinition = "TEXT")
public @Nullable String intervalSummary;
// --- Available stream names ---
@Column(columnDefinition = "text[]")
@JdbcTypeCode(SqlTypes.ARRAY)
public @Nullable String[] streamTypes;
// --- Skyline chart ---
@Column(columnDefinition = "TEXT")
public @Nullable String skylineChartBytes;
// --- Zone times (JSON) ---
@Column(columnDefinition = "TEXT")
public @Nullable String icuZoneTimes;
@Column(columnDefinition = "TEXT")
public @Nullable String icuHrZoneTimes;
@Column(columnDefinition = "TEXT")
public @Nullable String paceZoneTimes;
@Column(columnDefinition = "TEXT")
public @Nullable String gapZoneTimes;
@Column
public @Nullable Boolean useGapZoneTimes;
@Column
public @Nullable String tizOrder;
// --- Performance / load metrics ---
@Column
public @Nullable Double polarizationIndex;
@Column
public @Nullable Double decoupling;
@Column
public @Nullable Integer icuLapCount;
@Column
public @Nullable Integer icuJoulesAboveFtp;
@Column
public @Nullable Integer icuMaxWbalDepletion;
@Column
public @Nullable Double icuPowerHrZ2;
@Column
public @Nullable Integer icuPowerHrZ2Mins;
@Column
public @Nullable Double icuCadenceZ2;
@Column
public @Nullable Double icuRpe;
@Column
public @Nullable Double icuPowerHr;
@Column
public @Nullable Integer icuMedianTimeDelta;
@Column
public @Nullable Double p30sExponent;
// --- Intervals editing ---
@Column
public @Nullable Boolean icuIntervalsEdited;
@Column
public @Nullable Boolean lockIntervals;
@Column
public @Nullable Integer workoutShiftSecs;
// --- Swimming ---
@Column(name = "pool_length_m")
public @Nullable Integer poolLengthM;
@Column(columnDefinition = "TEXT")
public @Nullable String lengths;
// --- Load scores ---
@Column
public @Nullable Double powerLoad;
@Column
public @Nullable Double hrLoad;
@Column
public @Nullable Double paceLoad;
@Column
public @Nullable String hrLoadType;
@Column
public @Nullable String paceLoadType;
@Column
public @Nullable Double sessionRpe;
@Column
public @Nullable Double strainScore;
@Column
public @Nullable Double kgLifted;
// --- Weather ---
@Column(name = "average_altitude_m")
public @Nullable Double averageAltitudeM;
@Column(name = "average_weather_temp_c")
public @Nullable Double averageWeatherTempC;
@Column(name = "min_weather_temp_c")
public @Nullable Double minWeatherTempC;
@Column(name = "max_weather_temp_c")
public @Nullable Double maxWeatherTempC;
@Column(name = "average_feels_like_c")
public @Nullable Double averageFeelsLikeC;
@Column(name = "min_feels_like_c")
public @Nullable Double minFeelsLikeC;
@Column(name = "max_feels_like_c")
public @Nullable Double maxFeelsLikeC;
@Column(name = "average_wind_speed_mps")
public @Nullable Double averageWindSpeedMps;
@Column(name = "average_wind_gust_mps")
public @Nullable Double averageWindGustMps;
@Column
public @Nullable Double prevailingWindDeg;
@Column
public @Nullable Double headwindPercent;
@Column
public @Nullable Double tailwindPercent;
@Column
public @Nullable Double averageClouds;
@Column(name = "max_rain_mm")
public @Nullable Double maxRainMm;
@Column(name = "max_snow_mm")
public @Nullable Double maxSnowMm;
// --- Nutrition ---
@Column(name = "carbs_ingested_g")
public @Nullable Double carbsIngestedG;
// --- Running pace ---
@Column(name = "pace_mps")
public @Nullable Double paceMps;
// --- Athlete / HR ---
@Column
public @Nullable Integer athleteMaxHr;
// --- Coach / compliance ---
@Column
public @Nullable String compliance;
@Column
public @Nullable Boolean coachTick;
@Column(name = "activity_group")
public @Nullable String activityGroup;
// --- Source / sync metadata ---
@Column(columnDefinition = "TEXT")
public @Nullable String icuSyncError;
@Column
public @Nullable String icuColor;
@Column
public @Nullable String source;
@Column
public @Nullable String oauthClientId;
@Column
public @Nullable String oauthClientName;
@Column
public @Nullable Long stravaId;
@Column
public @Nullable Long routeId;
// --- Power field names ---
@Column(columnDefinition = "TEXT")
public @Nullable String powerFieldNames;
@SuppressWarnings("NullAway.Init")
public IntervalsActivity() {
// hibernate

View File

@@ -1,5 +1,13 @@
package com.peakup.connector.intervals;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import jakarta.annotation.Nullable;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.*;
@@ -7,7 +15,10 @@ import jakarta.ws.rs.core.MediaType;
import org.eclipse.microprofile.rest.client.annotation.RegisterClientHeaders;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@ApplicationScoped
@Path("/api/v1")
@@ -29,6 +40,87 @@ public interface IntervalsClient {
@Path("/activity/{id}")
IntervalsActivity getActivity(@PathParam("id") String id);
@GET
@Path("/activity/{id}/streams")
StreamsData getActivityStreams(@PathParam("id") String id);
/**
* Flat view of all activity stream arrays.
* The API returns {@code [{"type":"time","data":[...]}, ...]} — the inner
* {@link Deserializer} converts that into this flat structure.
*/
@JsonDeserialize(using = StreamsData.Deserializer.class)
class StreamsData {
public @Nullable List<Integer> time;
public @Nullable List<Double> distance;
public @Nullable List<Double> altitude;
public @Nullable List<Integer> heartrate;
public @Nullable List<Integer> watts;
public @Nullable List<Double> cadence;
public @Nullable List<Double> velocity_smooth;
/** Latitude per time point (intervals.icu does not provide a separate longitude stream). */
public @Nullable List<Double> latlng;
public @Nullable List<Double> temp;
public @Nullable List<Double> grade_smooth;
public @Nullable List<Boolean> moving;
public @Nullable List<Double> left_right_balance;
public Map<String, Object> unknownStreams = new LinkedHashMap<>();
/** Returns the number of data points (length of the time array). */
public int size() {
return time != null ? time.size() : 0;
}
@SuppressWarnings({"NullAway.Init", "unused"})
private StreamsData() {}
// --- inner deserializer ---
@JsonIgnoreProperties(ignoreUnknown = true)
static class StreamEntry {
public @Nullable String type;
public @Nullable List<Object> data;
@SuppressWarnings({"NullAway.Init", "unused"})
StreamEntry() {}
}
@SuppressWarnings("NullAway")
static class Deserializer extends JsonDeserializer<StreamsData> {
private static final TypeReference<List<Integer>> INT_LIST = new TypeReference<>() {};
private static final TypeReference<List<Double>> DBL_LIST = new TypeReference<>() {};
private static final TypeReference<List<Boolean>> BOOL_LIST = new TypeReference<>() {};
@Override
public StreamsData deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
ObjectMapper mapper = (ObjectMapper) p.getCodec();
JsonNode root = mapper.readTree(p);
StreamsData result = new StreamsData();
for (JsonNode entry : root) {
String type = entry.path("type").asText("");
JsonNode data = entry.path("data");
switch (type) {
case "time" -> result.time = mapper.convertValue(data, INT_LIST);
case "distance" -> result.distance = mapper.convertValue(data, DBL_LIST);
case "altitude" -> result.altitude = mapper.convertValue(data, DBL_LIST);
case "heartrate" -> result.heartrate = mapper.convertValue(data, INT_LIST);
case "watts" -> result.watts = mapper.convertValue(data, INT_LIST);
case "cadence" -> result.cadence = mapper.convertValue(data, DBL_LIST);
case "velocity_smooth" -> result.velocity_smooth = mapper.convertValue(data, DBL_LIST);
case "latlng" -> result.latlng = mapper.convertValue(data, DBL_LIST);
case "temp" -> result.temp = mapper.convertValue(data, DBL_LIST);
case "grade_smooth" -> result.grade_smooth = mapper.convertValue(data, DBL_LIST);
case "moving" -> result.moving = mapper.convertValue(data, BOOL_LIST);
case "left_right_balance" -> result.left_right_balance = mapper.convertValue(data, DBL_LIST);
default -> result.unknownStreams.put(type, mapper.convertValue(data, Object.class));
}
}
return result;
}
}
}
class IntervalsActivity {
public @Nullable String id; // intervals.icu activity id, e.g. "a12345678"
@@ -81,14 +173,218 @@ public interface IntervalsClient {
public @Nullable Integer feel; // 1–5
public @Nullable Double perceived_exertion; // RPE 1–10
// Power zone time-in-zone
public @Nullable java.util.List<IcuZoneTime> icu_zone_times;
// Zone times and definitions (custom_zones supersedes icu_zone_times)
public @Nullable List<ZoneSet> custom_zones;
// Nested collections
public @Nullable List<Integer> recording_stops;
public @Nullable List<Ignore> ignore_parts;
public @Nullable List<IcuAchievement> icu_achievements;
public @Nullable List<String> tags;
public @Nullable List<Attachment> attachments;
// Running dynamics
public @Nullable Double average_stride; // meters
// Environment
public @Nullable Double average_temp; // °C
public @Nullable Double min_temp;
public @Nullable Double max_temp;
public @Nullable Double avg_lr_balance; // L/R power balance %
// Grade Adjusted Pace
public @Nullable Double gap; // m/s
public @Nullable String gap_model;
// Device / equipment
public @Nullable String device_name;
public @Nullable Boolean device_watts;
public @Nullable String power_meter;
public @Nullable String power_meter_serial;
public @Nullable String power_meter_battery; // e.g. "GOOD", "LOW"
public @Nullable Double crank_length; // mm
public @Nullable String power_field;
public @Nullable Boolean use_elevation_correction;
// Nutrition
public @Nullable Double carbs_used; // grams
// Intervals.icu internal distance (may differ from Strava)
public @Nullable Double icu_distance; // meters
// Time accounting
public @Nullable Boolean icu_ignore_time;
public @Nullable Integer icu_recording_time; // seconds
public @Nullable Integer coasting_time; // seconds
public @Nullable Integer icu_warmup_time; // seconds
public @Nullable Integer icu_cooldown_time; // seconds
// Activity sub-type and pairing
public @Nullable String sub_type;
public @Nullable String paired_event_id;
// Athlete state at activity time
public @Nullable Double icu_weight; // kg
public @Nullable Integer lthr; // lactate threshold HR bpm
public @Nullable Integer icu_resting_hr; // bpm
public @Nullable Double threshold_pace; // m/s
public @Nullable Integer icu_power_spike_threshold;
public @Nullable Double icu_sweet_spot_min; // % FTP
public @Nullable Double icu_sweet_spot_max; // % FTP
public @Nullable Double trimp;
// W' (W-prime) and P-max
public @Nullable Integer icu_w_prime; // joules
public @Nullable Integer p_max; // watts
// Performance model (PM) — intervals.icu modelled values
public @Nullable Integer icu_pm_cp;
public @Nullable Integer icu_pm_w_prime; // joules
public @Nullable Integer icu_pm_p_max; // watts
public @Nullable Integer icu_pm_ftp;
public @Nullable Integer icu_pm_ftp_secs;
public @Nullable Integer icu_pm_ftp_watts;
// Rolling (recent-history) performance
public @Nullable Integer icu_rolling_cp;
public @Nullable Integer icu_rolling_w_prime; // joules
public @Nullable Integer icu_rolling_p_max; // watts
public @Nullable Integer icu_rolling_ftp;
public @Nullable Integer icu_rolling_ftp_delta;
// Sweet-spot CP model
public @Nullable Integer ss_cp;
public @Nullable Integer ss_w_prime; // joules
public @Nullable Integer ss_p_max; // watts
// Ignore flags
public @Nullable Boolean icu_ignore_power;
public @Nullable Boolean icu_ignore_hr;
public @Nullable Boolean ignore_velocity;
public @Nullable Boolean ignore_pace;
// Flags
public @Nullable Boolean has_heartrate;
public @Nullable String analyzed; // ISO datetime of analysis
public @Nullable Boolean has_weather;
public @Nullable Boolean has_segments;
// File / sync metadata
public @Nullable String file_type;
public @Nullable Integer file_sport_index;
public @Nullable String icu_athlete_id;
public @Nullable String created;
public @Nullable String icu_sync_date;
public @Nullable Long icu_chat_id;
// Local-timezone start (display only, not UTC)
public @Nullable String start_date_local;
// Zone definitions (HR / pace / power — parallel to custom_zones)
public @Nullable Object icu_hr_zones;
public @Nullable Object pace_zones;
public @Nullable Object icu_power_zones;
// Complex nested data — stored as raw JSON
public @Nullable Object icu_training_load_data;
public @Nullable Object interval_summary;
// Available data-stream names
public @Nullable List<String> stream_types;
// Skyline chart (base64-encoded)
public @Nullable String skyline_chart_bytes;
// Zone times (simplified parallel to custom_zones)
public @Nullable Object icu_zone_times;
public @Nullable Object icu_hr_zone_times;
public @Nullable Object pace_zone_times;
public @Nullable Object gap_zone_times;
public @Nullable Boolean use_gap_zone_times;
public @Nullable String tiz_order;
// Performance / load metrics
public @Nullable Double polarization_index;
public @Nullable Double decoupling; // aerobic decoupling %
public @Nullable Integer icu_lap_count;
public @Nullable Integer icu_joules_above_ftp;
public @Nullable Integer icu_max_wbal_depletion;
public @Nullable Double icu_power_hr_z2;
public @Nullable Integer icu_power_hr_z2_mins;
public @Nullable Double icu_cadence_z2;
public @Nullable Double icu_rpe;
public @Nullable Double icu_power_hr;
public @Nullable Integer icu_median_time_delta; // ms
public @Nullable Double p30s_exponent;
// Intervals editing
public @Nullable Boolean icu_intervals_edited;
public @Nullable Boolean lock_intervals;
public @Nullable Integer workout_shift_secs;
// Swimming
public @Nullable Integer pool_length; // meters
public @Nullable Object lengths; // lap objects
// Load scores
public @Nullable Double power_load;
public @Nullable Double hr_load;
public @Nullable Double pace_load;
public @Nullable String hr_load_type;
public @Nullable String pace_load_type;
public @Nullable Double session_rpe;
public @Nullable Double strain_score;
public @Nullable Double kg_lifted;
// Weather
public @Nullable Double average_altitude; // meters
public @Nullable Double average_weather_temp; // °C
public @Nullable Double min_weather_temp;
public @Nullable Double max_weather_temp;
public @Nullable Double average_feels_like; // °C
public @Nullable Double min_feels_like;
public @Nullable Double max_feels_like;
public @Nullable Double average_wind_speed; // m/s
public @Nullable Double average_wind_gust; // m/s
public @Nullable Double prevailing_wind_deg;
public @Nullable Double headwind_percent;
public @Nullable Double tailwind_percent;
public @Nullable Double average_clouds; // %
public @Nullable Double max_rain; // mm
public @Nullable Double max_snow; // mm
// Nutrition
public @Nullable Double carbs_ingested; // grams
// Running pace
public @Nullable Double pace; // m/s
// Athlete / HR
public @Nullable Integer athlete_max_hr;
// Coach / compliance
public @Nullable String compliance; // e.g. "GOOD", "MODERATE"
public @Nullable Boolean coach_tick;
public @Nullable String group;
// Source / sync metadata
public @Nullable String icu_sync_error;
public @Nullable String icu_color;
public @Nullable String source;
public @Nullable String oauth_client_id;
public @Nullable String oauth_client_name;
public @Nullable Long strava_id;
public @Nullable Long route_id;
// Power field names and misc
public @Nullable Object power_field_names;
public Map<String, Object> unknownFields = new LinkedHashMap<>();
@com.fasterxml.jackson.annotation.JsonAnySetter
public void setUnknownField(String name, Object value) {
unknownFields.put(name, value);
}
@SuppressWarnings({"NullAway.Init", "unused"})
private IntervalsActivity() {
@@ -97,6 +393,9 @@ public interface IntervalsClient {
public static class StravaGear {
public @Nullable String id;
public @Nullable String name;
public @Nullable Double distance;
public @Nullable Boolean primary;
@SuppressWarnings({"NullAway.Init", "unused"})
private StravaGear() {}
@@ -114,12 +413,59 @@ public interface IntervalsClient {
private IcuHrr() {}
}
public static class IcuZoneTime {
public @Nullable String id; // e.g. "Z1", "Z2", "SS"
public @Nullable Integer secs; // seconds spent in zone
public static class ZoneSet {
public @Nullable String code; // e.g. "POWER", "HR", "PACE", "GAP"
public @Nullable List<ZoneInfo> zones;
@SuppressWarnings({"NullAway.Init", "unused"})
private IcuZoneTime() {}
private ZoneSet() {}
}
public static class ZoneInfo {
public @Nullable String id; // e.g. "Z1", "Z2", "SS"
public @Nullable Double start; // % of FTP/LTHR
public @Nullable Double end;
public @Nullable Double start_value; // absolute watts/bpm/pace
public @Nullable Double end_value;
public @Nullable Integer secs;
@SuppressWarnings({"NullAway.Init", "unused"})
private ZoneInfo() {}
}
public static class IcuAchievement {
public @Nullable String id;
public @Nullable String type; // BEST_POWER, FTP_UP, LTHR_UP, BEST_PACE
public @Nullable String message;
public @Nullable Integer watts;
public @Nullable Integer secs;
public @Nullable Integer value;
public @Nullable Double distance;
public @Nullable Double pace;
@SuppressWarnings({"NullAway.Init", "unused"})
private IcuAchievement() {}
}
public static class Attachment {
public @Nullable String id;
public @Nullable String filename;
public @Nullable String mimetype;
public @Nullable String url;
@SuppressWarnings({"NullAway.Init", "unused"})
private Attachment() {}
}
public static class Ignore {
public @Nullable Integer start_index;
public @Nullable Integer end_index;
public @Nullable Boolean power;
public @Nullable Boolean pace;
public @Nullable Boolean hr;
@SuppressWarnings({"NullAway.Init", "unused"})
private Ignore() {}
}
}
}

View File

@@ -0,0 +1,10 @@
package com.peakup.model;
import jakarta.persistence.MappedSuperclass;
@MappedSuperclass
public abstract class AbstractDependentEntity extends AbstractEntity {
public abstract long getFK();
}

View File

@@ -0,0 +1,25 @@
package com.peakup.model;
import jakarta.persistence.*;
import lombok.Data;
import java.time.Instant;
@MappedSuperclass
@Data
@SuppressWarnings("NullAway.Init")
public abstract class AbstractTimescaleEntity {
@Id
@Column(nullable = false)
private Instant time;
protected AbstractTimescaleEntity() {
// Hibernate
}
protected AbstractTimescaleEntity(Instant time) {
this.time = time;
}
}

View File

@@ -10,6 +10,7 @@ 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
@@ -38,7 +39,7 @@ public class ActivityImportScheduler {
private final AtomicBoolean running = new AtomicBoolean(false);
@Scheduled(every = "5m")
@Scheduled(every = "20m")
void importActivities() {
if (!running.compareAndSet(false, true)) {
log.warn("Import already running, skipping this trigger.");

View File

@@ -0,0 +1,56 @@
-- V10: Replace icu_zone_times JSONB with relational model; add nested activity tables
ALTER TABLE intervals_activity
DROP COLUMN icu_zone_times,
ADD COLUMN gear_name VARCHAR(255),
ADD COLUMN gear_distance_m DOUBLE PRECISION,
ADD COLUMN gear_primary BOOLEAN,
ADD COLUMN recording_stops INTEGER[];
CREATE TABLE activity_zone (
activity_id BIGINT NOT NULL REFERENCES intervals_activity(id) ON DELETE CASCADE,
zone_set_code VARCHAR(50) NOT NULL,
zone_id VARCHAR(20) NOT NULL,
range_start DOUBLE PRECISION NOT NULL DEFAULT 0,
range_end DOUBLE PRECISION NOT NULL DEFAULT 0,
value_start DOUBLE PRECISION NOT NULL DEFAULT 0,
value_end DOUBLE PRECISION NOT NULL DEFAULT 0,
secs INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE activity_achievement (
id BIGINT PRIMARY KEY,
activity_id BIGINT NOT NULL REFERENCES intervals_activity(id) ON DELETE CASCADE,
achievement_id VARCHAR(255),
type VARCHAR(50),
message VARCHAR(500),
watts INTEGER,
secs INTEGER,
value INTEGER,
distance DOUBLE PRECISION,
pace DOUBLE PRECISION
);
CREATE TABLE activity_ignore_part (
activity_id BIGINT NOT NULL REFERENCES intervals_activity(id) ON DELETE CASCADE,
sort_index INTEGER NOT NULL,
start_index INTEGER NOT NULL,
end_index INTEGER NOT NULL,
power BOOLEAN,
pace BOOLEAN,
hr BOOLEAN
);
CREATE TABLE activity_tag (
activity_id BIGINT NOT NULL REFERENCES intervals_activity(id) ON DELETE CASCADE,
tag VARCHAR(255) NOT NULL
);
CREATE TABLE activity_attachment (
activity_id BIGINT NOT NULL REFERENCES intervals_activity(id) ON DELETE CASCADE,
sort_index INTEGER NOT NULL,
attachment_id VARCHAR(255),
filename VARCHAR(255),
mimetype VARCHAR(100),
url VARCHAR(1000)
);

View File

@@ -0,0 +1,91 @@
-- V11: Add all intervals.icu API fields not yet stored on intervals_activity
ALTER TABLE intervals_activity
-- Environment
ADD COLUMN min_temp_c DOUBLE PRECISION,
ADD COLUMN max_temp_c DOUBLE PRECISION,
ADD COLUMN avg_lr_balance DOUBLE PRECISION,
-- Grade Adjusted Pace
ADD COLUMN gap_mps DOUBLE PRECISION,
ADD COLUMN gap_model VARCHAR(100),
-- Device / equipment
ADD COLUMN device_name VARCHAR(255),
ADD COLUMN device_watts BOOLEAN,
ADD COLUMN power_meter VARCHAR(255),
ADD COLUMN power_meter_serial VARCHAR(255),
ADD COLUMN power_meter_battery DOUBLE PRECISION,
ADD COLUMN crank_length_mm DOUBLE PRECISION,
ADD COLUMN power_field VARCHAR(100),
ADD COLUMN use_elevation_correction BOOLEAN,
-- Nutrition
ADD COLUMN carbs_used_g DOUBLE PRECISION,
-- Intervals.icu internal distance
ADD COLUMN icu_distance_m DOUBLE PRECISION,
-- Time accounting
ADD COLUMN icu_ignore_time_s INTEGER,
ADD COLUMN icu_recording_time_s INTEGER,
ADD COLUMN coasting_time_s INTEGER,
ADD COLUMN icu_warmup_time_s INTEGER,
ADD COLUMN icu_cooldown_time_s INTEGER,
-- Activity sub-type and pairing
ADD COLUMN sub_type VARCHAR(100),
ADD COLUMN paired_event_id VARCHAR(255),
-- Athlete state at activity time
ADD COLUMN icu_weight_kg DOUBLE PRECISION,
ADD COLUMN lthr INTEGER,
ADD COLUMN icu_resting_hr INTEGER,
ADD COLUMN threshold_pace_mps DOUBLE PRECISION,
ADD COLUMN icu_power_spike_threshold INTEGER,
ADD COLUMN icu_sweet_spot_min DOUBLE PRECISION,
ADD COLUMN icu_sweet_spot_max DOUBLE PRECISION,
ADD COLUMN trimp DOUBLE PRECISION,
-- W' and P-max
ADD COLUMN icu_w_prime_j INTEGER,
ADD COLUMN p_max_watt INTEGER,
-- Performance model (PM)
ADD COLUMN icu_pm_cp INTEGER,
ADD COLUMN icu_pm_w_prime_j INTEGER,
ADD COLUMN icu_pm_p_max_watt INTEGER,
ADD COLUMN icu_pm_ftp INTEGER,
ADD COLUMN icu_pm_ftp_secs INTEGER,
ADD COLUMN icu_pm_ftp_watts INTEGER,
-- Rolling performance
ADD COLUMN icu_rolling_cp INTEGER,
ADD COLUMN icu_rolling_w_prime_j INTEGER,
ADD COLUMN icu_rolling_p_max_watt INTEGER,
ADD COLUMN icu_rolling_ftp INTEGER,
ADD COLUMN icu_rolling_ftp_delta INTEGER,
-- Sweet-spot CP model
ADD COLUMN ss_cp INTEGER,
ADD COLUMN ss_w_prime_j INTEGER,
ADD COLUMN ss_p_max_watt INTEGER,
-- Ignore flags
ADD COLUMN icu_ignore_power BOOLEAN,
ADD COLUMN icu_ignore_hr BOOLEAN,
ADD COLUMN ignore_velocity BOOLEAN,
ADD COLUMN ignore_pace BOOLEAN,
-- Activity flags
ADD COLUMN has_heartrate BOOLEAN,
ADD COLUMN analyzed BOOLEAN,
ADD COLUMN has_weather BOOLEAN,
ADD COLUMN has_segments BOOLEAN,
-- File / sync metadata
ADD COLUMN file_type VARCHAR(50),
ADD COLUMN file_sport_index INTEGER,
ADD COLUMN icu_athlete_id VARCHAR(50),
ADD COLUMN icu_created VARCHAR(50),
ADD COLUMN icu_sync_date VARCHAR(50),
ADD COLUMN icu_chat_id BIGINT,
-- Local-timezone start
ADD COLUMN start_date_local VARCHAR(50),
-- Zone definitions (JSON text)
ADD COLUMN icu_hr_zones TEXT,
ADD COLUMN pace_zones TEXT,
ADD COLUMN icu_power_zones TEXT,
-- Complex nested data (JSON text)
ADD COLUMN icu_training_load_data TEXT,
ADD COLUMN interval_summary TEXT,
-- Available stream names
ADD COLUMN stream_types TEXT[],
-- Skyline chart (base64)
ADD COLUMN skyline_chart_bytes TEXT;

View File

@@ -0,0 +1,5 @@
-- V12: icu_ignore_time is a boolean flag, not an integer duration
ALTER TABLE intervals_activity
DROP COLUMN icu_ignore_time_s,
ADD COLUMN icu_ignore_time BOOLEAN;

View File

@@ -0,0 +1,5 @@
-- V13: analyzed is an ISO timestamp string, not a boolean
ALTER TABLE intervals_activity
DROP COLUMN analyzed,
ADD COLUMN analyzed VARCHAR(50);

View File

@@ -0,0 +1,75 @@
-- V14: Add second batch of intervals.icu API fields
ALTER TABLE intervals_activity
-- Zone times (JSON)
ADD COLUMN icu_zone_times TEXT,
ADD COLUMN icu_hr_zone_times TEXT,
ADD COLUMN pace_zone_times TEXT,
ADD COLUMN gap_zone_times TEXT,
ADD COLUMN use_gap_zone_times BOOLEAN,
ADD COLUMN tiz_order VARCHAR(50),
-- Performance / load metrics
ADD COLUMN polarization_index DOUBLE PRECISION,
ADD COLUMN decoupling DOUBLE PRECISION,
ADD COLUMN icu_lap_count INTEGER,
ADD COLUMN icu_joules_above_ftp INTEGER,
ADD COLUMN icu_max_wbal_depletion INTEGER,
ADD COLUMN icu_power_hr_z2 DOUBLE PRECISION,
ADD COLUMN icu_power_hr_z2_mins INTEGER,
ADD COLUMN icu_cadence_z2 DOUBLE PRECISION,
ADD COLUMN icu_rpe DOUBLE PRECISION,
ADD COLUMN icu_power_hr DOUBLE PRECISION,
ADD COLUMN icu_median_time_delta INTEGER,
ADD COLUMN p30s_exponent DOUBLE PRECISION,
-- Intervals editing
ADD COLUMN icu_intervals_edited BOOLEAN,
ADD COLUMN lock_intervals BOOLEAN,
ADD COLUMN workout_shift_secs INTEGER,
-- Swimming
ADD COLUMN pool_length_m INTEGER,
ADD COLUMN lengths TEXT,
-- Load scores
ADD COLUMN power_load DOUBLE PRECISION,
ADD COLUMN hr_load DOUBLE PRECISION,
ADD COLUMN pace_load DOUBLE PRECISION,
ADD COLUMN hr_load_type VARCHAR(50),
ADD COLUMN pace_load_type VARCHAR(50),
ADD COLUMN session_rpe DOUBLE PRECISION,
ADD COLUMN strain_score DOUBLE PRECISION,
ADD COLUMN kg_lifted DOUBLE PRECISION,
-- Weather
ADD COLUMN average_altitude_m DOUBLE PRECISION,
ADD COLUMN average_weather_temp_c DOUBLE PRECISION,
ADD COLUMN min_weather_temp_c DOUBLE PRECISION,
ADD COLUMN max_weather_temp_c DOUBLE PRECISION,
ADD COLUMN average_feels_like_c DOUBLE PRECISION,
ADD COLUMN min_feels_like_c DOUBLE PRECISION,
ADD COLUMN max_feels_like_c DOUBLE PRECISION,
ADD COLUMN average_wind_speed_mps DOUBLE PRECISION,
ADD COLUMN average_wind_gust_mps DOUBLE PRECISION,
ADD COLUMN prevailing_wind_deg DOUBLE PRECISION,
ADD COLUMN headwind_percent DOUBLE PRECISION,
ADD COLUMN tailwind_percent DOUBLE PRECISION,
ADD COLUMN average_clouds DOUBLE PRECISION,
ADD COLUMN max_rain_mm DOUBLE PRECISION,
ADD COLUMN max_snow_mm DOUBLE PRECISION,
-- Nutrition
ADD COLUMN carbs_ingested_g DOUBLE PRECISION,
-- Running pace
ADD COLUMN pace_mps DOUBLE PRECISION,
-- Athlete / HR
ADD COLUMN athlete_max_hr INTEGER,
-- Coach / compliance
ADD COLUMN compliance DOUBLE PRECISION,
ADD COLUMN coach_tick BOOLEAN,
ADD COLUMN activity_group VARCHAR(255),
-- Source / sync metadata
ADD COLUMN icu_sync_error TEXT,
ADD COLUMN icu_color VARCHAR(20),
ADD COLUMN source VARCHAR(100),
ADD COLUMN oauth_client_id VARCHAR(255),
ADD COLUMN oauth_client_name VARCHAR(255),
ADD COLUMN strava_id BIGINT,
ADD COLUMN route_id BIGINT,
-- Power field names
ADD COLUMN power_field_names TEXT;

View File

@@ -0,0 +1,5 @@
-- V15: compliance is a string value (e.g. "GOOD"), not a number
ALTER TABLE intervals_activity
DROP COLUMN compliance,
ADD COLUMN compliance VARCHAR(50);

View File

@@ -0,0 +1,5 @@
-- V16: power_meter_battery is a status string (e.g. "GOOD"), not a number
ALTER TABLE intervals_activity
DROP COLUMN power_meter_battery,
ADD COLUMN power_meter_battery VARCHAR(20);

View File

@@ -0,0 +1,29 @@
-- V17: Activity stream points stored in a TimescaleDB hypertable.
-- The natural key (activity_id, time) is the primary key.
-- TimescaleDB requires the partition column (time) to be part of the primary key.
CREATE EXTENSION IF NOT EXISTS timescaledb;
CREATE TABLE activity_stream_point (
time TIMESTAMPTZ NOT NULL, -- actual UTC timestamp of the point
activity_id BIGINT NOT NULL,
elapsed_s INTEGER NOT NULL, -- elapsed seconds from activity start
distance_m DOUBLE PRECISION,
altitude_m DOUBLE PRECISION,
heartrate_bpm INTEGER,
watts INTEGER,
cadence_rpm DOUBLE PRECISION,
velocity_mps DOUBLE PRECISION,
lat DOUBLE PRECISION,
lng DOUBLE PRECISION,
temp_c DOUBLE PRECISION,
grade_smooth DOUBLE PRECISION,
moving BOOLEAN,
lr_balance DOUBLE PRECISION,
extra JSONB, -- any stream types not covered above
PRIMARY KEY (activity_id, time)
);
SELECT create_hypertable('activity_stream_point', by_range('time'));
CREATE INDEX ON activity_stream_point (activity_id, time DESC);

View File

@@ -1,7 +1,9 @@
package com.peakup.activity.importer;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.peakup.activity.model.ActivityAttachment;
import com.peakup.activity.model.ActivityStreamPoint;
import com.peakup.activity.model.ActivityZone;
import com.peakup.activity.model.IgnorePart;
import com.peakup.activity.model.IntervalsActivity;
import com.peakup.connector.intervals.IntervalsClient;
import com.peakup.connector.intervals.IntervalsContext;
@@ -13,7 +15,10 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static com.peakup.common.NullableUtils.defaultValue;
import static org.junit.jupiter.api.Assertions.*;
@@ -21,6 +26,106 @@ import static org.junit.jupiter.api.Assertions.*;
@QuarkusTest
class IntervalsActivityMappingTest {
// StreamsData field → ActivityStreamPoint field(s)
// "time" covers both: elapsedS (the raw elapsed value) and the inherited `time` (computed Instant)
private static final Map<String, List<String>> STREAMS_DTO_TO_ENTITY;
static {
Map<String, List<String>> m = new LinkedHashMap<>();
m.put("time", List.of("elapsedS", "time"));
m.put("distance", List.of("distanceM"));
m.put("altitude", List.of("altitudeM"));
m.put("heartrate", List.of("heartrateBpm"));
m.put("watts", List.of("watts"));
m.put("cadence", List.of("cadenceRpm"));
m.put("velocity_smooth", List.of("velocityMps"));
m.put("latlng", List.of("lat"));
m.put("temp", List.of("tempC"));
m.put("grade_smooth", List.of("gradeSmooth"));
m.put("moving", List.of("moving"));
m.put("left_right_balance", List.of("lrBalance"));
m.put("unknownStreams", List.of("extra"));
STREAMS_DTO_TO_ENTITY = Collections.unmodifiableMap(m);
}
private static final Map<String, List<String>> DTO_TO_ENTITY;
static {
Map<String, List<String>> m = new LinkedHashMap<>();
m.put("id", List.of("originId"));
m.put("external_id", List.of("stravaOriginId"));
m.put("start_date", List.of("startTime"));
m.put("distance", List.of("distanceM"));
m.put("moving_time", List.of("movingTimeS"));
m.put("elapsed_time", List.of("elapsedTimeS"));
m.put("average_speed", List.of("averageSpeedMps"));
m.put("max_speed", List.of("maxSpeedMps"));
m.put("average_heartrate", List.of("averageHeartrateBpm"));
m.put("max_heartrate", List.of("maxHeartrateBPM"));
m.put("average_cadence", List.of("averageCadenceRPM"));
m.put("icu_average_watts", List.of("averagePowerWatt"));
m.put("icu_weighted_avg_watts", List.of("normalizedPowerWatt"));
m.put("icu_joules", List.of("totalWorkKJ"));
m.put("total_elevation_gain", List.of("totalElevationGainM", "totalAscentM"));
m.put("total_elevation_loss", List.of("totalDescentM"));
m.put("min_altitude", List.of("minAltitudeM"));
m.put("max_altitude", List.of("maxAltitudeM"));
m.put("icu_training_load", List.of("trainingLoad"));
m.put("icu_intensity", List.of("intensityFactor"));
m.put("icu_efficiency_factor", List.of("efficiencyFactor"));
m.put("icu_ftp", List.of("ftp"));
m.put("icu_atl", List.of("atl"));
m.put("icu_ctl", List.of("ctl"));
m.put("icu_variability_index", List.of("variabilityIndex"));
m.put("average_stride", List.of("averageStrideLengthM"));
m.put("average_temp", List.of("averageTempC"));
m.put("custom_zones", List.of("zones"));
m.put("icu_hrr", List.of("hrrBpmDrop", "hrrStartBpm", "hrrEndBpm",
"hrrStartTimeSec", "hrrEndTimeSec", "hrrAverageWatts"));
m.put("gear", List.of("gearId", "gearName", "gearDistanceM", "gearPrimary"));
m.put("icu_achievements", List.of()); // stored in ActivityAchievement, not on entity
// New fields — unit-suffix renames
// icu_ignore_time → icuIgnoreTime (Boolean flag, direct camelCase match)
m.put("icu_recording_time", List.of("icuRecordingTimeS"));
m.put("coasting_time", List.of("coastingTimeS"));
m.put("icu_warmup_time", List.of("icuWarmupTimeS"));
m.put("icu_cooldown_time", List.of("icuCooldownTimeS"));
m.put("icu_distance", List.of("icuDistanceM"));
m.put("min_temp", List.of("minTempC"));
m.put("max_temp", List.of("maxTempC"));
m.put("gap", List.of("gapMps"));
m.put("crank_length", List.of("crankLengthMm"));
m.put("threshold_pace", List.of("thresholdPaceMps"));
m.put("icu_weight", List.of("icuWeightKg"));
m.put("carbs_used", List.of("carbsUsedG"));
m.put("icu_pm_w_prime", List.of("icuPmWPrimeJ"));
m.put("icu_pm_p_max", List.of("icuPmPMaxWatt"));
m.put("icu_rolling_w_prime", List.of("icuRollingWPrimeJ"));
m.put("icu_rolling_p_max", List.of("icuRollingPMaxWatt"));
m.put("ss_w_prime", List.of("ssWPrimeJ"));
m.put("ss_p_max", List.of("ssPMaxWatt"));
m.put("icu_w_prime", List.of("icuWPrimeJ"));
m.put("p_max", List.of("pMaxWatt"));
// created → icuCreated (to avoid clash with AbstractTimestampedEntity.createdAt)
m.put("created", List.of("icuCreated"));
// Second batch — unit-suffix renames
m.put("pool_length", List.of("poolLengthM"));
m.put("average_altitude", List.of("averageAltitudeM"));
m.put("average_weather_temp", List.of("averageWeatherTempC"));
m.put("min_weather_temp", List.of("minWeatherTempC"));
m.put("max_weather_temp", List.of("maxWeatherTempC"));
m.put("average_feels_like", List.of("averageFeelsLikeC"));
m.put("min_feels_like", List.of("minFeelsLikeC"));
m.put("max_feels_like", List.of("maxFeelsLikeC"));
m.put("average_wind_speed", List.of("averageWindSpeedMps"));
m.put("average_wind_gust", List.of("averageWindGustMps"));
m.put("max_rain", List.of("maxRainMm"));
m.put("max_snow", List.of("maxSnowMm"));
m.put("carbs_ingested", List.of("carbsIngestedG"));
m.put("pace", List.of("paceMps"));
// group → activityGroup (SQL reserved word avoidance)
m.put("group", List.of("activityGroup"));
DTO_TO_ENTITY = Collections.unmodifiableMap(m);
}
private static final String INTERVALS_ATHLETE_ID = "i157824";
private static final List<String> ORIGIN_IDS = List.of(
"i132106734", "i130231052", "i112771483", "i120252407", "i94213198"
@@ -43,10 +148,6 @@ class IntervalsActivityMappingTest {
@SuppressWarnings("NullAway.Init")
IntervalsClient intervalsClient;
@Inject
@SuppressWarnings("NullAway.Init")
ObjectMapper objectMapper;
private long athleteId;
@BeforeEach
@@ -64,10 +165,169 @@ class IntervalsActivityMappingTest {
for (String originId : ORIGIN_IDS) {
IntervalsClient.IntervalsActivity raw = intervalsClient.getActivity(originId);
IntervalsActivity mapped = importer.map(raw, athleteId);
checkCoverage(raw, "[" + originId + "] ");
assertMapping(raw, mapped, "[" + originId + "] ");
}
}
private void checkCoverage(IntervalsClient.IntervalsActivity raw, String ctx) {
assertTrue(raw.unknownFields.isEmpty(),
ctx + "API returned JSON fields not declared in DTO: " + raw.unknownFields.keySet());
for (java.lang.reflect.Field dtoField : IntervalsClient.IntervalsActivity.class.getDeclaredFields()) {
if (!java.lang.reflect.Modifier.isPublic(dtoField.getModifiers())) continue;
String dtoName = dtoField.getName();
if (dtoName.equals("unknownFields")) continue;
List<String> entityNames = DTO_TO_ENTITY.getOrDefault(dtoName, List.of(snakeToCamel(dtoName)));
for (String entityName : entityNames) {
assertNotNull(findEntityField(entityName),
ctx + "DTO field '" + dtoName + "' → no entity field '" + entityName + "'");
}
}
}
private @jakarta.annotation.Nullable java.lang.reflect.Field findEntityField(String name) {
Class<?> cls = IntervalsActivity.class;
while (cls != null && cls != Object.class) {
try { return cls.getDeclaredField(name); }
catch (NoSuchFieldException ignored) { cls = cls.getSuperclass(); }
}
return null;
}
private @jakarta.annotation.Nullable java.lang.reflect.Field findStreamEntityField(String name) {
Class<?> cls = ActivityStreamPoint.class;
while (cls != null && cls != Object.class) {
try { return cls.getDeclaredField(name); }
catch (NoSuchFieldException ignored) { cls = cls.getSuperclass(); }
}
return null;
}
@Test
void testStreamsMappedCorrectly() {
for (String originId : ORIGIN_IDS) {
IntervalsClient.IntervalsActivity raw = intervalsClient.getActivity(originId);
if (raw.start_date == null) continue;
IntervalsClient.StreamsData streams = intervalsClient.getActivityStreams(originId);
String ctx = "[" + originId + "] ";
checkStreamsCoverage(ctx);
if (streams.size() == 0) continue;
List<ActivityStreamPoint> points = importer.mapStreams(streams, Instant.parse(raw.start_date), 999L);
assertStreamsMapping(streams, points, Instant.parse(raw.start_date), ctx);
}
}
/** Reflection check: every StreamsData field has a corresponding ActivityStreamPoint field. */
private void checkStreamsCoverage(String ctx) {
for (java.lang.reflect.Field dtoField : IntervalsClient.StreamsData.class.getDeclaredFields()) {
if (!java.lang.reflect.Modifier.isPublic(dtoField.getModifiers())) continue;
String dtoName = dtoField.getName();
List<String> entityNames = STREAMS_DTO_TO_ENTITY.getOrDefault(dtoName, List.of(snakeToCamel(dtoName)));
for (String entityName : entityNames) {
assertNotNull(findStreamEntityField(entityName),
ctx + "StreamsData field '" + dtoName + "' → no ActivityStreamPoint field '" + entityName + "'");
}
}
}
/** Value assertions for sampled points (first, middle, last). */
private void assertStreamsMapping(IntervalsClient.StreamsData streams,
List<ActivityStreamPoint> points,
Instant startTime,
String ctx) {
assertEquals(streams.size(), points.size(), ctx + "point count");
int n = points.size();
int[] indices = {0, n / 2, n - 1};
for (int i : indices) {
ActivityStreamPoint p = points.get(i);
String pfx = ctx + "[" + i + "] ";
List<Integer> timeList = streams.time;
assertNotNull(timeList, ctx + "time list");
int elapsed = timeList.get(i);
assertEquals(elapsed, p.getElapsedS(), pfx + "elapsedS");
assertEquals(startTime.plusSeconds(elapsed), p.getTime(), pfx + "time");
if (streams.distance != null)
assertEquals(streams.distance.get(i), p.getDistanceM(), pfx + "distanceM");
else
assertNull(p.getDistanceM(), pfx + "distanceM null");
if (streams.altitude != null)
assertEquals(streams.altitude.get(i), p.getAltitudeM(), pfx + "altitudeM");
else
assertNull(p.getAltitudeM(), pfx + "altitudeM null");
if (streams.heartrate != null)
assertEquals(streams.heartrate.get(i), p.getHeartrateBpm(), pfx + "heartrateBpm");
else
assertNull(p.getHeartrateBpm(), pfx + "heartrateBpm null");
if (streams.watts != null)
assertEquals(streams.watts.get(i), p.getWatts(), pfx + "watts");
else
assertNull(p.getWatts(), pfx + "watts null");
if (streams.cadence != null)
assertEquals(streams.cadence.get(i), p.getCadenceRpm(), pfx + "cadenceRpm");
else
assertNull(p.getCadenceRpm(), pfx + "cadenceRpm null");
if (streams.velocity_smooth != null)
assertEquals(streams.velocity_smooth.get(i), p.getVelocityMps(), pfx + "velocityMps");
else
assertNull(p.getVelocityMps(), pfx + "velocityMps null");
if (streams.latlng != null)
assertEquals(streams.latlng.get(i), p.getLat(), pfx + "lat");
else
assertNull(p.getLat(), pfx + "lat null");
// lng not provided by intervals.icu streams
assertNull(p.getLng(), pfx + "lng always null");
if (streams.temp != null)
assertEquals(streams.temp.get(i), p.getTempC(), pfx + "tempC");
else
assertNull(p.getTempC(), pfx + "tempC null");
if (streams.grade_smooth != null)
assertEquals(streams.grade_smooth.get(i), p.getGradeSmooth(), pfx + "gradeSmooth");
else
assertNull(p.getGradeSmooth(), pfx + "gradeSmooth null");
if (streams.moving != null)
assertEquals(streams.moving.get(i), p.getMoving(), pfx + "moving");
else
assertNull(p.getMoving(), pfx + "moving null");
if (streams.left_right_balance != null)
assertEquals(streams.left_right_balance.get(i), p.getLrBalance(), pfx + "lrBalance");
else
assertNull(p.getLrBalance(), pfx + "lrBalance null");
if (streams.unknownStreams.isEmpty())
assertNull(p.getExtra(), pfx + "extra null");
else
assertNotNull(p.getExtra(), pfx + "extra non-null");
}
}
private String snakeToCamel(String snake) {
StringBuilder sb = new StringBuilder();
boolean upper = false;
for (int i = 0; i < snake.length(); i++) {
char c = snake.charAt(i);
if (c == '_') { upper = true; }
else if (upper) { sb.append(Character.toUpperCase(c)); upper = false; }
else { sb.append(c); }
}
return sb.toString();
}
private void assertMapping(IntervalsClient.IntervalsActivity raw, IntervalsActivity entity, String ctx) {
// --- AbstractActivity fields ---
assertEquals(defaultValue(raw.id), entity.originId, ctx + "originId");
@@ -93,12 +353,19 @@ class IntervalsActivityMappingTest {
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 ---
// --- IntervalsActivity-specific scalar 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");
if (raw.gear != null) {
assertEquals(defaultValue(raw.gear.id), entity.gearId, ctx + "gearId");
assertEquals(raw.gear.name, entity.gearName, ctx + "gearName");
assertEquals(raw.gear.distance, entity.gearDistanceM, ctx + "gearDistanceM");
assertEquals(raw.gear.primary, entity.gearPrimary, ctx + "gearPrimary");
} else {
assertEquals("", entity.gearId, ctx + "gearId (no gear)");
}
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");
@@ -125,18 +392,213 @@ class IntervalsActivityMappingTest {
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());
// --- recording_stops ---
if (raw.recording_stops != null) {
assertNotNull(entity.recordingStops, ctx + "recordingStops");
assertEquals(raw.recording_stops.size(), entity.recordingStops.length, ctx + "recordingStops.length");
for (int i = 0; i < raw.recording_stops.size(); i++) {
assertEquals(raw.recording_stops.get(i).intValue(), entity.recordingStops[i], ctx + "recordingStops[" + i + "]");
}
}
// --- ignore_parts ---
int expectedIgnoreParts = raw.ignore_parts != null ? raw.ignore_parts.size() : 0;
assertEquals(expectedIgnoreParts, entity.ignoreParts.size(), ctx + "ignoreParts.size");
if (raw.ignore_parts != null) {
for (int i = 0; i < raw.ignore_parts.size(); i++) {
IntervalsClient.IntervalsActivity.Ignore rawPart = raw.ignore_parts.get(i);
IgnorePart part = entity.ignoreParts.get(i);
assertEquals(rawPart.start_index != null ? rawPart.start_index : 0, part.startIndex, ctx + "ignoreParts[" + i + "].startIndex");
assertEquals(rawPart.end_index != null ? rawPart.end_index : 0, part.endIndex, ctx + "ignoreParts[" + i + "].endIndex");
assertEquals(rawPart.power, part.power, ctx + "ignoreParts[" + i + "].power");
assertEquals(rawPart.pace, part.pace, ctx + "ignoreParts[" + i + "].pace");
assertEquals(rawPart.hr, part.hr, ctx + "ignoreParts[" + i + "].hr");
}
}
// --- tags ---
int expectedTags = raw.tags != null ? raw.tags.size() : 0;
assertEquals(expectedTags, entity.tags.size(), ctx + "tags.size");
if (raw.tags != null) {
assertEquals(raw.tags, entity.tags, ctx + "tags");
}
// --- zones ---
if (raw.custom_zones != null) {
int expectedZones = raw.custom_zones.stream()
.mapToInt(zs -> zs.zones != null ? zs.zones.size() : 0)
.sum();
assertEquals(expectedZones, entity.zones.size(), ctx + "zones.size");
int idx = 0;
for (IntervalsClient.IntervalsActivity.ZoneSet zoneSet : raw.custom_zones) {
if (zoneSet.zones == null) continue;
for (IntervalsClient.IntervalsActivity.ZoneInfo zi : zoneSet.zones) {
ActivityZone zone = entity.zones.get(idx++);
assertEquals(defaultValue(zoneSet.code), zone.zoneSetCode, ctx + "zones[" + idx + "].zoneSetCode");
assertEquals(defaultValue(zi.id), zone.zoneId, ctx + "zones[" + idx + "].zoneId");
assertEquals(defaultValue(zi.start), zone.rangeStart, 0.001, ctx + "zones[" + idx + "].rangeStart");
assertEquals(defaultValue(zi.end), zone.rangeEnd, 0.001, ctx + "zones[" + idx + "].rangeEnd");
assertEquals(defaultValue(zi.start_value), zone.valueStart, 0.001, ctx + "zones[" + idx + "].valueStart");
assertEquals(defaultValue(zi.end_value), zone.valueEnd, 0.001, ctx + "zones[" + idx + "].valueEnd");
assertEquals(zi.secs != null ? zi.secs : 0, zone.secs, ctx + "zones[" + idx + "].secs");
}
}
}
// --- attachments ---
int expectedAttachments = raw.attachments != null ? raw.attachments.size() : 0;
assertEquals(expectedAttachments, entity.attachments.size(), ctx + "attachments.size");
if (raw.attachments != null) {
for (int i = 0; i < raw.attachments.size(); i++) {
IntervalsClient.IntervalsActivity.Attachment rawAtt = raw.attachments.get(i);
ActivityAttachment att = entity.attachments.get(i);
assertEquals(rawAtt.id, att.attachmentId, ctx + "attachments[" + i + "].attachmentId");
assertEquals(rawAtt.filename, att.filename, ctx + "attachments[" + i + "].filename");
assertEquals(rawAtt.mimetype, att.mimetype, ctx + "attachments[" + i + "].mimetype");
assertEquals(rawAtt.url, att.url, ctx + "attachments[" + i + "].url");
}
}
// --- new extended fields ---
assertEquals(raw.min_temp, entity.minTempC, ctx + "minTempC");
assertEquals(raw.max_temp, entity.maxTempC, ctx + "maxTempC");
assertEquals(raw.avg_lr_balance, entity.avgLrBalance, ctx + "avgLrBalance");
assertEquals(raw.gap, entity.gapMps, ctx + "gapMps");
assertEquals(raw.gap_model, entity.gapModel, ctx + "gapModel");
assertEquals(raw.device_name, entity.deviceName, ctx + "deviceName");
assertEquals(raw.device_watts, entity.deviceWatts, ctx + "deviceWatts");
assertEquals(raw.power_meter, entity.powerMeter, ctx + "powerMeter");
assertEquals(raw.power_meter_serial, entity.powerMeterSerial, ctx + "powerMeterSerial");
assertEquals(raw.power_meter_battery, entity.powerMeterBattery, ctx + "powerMeterBattery");
assertEquals(raw.crank_length, entity.crankLengthMm, ctx + "crankLengthMm");
assertEquals(raw.power_field, entity.powerField, ctx + "powerField");
assertEquals(raw.use_elevation_correction, entity.useElevationCorrection, ctx + "useElevationCorrection");
assertEquals(raw.carbs_used, entity.carbsUsedG, ctx + "carbsUsedG");
assertEquals(raw.icu_distance, entity.icuDistanceM, ctx + "icuDistanceM");
assertEquals(raw.icu_ignore_time, entity.icuIgnoreTime, ctx + "icuIgnoreTime");
assertEquals(raw.icu_recording_time, entity.icuRecordingTimeS, ctx + "icuRecordingTimeS");
assertEquals(raw.coasting_time, entity.coastingTimeS, ctx + "coastingTimeS");
assertEquals(raw.icu_warmup_time, entity.icuWarmupTimeS, ctx + "icuWarmupTimeS");
assertEquals(raw.icu_cooldown_time, entity.icuCooldownTimeS, ctx + "icuCooldownTimeS");
assertEquals(raw.sub_type, entity.subType, ctx + "subType");
assertEquals(raw.paired_event_id, entity.pairedEventId, ctx + "pairedEventId");
assertEquals(raw.icu_weight, entity.icuWeightKg, ctx + "icuWeightKg");
assertEquals(raw.lthr, entity.lthr, ctx + "lthr");
assertEquals(raw.icu_resting_hr, entity.icuRestingHr, ctx + "icuRestingHr");
assertEquals(raw.threshold_pace, entity.thresholdPaceMps, ctx + "thresholdPaceMps");
assertEquals(raw.icu_power_spike_threshold, entity.icuPowerSpikeThreshold, ctx + "icuPowerSpikeThreshold");
assertEquals(raw.icu_sweet_spot_min, entity.icuSweetSpotMin, ctx + "icuSweetSpotMin");
assertEquals(raw.icu_sweet_spot_max, entity.icuSweetSpotMax, ctx + "icuSweetSpotMax");
assertEquals(raw.trimp, entity.trimp, ctx + "trimp");
assertEquals(raw.icu_w_prime, entity.icuWPrimeJ, ctx + "icuWPrimeJ");
assertEquals(raw.p_max, entity.pMaxWatt, ctx + "pMaxWatt");
assertEquals(raw.icu_pm_cp, entity.icuPmCp, ctx + "icuPmCp");
assertEquals(raw.icu_pm_w_prime, entity.icuPmWPrimeJ, ctx + "icuPmWPrimeJ");
assertEquals(raw.icu_pm_p_max, entity.icuPmPMaxWatt, ctx + "icuPmPMaxWatt");
assertEquals(raw.icu_pm_ftp, entity.icuPmFtp, ctx + "icuPmFtp");
assertEquals(raw.icu_pm_ftp_secs, entity.icuPmFtpSecs, ctx + "icuPmFtpSecs");
assertEquals(raw.icu_pm_ftp_watts, entity.icuPmFtpWatts, ctx + "icuPmFtpWatts");
assertEquals(raw.icu_rolling_cp, entity.icuRollingCp, ctx + "icuRollingCp");
assertEquals(raw.icu_rolling_w_prime, entity.icuRollingWPrimeJ, ctx + "icuRollingWPrimeJ");
assertEquals(raw.icu_rolling_p_max, entity.icuRollingPMaxWatt, ctx + "icuRollingPMaxWatt");
assertEquals(raw.icu_rolling_ftp, entity.icuRollingFtp, ctx + "icuRollingFtp");
assertEquals(raw.icu_rolling_ftp_delta, entity.icuRollingFtpDelta, ctx + "icuRollingFtpDelta");
assertEquals(raw.ss_cp, entity.ssCp, ctx + "ssCp");
assertEquals(raw.ss_w_prime, entity.ssWPrimeJ, ctx + "ssWPrimeJ");
assertEquals(raw.ss_p_max, entity.ssPMaxWatt, ctx + "ssPMaxWatt");
assertEquals(raw.icu_ignore_power, entity.icuIgnorePower, ctx + "icuIgnorePower");
assertEquals(raw.icu_ignore_hr, entity.icuIgnoreHr, ctx + "icuIgnoreHr");
assertEquals(raw.ignore_velocity, entity.ignoreVelocity, ctx + "ignoreVelocity");
assertEquals(raw.ignore_pace, entity.ignorePace, ctx + "ignorePace");
assertEquals(raw.has_heartrate, entity.hasHeartrate, ctx + "hasHeartrate");
assertEquals(raw.analyzed, entity.analyzed, ctx + "analyzed");
assertEquals(raw.has_weather, entity.hasWeather, ctx + "hasWeather");
assertEquals(raw.has_segments, entity.hasSegments, ctx + "hasSegments");
assertEquals(raw.file_type, entity.fileType, ctx + "fileType");
assertEquals(raw.file_sport_index, entity.fileSportIndex, ctx + "fileSportIndex");
assertEquals(raw.icu_athlete_id, entity.icuAthleteId, ctx + "icuAthleteId");
assertEquals(raw.created, entity.icuCreated, ctx + "icuCreated");
assertEquals(raw.icu_sync_date, entity.icuSyncDate, ctx + "icuSyncDate");
assertEquals(raw.icu_chat_id, entity.icuChatId, ctx + "icuChatId");
assertEquals(raw.start_date_local, entity.startDateLocal, ctx + "startDateLocal");
assertEquals(raw.skyline_chart_bytes, entity.skylineChartBytes, ctx + "skylineChartBytes");
// complex JSON fields: null ↔ null, non-null ↔ non-null
assertEquals(raw.icu_hr_zones == null, entity.icuHrZones == null, ctx + "icuHrZones nullness");
assertEquals(raw.pace_zones == null, entity.paceZones == null, ctx + "paceZones nullness");
assertEquals(raw.icu_power_zones == null, entity.icuPowerZones == null, ctx + "icuPowerZones nullness");
assertEquals(raw.icu_training_load_data == null, entity.icuTrainingLoadData == null, ctx + "icuTrainingLoadData nullness");
assertEquals(raw.interval_summary == null, entity.intervalSummary == null, ctx + "intervalSummary nullness");
// stream_types
if (raw.stream_types != null) {
assertNotNull(entity.streamTypes, ctx + "streamTypes");
assertEquals(raw.stream_types.size(), entity.streamTypes.length, ctx + "streamTypes.length");
for (int i = 0; i < raw.stream_types.size(); i++) {
assertEquals(raw.stream_types.get(i), entity.streamTypes[i], ctx + "streamTypes[" + i + "]");
}
} else {
assertNull(entity.streamTypes, ctx + "streamTypes null");
}
// --- second batch ---
assertEquals(raw.icu_zone_times == null, entity.icuZoneTimes == null, ctx + "icuZoneTimes nullness");
assertEquals(raw.icu_hr_zone_times == null, entity.icuHrZoneTimes == null, ctx + "icuHrZoneTimes nullness");
assertEquals(raw.pace_zone_times == null, entity.paceZoneTimes == null, ctx + "paceZoneTimes nullness");
assertEquals(raw.gap_zone_times == null, entity.gapZoneTimes == null, ctx + "gapZoneTimes nullness");
assertEquals(raw.use_gap_zone_times, entity.useGapZoneTimes, ctx + "useGapZoneTimes");
assertEquals(raw.tiz_order, entity.tizOrder, ctx + "tizOrder");
assertEquals(raw.polarization_index, entity.polarizationIndex, ctx + "polarizationIndex");
assertEquals(raw.decoupling, entity.decoupling, ctx + "decoupling");
assertEquals(raw.icu_lap_count, entity.icuLapCount, ctx + "icuLapCount");
assertEquals(raw.icu_joules_above_ftp, entity.icuJoulesAboveFtp, ctx + "icuJoulesAboveFtp");
assertEquals(raw.icu_max_wbal_depletion, entity.icuMaxWbalDepletion, ctx + "icuMaxWbalDepletion");
assertEquals(raw.icu_power_hr_z2, entity.icuPowerHrZ2, ctx + "icuPowerHrZ2");
assertEquals(raw.icu_power_hr_z2_mins, entity.icuPowerHrZ2Mins, ctx + "icuPowerHrZ2Mins");
assertEquals(raw.icu_cadence_z2, entity.icuCadenceZ2, ctx + "icuCadenceZ2");
assertEquals(raw.icu_rpe, entity.icuRpe, ctx + "icuRpe");
assertEquals(raw.icu_power_hr, entity.icuPowerHr, ctx + "icuPowerHr");
assertEquals(raw.icu_median_time_delta, entity.icuMedianTimeDelta, ctx + "icuMedianTimeDelta");
assertEquals(raw.p30s_exponent, entity.p30sExponent, ctx + "p30sExponent");
assertEquals(raw.icu_intervals_edited, entity.icuIntervalsEdited, ctx + "icuIntervalsEdited");
assertEquals(raw.lock_intervals, entity.lockIntervals, ctx + "lockIntervals");
assertEquals(raw.workout_shift_secs, entity.workoutShiftSecs, ctx + "workoutShiftSecs");
assertEquals(raw.pool_length, entity.poolLengthM, ctx + "poolLengthM");
assertEquals(raw.lengths == null, entity.lengths == null, ctx + "lengths nullness");
assertEquals(raw.power_load, entity.powerLoad, ctx + "powerLoad");
assertEquals(raw.hr_load, entity.hrLoad, ctx + "hrLoad");
assertEquals(raw.pace_load, entity.paceLoad, ctx + "paceLoad");
assertEquals(raw.hr_load_type, entity.hrLoadType, ctx + "hrLoadType");
assertEquals(raw.pace_load_type, entity.paceLoadType, ctx + "paceLoadType");
assertEquals(raw.session_rpe, entity.sessionRpe, ctx + "sessionRpe");
assertEquals(raw.strain_score, entity.strainScore, ctx + "strainScore");
assertEquals(raw.kg_lifted, entity.kgLifted, ctx + "kgLifted");
assertEquals(raw.average_altitude, entity.averageAltitudeM, ctx + "averageAltitudeM");
assertEquals(raw.average_weather_temp, entity.averageWeatherTempC, ctx + "averageWeatherTempC");
assertEquals(raw.min_weather_temp, entity.minWeatherTempC, ctx + "minWeatherTempC");
assertEquals(raw.max_weather_temp, entity.maxWeatherTempC, ctx + "maxWeatherTempC");
assertEquals(raw.average_feels_like, entity.averageFeelsLikeC, ctx + "averageFeelsLikeC");
assertEquals(raw.min_feels_like, entity.minFeelsLikeC, ctx + "minFeelsLikeC");
assertEquals(raw.max_feels_like, entity.maxFeelsLikeC, ctx + "maxFeelsLikeC");
assertEquals(raw.average_wind_speed, entity.averageWindSpeedMps, ctx + "averageWindSpeedMps");
assertEquals(raw.average_wind_gust, entity.averageWindGustMps, ctx + "averageWindGustMps");
assertEquals(raw.prevailing_wind_deg, entity.prevailingWindDeg, ctx + "prevailingWindDeg");
assertEquals(raw.headwind_percent, entity.headwindPercent, ctx + "headwindPercent");
assertEquals(raw.tailwind_percent, entity.tailwindPercent, ctx + "tailwindPercent");
assertEquals(raw.average_clouds, entity.averageClouds, ctx + "averageClouds");
assertEquals(raw.max_rain, entity.maxRainMm, ctx + "maxRainMm");
assertEquals(raw.max_snow, entity.maxSnowMm, ctx + "maxSnowMm");
assertEquals(raw.carbs_ingested, entity.carbsIngestedG, ctx + "carbsIngestedG");
assertEquals(raw.pace, entity.paceMps, ctx + "paceMps");
assertEquals(raw.athlete_max_hr, entity.athleteMaxHr, ctx + "athleteMaxHr");
assertEquals(raw.compliance, entity.compliance, ctx + "compliance");
assertEquals(raw.coach_tick, entity.coachTick, ctx + "coachTick");
assertEquals(raw.group, entity.activityGroup, ctx + "activityGroup");
assertEquals(raw.icu_sync_error, entity.icuSyncError, ctx + "icuSyncError");
assertEquals(raw.icu_color, entity.icuColor, ctx + "icuColor");
assertEquals(raw.source, entity.source, ctx + "source");
assertEquals(raw.oauth_client_id, entity.oauthClientId, ctx + "oauthClientId");
assertEquals(raw.oauth_client_name, entity.oauthClientName, ctx + "oauthClientName");
assertEquals(raw.strava_id, entity.stravaId, ctx + "stravaId");
assertEquals(raw.route_id, entity.routeId, ctx + "routeId");
assertEquals(raw.power_field_names == null, entity.powerFieldNames == null, ctx + "powerFieldNames nullness");
}
}