feat(komoot): refine activity list for import status and mapped types
Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>
This commit is contained in:
parent
aef23720d6
commit
6d89426584
5 changed files with 50 additions and 19 deletions
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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("'", ''');
|
.replaceAll("'", ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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('');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue