Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
### Unreleased
- Added configurable rolling date ranges to scheduled calendar synchronization.
- Added safe multi-day TrainerRoad to TrainingPeaks reconciliation by processing each day independently.
- Preserved existing schedules as current-day schedules through default offsets.

### 0.12.3
- Updated Java to 21.0.3

Expand Down
28 changes: 21 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,15 @@ It provides a responsive, mobile-friendly interface for manual synchronization,

### TrainerRoad to TrainingPeaks reconciliation

For one-day TrainerRoad → TrainingPeaks operations, TP2Intervals can reconcile changed planned workouts instead of only adding another copy.
For selected TrainerRoad → TrainingPeaks operations, TP2Intervals can reconcile changed planned workouts instead of only adding another copy.

This behavior is used by:

- **Only today**;
- **Only tomorrow**;
- scheduled TrainerRoad → TrainingPeaks synchronization.
- scheduled TrainerRoad → TrainingPeaks synchronization, including multi-day rolling periods.

Scheduled periods that span more than one day are reconciled independently, one day at a time. This preserves the same safety rules for every date in the configured period.

When the TrainerRoad workout identifier changes, TP2Intervals:

Expand Down Expand Up @@ -88,7 +90,7 @@ Some TrainingPeaks operations depend on whether the configured account is an ath

| Source | Destination | Manual calendar sync | Scheduled sync | Changed-workout reconciliation |
|---|---|:---:|:---:|:---:|
| TrainerRoad | TrainingPeaks | Yes | Yes | Yes, for one-day operations |
| TrainerRoad | TrainingPeaks | Yes | Yes | Yes, for quick one-day actions and scheduled ranges |
| TrainerRoad | Intervals.icu | Yes | Yes | No |
| TrainingPeaks | Intervals.icu | Yes | Yes | No |
| Intervals.icu | TrainingPeaks | Yes | Yes | No |
Expand All @@ -101,14 +103,26 @@ The **Scheduler** page allows you to create recurring calendar synchronization j

Current scheduler behavior:

- scheduled jobs process the **current day**;
- each schedule uses a configurable rolling period relative to the execution date;
- offsets from `-365` to `365` days are supported;
- existing schedules without an explicit period continue to process the current day;
- all schedules use the global scheduler interval, which defaults to **1 hour**;
- schedules are persisted in SQLite and survive container restarts;
- duplicate schedule definitions are rejected;
- each schedule can be executed immediately with **Run now**;
- schedules can be deleted without deleting previously synchronized workouts.

TrainerRoad → TrainingPeaks schedules automatically use the safe changed-workout reconciliation described above. Other directions use normal synchronization with the configured duplicate-skipping behavior.
TrainerRoad → TrainingPeaks schedules automatically use the safe changed-workout reconciliation described above. Multi-day periods are reconciled one day at a time. Other directions use normal synchronization with the configured duplicate-skipping behavior.

Examples of rolling periods:

| Start offset | End offset | Period processed on every run |
|---:|---:|---|
| `0` | `0` | Today |
| `0` | `1` | Today and tomorrow |
| `0` | `6` | Today and the following six days |
| `-1` | `1` | Yesterday, today, and tomorrow |
| `1` | `7` | Tomorrow through seven days from today |

### Synchronization history

Expand Down Expand Up @@ -434,9 +448,9 @@ The TrainingPeaks and TrainerRoad integrations depend on web endpoints and sessi

- TrainerRoad ramp steps are not currently supported.
- TrainerRoad is supported as a source, not as a synchronization destination.
- Changed-workout replacement is currently limited to one-day TrainerRoad → TrainingPeaks operations.
- Changed-workout replacement is available for **Only today**, **Only tomorrow**, and scheduled TrainerRoad → TrainingPeaks rolling periods. The regular manual **Confirm** action still uses non-destructive copy behavior.
- Changed-workout detection is primarily based on the TrainerRoad workout identifier. A content change that keeps the same identifier may be treated as already synchronized.
- Scheduled jobs always process the current day.
- Scheduler periods are relative to the execution date and are limited to offsets between `-365` and `365` days.
- The scheduler interval is global for the application instance and cannot be configured per schedule.
- Synchronization history currently covers calendar-to-calendar operations only.
- TrainingPeaks capabilities can differ between athlete, coach, free, and Premium accounts.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,49 @@ class WorkoutService(
): CopyWorkoutsResponse {

return if (shouldReconcileChangedWorkouts(request)) {
reconcileTrainerRoadToTrainingPeaks(request)
reconcileTrainerRoadToTrainingPeaksRange(request)
} else {
copyWorkoutsNormally(request)
}
}

