Improve import

This commit is contained in:
2026-03-21 23:00:53 +02:00
parent 4583989845
commit 66b610b19f
21 changed files with 736 additions and 108 deletions

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
quarkus.http.port=8111
quarkus.scheduler.enabled=true
quarkus.datasource.db-kind=postgresql
quarkus.datasource.username=peakup

View File

@@ -0,0 +1,2 @@
ALTER TABLE intervals_activity
ADD COLUMN stream_import_error TEXT;

View File

@@ -0,0 +1,2 @@
ALTER TABLE activity_stream_point
DROP COLUMN extra;

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

View File

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

View File

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

View File

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

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

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