From 5945a2b1391714c97fcdf7f37d62f3e0a3776e10 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Tue, 28 Apr 2026 15:44:06 +0200 Subject: [PATCH] feat(komoot): link imported activities to their FitPub detail pages Signed-off-by: Marcus Fihlon --- .../model/dto/KomootActivitySummaryDTO.java | 4 ++- .../fitpub/repository/ActivityRepository.java | 16 +++++++++++ .../fitpub/service/KomootImportService.java | 27 +++++++++++++++---- .../templates/activities/komoot.html | 11 +++++++- .../service/KomootImportServiceTest.java | 25 ++++++++++++++++- 5 files changed, 75 insertions(+), 8 deletions(-) diff --git a/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java index d551b49..a75ae5b 100644 --- a/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java +++ b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java @@ -1,6 +1,7 @@ package net.javahippie.fitpub.model.dto; import java.time.OffsetDateTime; +import java.util.UUID; /** * Reduced activity representation returned by the Komoot import preview. @@ -17,6 +18,7 @@ public record KomootActivitySummaryDTO( Integer durationSeconds, Integer timeInMotionSeconds, Double elevationUp, - boolean imported + boolean imported, + UUID fitPubActivityId ) { } diff --git a/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java b/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java index d44dc1f..7a8c03d 100644 --- a/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java +++ b/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java @@ -20,6 +20,11 @@ import java.util.UUID; @Repository public interface ActivityRepository extends JpaRepository { + interface KomootImportLinkProjection { + UUID getId(); + Long getKomootActivityId(); + } + @Query("SELECT a.id FROM Activity a") List findAllIds(); @@ -35,6 +40,17 @@ public interface ActivityRepository extends JpaRepository { */ Optional findByUserIdAndKomootActivityId(UUID userId, Long komootActivityId); + /** + * Finds imported Komoot activities for the given user and Komoot IDs. + */ + @Query("SELECT a.id AS id, a.komootActivityId AS komootActivityId " + + "FROM Activity a " + + "WHERE a.userId = :userId AND a.komootActivityId IN :komootActivityIds") + List findKomootImportLinksByUserIdAndKomootActivityIdIn( + @Param("userId") UUID userId, + @Param("komootActivityIds") List komootActivityIds + ); + /** * Find all activities for a specific user. * diff --git a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java index 6694d30..b57f1c4 100644 --- a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java +++ b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java @@ -33,8 +33,10 @@ import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Base64; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.UUID; @@ -74,6 +76,14 @@ public class KomootImportService { List activities = new ArrayList<>(); Set importedKomootActivityIds = new HashSet<>( activityRepository.findImportedKomootActivityIdsByUserId(fitPubUserId)); + Map fitPubActivityIdsByKomootId = new HashMap<>(); + if (!importedKomootActivityIds.isEmpty()) { + activityRepository.findKomootImportLinksByUserIdAndKomootActivityIdIn( + fitPubUserId, + new ArrayList<>(importedKomootActivityIds) + ) + .forEach(link -> fitPubActivityIdsByKomootId.put(link.getKomootActivityId(), link.getId())); + } URI nextUri = buildInitialUri(request); HttpEntity httpEntity = new HttpEntity<>(buildHeaders(request.email(), request.password())); @@ -87,7 +97,7 @@ public class KomootImportService { if (root == null) { throw new IllegalStateException("Komoot returned an empty response body."); } - extractActivities(root, activities, importedKomootActivityIds); + extractActivities(root, activities, importedKomootActivityIds, fitPubActivityIdsByKomootId); nextUri = extractNextUri(root); if (nextUri != null) { pauseBeforeNextPageRequest(); @@ -112,9 +122,10 @@ public class KomootImportService { } public KomootImportExecutionResponse importActivity(KomootActivityImportRequest request, UUID fitPubUserId) { - if (activityRepository.findByUserIdAndKomootActivityId(fitPubUserId, request.activityId()).isPresent()) { + Activity existingActivity = activityRepository.findByUserIdAndKomootActivityId(fitPubUserId, request.activityId()).orElse(null); + if (existingActivity != null) { return new KomootImportExecutionResponse( - null, + existingActivity.getId(), request.activityId(), "SKIPPED_ALREADY_IMPORTED", "Komoot activity " + request.activityId() + " was already imported." @@ -248,7 +259,12 @@ public class KomootImportService { return "Basic " + encoded; } - private void extractActivities(JsonNode root, List activities, Set importedKomootActivityIds) { + private void extractActivities( + JsonNode root, + List activities, + Set importedKomootActivityIds, + Map fitPubActivityIdsByKomootId + ) { JsonNode tours = root.path("_embedded").path("tours"); if (!tours.isArray()) { return; @@ -268,7 +284,8 @@ public class KomootImportService { nullableInteger(tour, "duration"), nullableInteger(tour, "time_in_motion"), nullableDouble(tour, "elevation_up"), - importedKomootActivityIds.contains(activityId) + importedKomootActivityIds.contains(activityId), + fitPubActivityIdsByKomootId.get(activityId) )); } } diff --git a/src/main/resources/templates/activities/komoot.html b/src/main/resources/templates/activities/komoot.html index 9fc1e91..07f61c0 100644 --- a/src/main/resources/templates/activities/komoot.html +++ b/src/main/resources/templates/activities/komoot.html @@ -236,6 +236,14 @@ return `${escapeHtml(activityType)}`; } + function renderActivityTitle(activity) { + const title = escapeHtml(activity.name || 'Untitled activity'); + if (activity.fitPubActivityId) { + return `${title}`; + } + return `
${title}
`; + } + function renderImportStatus(activity) { if (activity.uiImportStatus === 'queued') { return ''; @@ -268,7 +276,7 @@ resultsBody.innerHTML = activities.map(activity => ` -
${escapeHtml(activity.name || 'Untitled activity')}
+ ${renderActivityTitle(activity)} ${formatDate(activity.date)} ${formatActivityTypeBadge(activity.mappedActivityType)} ${formatDistance(activity.distanceMeters)} @@ -431,6 +439,7 @@ const data = await response.json(); activity.imported = data.status === 'IMPORTED' || data.status === 'SKIPPED_ALREADY_IMPORTED'; + activity.fitPubActivityId = data.importedActivityId || activity.fitPubActivityId; activity.uiImportStatus = activity.imported ? 'imported' : null; activity.uiImportError = null; if (data.status === 'IMPORTED') { diff --git a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java index 4bb13ef..319ad83 100644 --- a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java +++ b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java @@ -41,6 +41,20 @@ import static org.springframework.test.web.client.response.MockRestResponseCreat class KomootImportServiceTest { + private static ActivityRepository.KomootImportLinkProjection importLink(UUID activityId, Long komootActivityId) { + return new ActivityRepository.KomootImportLinkProjection() { + @Override + public UUID getId() { + return activityId; + } + + @Override + public Long getKomootActivityId() { + return komootActivityId; + } + }; + } + private RestTemplate restTemplate; private MockRestServiceServer server; private KomootImportService service; @@ -77,8 +91,11 @@ class KomootImportServiceTest { UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); KomootImportService throttledService = spy(service); doNothing().when(throttledService).pauseBeforeNextPageRequest(); + UUID existingActivityId = UUID.fromString("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"); when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of(1002L)); + when(activityRepository.findKomootImportLinksByUserIdAndKomootActivityIdIn(userId, List.of(1002L))) + .thenReturn(List.of(importLink(existingActivityId, 1002L))); 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)) @@ -142,9 +159,11 @@ class KomootImportServiceTest { assertThat(response.activities()).hasSize(2); assertThat(response.activities().get(0).id()).isEqualTo(1001L); assertThat(response.activities().get(0).imported()).isFalse(); + assertThat(response.activities().get(0).fitPubActivityId()).isNull(); assertThat(response.activities().get(0).timeInMotionSeconds()).isEqualTo(7800); assertThat(response.activities().get(1).name()).isEqualTo("Lunch Walk"); assertThat(response.activities().get(1).imported()).isTrue(); + assertThat(response.activities().get(1).fitPubActivityId()).isEqualTo(existingActivityId); verify(throttledService).pauseBeforeNextPageRequest(); server.verify(); @@ -156,8 +175,11 @@ class KomootImportServiceTest { String authHeader = "Basic " + Base64.getEncoder() .encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8)); UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + UUID existingActivityId = UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff"); when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of(1003L)); + when(activityRepository.findKomootImportLinksByUserIdAndKomootActivityIdIn(userId, List.of(1003L))) + .thenReturn(List.of(importLink(existingActivityId, 1003L))); 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)) @@ -202,6 +224,7 @@ class KomootImportServiceTest { assertThat(response.activities()).extracting("id").containsExactly(1002L, 1003L); assertThat(response.activities().get(0).imported()).isFalse(); assertThat(response.activities().get(1).imported()).isTrue(); + assertThat(response.activities().get(1).fitPubActivityId()).isEqualTo(existingActivityId); server.verify(); } @@ -303,7 +326,7 @@ class KomootImportServiceTest { userId ); - assertThat(response.importedActivityId()).isNull(); + assertThat(response.importedActivityId()).isEqualTo(existingActivityId); assertThat(response.importedKomootActivityId()).isEqualTo(3002L); assertThat(response.status()).isEqualTo("SKIPPED_ALREADY_IMPORTED"); }