feat(komoot): refine activity list for import status and mapped types

Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>
This commit is contained in:
Marcus Fihlon 2026-04-28 14:10:06 +02:00
parent aef23720d6
commit 6d89426584
Signed by: McPringle
GPG key ID: C6B7F469EE363E1F
5 changed files with 50 additions and 19 deletions

View file

@ -37,9 +37,13 @@ public class KomootImportController {
@Valid @RequestBody KomootImportRequest request, @Valid @RequestBody KomootImportRequest request,
Authentication authentication Authentication authentication
) { ) {
UUID fitPubUserId = userRepository.findByUsername(authentication.getName())
.orElseThrow(() -> new IllegalArgumentException("Authenticated user not found"))
.getId();
log.info("User {} requested Komoot activity preview for Komoot ID {}", log.info("User {} requested Komoot activity preview for Komoot ID {}",
authentication.getName(), request.userId()); authentication.getName(), request.userId());
KomootActivitiesResponse response = komootImportService.fetchCompletedActivities(request); KomootActivitiesResponse response = komootImportService.fetchCompletedActivities(request, fitPubUserId);
return ResponseEntity.ok(response); return ResponseEntity.ok(response);
} }

View file

@ -9,12 +9,14 @@ public record KomootActivitySummaryDTO(
long id, long id,
String name, String name,
String sport, String sport,
String mappedActivityType,
String status, String status,
String type, String type,
OffsetDateTime date, OffsetDateTime date,
Double distanceMeters, Double distanceMeters,
Integer durationSeconds, Integer durationSeconds,
Integer timeInMotionSeconds, Integer timeInMotionSeconds,
Double elevationUp Double elevationUp,
boolean imported
) { ) {
} }

View file