private fun reconcileTrainerRoadToTrainingPeaksRange(
request: CopyFromCalendarToCalendarRequest
): CopyWorkoutsResponse {
require(!request.startDate.isAfter(request.endDate)) {
"Start date cannot be after end date"
}

val responses = mutableListOf<CopyWorkoutsResponse>()
var currentDate = request.startDate

while (!currentDate.isAfter(request.endDate)) {
responses += reconcileTrainerRoadToTrainingPeaks(
request.copy(
startDate = currentDate,
endDate = currentDate
)
)

currentDate = currentDate.plusDays(1)
}

return CopyWorkoutsResponse(
copied = responses.sumOf { it.copied },
filteredOut = responses.sumOf { it.filteredOut },
skippedByType = responses.sumOf { it.skippedByType },
skippedAlreadySynced =
responses.sumOf { it.skippedAlreadySynced },
startDate = request.startDate,
endDate = request.endDate,
externalData = ExternalData.empty(),
failed = responses.sumOf { it.failed },
failedWorkouts = responses.flatMap { it.failedWorkouts },
removed = responses.sumOf { it.removed },
failedToRemove = responses.sumOf { it.failedToRemove },
failedRemovals = responses.flatMap { it.failedRemovals }
)
}

private fun reconcileTrainerRoadToTrainingPeaks(
request: CopyFromCalendarToCalendarRequest
Expand Down Expand Up @@ -371,7 +409,6 @@ class WorkoutService(
request: CopyFromCalendarToCalendarRequest
): Boolean {
return request.replaceChangedWorkouts &&
request.startDate == request.endDate &&
request.sourcePlatform == Platform.TRAINER_ROAD &&
request.targetPlatform == Platform.TRAINING_PEAKS
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package org.freekode.tp2intervals.app.workout.schedule

import org.freekode.tp2intervals.app.workout.CopyFromCalendarToCalendarRequest
import org.freekode.tp2intervals.domain.Platform
import org.freekode.tp2intervals.domain.TrainingType
import java.time.LocalDate

data class C2CScheduledRequest(
val types: List<TrainingType>,
val skipSynced: Boolean,
val sourcePlatform: Platform,
val targetPlatform: Platform,
val startOffsetDays: Int = 0,
val endOffsetDays: Int = 0
) : Schedulable {

init {
require(startOffsetDays in MIN_OFFSET_DAYS..MAX_OFFSET_DAYS) {
"Start offset must be between $MIN_OFFSET_DAYS and $MAX_OFFSET_DAYS days"
}

require(endOffsetDays in MIN_OFFSET_DAYS..MAX_OFFSET_DAYS) {
"End offset must be between $MIN_OFFSET_DAYS and $MAX_OFFSET_DAYS days"
}

require(startOffsetDays <= endOffsetDays) {
"Start offset cannot be after end offset"
}
}

fun toCopyRequest(
referenceDate: LocalDate = LocalDate.now()
) = CopyFromCalendarToCalendarRequest(
startDate = referenceDate.plusDays(startOffsetDays.toLong()),
endDate = referenceDate.plusDays(endOffsetDays.toLong()),
types = types,
skipSynced = skipSynced,
sourcePlatform = sourcePlatform,
targetPlatform = targetPlatform,
replaceChangedWorkouts =
sourcePlatform == Platform.TRAINER_ROAD &&
targetPlatform == Platform.TRAINING_PEAKS
)

companion object {
const val MIN_OFFSET_DAYS = -365
const val MAX_OFFSET_DAYS = 365
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,7 @@ data class ScheduledSyncResponse(
val types: List<TrainingType>,
val skipSynced: Boolean,
val sourcePlatform: Platform,
val targetPlatform: Platform
)
val targetPlatform: Platform,
val startOffsetDays: Int,
val endOffsetDays: Int
)
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ package org.freekode.tp2intervals.app.workout.schedule

import com.fasterxml.jackson.databind.ObjectMapper
import org.freekode.tp2intervals.app.workout.CopyWorkoutsResponse
import org.freekode.tp2intervals.app.workout.execution.SyncExecutionService
import org.freekode.tp2intervals.app.workout.execution.SyncExecutionTrigger
import org.freekode.tp2intervals.infrastructure.schedule.ScheduleRequestEntity
import org.freekode.tp2intervals.infrastructure.schedule.ScheduleRequestRepository
import org.slf4j.LoggerFactory
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import java.util.concurrent.TimeUnit
import org.freekode.tp2intervals.app.workout.execution.SyncExecutionService
import org.freekode.tp2intervals.app.workout.execution.SyncExecutionTrigger

@Service
class WorkoutScheduledJob(
Expand All @@ -19,17 +19,21 @@ class WorkoutScheduledJob(
) {
private val log = LoggerFactory.getLogger(this.javaClass)

fun addRequest(request: C2CTodayScheduledRequest) {
val requestJson = objectMapper.writeValueAsString(request)
fun addRequest(request: C2CScheduledRequest) {
val alreadyExists = scheduleRequestRepository
.findAll()
.asSequence()
.map { it.toSchedulable() }
.any { it == request }

require(
scheduleRequestRepository.findByRequestJson(requestJson) == null
) {
require(!alreadyExists) {
"This scheduled sync already exists"
}

scheduleRequestRepository.save(
ScheduleRequestEntity(requestJson)
ScheduleRequestEntity(
objectMapper.writeValueAsString(request)
)
)
}

Expand All @@ -44,7 +48,9 @@ class WorkoutScheduledJob(
types = request.types,
skipSynced = request.skipSynced,
sourcePlatform = request.sourcePlatform,
targetPlatform = request.targetPlatform
targetPlatform = request.targetPlatform,
startOffsetDays = request.startOffsetDays,
endOffsetDays = request.endOffsetDays
)
}

Expand All @@ -66,7 +72,7 @@ class WorkoutScheduledJob(
}

return syncExecutionService.execute(
request = entity.toSchedulable().forToday(),
request = entity.toSchedulable().toCopyRequest(),
trigger = SyncExecutionTrigger.RUN_NOW,
scheduleId = entity.id
)
Expand All @@ -89,7 +95,7 @@ class WorkoutScheduledJob(
requests.forEach { entity ->
try {
syncExecutionService.execute(
request = entity.toSchedulable().forToday(),
request = entity.toSchedulable().toCopyRequest(),
trigger = SyncExecutionTrigger.SCHEDULED,
scheduleId = entity.id
)
Expand All @@ -106,11 +112,11 @@ class WorkoutScheduledJob(
}

private fun ScheduleRequestEntity.toSchedulable():
C2CTodayScheduledRequest {
C2CScheduledRequest {

return objectMapper.readValue(
requireNotNull(requestJson),
C2CTodayScheduledRequest::class.java
C2CScheduledRequest::class.java
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,5 @@ import org.springframework.data.repository.CrudRepository
import org.springframework.stereotype.Repository

@Repository
interface ScheduleRequestRepository : CrudRepository<ScheduleRequestEntity, Int> {
fun findByRequestJson(requestJson: String): ScheduleRequestEntity?
}
interface ScheduleRequestRepository :
CrudRepository<ScheduleRequestEntity, Int>
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package org.freekode.tp2intervals.rest.workout

import org.freekode.tp2intervals.app.workout.schedule.C2CTodayScheduledRequest
import org.freekode.tp2intervals.app.workout.schedule.C2CScheduledRequest
import org.freekode.tp2intervals.app.workout.schedule.WorkoutScheduledJob
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
Expand All @@ -16,8 +16,8 @@ class WorkoutScheduledJobController(
@PostMapping(
"/api/workout/copy-calendar-to-calendar/schedule"
)
fun scheduleC2CTodayRequest(
@RequestBody request: C2CTodayScheduledRequest
fun scheduleC2CRequest(
@RequestBody request: C2CScheduledRequest
) {
workoutScheduledJob.addRequest(request)
}
Expand All @@ -43,4 +43,4 @@ class WorkoutScheduledJobController(
) {
workoutScheduledJob.deleteRequest(id)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package org.freekode.tp2intervals.app.workout

import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatIllegalArgumentException
import org.freekode.tp2intervals.app.workout.schedule.C2CScheduledRequest
import org.freekode.tp2intervals.domain.Platform
import org.freekode.tp2intervals.domain.TrainingType
import org.junit.jupiter.api.Test
import java.time.LocalDate

class C2CScheduledRequestTest {

@Test
fun `resolves offsets against the execution date`() {
val request = C2CScheduledRequest(
types = listOf(TrainingType.BIKE),
skipSynced = true,
sourcePlatform = Platform.TRAINER_ROAD,
targetPlatform = Platform.TRAINING_PEAKS,
startOffsetDays = -1,
endOffsetDays = 2
)

val copyRequest = request.toCopyRequest(
referenceDate = LocalDate.of(2026, 7, 13)
)

assertThat(copyRequest.startDate)
.isEqualTo(LocalDate.of(2026, 7, 12))
assertThat(copyRequest.endDate)
.isEqualTo(LocalDate.of(2026, 7, 15))
assertThat(copyRequest.replaceChangedWorkouts).isTrue()
}

@Test
fun `defaults preserve current-day schedules`() {
val request = C2CScheduledRequest(
types = listOf(TrainingType.BIKE),
skipSynced = true,
sourcePlatform = Platform.INTERVALS,
targetPlatform = Platform.TRAINING_PEAKS
)

val referenceDate = LocalDate.of(2026, 7, 13)
val copyRequest = request.toCopyRequest(referenceDate)

assertThat(copyRequest.startDate).isEqualTo(referenceDate)
assertThat(copyRequest.endDate).isEqualTo(referenceDate)
assertThat(copyRequest.replaceChangedWorkouts).isFalse()
}

@Test
fun `rejects a start offset after the end offset`() {
assertThatIllegalArgumentException().isThrownBy {
C2CScheduledRequest(
types = listOf(TrainingType.BIKE),
skipSynced = true,
sourcePlatform = Platform.INTERVALS,
targetPlatform = Platform.TRAINING_PEAKS,
startOffsetDays = 2,
endOffsetDays = 1
)
}
}
}
Loading