From aef23720d62fb90b3ad63b265b367581a44280b1 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Tue, 28 Apr 2026 13:46:30 +0200 Subject: [PATCH] feat(komoot): add server-side date range filtering for activities Signed-off-by: Marcus Fihlon --- .../fitpub/model/dto/KomootImportRequest.java | 17 +- .../fitpub/service/KomootImportService.java | 47 ++++- .../templates/activities/komoot.html | 31 +++- .../service/KomootImportServiceTest.java | 172 +++++++++++++++++- 4 files changed, 249 insertions(+), 18 deletions(-) diff --git a/src/main/java/net/javahippie/fitpub/model/dto/KomootImportRequest.java b/src/main/java/net/javahippie/fitpub/model/dto/KomootImportRequest.java index 1851749..345f1b9 100644 --- a/src/main/java/net/javahippie/fitpub/model/dto/KomootImportRequest.java +++ b/src/main/java/net/javahippie/fitpub/model/dto/KomootImportRequest.java @@ -4,6 +4,8 @@ import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; +import java.time.LocalDate; + /** * Request payload for fetching completed activities from Komoot. * @@ -19,6 +21,19 @@ public record KomootImportRequest( @NotBlank @Pattern(regexp = "\\d+", message = "Komoot user ID must contain digits only") - String userId + String userId, + + LocalDate startDate, + + LocalDate endDate ) { + public KomootImportRequest { + boolean onlyOneDateProvided = (startDate == null) != (endDate == null); + if (onlyOneDateProvided) { + throw new IllegalArgumentException("Start date and end date must either both be set or both be empty."); + } + if (startDate != null && startDate.isAfter(endDate)) { + throw new IllegalArgumentException("Start date must be before or equal to end date."); + } + } } diff --git a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java index 86159fd..d37ab78 100644 --- a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java +++ b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java @@ -24,7 +24,12 @@ import org.springframework.web.util.UriComponentsBuilder; import java.net.URI; import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.LocalTime; import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Base64; import java.util.Comparator; @@ -47,6 +52,7 @@ public class KomootImportService { private static final int PAGE_SIZE = 100; private static final String KOMOOT_LANGUAGE = "en"; + private static final DateTimeFormatter KOMOOT_DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX"); private final RestTemplate restTemplate; private final ActivityRepository activityRepository; private final ActivityFileService activityFileService; @@ -58,7 +64,7 @@ public class KomootImportService { public KomootActivitiesResponse fetchCompletedActivities(KomootImportRequest request) { List activities = new ArrayList<>(); - URI nextUri = buildInitialUri(request.userId()); + URI nextUri = buildInitialUri(request); HttpEntity httpEntity = new HttpEntity<>(buildHeaders(request.email(), request.password())); try { @@ -149,19 +155,25 @@ public class KomootImportService { ); } - private URI buildInitialUri(String userId) { + private URI buildInitialUri(KomootImportRequest request) { String normalizedBaseUrl = komootBaseUrl.endsWith("/") ? komootBaseUrl.substring(0, komootBaseUrl.length() - 1) : komootBaseUrl; - return UriComponentsBuilder.fromUriString(normalizedBaseUrl + "/api/v007/users/" + userId + "/tours/") + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(normalizedBaseUrl + "/api/v007/users/" + request.userId() + "/tours/") .queryParam("type", "tour_recorded") - .queryParam("status", "private") - .queryParam("name", "") - .queryParam("hl", KOMOOT_LANGUAGE) .queryParam("sort_field", "date") .queryParam("sort_direction", "desc") - .queryParam("page", 0) - .queryParam("limit", PAGE_SIZE) - .build() - .toUri(); + .queryParam("limit", PAGE_SIZE); + + if (request.startDate() != null && request.endDate() != null) { + builder.queryParam("start_date", formatKomootStartDate(request.startDate())) + .queryParam("end_date", formatKomootEndDate(request.endDate())); + } else { + builder.queryParam("status", "private") + .queryParam("name", "") + .queryParam("hl", KOMOOT_LANGUAGE) + .queryParam("page", 0); + } + + return builder.build().toUri(); } private URI buildDetailUri(long activityId) { @@ -314,6 +326,21 @@ public class KomootImportService { return new ImportCandidateContext(importedKomootActivityIds, activities, candidate); } + private String formatKomootStartDate(LocalDate localDate) { + return localDate.atStartOfDay(ZoneId.systemDefault()) + .toInstant() + .atOffset(ZoneOffset.UTC) + .format(KOMOOT_DATE_TIME_FORMATTER); + } + + private String formatKomootEndDate(LocalDate localDate) { + return localDate.atTime(LocalTime.of(23, 59, 59, 999_000_000)) + .atZone(ZoneId.systemDefault()) + .toInstant() + .atOffset(ZoneOffset.UTC) + .format(KOMOOT_DATE_TIME_FORMATTER); + } + private Activity.Visibility mapVisibility(String komootStatus) { if (komootStatus == null) { return Activity.Visibility.PRIVATE; diff --git a/src/main/resources/templates/activities/komoot.html b/src/main/resources/templates/activities/komoot.html index ccdab09..c99e6db 100644 --- a/src/main/resources/templates/activities/komoot.html +++ b/src/main/resources/templates/activities/komoot.html @@ -47,6 +47,19 @@
You can find the Komoot ID in your Komoot account settings.
+
+
+
+ + +
+
+ + +
+
+
Both dates must be set together. Inclusive, day-based filter.
+
@@ -225,13 +238,21 @@ function buildPayload() { const formData = new FormData(form); + const startDate = formData.get('startDate'); + const endDate = formData.get('endDate'); return { email: formData.get('email'), password: formData.get('password'), - userId: formData.get('userId') + userId: formData.get('userId'), + startDate: startDate || null, + endDate: endDate || null }; } + function hasIncompleteDateRange(payload) { + return Boolean(payload.startDate) !== Boolean(payload.endDate); + } + form.addEventListener('submit', async function(event) { event.preventDefault(); clearError(); @@ -239,6 +260,10 @@ resultsSection.classList.add('d-none'); const payload = buildPayload(); + if (hasIncompleteDateRange(payload)) { + showError('Start date and end date must either both be set or both be empty.'); + return; + } setLoading(true); @@ -280,6 +305,10 @@ resetAlertToError(); const payload = buildPayload(); + if (hasIncompleteDateRange(payload)) { + showError('Start date and end date must either both be set or both be empty.'); + return; + } setImportLoading(true); try { diff --git a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java index 23fd527..e0afbe1 100644 --- a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java +++ b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java @@ -6,6 +6,7 @@ import net.javahippie.fitpub.model.dto.KomootImportRequest; import net.javahippie.fitpub.model.entity.Activity; import net.javahippie.fitpub.repository.ActivityRepository; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; @@ -16,11 +17,14 @@ import org.springframework.test.web.client.MockRestServiceServer; import org.springframework.web.client.RestTemplate; import java.nio.charset.StandardCharsets; +import java.time.LocalDate; import java.util.Base64; import java.util.List; +import java.util.TimeZone; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -39,9 +43,12 @@ class KomootImportServiceTest { private ActivityRepository activityRepository; private ActivityFileService activityFileService; private ActivityPostProcessingService activityPostProcessingService; + private TimeZone originalTimeZone; @BeforeEach void setUp() { + originalTimeZone = TimeZone.getDefault(); + TimeZone.setDefault(TimeZone.getTimeZone("Europe/Zurich")); restTemplate = new RestTemplate(); server = MockRestServiceServer.bindTo(restTemplate).build(); activityRepository = mock(ActivityRepository.class); @@ -51,12 +58,17 @@ class KomootImportServiceTest { ReflectionTestUtils.setField(service, "komootBaseUrl", "https://www.komoot.com"); } + @AfterEach + void tearDown() { + TimeZone.setDefault(originalTimeZone); + } + @Test void shouldFetchAndMergePagedCompletedActivities() { String authHeader = "Basic " + Base64.getEncoder() .encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8)); - server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&status=private&name=&hl=en&sort_field=date&sort_direction=desc&page=0&limit=100")) + server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&sort_field=date&sort_direction=desc&limit=100&status=private&name=&hl=en&page=0")) .andExpect(method(HttpMethod.GET)) .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) .andRespond(withSuccess(""" @@ -111,7 +123,7 @@ class KomootImportServiceTest { """, MediaType.APPLICATION_JSON)); KomootActivitiesResponse response = service.fetchCompletedActivities( - new KomootImportRequest("user@example.com", "secret", "123456")); + new KomootImportRequest("user@example.com", "secret", "123456", null, null)); assertThat(response.totalCount()).isEqualTo(2); assertThat(response.activities()).hasSize(2); @@ -122,6 +134,69 @@ class KomootImportServiceTest { server.verify(); } + @Test + @DisplayName("Should filter loaded Komoot activities by inclusive date range") + void shouldFilterCompletedActivitiesByInclusiveDateRange() { + String authHeader = "Basic " + Base64.getEncoder() + .encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8)); + + server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&sort_field=date&sort_direction=desc&limit=100&start_date=2026-04-25T22:00:00.000Z&end_date=2026-04-27T21:59:59.999Z")) + .andExpect(method(HttpMethod.GET)) + .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) + .andRespond(withSuccess(""" + { + "_embedded": { + "tours": [ + { + "id": 1002, + "name": "Included Start", + "sport": "hike", + "status": "private", + "type": "tour_recorded", + "date": "2026-04-26T00:00:00+02:00" + }, + { + "id": 1003, + "name": "Included End", + "sport": "run", + "status": "private", + "type": "tour_recorded", + "date": "2026-04-27T23:59:59+02:00" + } + ] + }, + "_links": {} + } + """, MediaType.APPLICATION_JSON)); + + KomootActivitiesResponse response = service.fetchCompletedActivities( + new KomootImportRequest( + "user@example.com", + "secret", + "123456", + LocalDate.of(2026, 4, 26), + LocalDate.of(2026, 4, 27) + )); + + assertThat(response.totalCount()).isEqualTo(2); + assertThat(response.activities()).extracting("id").containsExactly(1002L, 1003L); + + server.verify(); + } + + @Test + @DisplayName("Should reject incomplete Komoot date range") + void shouldRejectIncompleteDateRange() { + assertThatThrownBy(() -> new KomootImportRequest( + "user@example.com", + "secret", + "123456", + LocalDate.of(2026, 4, 27), + null + )).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Start date and end date must either both be set or both be empty."); + } + @Test @DisplayName("Should import newest not-yet-imported Komoot activity via GPX and override metadata") void shouldImportNewestNotYetImportedActivity() { @@ -132,7 +207,7 @@ class KomootImportServiceTest { when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of()); - server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&status=private&name=&hl=en&sort_field=date&sort_direction=desc&page=0&limit=100")) + server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&sort_field=date&sort_direction=desc&limit=100&status=private&name=&hl=en&page=0")) .andExpect(method(HttpMethod.GET)) .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) .andRespond(withSuccess(""" @@ -190,7 +265,7 @@ class KomootImportServiceTest { when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0)); KomootImportExecutionResponse response = service.importFirstNewActivity( - new KomootImportRequest("user@example.com", "secret", "123456"), + new KomootImportRequest("user@example.com", "secret", "123456", null, null), userId ); @@ -206,6 +281,91 @@ class KomootImportServiceTest { server.verify(); } + @Test + @DisplayName("Should respect date range when choosing Komoot import candidate") + void shouldRespectDateRangeWhenImportingFirstNewActivity() { + String authHeader = "Basic " + Base64.getEncoder() + .encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8)); + UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + UUID importedActivityId = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd"); + + when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of()); + + server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&sort_field=date&sort_direction=desc&limit=100&start_date=2026-04-26T22:00:00.000Z&end_date=2026-04-27T21:59:59.999Z")) + .andExpect(method(HttpMethod.GET)) + .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) + .andRespond(withSuccess(""" + { + "_embedded": { + "tours": [ + { + "id": 3002, + "name": "Inside Range Candidate", + "sport": "mtb_easy", + "status": "public", + "type": "tour_recorded", + "date": "2026-04-27T18:15:00+02:00" + } + ] + }, + "_links": {} + } + """, MediaType.APPLICATION_JSON)); + + server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/3002?hl=en")) + .andExpect(method(HttpMethod.GET)) + .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) + .andRespond(withSuccess(""" + { + "id": "3002", + "name": "Inside Range Candidate", + "description": "Imported from Komoot", + "status": "public", + "sport": "mtb_easy" + } + """, MediaType.APPLICATION_JSON)); + + server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/3002.gpx")) + .andExpect(method(HttpMethod.GET)) + .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) + .andRespond(withSuccess(""" + + + Inside Range Candidate + + """, MediaType.APPLICATION_XML)); + + Activity importedActivity = Activity.builder() + .id(importedActivityId) + .userId(userId) + .activityType(Activity.ActivityType.OTHER) + .title("GPX Title") + .description(null) + .visibility(Activity.Visibility.PRIVATE) + .sourceFileFormat("GPX") + .build(); + + when(activityFileService.processActivityFile(any(), any(), any(), any(), any())).thenReturn(importedActivity); + when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + KomootImportExecutionResponse response = service.importFirstNewActivity( + new KomootImportRequest( + "user@example.com", + "secret", + "123456", + LocalDate.of(2026, 4, 27), + LocalDate.of(2026, 4, 27) + ), + userId + ); + + assertThat(response.importedKomootActivityId()).isEqualTo(3002L); + assertThat(importedActivity.getKomootActivityId()).isEqualTo(3002L); + + verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId); + server.verify(); + } + @Test @DisplayName("Should fall back to OTHER when Komoot sport cannot be mapped") void shouldFallbackToOtherForUnknownKomootSport() { @@ -216,7 +376,7 @@ class KomootImportServiceTest { when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of()); - server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&status=private&name=&hl=en&sort_field=date&sort_direction=desc&page=0&limit=100")) + server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&sort_field=date&sort_direction=desc&limit=100&status=private&name=&hl=en&page=0")) .andExpect(method(HttpMethod.GET)) .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) .andRespond(withSuccess(""" @@ -274,7 +434,7 @@ class KomootImportServiceTest { when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0)); KomootImportExecutionResponse response = service.importFirstNewActivity( - new KomootImportRequest("user@example.com", "secret", "123456"), + new KomootImportRequest("user@example.com", "secret", "123456", null, null), userId );