@ -61,8 +61,10 @@ public class KomootImportService {
@Value("${fitpub.komoot.base-url:https://www.komoot.com}") @Value("${fitpub.komoot.base-url:https://www.komoot.com}")
private String komootBaseUrl; private String komootBaseUrl;
public KomootActivitiesResponse fetchCompletedActivities(KomootImportRequest request) { public KomootActivitiesResponse fetchCompletedActivities(KomootImportRequest request, UUID fitPubUserId) {
List<KomootActivitySummaryDTO> activities = new ArrayList<>(); List<KomootActivitySummaryDTO> activities = new ArrayList<>();
Set<Long> importedKomootActivityIds = new HashSet<>(
activityRepository.findImportedKomootActivityIdsByUserId(fitPubUserId));
URI nextUri = buildInitialUri(request); URI nextUri = buildInitialUri(request);
HttpEntity<Void> httpEntity = new HttpEntity<>(buildHeaders(request.email(), request.password())); HttpEntity<Void> httpEntity = new HttpEntity<>(buildHeaders(request.email(), request.password()));
@ -76,7 +78,7 @@ public class KomootImportService {
if (root == null) { if (root == null) {
throw new IllegalStateException("Komoot returned an empty response body."); throw new IllegalStateException("Komoot returned an empty response body.");
} }
extractActivities(root, activities); extractActivities(root, activities, importedKomootActivityIds);
nextUri = extractNextUri(root); nextUri = extractNextUri(root);
} }
} catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) {
@ -223,24 +225,27 @@ public class KomootImportService {
return "Basic " + encoded; return "Basic " + encoded;
} }
private void extractActivities(JsonNode root, List<KomootActivitySummaryDTO> activities) { private void extractActivities(JsonNode root, List<KomootActivitySummaryDTO> activities, Set<Long> importedKomootActivityIds) {
JsonNode tours = root.path("_embedded").path("tours"); JsonNode tours = root.path("_embedded").path("tours");
if (!tours.isArray()) { if (!tours.isArray()) {
return; return;
} }
for (JsonNode tour : tours) { for (JsonNode tour : tours) {
long activityId = tour.path("id").asLong();
activities.add(new KomootActivitySummaryDTO( activities.add(new KomootActivitySummaryDTO(
tour.path("id").asLong(), activityId,
nullableText(tour, "name"), nullableText(tour, "name"),
nullableText(tour, "sport"), nullableText(tour, "sport"),
mapKomootSportToActivityType(nullableText(tour, "sport")).name(),
nullableText(tour, "status"), nullableText(tour, "status"),
nullableText(tour, "type"), nullableText(tour, "type"),
parseDate(tour.path("date").asText(null)), parseDate(tour.path("date").asText(null)),
nullableDouble(tour, "distance"), nullableDouble(tour, "distance"),
nullableInteger(tour, "duration"), nullableInteger(tour, "duration"),
nullableInteger(tour, "time_in_motion"), nullableInteger(tour, "time_in_motion"),
nullableDouble(tour, "elevation_up") nullableDouble(tour, "elevation_up"),
importedKomootActivityIds.contains(activityId)
)); ));
} }
} }
@ -312,7 +317,7 @@ public class KomootImportService {
Set<Long> importedKomootActivityIds = new HashSet<>( Set<Long> importedKomootActivityIds = new HashSet<>(
activityRepository.findImportedKomootActivityIdsByUserId(fitPubUserId)); activityRepository.findImportedKomootActivityIdsByUserId(fitPubUserId));
List<KomootActivitySummaryDTO> activities = new ArrayList<>(fetchCompletedActivities(request).activities()); List<KomootActivitySummaryDTO> activities = new ArrayList<>(fetchCompletedActivities(request, fitPubUserId).activities());
activities.sort(Comparator.comparing( activities.sort(Comparator.comparing(
KomootActivitySummaryDTO::date, KomootActivitySummaryDTO::date,
Comparator.nullsLast(Comparator.reverseOrder()) Comparator.nullsLast(Comparator.reverseOrder())

View file

@ -74,7 +74,7 @@
</button> </button>
<button type="submit" class="btn btn-primary" id="loadActivitiesBtn"> <button type="submit" class="btn btn-primary" id="loadActivitiesBtn">
<span id="loadActivitiesText"> <span id="loadActivitiesText">
<i class="bi bi-arrow-repeat"></i> Load Completed Activities <i class="bi bi-arrow-repeat"></i> Load Komoot Activities
</span> </span>
<span id="loadActivitiesSpinner" class="d-none"> <span id="loadActivitiesSpinner" class="d-none">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> <span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
@ -90,7 +90,7 @@
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0"> <h4 class="mb-0">
<i class="bi bi-list-check"></i> <i class="bi bi-list-check"></i>
Completed Activities Komoot Activities
</h4> </h4>
<span class="badge text-bg-secondary" id="resultCount">0</span> <span class="badge text-bg-secondary" id="resultCount">0</span>
</div> </div>
@ -101,10 +101,11 @@
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>Date</th> <th>Date</th>
<th>Sport</th> <th>Type</th>
<th>Distance</th> <th>Distance</th>
<th>Duration</th> <th>Duration</th>
<th>Elevation</th> <th>Elevation</th>
<th class="text-center">Status</th>
</tr> </tr>
</thead> </thead>
<tbody id="resultsBody"></tbody> <tbody id="resultsBody"></tbody>
@ -210,26 +211,33 @@
.replaceAll("'", '&#39;'); .replaceAll("'", '&#39;');
} }
function formatActivityTypeBadge(activityType) {
const normalizedType = String(activityType).toLowerCase().replaceAll('_', '-');
return `<span class="activity-type-badge activity-type-${escapeHtml(normalizedType)}">${escapeHtml(activityType)}</span>`;
}
function renderActivities(activities) { function renderActivities(activities) {
resultCount.textContent = activities.length; resultCount.textContent = activities.length;
if (activities.length === 0) { if (activities.length === 0) {
resultsBody.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-4">No completed activities found.</td></tr>'; resultsBody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4">No completed activities found.</td></tr>';
resultsSection.classList.remove('d-none'); resultsSection.classList.remove('d-none');
return; return;
} }
resultsBody.innerHTML = activities.map(activity => ` resultsBody.innerHTML = activities.map(activity => `
<tr> <tr>
<td> <td><div class="fw-semibold">${escapeHtml(activity.name || 'Untitled activity')}</div></td>
<div class="fw-semibold">${escapeHtml(activity.name || 'Untitled activity')}</div>
<div class="text-muted small">${escapeHtml(activity.type || '-')}</div>
</td>
<td>${formatDate(activity.date)}</td> <td>${formatDate(activity.date)}</td>
<td>${escapeHtml(activity.sport || '-')}</td> <td>${formatActivityTypeBadge(activity.mappedActivityType)}</td>
<td>${formatDistance(activity.distanceMeters)}</td> <td>${formatDistance(activity.distanceMeters)}</td>
<td>${formatDuration(activity.timeInMotionSeconds || activity.durationSeconds)}</td> <td>${formatDuration(activity.timeInMotionSeconds || activity.durationSeconds)}</td>
<td>${formatElevation(activity.elevationUp)}</td> <td>${formatElevation(activity.elevationUp)}</td>
<td class="text-center">
${activity.imported
? '<i class="bi bi-check-circle-fill text-success" title="Already imported" aria-label="Already imported"></i>'
: '<i class="bi bi-plus-circle text-muted" title="New activity" aria-label="New activity"></i>'}
</td>
</tr> </tr>
`).join(''); `).join('');

View file

@ -67,6 +67,9 @@ class KomootImportServiceTest {
void shouldFetchAndMergePagedCompletedActivities() { void shouldFetchAndMergePagedCompletedActivities() {
String authHeader = "Basic " + Base64.getEncoder() String authHeader = "Basic " + Base64.getEncoder()
.encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8)); .encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8));
UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of(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")) 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(method(HttpMethod.GET))
@ -123,13 +126,16 @@ class KomootImportServiceTest {
""", MediaType.APPLICATION_JSON)); """, MediaType.APPLICATION_JSON));
KomootActivitiesResponse response = service.fetchCompletedActivities( KomootActivitiesResponse response = service.fetchCompletedActivities(
new KomootImportRequest("user@example.com", "secret", "123456", null, null)); new KomootImportRequest("user@example.com", "secret", "123456", null, null),
userId);
assertThat(response.totalCount()).isEqualTo(2); assertThat(response.totalCount()).isEqualTo(2);
assertThat(response.activities()).hasSize(2); assertThat(response.activities()).hasSize(2);
assertThat(response.activities().get(0).id()).isEqualTo(1001L); assertThat(response.activities().get(0).id()).isEqualTo(1001L);
assertThat(response.activities().get(0).imported()).isFalse();
assertThat(response.activities().get(0).timeInMotionSeconds()).isEqualTo(7800); assertThat(response.activities().get(0).timeInMotionSeconds()).isEqualTo(7800);
assertThat(response.activities().get(1).name()).isEqualTo("Lunch Walk"); assertThat(response.activities().get(1).name()).isEqualTo("Lunch Walk");
assertThat(response.activities().get(1).imported()).isTrue();
server.verify(); server.verify();
} }
@ -139,6 +145,9 @@ class KomootImportServiceTest {
void shouldFilterCompletedActivitiesByInclusiveDateRange() { void shouldFilterCompletedActivitiesByInclusiveDateRange() {
String authHeader = "Basic " + Base64.getEncoder() String authHeader = "Basic " + Base64.getEncoder()
.encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8)); .encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8));
UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of(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")) 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(method(HttpMethod.GET))
@ -176,10 +185,13 @@ class KomootImportServiceTest {
"123456", "123456",
LocalDate.of(2026, 4, 26), LocalDate.of(2026, 4, 26),
LocalDate.of(2026, 4, 27) LocalDate.of(2026, 4, 27)
)); ),
userId);
assertThat(response.totalCount()).isEqualTo(2); assertThat(response.totalCount()).isEqualTo(2);
assertThat(response.activities()).extracting("id").containsExactly(1002L, 1003L); assertThat(response.activities()).extracting("id").containsExactly(1002L, 1003L);
assertThat(response.activities().get(0).imported()).isFalse();
assertThat(response.activities().get(1).imported()).isTrue();
server.verify(); server.verify();
} }