Improve import
This commit is contained in:
2
.idea/QuarkusDeploymentProjectService.xml
generated
2
.idea/QuarkusDeploymentProjectService.xml
generated
@@ -6,7 +6,7 @@
|
||||
<entry key="peakup-backend">
|
||||
<value>
|
||||
<ModuleState>
|
||||
<option name="hash" value="746995196" />
|
||||
<option name="hash" value="2132754668" />
|
||||
<option name="version" value="1" />
|
||||
</ModuleState>
|
||||
</value>
|
||||
|
||||
45
.idea/compiler.xml
generated
45
.idea/compiler.xml
generated
@@ -58,51 +58,6 @@
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/org/antlr/antlr4-runtime/4.13.2/antlr4-runtime-4.13.2.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/net/bytebuddy/byte-buddy/1.17.8/byte-buddy-1.17.8.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/org/jboss/logging/jboss-logging/3.6.1.Final/jboss-logging-3.6.1.Final.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/org/projectlombok/lombok/1.18.42/lombok-1.18.42.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/com/google/errorprone/error_prone_core/2.37.0/error_prone_core-2.37.0.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/com/google/errorprone/error_prone_annotation/2.37.0/error_prone_annotation-2.37.0.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/com/google/errorprone/error_prone_check_api/2.37.0/error_prone_check_api-2.37.0.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/io/github/java-diff-utils/java-diff-utils/4.12/java-diff-utils-4.12.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/com/github/kevinstern/software-and-algorithms/1.0/software-and-algorithms-1.0.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/com/github/ben-manes/caffeine/caffeine/3.0.5/caffeine-3.0.5.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/com/google/googlejavaformat/google-java-format/1.24.0/google-java-format-1.24.0.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/org/pcollections/pcollections/4.0.1/pcollections-4.0.1.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/com/google/guava/guava/32.1.1-jre/guava-32.1.1-jre.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/com/google/guava/failureaccess/1.0.1/failureaccess-1.0.1.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/com/google/guava/listenablefuture/9999.0-empty-to-avoid-conflict-with-guava/listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/com/google/code/findbugs/jsr305/3.0.2/jsr305-3.0.2.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/org/checkerframework/checker-qual/3.33.0/checker-qual-3.33.0.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/com/google/j2objc/j2objc-annotations/2.8/j2objc-annotations-2.8.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/com/google/auto/auto-common/1.2.2/auto-common-1.2.2.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/io/github/eisop/dataflow-errorprone/3.41.0-eisop1/dataflow-errorprone-3.41.0-eisop1.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/com/google/auto/value/auto-value-annotations/1.9/auto-value-annotations-1.9.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/com/google/errorprone/error_prone_annotations/2.37.0/error_prone_annotations-2.37.0.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/com/google/protobuf/protobuf-java/3.25.5/protobuf-java-3.25.5.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/com/google/auto/service/auto-service-annotations/1.0.1/auto-service-annotations-1.0.1.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/org/jspecify/jspecify/1.0.0/jspecify-1.0.0.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/javax/inject/javax.inject/1/javax.inject-1.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/com/uber/nullaway/nullaway/0.13.1/nullaway-0.13.1.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/org/checkerframework/dataflow-nullaway/3.53.0/dataflow-nullaway-3.53.0.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/org/hibernate/orm/hibernate-processor/7.2.4.Final/hibernate-processor-7.2.4.Final.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/org/hibernate/orm/hibernate-core/7.2.4.Final/hibernate-core-7.2.4.Final.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/jakarta/persistence/jakarta.persistence-api/3.2.0/jakarta.persistence-api-3.2.0.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/jakarta/transaction/jakarta.transaction-api/2.0.1/jakarta.transaction-api-2.0.1.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/com/fasterxml/classmate/1.7.1/classmate-1.7.1.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/jakarta/inject/jakarta.inject-api/2.0.1/jakarta.inject-api-2.0.1.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/org/hibernate/models/hibernate-models/1.0.1/hibernate-models-1.0.1.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/io/smallrye/jandex/3.3.2/jandex-3.3.2.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/jakarta/xml/bind/jakarta.xml.bind-api/4.0.4/jakarta.xml.bind-api-4.0.4.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/jakarta/activation/jakarta.activation-api/2.1.4/jakarta.activation-api-2.1.4.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/org/glassfish/jaxb/jaxb-runtime/4.0.6/jaxb-runtime-4.0.6.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/org/glassfish/jaxb/jaxb-core/4.0.6/jaxb-core-4.0.6.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/org/eclipse/angus/angus-activation/2.0.3/angus-activation-2.0.3.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/org/glassfish/jaxb/txw2/4.0.6/txw2-4.0.6.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/com/sun/istack/istack-commons-runtime/4.1.2/istack-commons-runtime-4.1.2.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/jakarta/validation/jakarta.validation-api/3.1.1/jakarta.validation-api-3.1.1.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/jakarta/annotation/jakarta.annotation-api/3.0.0/jakarta.annotation-api-3.0.0.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/org/antlr/antlr4-runtime/4.13.2/antlr4-runtime-4.13.2.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/net/bytebuddy/byte-buddy/1.17.8/byte-buddy-1.17.8.jar" />
|
||||
<entry name="$USER_HOME$/.m2/peak-repo/org/jboss/logging/jboss-logging/3.6.1.Final/jboss-logging-3.6.1.Final.jar" />
|
||||
</processorPath>
|
||||
<module name="peakup-backend" />
|
||||
</profile>
|
||||
|
||||
27
CLAUDE.md
27
CLAUDE.md
@@ -1,6 +1,6 @@
|
||||
# CLAUDE.md — Project-Specific Guidelines for Claude Code (claude.ai/code)
|
||||
|
||||
**Last updated:** 2026-03-20
|
||||
**Last updated:** 2026-03-21
|
||||
**Purpose:** This file is the single source of truth for how Claude must behave when working in this repository.
|
||||
Claude **must** follow these rules 100 % of the time. No exceptions.
|
||||
|
||||
@@ -10,6 +10,8 @@ Claude **must** follow these rules 100 % of the time. No exceptions.
|
||||
*before** and **after** every change.
|
||||
- Never assume file contents, package structure, or conventions — read them first.
|
||||
- Never start editing, moving, or creating files without following the correct workflow.
|
||||
- **Always write clean code.** When a change makes a field, method, class, column, or migration step unreachable or
|
||||
unwritten, remove it — do not leave dead code behind. **always ask the user before keeping or removing it.**
|
||||
- If a task feels truly trivial (rename variable, fix typo, add comment, single-line fix), **always ask first**:
|
||||
> “This looks trivial. May I edit directly, or should I still follow the full Proposal → Validate → Implement
|
||||
process?”
|
||||
@@ -174,27 +176,34 @@ $MVN test -Dtest=XXX
|
||||
### Activity Connector Pattern
|
||||
|
||||
`activity/connector/ActivityConnector<T>` — interface:
|
||||
|
||||
- `fetchRawActivities(@Nullable Instant after, @Nullable Instant before): List<T>`
|
||||
|
||||
Implementations:
|
||||
- `StravaActivityConnector` — paginates Strava API (up to 10 pages × 200 activities); uses `StravaContext` + `@RestClient StravaClient`
|
||||
- `IntervalsActivityConnector` — fetches activities for a date range from Intervals.icu; uses `IntervalsContext` + `@RestClient IntervalsClient`
|
||||
|
||||
- `StravaActivityConnector` — paginates Strava API (up to 10 pages × 200 activities); uses `StravaContext` +
|
||||
`@RestClient StravaClient`
|
||||
- `IntervalsActivityConnector` — fetches activities for a date range from Intervals.icu; uses `IntervalsContext` +
|
||||
`@RestClient IntervalsClient`
|
||||
|
||||
New connectors must implement this interface.
|
||||
|
||||
### Activity Importer Pattern
|
||||
|
||||
`activity/importer/AbstractActivityImporter<R, A extends AbstractActivity>` — abstract base:
|
||||
|
||||
- `getDAO(): AbstractDAO<A>` — abstract
|
||||
- `findExisting(R raw): @Nullable A` — abstract, checks for duplicate by origin ID
|
||||
- `map(R raw, Long athleteId): A` — abstract, maps raw API response → entity
|
||||
- `persist(Long athleteId, List<R> raws): int` — template method; calls findExisting/map/insert
|
||||
|
||||
Implementations:
|
||||
|
||||
- `StravaActivityImporter` extends `AbstractActivityImporter<StravaClient.Activity, StravaActivity>`
|
||||
- `IntervalsActivityImporter` extends `AbstractActivityImporter<IntervalsClient.IntervalsActivity, IntervalsActivity>`
|
||||
- Overrides `persist()` with per-activity `@Transactional persistSingle()` via CDI self-injection (`@Inject IntervalsActivityImporter self`)
|
||||
- Also imports `ActivityStreamPoint` and `ActivityMapStreamPoint` after activity insert/update
|
||||
- Overrides `persist()` with per-activity `@Transactional persistSingle()` via CDI self-injection (
|
||||
`@Inject IntervalsActivityImporter self`)
|
||||
- Also imports `ActivityStreamPoint` and `ActivityMapStreamPoint` after activity insert/update
|
||||
|
||||
New importers must extend `AbstractActivityImporter` and follow this pattern.
|
||||
|
||||
@@ -203,10 +212,13 @@ New importers must extend `AbstractActivityImporter` and follow this pattern.
|
||||
Stored in two TimescaleDB hypertables (extend `AbstractTimescaleEntity`):
|
||||
|
||||
`ActivityStreamPoint` (`activity_stream_point`) — per-second sensor data:
|
||||
|
||||
- Composite PK: `(activityId, time)` via `@IdClass`
|
||||
- Fields: `elapsedS`, `distanceM`, `altitudeM`, `heartrateBpm`, `watts`, `torqueNm`, `cadenceRpm`, `velocityMps`, `lat`, `lng`, `tempC`, `gradeSmooth`, `moving`, `lrBalance`, `extra` (JSONB for unknown streams)
|
||||
- Fields: `elapsedS`, `distanceM`, `altitudeM`, `heartrateBpm`, `watts`, `torqueNm`, `cadenceRpm`, `velocityMps`, `lat`,
|
||||
`lng`, `tempC`, `gradeSmooth`, `moving`, `lrBalance`, `extra` (JSONB for unknown streams)
|
||||
|
||||
`ActivityMapStreamPoint` (`activity_map_stream_point`) — GPS track:
|
||||
|
||||
- Composite PK: `(activityId, time)` via `@IdClass`
|
||||
- Fields: `lat`, `lng`
|
||||
|
||||
@@ -250,7 +262,8 @@ script `src/test/resources/skripts.sql` seeds test data.
|
||||
|
||||
## Frontend (peakup-frontend)
|
||||
|
||||
> ⚠️ **NOT YET IMPLEMENTED** — This module does not exist yet. The section below documents the intended stack and conventions for when it is built.
|
||||
> ⚠️ **NOT YET IMPLEMENTED** — This module does not exist yet. The section below documents the intended stack and
|
||||
> conventions for when it is built.
|
||||
|
||||
### Stack
|
||||
|
||||
|
||||
@@ -114,6 +114,11 @@
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-jdbc-h2</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-junit5</artifactId>
|
||||
|
||||
@@ -20,9 +20,9 @@ public class ActivityStreamDAO {
|
||||
private static final String INSERT_SQL = """
|
||||
INSERT INTO activity_stream_point
|
||||
(time, activity_id, elapsed_s, distance_m, altitude_m, heartrate_bpm, watts, torque_nm,
|
||||
cadence_rpm, velocity_mps, lat, lng, temp_c, grade_smooth, moving, lr_balance, extra)
|
||||
cadence_rpm, velocity_mps, lat, lng, temp_c, grade_smooth, moving, lr_balance)
|
||||
VALUES
|
||||
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb)
|
||||
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (activity_id, time) DO NOTHING
|
||||
""";
|
||||
|
||||
@@ -57,7 +57,6 @@ public class ActivityStreamDAO {
|
||||
setNullable(ps, 14, p.getGradeSmooth(), Types.DOUBLE);
|
||||
setNullable(ps, 15, p.getMoving(), Types.BOOLEAN);
|
||||
setNullable(ps, 16, p.getLrBalance(), Types.DOUBLE);
|
||||
setNullable(ps, 17, p.getExtra(), Types.VARCHAR);
|
||||
ps.addBatch();
|
||||
}
|
||||
ps.executeBatch();
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.peakup.activity.model.IntervalsActivity;
|
||||
import com.peakup.activity.model.IntervalsActivity_;
|
||||
import com.peakup.activity.model.StreamStatusEnum;
|
||||
import com.peakup.dao.AbstractDAO;
|
||||
import com.peakup.model.AbstractEntity_;
|
||||
import jakarta.annotation.Nullable;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.persistence.criteria.CriteriaBuilder;
|
||||
@@ -59,11 +60,11 @@ public class IntervalsActivityDAO extends AbstractDAO<IntervalsActivity> {
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
public List<IntervalsActivity> findPendingStreams(Long athleteId) {
|
||||
public List<Long> findPendingStreamIds(Long athleteId) {
|
||||
CriteriaBuilder cb = em.getCriteriaBuilder();
|
||||
CriteriaQuery<IntervalsActivity> cq = cb.createQuery(IntervalsActivity.class);
|
||||
CriteriaQuery<Long> cq = cb.createQuery(Long.class);
|
||||
Root<IntervalsActivity> root = cq.from(IntervalsActivity.class);
|
||||
cq.select(root);
|
||||
cq.select(root.get(AbstractEntity_.id));
|
||||
cq.where(
|
||||
cb.equal(root.get(AbstractActivity_.athleteId), athleteId),
|
||||
cb.equal(root.get(IntervalsActivity_.streamStatus), StreamStatusEnum.PENDING)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.peakup.activity.importer;
|
||||
|
||||
import com.peakup.common.PeakUpException;
|
||||
|
||||
public class ImportException extends PeakUpException {
|
||||
public ImportException(String msg) { super(msg); }
|
||||
}
|
||||
@@ -7,14 +7,7 @@ import com.peakup.activity.dao.ActivityAchievementDAO;
|
||||
import com.peakup.activity.dao.ActivityMapStreamDAO;
|
||||
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.ActivityMapStreamPoint;
|
||||
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.activity.model.StreamStatusEnum;
|
||||
import com.peakup.activity.model.*;
|
||||
import com.peakup.connector.intervals.IntervalsClient;
|
||||
import com.peakup.dao.AbstractDAO;
|
||||
import jakarta.annotation.Nullable;
|
||||
@@ -27,7 +20,9 @@ import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static com.peakup.common.NullableUtils.defaultValue;
|
||||
|
||||
@@ -333,28 +328,24 @@ public class IntervalsActivityImporter
|
||||
|
||||
@Transactional
|
||||
public boolean persistSingle(IntervalsClient.IntervalsActivity raw, Long athleteId) {
|
||||
IntervalsActivity existing = findExisting(raw);
|
||||
if (existing != null) {
|
||||
if (existing.streamStatus == StreamStatusEnum.PENDING) {
|
||||
tryImportStreams(existing);
|
||||
intervalsActivityDAO.update(existing);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (findExisting(raw) != null) return false;
|
||||
IntervalsActivity activity = map(raw, athleteId);
|
||||
intervalsActivityDAO.insert(activity);
|
||||
persistAchievements(raw, activity);
|
||||
tryImportStreams(activity);
|
||||
intervalsActivityDAO.update(activity);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void tryImportStreams(IntervalsActivity activity) {
|
||||
if (activity.originId == null || activity.startTime == null || activity.getId() == null) return;
|
||||
if (activity.getId() == null) return;
|
||||
try {
|
||||
boolean s = importStreams(activity.originId, activity.startTime, activity.getId());
|
||||
boolean m = importMapStream(activity.originId, activity.startTime, activity.getId());
|
||||
activity.streamStatus = (s || m) ? StreamStatusEnum.IMPORTED : StreamStatusEnum.NO_STREAM;
|
||||
activity.streamImportError = null;
|
||||
} catch (ImportException e) {
|
||||
log.error("Stream import error for activity {}: {}", activity.originId, e.getMessage());
|
||||
activity.streamStatus = StreamStatusEnum.ERROR;
|
||||
activity.streamImportError = e.getMessage();
|
||||
} catch (Exception e) {
|
||||
log.warn("Stream import failed for activity {}: {}", activity.originId, e.getMessage());
|
||||
// leave PENDING — scheduler will retry
|
||||
@@ -362,15 +353,16 @@ public class IntervalsActivityImporter
|
||||
}
|
||||
|
||||
public void importPendingStreams(Long athleteId) {
|
||||
List<IntervalsActivity> pending = intervalsActivityDAO.findPendingStreams(athleteId);
|
||||
log.info("Found {} pending stream activities for athleteId {}", pending.size(), athleteId);
|
||||
for (IntervalsActivity activity : pending) {
|
||||
self.importStreamForActivity(activity);
|
||||
List<Long> pendingIds = intervalsActivityDAO.findPendingStreamIds(athleteId);
|
||||
log.info("Found {} pending stream activities for athleteId {}", pendingIds.size(), athleteId);
|
||||
for (Long activityId : pendingIds) {
|
||||
self.importStreamForActivity(activityId);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void importStreamForActivity(IntervalsActivity activity) {
|
||||
public void importStreamForActivity(Long activityId) {
|
||||
IntervalsActivity activity = intervalsActivityDAO.getById(activityId);
|
||||
tryImportStreams(activity);
|
||||
intervalsActivityDAO.update(activity);
|
||||
}
|
||||
@@ -412,9 +404,9 @@ public class IntervalsActivityImporter
|
||||
|
||||
// package-private for testing
|
||||
List<ActivityMapStreamPoint> mapMapStream(IntervalsClient.MapData mapData,
|
||||
List<Integer> timeList,
|
||||
Instant startTime,
|
||||
long activityId) {
|
||||
List<Integer> timeList,
|
||||
Instant startTime,
|
||||
long activityId) {
|
||||
List<List<Double>> latlngs = mapData.latlngs;
|
||||
if (latlngs == null) return List.of();
|
||||
int n = Math.min(latlngs.size(), timeList.size());
|
||||
@@ -433,6 +425,23 @@ public class IntervalsActivityImporter
|
||||
|
||||
// package-private for testing
|
||||
List<ActivityStreamPoint> mapStreams(IntervalsClient.StreamsData streams, Instant startTime, long activityId) {
|
||||
if (!streams.unknownStreams.isEmpty()) {
|
||||
String unknownTypes = streams.unknownStreams.entrySet().stream()
|
||||
.map(e -> {
|
||||
Object v = e.getValue();
|
||||
String typeName;
|
||||
if (v instanceof Collection<?> col) {
|
||||
Object first = col.stream().findFirst().orElse(null);
|
||||
typeName = "List<" + (first != null ? first.getClass().getSimpleName() : "?") + ">";
|
||||
} else {
|
||||
typeName = v.getClass().getSimpleName();
|
||||
}
|
||||
return e.getKey() + " (" + typeName + ")";
|
||||
})
|
||||
.collect(Collectors.joining(", "));
|
||||
log.error("Cannot map unknown stream type(s) for activity {}: {}", activityId, unknownTypes);
|
||||
throw new ImportException("Cannot map unknown stream type(s): " + unknownTypes);
|
||||
}
|
||||
int n = streams.size();
|
||||
List<ActivityStreamPoint> points = new ArrayList<>(n);
|
||||
for (int i = 0; i < n; i++) {
|
||||
@@ -441,19 +450,18 @@ public class IntervalsActivityImporter
|
||||
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.torque != null) p.setTorqueNm(streams.torque.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.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.torque != null) p.setTorqueNm(streams.torque.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);
|
||||
}
|
||||
@@ -475,6 +483,14 @@ public class IntervalsActivityImporter
|
||||
? LocalDate.ofInstant(latest.startTime, ZoneOffset.UTC).minusDays(2).toString()
|
||||
: "2010-01-01";
|
||||
String newest = LocalDate.now(ZoneOffset.UTC).toString();
|
||||
importActivitiesForRange(athleteId, intervalsAthleteId, oldest, newest);
|
||||
}
|
||||
|
||||
public void importActivities(Long athleteId, String intervalsAthleteId, LocalDate from, LocalDate to) {
|
||||
importActivitiesForRange(athleteId, intervalsAthleteId, from.toString(), to.toString());
|
||||
}
|
||||
|
||||
private void importActivitiesForRange(Long athleteId, String intervalsAthleteId, String oldest, String newest) {
|
||||
List<IntervalsClient.IntervalsActivity> allRaws =
|
||||
intervalsActivityConnector.fetchForRange(intervalsAthleteId, oldest, newest);
|
||||
allRaws.stream()
|
||||
|
||||
@@ -64,9 +64,6 @@ public class ActivityStreamPoint extends AbstractTimescaleEntity {
|
||||
@Column(name = "lr_balance")
|
||||
private @Nullable Double lrBalance;
|
||||
|
||||
@Column(columnDefinition = "JSONB")
|
||||
private @Nullable String extra;
|
||||
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
public ActivityStreamPoint() {
|
||||
// Hibernate
|
||||
|
||||
@@ -550,6 +550,9 @@ public class IntervalsActivity extends AbstractActivity {
|
||||
@Column
|
||||
public StreamStatusEnum streamStatus = StreamStatusEnum.PENDING;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
public @Nullable String streamImportError;
|
||||
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
public IntervalsActivity() {
|
||||
// hibernate
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.peakup.activity.model;
|
||||
import com.peakup.dao.PersistableEnum;
|
||||
|
||||
public enum StreamStatusEnum implements PersistableEnum<StreamStatusEnum> {
|
||||
PENDING(0), IMPORTED(1), NO_STREAM(2);
|
||||
PENDING(0), IMPORTED(1), NO_STREAM(2), ERROR(3);
|
||||
|
||||
private final int key;
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.peakup.common;
|
||||
|
||||
public class PeakUpException extends RuntimeException {
|
||||
public PeakUpException(String msg) { super(msg); }
|
||||
public PeakUpException(String msg, Throwable cause) { super(msg, cause); }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
quarkus.http.port=8111
|
||||
quarkus.scheduler.enabled=true
|
||||
|
||||
quarkus.datasource.db-kind=postgresql
|
||||
quarkus.datasource.username=peakup
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE intervals_activity
|
||||
ADD COLUMN stream_import_error TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE activity_stream_point
|
||||
DROP COLUMN extra;
|
||||
105
peakup-backend/src/test/java/com/peakup/AbstractPeakUpTest.java
Normal file
105
peakup-backend/src/test/java/com/peakup/AbstractPeakUpTest.java
Normal file
@@ -0,0 +1,105 @@
|
||||
package com.peakup;
|
||||
|
||||
import io.agroal.api.AgroalDataSource;
|
||||
import jakarta.inject.Inject;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
|
||||
/**
|
||||
* Base class for H2 in-memory tests.
|
||||
* Before each test method the entire H2 database is dropped (flyway.clean) and
|
||||
* all migrations are replayed (flyway.migrate). Subclasses declare whether the
|
||||
* re-created database should remain empty or be seeded with athletes 90/91 and
|
||||
* their connectors.
|
||||
*
|
||||
* <p>Concrete subclasses must carry {@code @QuarkusTest} and
|
||||
* {@code @TestProfile(H2TestProfile.class)}.
|
||||
*/
|
||||
public abstract class AbstractPeakUpTest {
|
||||
|
||||
public enum DbMode {
|
||||
/** Migrations + athletes 90 (Ingo) and 91 (Mirja) with their connectors — no activity data. */
|
||||
EMPTY,
|
||||
/** Migrations + athletes 90/91 + connectors + full activity rows. */
|
||||
WITH_DATA
|
||||
}
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
Flyway flyway;
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
AgroalDataSource dataSource;
|
||||
|
||||
/** Return the desired database state at the start of each test. */
|
||||
protected abstract DbMode dbMode();
|
||||
|
||||
@BeforeEach
|
||||
void resetDatabase() throws SQLException {
|
||||
flyway.clean();
|
||||
flyway.migrate();
|
||||
seedAthletes();
|
||||
if (dbMode() == DbMode.WITH_DATA) {
|
||||
seedActivities();
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Seed helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
protected void seedActivities() throws SQLException {
|
||||
// TODO: seed activity test data
|
||||
}
|
||||
|
||||
private void seedAthletes() throws SQLException {
|
||||
try (Connection conn = dataSource.getConnection();
|
||||
Statement stmt = conn.createStatement()) {
|
||||
|
||||
// Athlete 90 — Ingo
|
||||
stmt.execute("""
|
||||
INSERT INTO athlete (id, first_name, last_name, weight, created_at, modified_at)
|
||||
VALUES (90, 'Ingo', 'Schnabel', 93.0, now(), now())
|
||||
""");
|
||||
stmt.execute("""
|
||||
INSERT INTO strava_connector
|
||||
(athlete_id, strava_athlete_id, access_token, refresh_token,
|
||||
expires_at, client_id, client_secret, created_at, modified_at)
|
||||
VALUES (90, '36523216',
|
||||
'5db8940738e311b8f659c98940c193972b62d983',
|
||||
'685ae77fcc95c4349231d9cd2657c4dc5ebb2388',
|
||||
1767048882, '116054',
|
||||
'59459bc900769ab3bb20d0ece6f3552b51d969ea',
|
||||
now(), now())
|
||||
""");
|
||||
stmt.execute("""
|
||||
INSERT INTO intervals_connector
|
||||
(athlete_id, api_key, intervals_athlete_id, created_at, modified_at)
|
||||
VALUES (90, '38he2acw5nakpy749jabprdtz', 'i157824', now(), now())
|
||||
""");
|
||||
|
||||
// Athlete 91 — Mirja
|
||||
stmt.execute("""
|
||||
INSERT INTO athlete (id, first_name, last_name, weight, created_at, modified_at)
|
||||
VALUES (91, 'Mirja', 'Schnabel', 50.0, now(), now())
|
||||
""");
|
||||
stmt.execute("""
|
||||
INSERT INTO strava_connector
|
||||
(athlete_id, strava_athlete_id, access_token, refresh_token,
|
||||
expires_at, client_id, client_secret, created_at, modified_at)
|
||||
VALUES (91, '108631588',
|
||||
'da11e895be4fae04fc6b1d0d2444c5efa805b0d7',
|
||||
'f1858f584e5dd4e41216a5421feba41f33fe7764',
|
||||
1767048882, '196530',
|
||||
'9c251d9add6a46b4af26d371108bfb84d42f2ec8',
|
||||
now(), now())
|
||||
""");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.peakup.activity.importer;
|
||||
|
||||
import com.peakup.AbstractPeakUpTest;
|
||||
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.service.StravaConnectorService;
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.quarkus.test.junit.TestProfile;
|
||||
import jakarta.inject.Inject;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
@QuarkusTest
|
||||
@TestProfile(H2TestProfile.class)
|
||||
class ActivityImportTest extends AbstractPeakUpTest {
|
||||
|
||||
private static final long ATHLETE_ID = 90L;
|
||||
|
||||
@Override
|
||||
protected DbMode dbMode() {
|
||||
return DbMode.EMPTY;
|
||||
}
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
StravaConnectorService stravaConnectorService;
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
IntervalsConnectorService intervalsConnectorService;
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
StravaActivityImporter stravaActivityImporter;
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
IntervalsActivityImporter intervalsActivityImporter;
|
||||
|
||||
@Inject
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
IntervalsContext intervalsContext;
|
||||
|
||||
@Test
|
||||
void testFullImportPipelineForAthlete90() {
|
||||
// 1. Load and verify connectors
|
||||
var stravaConnector = stravaConnectorService.getStravaConnectors().stream()
|
||||
.filter(c -> ATHLETE_ID == c.getAthleteId())
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalStateException("No Strava connector for athlete " + ATHLETE_ID));
|
||||
assertNotNull(stravaConnector);
|
||||
|
||||
IntervalsConnector intervalsConnector = intervalsConnectorService.getIntervalsConnectors().stream()
|
||||
.filter(c -> ATHLETE_ID == c.getAthleteId())
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalStateException("No Intervals connector for athlete " + ATHLETE_ID));
|
||||
|
||||
// 2. Import Strava activities
|
||||
assertDoesNotThrow(() -> stravaActivityImporter.importActivities(ATHLETE_ID));
|
||||
|
||||
// 3. Import Intervals activities
|
||||
intervalsContext.setAthleteId(ATHLETE_ID);
|
||||
try {
|
||||
assertDoesNotThrow(() ->
|
||||
intervalsActivityImporter.importActivities(ATHLETE_ID, intervalsConnector.getIntervalsAthleteId(),
|
||||
LocalDate.of(2026, 3, 1), LocalDate.of(2026, 4, 1)));
|
||||
} finally {
|
||||
intervalsContext.clear();
|
||||
}
|
||||
|
||||
// 4. Import pending streams and map streams
|
||||
intervalsContext.setAthleteId(ATHLETE_ID);
|
||||
try {
|
||||
assertDoesNotThrow(() -> intervalsActivityImporter.importPendingStreams(ATHLETE_ID));
|
||||
} finally {
|
||||
intervalsContext.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.peakup.activity.importer;
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTestProfile;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public class H2TestProfile implements QuarkusTestProfile {
|
||||
@Override
|
||||
public Map<String, String> getConfigOverrides() {
|
||||
return Map.of(
|
||||
"quarkus.datasource.db-kind", "h2",
|
||||
"quarkus.datasource.jdbc.url",
|
||||
"jdbc:h2:mem:peakup_test;DB_CLOSE_DELAY=-1;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;NON_KEYWORDS=VALUE,TYPE",
|
||||
"quarkus.datasource.username", "sa",
|
||||
"quarkus.datasource.password", "",
|
||||
"quarkus.flyway.locations", "db/h2-migration",
|
||||
"quarkus.flyway.clean-disabled", "false",
|
||||
"quarkus.scheduler.enabled", "false",
|
||||
"quarkus.http.test-port", "0"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ class IntervalsActivityMappingTest {
|
||||
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"));
|
||||
// unknownStreams is not mapped to any entity field — unknown types throw ImportException
|
||||
STREAMS_DTO_TO_ENTITY = Collections.unmodifiableMap(m);
|
||||
}
|
||||
|
||||
@@ -180,6 +180,7 @@ class IntervalsActivityMappingTest {
|
||||
assertEquals(0, StreamStatusEnum.PENDING.getKey());
|
||||
assertEquals(1, StreamStatusEnum.IMPORTED.getKey());
|
||||
assertEquals(2, StreamStatusEnum.NO_STREAM.getKey());
|
||||
assertEquals(3, StreamStatusEnum.ERROR.getKey());
|
||||
}
|
||||
|
||||
private void checkCoverage(IntervalsClient.IntervalsActivity raw, String ctx) {
|
||||
@@ -278,6 +279,7 @@ class IntervalsActivityMappingTest {
|
||||
for (java.lang.reflect.Field dtoField : IntervalsClient.StreamsData.class.getDeclaredFields()) {
|
||||
if (!java.lang.reflect.Modifier.isPublic(dtoField.getModifiers())) continue;
|
||||
String dtoName = dtoField.getName();
|
||||
if (dtoName.equals("unknownStreams")) continue; // not mapped — unknown types throw ImportException
|
||||
List<String> entityNames = STREAMS_DTO_TO_ENTITY.getOrDefault(dtoName, List.of(snakeToCamel(dtoName)));
|
||||
for (String entityName : entityNames) {
|
||||
assertNotNull(findStreamEntityField(entityName),
|
||||
@@ -366,11 +368,6 @@ class IntervalsActivityMappingTest {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
406
peakup-backend/src/test/resources/db/h2-migration/V1__schema.sql
Normal file
406
peakup-backend/src/test/resources/db/h2-migration/V1__schema.sql
Normal file
@@ -0,0 +1,406 @@
|
||||
-- H2-compatible consolidated schema (final state of all 19 PostgreSQL migrations)
|
||||
-- Adaptations from PostgreSQL:
|
||||
-- - No TimescaleDB extension or create_hypertable() calls
|
||||
-- - JSONB → VARCHAR(32767)
|
||||
-- - TEXT[] / INTEGER[] → VARCHAR(32767)
|
||||
-- - activity_stream_point and activity_map_stream_point are regular tables (not hypertables)
|
||||
|
||||
-- ============================================================
|
||||
-- GLOBAL SEQUENCE
|
||||
-- ============================================================
|
||||
CREATE SEQUENCE global_id_seq START WITH 100 INCREMENT BY 1;
|
||||
|
||||
-- ============================================================
|
||||
-- ATHLETE
|
||||
-- ============================================================
|
||||
CREATE TABLE athlete
|
||||
(
|
||||
id BIGINT DEFAULT nextval('global_id_seq') PRIMARY KEY,
|
||||
first_name VARCHAR(255),
|
||||
last_name VARCHAR(255),
|
||||
weight DOUBLE PRECISION,
|
||||
sex INTEGER,
|
||||
created_at TIMESTAMP DEFAULT now() NOT NULL,
|
||||
modified_at TIMESTAMP DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- STRAVA_CONNECTOR
|
||||
-- ============================================================
|
||||
CREATE TABLE strava_connector
|
||||
(
|
||||
id BIGINT DEFAULT nextval('global_id_seq') PRIMARY KEY,
|
||||
athlete_id BIGINT UNIQUE,
|
||||
strava_athlete_id VARCHAR(255),
|
||||
access_token VARCHAR(512),
|
||||
refresh_token VARCHAR(512),
|
||||
expires_at BIGINT,
|
||||
client_id VARCHAR(255),
|
||||
client_secret VARCHAR(255),
|
||||
created_at TIMESTAMP DEFAULT now() NOT NULL,
|
||||
modified_at TIMESTAMP DEFAULT now() NOT NULL,
|
||||
CONSTRAINT fk_strava_athlete FOREIGN KEY (athlete_id) REFERENCES athlete (id)
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- INTERVALS_CONNECTOR
|
||||
-- ============================================================
|
||||
CREATE TABLE intervals_connector
|
||||
(
|
||||
id BIGINT DEFAULT nextval('global_id_seq') PRIMARY KEY,
|
||||
athlete_id BIGINT UNIQUE NOT NULL,
|
||||
intervals_athlete_id VARCHAR(50) NOT NULL,
|
||||
api_key VARCHAR(512) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT now() NOT NULL,
|
||||
modified_at TIMESTAMP DEFAULT now() NOT NULL,
|
||||
CONSTRAINT fk_intervals_athlete FOREIGN KEY (athlete_id) REFERENCES athlete (id)
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- STRAVA_ACTIVITY (final state after V2, V4, V6, V7)
|
||||
-- ============================================================
|
||||
CREATE TABLE strava_activity
|
||||
(
|
||||
id BIGINT DEFAULT nextval('global_id_seq') PRIMARY KEY,
|
||||
athlete_id BIGINT,
|
||||
created_at TIMESTAMP DEFAULT now() NOT NULL,
|
||||
modified_at TIMESTAMP DEFAULT now() NOT NULL,
|
||||
origin_id VARCHAR(50) NOT NULL,
|
||||
name TEXT,
|
||||
type VARCHAR(100),
|
||||
sport_type VARCHAR(100),
|
||||
device_name VARCHAR(255),
|
||||
start_time TIMESTAMP NOT NULL,
|
||||
timezone VARCHAR(100),
|
||||
distance_m DOUBLE PRECISION,
|
||||
moving_time_s BIGINT,
|
||||
elapsed_time_s BIGINT,
|
||||
total_elevation_gain_m DOUBLE PRECISION,
|
||||
average_speed_mps DOUBLE PRECISION,
|
||||
max_speed_mps DOUBLE PRECISION,
|
||||
average_heartrate_bpm DOUBLE PRECISION,
|
||||
max_heartrate_bpm DOUBLE PRECISION,
|
||||
average_cadence_rpm DOUBLE PRECISION,
|
||||
average_power_watt DOUBLE PRECISION,
|
||||
max_power_watt DOUBLE PRECISION,
|
||||
normalized_power_watt DOUBLE PRECISION,
|
||||
total_work_kj DOUBLE PRECISION,
|
||||
calories DOUBLE PRECISION,
|
||||
intervals_enrichment_status INTEGER DEFAULT 0 NOT NULL,
|
||||
CONSTRAINT fk_strava_act_athlete FOREIGN KEY (athlete_id) REFERENCES athlete (id)
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_strava_activity_origin ON strava_activity (origin_id);
|
||||
CREATE INDEX idx_strava_activity_athlete_time ON strava_activity (athlete_id, start_time DESC);
|
||||
CREATE INDEX idx_strava_activity_pending ON strava_activity (athlete_id, intervals_enrichment_status);
|
||||
|
||||
-- ============================================================
|
||||
-- INTERVALS_ACTIVITY (final state after V6–V19)
|
||||
-- ============================================================
|
||||
CREATE TABLE intervals_activity
|
||||
(
|
||||
id BIGINT DEFAULT nextval('global_id_seq') PRIMARY KEY,
|
||||
athlete_id BIGINT,
|
||||
created_at TIMESTAMP DEFAULT now() NOT NULL,
|
||||
modified_at TIMESTAMP DEFAULT now() NOT NULL,
|
||||
origin_id TEXT,
|
||||
name TEXT,
|
||||
type VARCHAR(100),
|
||||
sport_type VARCHAR(100),
|
||||
start_time TIMESTAMP NOT NULL,
|
||||
timezone VARCHAR(100),
|
||||
distance_m DOUBLE PRECISION,
|
||||
moving_time_s BIGINT,
|
||||
elapsed_time_s BIGINT,
|
||||
total_elevation_gain_m DOUBLE PRECISION,
|
||||
average_speed_mps DOUBLE PRECISION,
|
||||
max_speed_mps DOUBLE PRECISION,
|
||||
average_heartrate_bpm DOUBLE PRECISION,
|
||||
max_heartrate_bpm DOUBLE PRECISION,
|
||||
average_cadence_rpm DOUBLE PRECISION,
|
||||
average_power_watt DOUBLE PRECISION,
|
||||
max_power_watt DOUBLE PRECISION,
|
||||
normalized_power_watt DOUBLE PRECISION,
|
||||
total_work_kj DOUBLE PRECISION,
|
||||
calories DOUBLE PRECISION,
|
||||
strava_origin_id TEXT,
|
||||
description TEXT,
|
||||
trainer BOOLEAN,
|
||||
commute BOOLEAN,
|
||||
race BOOLEAN,
|
||||
gear_id VARCHAR(255),
|
||||
total_ascent_m DOUBLE PRECISION,
|
||||
total_descent_m DOUBLE PRECISION,
|
||||
min_altitude_m DOUBLE PRECISION,
|
||||
max_altitude_m DOUBLE PRECISION,
|
||||
training_load DOUBLE PRECISION,
|
||||
intensity_factor DOUBLE PRECISION,
|
||||
efficiency_factor DOUBLE PRECISION,
|
||||
ftp INTEGER,
|
||||
atl DOUBLE PRECISION,
|
||||
ctl DOUBLE PRECISION,
|
||||
hrr_bpm_drop DOUBLE PRECISION,
|
||||
variability_index DOUBLE PRECISION,
|
||||
feel INTEGER,
|
||||
perceived_exertion DOUBLE PRECISION,
|
||||
average_stride_length_m DOUBLE PRECISION,
|
||||
average_temp_c DOUBLE PRECISION,
|
||||
-- V8 HRR fields
|
||||
hrr_start_bpm INTEGER,
|
||||
hrr_end_bpm INTEGER,
|
||||
hrr_start_time_s INTEGER,
|
||||
hrr_end_time_s INTEGER,
|
||||
hrr_average_watts INTEGER,
|
||||
-- V10 gear + recording
|
||||
gear_name VARCHAR(255),
|
||||
gear_distance_m DOUBLE PRECISION,
|
||||
gear_primary BOOLEAN,
|
||||
recording_stops INTEGER ARRAY,
|
||||
-- V11 fields
|
||||
min_temp_c DOUBLE PRECISION,
|
||||
max_temp_c DOUBLE PRECISION,
|
||||
avg_lr_balance DOUBLE PRECISION,
|
||||
gap_mps DOUBLE PRECISION,
|
||||
gap_model VARCHAR(100),
|
||||
device_name VARCHAR(255),
|
||||
device_watts BOOLEAN,
|
||||
power_meter VARCHAR(255),
|
||||
power_meter_serial VARCHAR(255),
|
||||
crank_length_mm DOUBLE PRECISION,
|
||||
power_field VARCHAR(100),
|
||||
use_elevation_correction BOOLEAN,
|
||||
carbs_used_g DOUBLE PRECISION,
|
||||
icu_distance_m DOUBLE PRECISION,
|
||||
icu_recording_time_s INTEGER,
|
||||
coasting_time_s INTEGER,
|
||||
icu_warmup_time_s INTEGER,
|
||||
icu_cooldown_time_s INTEGER,
|
||||
sub_type VARCHAR(100),
|
||||
paired_event_id VARCHAR(255),
|
||||
icu_weight_kg DOUBLE PRECISION,
|
||||
lthr INTEGER,
|
||||
icu_resting_hr INTEGER,
|
||||
threshold_pace_mps DOUBLE PRECISION,
|
||||
icu_power_spike_threshold INTEGER,
|
||||
icu_sweet_spot_min DOUBLE PRECISION,
|
||||
icu_sweet_spot_max DOUBLE PRECISION,
|
||||
trimp DOUBLE PRECISION,
|
||||
icu_w_prime_j INTEGER,
|
||||
p_max_watt INTEGER,
|
||||
icu_pm_cp INTEGER,
|
||||
icu_pm_w_prime_j INTEGER,
|
||||
icu_pm_p_max_watt INTEGER,
|
||||
icu_pm_ftp INTEGER,
|
||||
icu_pm_ftp_secs INTEGER,
|
||||
icu_pm_ftp_watts INTEGER,
|
||||
icu_rolling_cp INTEGER,
|
||||
icu_rolling_w_prime_j INTEGER,
|
||||
icu_rolling_p_max_watt INTEGER,
|
||||
icu_rolling_ftp INTEGER,
|
||||
icu_rolling_ftp_delta INTEGER,
|
||||
ss_cp INTEGER,
|
||||
ss_w_prime_j INTEGER,
|
||||
ss_p_max_watt INTEGER,
|
||||
icu_ignore_power BOOLEAN,
|
||||
icu_ignore_hr BOOLEAN,
|
||||
ignore_velocity BOOLEAN,
|
||||
ignore_pace BOOLEAN,
|
||||
has_heartrate BOOLEAN,
|
||||
has_weather BOOLEAN,
|
||||
has_segments BOOLEAN,
|
||||
file_type VARCHAR(50),
|
||||
file_sport_index INTEGER,
|
||||
icu_athlete_id VARCHAR(50),
|
||||
icu_created VARCHAR(50),
|
||||
icu_sync_date VARCHAR(50),
|
||||
icu_chat_id BIGINT,
|
||||
start_date_local VARCHAR(50),
|
||||
icu_hr_zones TEXT,
|
||||
pace_zones TEXT,
|
||||
icu_power_zones TEXT,
|
||||
icu_training_load_data TEXT,
|
||||
interval_summary TEXT,
|
||||
stream_types VARCHAR ARRAY,
|
||||
skyline_chart_bytes TEXT,
|
||||
-- V12: icu_ignore_time is boolean
|
||||
icu_ignore_time BOOLEAN,
|
||||
-- V13: analyzed is a timestamp string
|
||||
analyzed VARCHAR(50),
|
||||
-- V14 fields
|
||||
icu_zone_times TEXT,
|
||||
icu_hr_zone_times TEXT,
|
||||
pace_zone_times TEXT,
|
||||
gap_zone_times TEXT,
|
||||
use_gap_zone_times BOOLEAN,
|
||||
tiz_order VARCHAR(50),
|
||||
polarization_index DOUBLE PRECISION,
|
||||
decoupling DOUBLE PRECISION,
|
||||
icu_lap_count INTEGER,
|
||||
icu_joules_above_ftp INTEGER,
|
||||
icu_max_wbal_depletion INTEGER,
|
||||
icu_power_hr_z2 DOUBLE PRECISION,
|
||||
icu_power_hr_z2_mins INTEGER,
|
||||
icu_cadence_z2 DOUBLE PRECISION,
|
||||
icu_rpe DOUBLE PRECISION,
|
||||
icu_power_hr DOUBLE PRECISION,
|
||||
icu_median_time_delta INTEGER,
|
||||
p30s_exponent DOUBLE PRECISION,
|
||||
icu_intervals_edited BOOLEAN,
|
||||
lock_intervals BOOLEAN,
|
||||
workout_shift_secs INTEGER,
|
||||
pool_length_m INTEGER,
|
||||
lengths TEXT,
|
||||
power_load DOUBLE PRECISION,
|
||||
hr_load DOUBLE PRECISION,
|
||||
pace_load DOUBLE PRECISION,
|
||||
hr_load_type VARCHAR(50),
|
||||
pace_load_type VARCHAR(50),
|
||||
session_rpe DOUBLE PRECISION,
|
||||
strain_score DOUBLE PRECISION,
|
||||
kg_lifted DOUBLE PRECISION,
|
||||
average_altitude_m DOUBLE PRECISION,
|
||||
average_weather_temp_c DOUBLE PRECISION,
|
||||
min_weather_temp_c DOUBLE PRECISION,
|
||||
max_weather_temp_c DOUBLE PRECISION,
|
||||
average_feels_like_c DOUBLE PRECISION,
|
||||
min_feels_like_c DOUBLE PRECISION,
|
||||
max_feels_like_c DOUBLE PRECISION,
|
||||
average_wind_speed_mps DOUBLE PRECISION,
|
||||
average_wind_gust_mps DOUBLE PRECISION,
|
||||
prevailing_wind_deg DOUBLE PRECISION,
|
||||
headwind_percent DOUBLE PRECISION,
|
||||
tailwind_percent DOUBLE PRECISION,
|
||||
average_clouds DOUBLE PRECISION,
|
||||
max_rain_mm DOUBLE PRECISION,
|
||||
max_snow_mm DOUBLE PRECISION,
|
||||
carbs_ingested_g DOUBLE PRECISION,
|
||||
pace_mps DOUBLE PRECISION,
|
||||
athlete_max_hr INTEGER,
|
||||
-- V15: compliance as string
|
||||
compliance VARCHAR(50),
|
||||
coach_tick BOOLEAN,
|
||||
activity_group VARCHAR(255),
|
||||
icu_sync_error TEXT,
|
||||
icu_color VARCHAR(20),
|
||||
source VARCHAR(100),
|
||||
oauth_client_id VARCHAR(255),
|
||||
oauth_client_name VARCHAR(255),
|
||||
strava_id BIGINT,
|
||||
route_id BIGINT,
|
||||
power_field_names TEXT,
|
||||
-- V16: power_meter_battery as string
|
||||
power_meter_battery VARCHAR(20),
|
||||
-- V19: stream_status
|
||||
stream_status SMALLINT DEFAULT 0 NOT NULL,
|
||||
-- V20: stream_import_error
|
||||
stream_import_error VARCHAR(32767),
|
||||
CONSTRAINT fk_intervals_act_athlete FOREIGN KEY (athlete_id) REFERENCES athlete (id)
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_intervals_activity_origin ON intervals_activity (origin_id);
|
||||
CREATE UNIQUE INDEX idx_intervals_activity_strava_id ON intervals_activity (strava_origin_id);
|
||||
CREATE INDEX idx_intervals_activity_athlete_time ON intervals_activity (athlete_id, start_time DESC);
|
||||
|
||||
-- ============================================================
|
||||
-- ACTIVITY_ZONE (V10)
|
||||
-- ============================================================
|
||||
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 DEFAULT 0 NOT NULL,
|
||||
range_end DOUBLE PRECISION DEFAULT 0 NOT NULL,
|
||||
value_start DOUBLE PRECISION DEFAULT 0 NOT NULL,
|
||||
value_end DOUBLE PRECISION DEFAULT 0 NOT NULL,
|
||||
secs INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- ACTIVITY_ACHIEVEMENT (V10)
|
||||
-- ============================================================
|
||||
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
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- ACTIVITY_IGNORE_PART (V10)
|
||||
-- ============================================================
|
||||
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
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- ACTIVITY_TAG (V10)
|
||||
-- ============================================================
|
||||
CREATE TABLE activity_tag
|
||||
(
|
||||
activity_id BIGINT NOT NULL REFERENCES intervals_activity (id) ON DELETE CASCADE,
|
||||
tag VARCHAR(255) NOT NULL
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- ACTIVITY_ATTACHMENT (V10)
|
||||
-- ============================================================
|
||||
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)
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- ACTIVITY_STREAM_POINT (V17 — regular table, no TimescaleDB)
|
||||
-- ============================================================
|
||||
CREATE TABLE activity_stream_point
|
||||
(
|
||||
time TIMESTAMP NOT NULL,
|
||||
activity_id BIGINT NOT NULL,
|
||||
elapsed_s INTEGER NOT NULL,
|
||||
distance_m DOUBLE PRECISION,
|
||||
altitude_m DOUBLE PRECISION,
|
||||
heartrate_bpm INTEGER,
|
||||
watts INTEGER,
|
||||
torque_nm 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,
|
||||
PRIMARY KEY (activity_id, time)
|
||||
);
|
||||
CREATE INDEX idx_asp_activity_time ON activity_stream_point (activity_id, time DESC);
|
||||
|
||||
-- ============================================================
|
||||
-- ACTIVITY_MAP_STREAM_POINT (V18 — regular table, no TimescaleDB)
|
||||
-- ============================================================
|
||||
CREATE TABLE activity_map_stream_point
|
||||
(
|
||||
time TIMESTAMP NOT NULL,
|
||||
activity_id BIGINT NOT NULL,
|
||||
lat DOUBLE PRECISION NOT NULL,
|
||||
lng DOUBLE PRECISION NOT NULL,
|
||||
PRIMARY KEY (activity_id, time)
|
||||
);
|
||||
CREATE INDEX idx_amsp_activity_time ON activity_map_stream_point (activity_id, time DESC);
|
||||
7
resources/delete_all_activities.sql
Normal file
7
resources/delete_all_activities.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- Delete all activities and streams from local dev database.
|
||||
-- Order matters: streams reference activities, so delete streams first.
|
||||
|
||||
DELETE FROM activity_stream_point;
|
||||
DELETE FROM activity_map_stream_point;
|
||||
DELETE FROM intervals_activity;
|
||||
DELETE FROM strava_activity;
|
||||
Reference in New Issue
Block a user