feat(komoot): import listed activities sequentially with per-row status updates

Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>
This commit is contained in:
Marcus Fihlon 2026-04-28 14:59:05 +02:00
parent 6d89426584
commit 0387ca01e3
Signed by: McPringle
GPG key ID: C6B7F469EE363E1F
10 changed files with 336 additions and 240 deletions

View file

@ -3,6 +3,7 @@ package net.javahippie.fitpub.controller;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import net.javahippie.fitpub.model.dto.KomootActivityImportRequest;
import net.javahippie.fitpub.model.dto.KomootActivitiesResponse; import net.javahippie.fitpub.model.dto.KomootActivitiesResponse;
import net.javahippie.fitpub.model.dto.KomootImportExecutionResponse; import net.javahippie.fitpub.model.dto.KomootImportExecutionResponse;
import net.javahippie.fitpub.model.dto.KomootImportRequest; import net.javahippie.fitpub.model.dto.KomootImportRequest;
@ -47,19 +48,19 @@ public class KomootImportController {
return ResponseEntity.ok(response); return ResponseEntity.ok(response);
} }
@PostMapping("/activities/import-first") @PostMapping("/activities/import")
public ResponseEntity<KomootImportExecutionResponse> importFirstNewActivity( public ResponseEntity<KomootImportExecutionResponse> importActivity(
@Valid @RequestBody KomootImportRequest request, @Valid @RequestBody KomootActivityImportRequest request,
Authentication authentication Authentication authentication
) { ) {
UUID fitPubUserId = userRepository.findByUsername(authentication.getName()) UUID fitPubUserId = userRepository.findByUsername(authentication.getName())
.orElseThrow(() -> new IllegalArgumentException("Authenticated user not found")) .orElseThrow(() -> new IllegalArgumentException("Authenticated user not found"))
.getId(); .getId();
log.info("User {} requested Komoot import for the first new activity", log.info("User {} requested Komoot import for activity {}",
authentication.getName()); authentication.getName(), request.activityId());
KomootImportExecutionResponse response = komootImportService.importFirstNewActivity( KomootImportExecutionResponse response = komootImportService.importActivity(
request, request,
fitPubUserId fitPubUserId
); );

View file

@ -0,0 +1,28 @@
package net.javahippie.fitpub.model.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
/**
* Request payload for importing one specific Komoot activity.
*
* <p>The password is only used for the current request and is never persisted.</p>
*/
public record KomootActivityImportRequest(
@NotBlank
@Email
String email,
@NotBlank
String password,
@NotBlank
@Pattern(regexp = "\\d+", message = "Komoot user ID must contain digits only")
String userId,
@NotNull
Long activityId
) {
}

View file

@ -0,0 +1,14 @@
package net.javahippie.fitpub.model.dto;
import java.util.UUID;
/**
* Response for importing exactly one Komoot activity into FitPub.
*/
public record KomootImportExecutionResponse(
UUID importedActivityId,
Long importedKomootActivityId,
String status,
String message
) {
}

View file

@ -30,6 +30,11 @@ public interface ActivityRepository extends JpaRepository<Activity, UUID> {
"WHERE a.userId = :userId AND a.komootActivityId IS NOT NULL") "WHERE a.userId = :userId AND a.komootActivityId IS NOT NULL")
List<Long> findImportedKomootActivityIdsByUserId(@Param("userId") UUID userId); List<Long> findImportedKomootActivityIdsByUserId(@Param("userId") UUID userId);
/**
* Finds a previously imported Komoot activity for the given user.
*/
Optional<Activity> findByUserIdAndKomootActivityId(UUID userId, Long komootActivityId);
/** /**
* Find all activities for a specific user. * Find all activities for a specific user.
* *

View file

@ -8,6 +8,7 @@ import net.javahippie.fitpub.repository.ActivityRepository;
import net.javahippie.fitpub.repository.ActivitySummaryRepository; import net.javahippie.fitpub.repository.ActivitySummaryRepository;
import net.javahippie.fitpub.repository.AchievementRepository; import net.javahippie.fitpub.repository.AchievementRepository;
import net.javahippie.fitpub.repository.PersonalRecordRepository; import net.javahippie.fitpub.repository.PersonalRecordRepository;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@ -67,17 +68,7 @@ public class ActivitySummaryService {
LocalDate weekStart = date.with(TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY)); LocalDate weekStart = date.with(TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY));
LocalDate weekEnd = weekStart.plusDays(6); LocalDate weekEnd = weekStart.plusDays(6);
ActivitySummary summary = activitySummaryRepository saveSummaryWithRetry(userId, ActivitySummary.PeriodType.WEEK, weekStart, weekEnd);
.findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.WEEK, weekStart)
.orElse(ActivitySummary.builder()
.userId(userId)
.periodType(ActivitySummary.PeriodType.WEEK)
.periodStart(weekStart)
.periodEnd(weekEnd)
.build());
calculateAndUpdateSummary(summary, userId, weekStart.atStartOfDay(), weekEnd.plusDays(1).atStartOfDay());
activitySummaryRepository.save(summary);
} }
/** /**
@ -88,17 +79,7 @@ public class ActivitySummaryService {
LocalDate monthStart = date.with(TemporalAdjusters.firstDayOfMonth()); LocalDate monthStart = date.with(TemporalAdjusters.firstDayOfMonth());
LocalDate monthEnd = date.with(TemporalAdjusters.lastDayOfMonth()); LocalDate monthEnd = date.with(TemporalAdjusters.lastDayOfMonth());
ActivitySummary summary = activitySummaryRepository saveSummaryWithRetry(userId, ActivitySummary.PeriodType.MONTH, monthStart, monthEnd);
.findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.MONTH, monthStart)
.orElse(ActivitySummary.builder()
.userId(userId)
.periodType(ActivitySummary.PeriodType.MONTH)
.periodStart(monthStart)
.periodEnd(monthEnd)
.build());
calculateAndUpdateSummary(summary, userId, monthStart.atStartOfDay(), monthEnd.plusDays(1).atStartOfDay());
activitySummaryRepository.save(summary);
} }
/** /**
@ -109,17 +90,46 @@ public class ActivitySummaryService {
LocalDate yearStart = date.with(TemporalAdjusters.firstDayOfYear()); LocalDate yearStart = date.with(TemporalAdjusters.firstDayOfYear());
LocalDate yearEnd = date.with(TemporalAdjusters.lastDayOfYear()); LocalDate yearEnd = date.with(TemporalAdjusters.lastDayOfYear());
saveSummaryWithRetry(userId, ActivitySummary.PeriodType.YEAR, yearStart, yearEnd);
}
private void saveSummaryWithRetry(
UUID userId,
ActivitySummary.PeriodType periodType,
LocalDate periodStart,
LocalDate periodEnd
) {
ActivitySummary summary = activitySummaryRepository ActivitySummary summary = activitySummaryRepository
.findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.YEAR, yearStart) .findByUserIdAndPeriodTypeAndPeriodStart(userId, periodType, periodStart)
.orElse(ActivitySummary.builder() .orElse(ActivitySummary.builder()
.userId(userId) .userId(userId)
.periodType(ActivitySummary.PeriodType.YEAR) .periodType(periodType)
.periodStart(yearStart) .periodStart(periodStart)
.periodEnd(yearEnd) .periodEnd(periodEnd)
.build()); .build());
calculateAndUpdateSummary(summary, userId, yearStart.atStartOfDay(), yearEnd.plusDays(1).atStartOfDay()); LocalDateTime startDateTime = periodStart.atStartOfDay();
LocalDateTime endDateTime = periodEnd.plusDays(1).atStartOfDay();
calculateAndUpdateSummary(summary, userId, startDateTime, endDateTime);
try {
activitySummaryRepository.save(summary); activitySummaryRepository.save(summary);
} catch (DataIntegrityViolationException e) {
log.debug(
"Summary already created concurrently for user {}, period {} starting {}. Retrying as update.",
userId,
periodType,
periodStart
);
ActivitySummary existingSummary = activitySummaryRepository
.findByUserIdAndPeriodTypeAndPeriodStart(userId, periodType, periodStart)
.orElseThrow(() -> e);
calculateAndUpdateSummary(existingSummary, userId, startDateTime, endDateTime);
activitySummaryRepository.save(existingSummary);
}
} }
/** /**

View file

@ -3,6 +3,7 @@ package net.javahippie.fitpub.service;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import net.javahippie.fitpub.model.dto.KomootActivityImportRequest;
import net.javahippie.fitpub.model.dto.KomootActivitiesResponse; import net.javahippie.fitpub.model.dto.KomootActivitiesResponse;
import net.javahippie.fitpub.model.dto.KomootActivitySummaryDTO; import net.javahippie.fitpub.model.dto.KomootActivitySummaryDTO;
import net.javahippie.fitpub.model.dto.KomootImportExecutionResponse; import net.javahippie.fitpub.model.dto.KomootImportExecutionResponse;
@ -32,7 +33,6 @@ import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Base64; import java.util.Base64;
import java.util.Comparator;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
@ -95,31 +95,28 @@ public class KomootImportService {
return new KomootActivitiesResponse(request.userId(), activities.size(), activities); return new KomootActivitiesResponse(request.userId(), activities.size(), activities);
} }
public KomootImportExecutionResponse importFirstNewActivity(KomootImportRequest request, UUID fitPubUserId) { public KomootImportExecutionResponse importActivity(KomootActivityImportRequest request, UUID fitPubUserId) {
ImportCandidateContext context = buildImportCandidateContext(request, fitPubUserId); if (activityRepository.findByUserIdAndKomootActivityId(fitPubUserId, request.activityId()).isPresent()) {
if (context.candidate() == null) {
return new KomootImportExecutionResponse( return new KomootImportExecutionResponse(
null, null,
null, request.activityId(),
context.importedKomootActivityIds().size(), "SKIPPED_ALREADY_IMPORTED",
context.activities().size(), "Komoot activity " + request.activityId() + " was already imported."
"No new Komoot activities found to import."
); );
} }
KomootActivitySummaryDTO candidate = context.candidate(); JsonNode details = fetchActivityDetails(request.email(), request.password(), request.activityId());
JsonNode details = fetchActivityDetails(request, candidate.id()); byte[] gpxData = fetchActivityGpx(request.email(), request.password(), request.activityId());
byte[] gpxData = fetchActivityGpx(request, candidate.id());
ByteArrayMultipartFile gpxFile = new ByteArrayMultipartFile( ByteArrayMultipartFile gpxFile = new ByteArrayMultipartFile(
"file", "file",
"komoot-" + candidate.id() + ".gpx", "komoot-" + request.activityId() + ".gpx",
"application/gpx+xml", "application/gpx+xml",
gpxData gpxData
); );
Activity.Visibility mappedVisibility = mapVisibility(nullableText(details, "status")); Activity.Visibility mappedVisibility = mapVisibility(nullableText(details, "status"));
String mappedTitle = firstNonBlank(nullableText(details, "name"), candidate.name(), "Komoot Activity " + candidate.id()); String mappedTitle = firstNonBlank(nullableText(details, "name"), null, "Komoot Activity " + request.activityId());
String mappedDescription = nullableText(details, "description"); String mappedDescription = nullableText(details, "description");
Activity.ActivityType mappedActivityType = mapKomootSportToActivityType(nullableText(details, "sport")); Activity.ActivityType mappedActivityType = mapKomootSportToActivityType(nullableText(details, "sport"));
@ -131,7 +128,7 @@ public class KomootImportService {
mappedVisibility mappedVisibility
); );
importedActivity.setKomootActivityId(candidate.id()); importedActivity.setKomootActivityId(request.activityId());
importedActivity.setTitle(mappedTitle); importedActivity.setTitle(mappedTitle);
importedActivity.setDescription(mappedDescription); importedActivity.setDescription(mappedDescription);
importedActivity.setVisibility(mappedVisibility); importedActivity.setVisibility(mappedVisibility);
@ -142,7 +139,7 @@ public class KomootImportService {
log.info( log.info(
"Imported Komoot activity {} into FitPub activity {} with visibility {} and type {}", "Imported Komoot activity {} into FitPub activity {} with visibility {} and type {}",
candidate.id(), request.activityId(),
importedActivity.getId(), importedActivity.getId(),
importedActivity.getVisibility(), importedActivity.getVisibility(),
importedActivity.getActivityType() importedActivity.getActivityType()
@ -150,10 +147,9 @@ public class KomootImportService {
return new KomootImportExecutionResponse( return new KomootImportExecutionResponse(
importedActivity.getId(), importedActivity.getId(),
candidate.id(), request.activityId(),
context.importedKomootActivityIds().size() + 1, "IMPORTED",
context.activities().size(), "Imported Komoot activity " + request.activityId() + " into FitPub activity " + importedActivity.getId()
"Imported Komoot activity " + candidate.id() + " into FitPub activity " + importedActivity.getId()
); );
} }
@ -250,12 +246,12 @@ public class KomootImportService {
} }
} }
private JsonNode fetchActivityDetails(KomootImportRequest request, long activityId) { private JsonNode fetchActivityDetails(String email, String password, long activityId) {
try { try {
ResponseEntity<JsonNode> response = restTemplate.exchange( ResponseEntity<JsonNode> response = restTemplate.exchange(
buildDetailUri(activityId), buildDetailUri(activityId),
HttpMethod.GET, HttpMethod.GET,
new HttpEntity<>(buildHeaders(request.email(), request.password())), new HttpEntity<>(buildHeaders(email, password)),
JsonNode.class JsonNode.class
); );
@ -273,8 +269,8 @@ public class KomootImportService {
} }
} }
private byte[] fetchActivityGpx(KomootImportRequest request, long activityId) { private byte[] fetchActivityGpx(String email, String password, long activityId) {
HttpEntity<Void> httpEntity = new HttpEntity<>(buildGpxHeaders(request.email(), request.password())); HttpEntity<Void> httpEntity = new HttpEntity<>(buildGpxHeaders(email, password));
List<URI> candidateUris = buildGpxCandidateUris(activityId); List<URI> candidateUris = buildGpxCandidateUris(activityId);
Exception lastException = null; Exception lastException = null;
@ -313,24 +309,6 @@ public class KomootImportService {
throw new IllegalStateException("Failed to download GPX from Komoot for activity " + activityId, lastException); throw new IllegalStateException("Failed to download GPX from Komoot for activity " + activityId, lastException);
} }
private ImportCandidateContext buildImportCandidateContext(KomootImportRequest request, UUID fitPubUserId) {
Set<Long> importedKomootActivityIds = new HashSet<>(
activityRepository.findImportedKomootActivityIdsByUserId(fitPubUserId));
List<KomootActivitySummaryDTO> activities = new ArrayList<>(fetchCompletedActivities(request, fitPubUserId).activities());
activities.sort(Comparator.comparing(
KomootActivitySummaryDTO::date,
Comparator.nullsLast(Comparator.reverseOrder())
));
KomootActivitySummaryDTO candidate = activities.stream()
.filter(activity -> !importedKomootActivityIds.contains(activity.id()))
.findFirst()
.orElse(null);
return new ImportCandidateContext(importedKomootActivityIds, activities, candidate);
}
private String formatKomootStartDate(LocalDate localDate) { private String formatKomootStartDate(LocalDate localDate) {
return localDate.atStartOfDay(ZoneId.systemDefault()) return localDate.atStartOfDay(ZoneId.systemDefault())
.toInstant() .toInstant()
@ -396,13 +374,6 @@ public class KomootImportService {
return fallback; return fallback;
} }
private record ImportCandidateContext(
Set<Long> importedKomootActivityIds,
List<KomootActivitySummaryDTO> activities,
KomootActivitySummaryDTO candidate
) {
}
private URI extractNextUri(JsonNode root) { private URI extractNextUri(JsonNode root) {
String nextHref = root.path("_links").path("next").path("href").asText(null); String nextHref = root.path("_links").path("next").path("href").asText(null);
if (nextHref == null || nextHref.isBlank()) { if (nextHref == null || nextHref.isBlank()) {

View file

@ -18,7 +18,7 @@
</h2> </h2>
<div class="alert alert-secondary"> <div class="alert alert-secondary">
<div class="fw-semibold mb-1">Komoot Import</div> <div class="fw-semibold mb-1">Important</div>
<div class="small mb-2"> <div class="small mb-2">
Your Komoot credentials are only used for this request and are not stored in FitPub. Your Komoot credentials are only used for this request and are not stored in FitPub.
</div> </div>
@ -63,15 +63,6 @@
</div> </div>
<div class="mt-4 d-flex justify-content-end gap-2 flex-wrap"> <div class="mt-4 d-flex justify-content-end gap-2 flex-wrap">
<button type="button" class="btn btn-success" id="importFirstBtn">
<span id="importFirstText">
<i class="bi bi-download"></i> Import First New Activity
</span>
<span id="importFirstSpinner" class="d-none">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
Importing...
</span>
</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 Komoot Activities <i class="bi bi-arrow-repeat"></i> Load Komoot Activities
@ -81,6 +72,15 @@
Loading... Loading...
</span> </span>
</button> </button>
<button type="button" class="btn btn-success" id="importFirstBtn" disabled>
<span id="importFirstText">
<i class="bi bi-download"></i> Import Listed Activities
</span>
<span id="importFirstSpinner" class="d-none">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
Importing...
</span>
</button>
</div> </div>
</form> </form>
</div> </div>
@ -130,6 +130,11 @@
const importFirstBtn = document.getElementById('importFirstBtn'); const importFirstBtn = document.getElementById('importFirstBtn');
const importFirstText = document.getElementById('importFirstText'); const importFirstText = document.getElementById('importFirstText');
const importFirstSpinner = document.getElementById('importFirstSpinner'); const importFirstSpinner = document.getElementById('importFirstSpinner');
let currentActivities = [];
function updateImportButtonState() {
importFirstBtn.disabled = currentActivities.length === 0;
}
function setLoading(loading) { function setLoading(loading) {
loadActivitiesBtn.disabled = loading; loadActivitiesBtn.disabled = loading;
@ -216,6 +221,23 @@
return `<span class="activity-type-badge activity-type-${escapeHtml(normalizedType)}">${escapeHtml(activityType)}</span>`; return `<span class="activity-type-badge activity-type-${escapeHtml(normalizedType)}">${escapeHtml(activityType)}</span>`;
} }
function renderImportStatus(activity) {
if (activity.uiImportStatus === 'importing') {
return '<span class="spinner-border spinner-border-sm text-primary" role="status" aria-label="Importing"></span>';
}
if (activity.uiImportStatus === 'error') {
const title = escapeHtml(activity.uiImportError || 'Import failed');
return `<i class="bi bi-exclamation-circle-fill text-danger" title="${title}" aria-label="${title}"></i>`;
}
if (activity.imported) {
return '<i class="bi bi-check-circle-fill text-success" title="Already imported" aria-label="Already imported"></i>';
}
return '<i class="bi bi-plus-circle text-muted" title="New activity" aria-label="New activity"></i>';
}
function renderActivities(activities) { function renderActivities(activities) {
resultCount.textContent = activities.length; resultCount.textContent = activities.length;
@ -233,11 +255,7 @@
<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"> <td class="text-center">${renderImportStatus(activity)}</td>
${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('');
@ -257,6 +275,16 @@
}; };
} }
function buildImportPayload(activityId) {
const payload = buildPayload();
return {
email: payload.email,
password: payload.password,
userId: payload.userId,
activityId: activityId
};
}
function hasIncompleteDateRange(payload) { function hasIncompleteDateRange(payload) {
return Boolean(payload.startDate) !== Boolean(payload.endDate); return Boolean(payload.startDate) !== Boolean(payload.endDate);
} }
@ -274,6 +302,8 @@
} }
setLoading(true); setLoading(true);
currentActivities = [];
updateImportButtonState();
try { try {
const response = await FitPubAuth.authenticatedFetch('/api/komoot-import/activities', { const response = await FitPubAuth.authenticatedFetch('/api/komoot-import/activities', {
@ -293,8 +323,13 @@
} }
const data = await response.json(); const data = await response.json();
renderActivities(data.activities || []); currentActivities = (data.activities || []).map(activity => ({
form.querySelector('#password').value = ''; ...activity,
uiImportStatus: null,
uiImportError: null
}));
updateImportButtonState();
renderActivities(currentActivities);
} catch (error) { } catch (error) {
let message = error instanceof Error ? error.message : 'Failed to load Komoot activities.'; let message = error instanceof Error ? error.message : 'Failed to load Komoot activities.';
@ -317,12 +352,34 @@
showError('Start date and end date must either both be set or both be empty.'); showError('Start date and end date must either both be set or both be empty.');
return; return;
} }
if (currentActivities.length === 0) {
showError('Load Komoot activities before starting the import.');
return;
}
setImportLoading(true); setImportLoading(true);
try { try {
const response = await FitPubAuth.authenticatedFetch('/api/komoot-import/activities/import-first', { const activitiesToImport = currentActivities
.filter(activity => !activity.imported)
.sort((left, right) => new Date(left.date).getTime() - new Date(right.date).getTime());
if (activitiesToImport.length === 0) {
showStatus('All listed Komoot activities are already imported.');
return;
}
let importedCount = 0;
let failedCount = 0;
for (const activity of activitiesToImport) {
activity.uiImportStatus = 'importing';
activity.uiImportError = null;
renderActivities(currentActivities);
try {
const response = await FitPubAuth.authenticatedFetch('/api/komoot-import/activities/import', {
method: 'POST', method: 'POST',
body: payload body: buildImportPayload(activity.id)
}); });
if (!response.ok) { if (!response.ok) {
@ -337,9 +394,24 @@
} }
const data = await response.json(); const data = await response.json();
showStatus(data.message || 'Komoot activity imported.'); activity.imported = data.status === 'IMPORTED' || data.status === 'SKIPPED_ALREADY_IMPORTED';
activity.uiImportStatus = activity.imported ? 'imported' : null;
activity.uiImportError = null;
if (data.status === 'IMPORTED') {
importedCount += 1;
}
} catch (error) { } catch (error) {
let message = error instanceof Error ? error.message : 'Failed to import Komoot activity.'; failedCount += 1;
activity.uiImportStatus = 'error';
activity.uiImportError = error instanceof Error ? error.message : 'Failed to import Komoot activity.';
}
renderActivities(currentActivities);
}
showStatus(`Imported ${importedCount} Komoot activit${importedCount === 1 ? 'y' : 'ies'}${failedCount > 0 ? `, ${failedCount} failed` : ''}.`);
} catch (error) {
let message = error instanceof Error ? error.message : 'Failed to import Komoot activities.';
if (error instanceof Error && error.message === 'Authentication failed') { if (error instanceof Error && error.message === 'Authentication failed') {
return; return;
@ -350,6 +422,8 @@
setImportLoading(false); setImportLoading(false);
} }
}); });
updateImportButtonState();
}); });
</script> </script>
</th:block> </th:block>

View file

@ -0,0 +1,95 @@
package net.javahippie.fitpub.service;
import net.javahippie.fitpub.model.entity.Activity;
import net.javahippie.fitpub.model.entity.ActivitySummary;
import net.javahippie.fitpub.repository.ActivityRepository;
import net.javahippie.fitpub.repository.ActivitySummaryRepository;
import net.javahippie.fitpub.repository.AchievementRepository;
import net.javahippie.fitpub.repository.PersonalRecordRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataIntegrityViolationException;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ActivitySummaryServiceTest {
private ActivitySummaryRepository activitySummaryRepository;
private ActivityRepository activityRepository;
private PersonalRecordRepository personalRecordRepository;
private AchievementRepository achievementRepository;
private ActivitySummaryService service;
@BeforeEach
void setUp() {
activitySummaryRepository = mock(ActivitySummaryRepository.class);
activityRepository = mock(ActivityRepository.class);
personalRecordRepository = mock(PersonalRecordRepository.class);
achievementRepository = mock(AchievementRepository.class);
service = new ActivitySummaryService(
activitySummaryRepository,
activityRepository,
personalRecordRepository,
achievementRepository
);
}
@Test
@DisplayName("Should retry summary save as update when concurrent insert hits unique constraint")
void shouldRetrySummarySaveAfterConcurrentInsert() {
UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
LocalDate date = LocalDate.of(2025, 10, 8);
LocalDate weekStart = LocalDate.of(2025, 10, 6);
LocalDate weekEnd = LocalDate.of(2025, 10, 12);
ActivitySummary existingSummary = ActivitySummary.builder()
.id(UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"))
.userId(userId)
.periodType(ActivitySummary.PeriodType.WEEK)
.periodStart(weekStart)
.periodEnd(weekEnd)
.build();
when(activitySummaryRepository.findByUserIdAndPeriodTypeAndPeriodStart(
userId, ActivitySummary.PeriodType.WEEK, weekStart
)).thenReturn(Optional.empty(), Optional.of(existingSummary));
when(activityRepository.findByUserIdAndStartedAtBetweenOrderByStartedAtDesc(
eq(userId),
eq(weekStart.atStartOfDay()),
eq(weekEnd.plusDays(1).atStartOfDay())
)).thenReturn(List.of(
Activity.builder()
.userId(userId)
.activityType(Activity.ActivityType.RIDE)
.startedAt(LocalDateTime.of(2025, 10, 8, 9, 0))
.totalDurationSeconds(3600L)
.build()
));
when(personalRecordRepository.countByUserIdAndDateRange(any(), any(), any())).thenReturn(0L);
when(achievementRepository.countByUserIdAndDateRange(any(), any(), any())).thenReturn(0L);
when(activitySummaryRepository.save(any(ActivitySummary.class)))
.thenThrow(new DataIntegrityViolationException("duplicate"))
.thenReturn(existingSummary);
service.updateWeeklySummary(userId, date);
verify(activitySummaryRepository, times(2))
.findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.WEEK, weekStart);
verify(activitySummaryRepository, times(2)).save(any(ActivitySummary.class));
}
}

View file

@ -1,5 +1,6 @@
package net.javahippie.fitpub.service; package net.javahippie.fitpub.service;
import net.javahippie.fitpub.model.dto.KomootActivityImportRequest;
import net.javahippie.fitpub.model.dto.KomootActivitiesResponse; import net.javahippie.fitpub.model.dto.KomootActivitiesResponse;
import net.javahippie.fitpub.model.dto.KomootImportExecutionResponse; import net.javahippie.fitpub.model.dto.KomootImportExecutionResponse;
import net.javahippie.fitpub.model.dto.KomootImportRequest; import net.javahippie.fitpub.model.dto.KomootImportRequest;
@ -20,6 +21,7 @@ import java.nio.charset.StandardCharsets;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.Base64; import java.util.Base64;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.TimeZone; import java.util.TimeZone;
import java.util.UUID; import java.util.UUID;
@ -210,35 +212,14 @@ class KomootImportServiceTest {
} }
@Test @Test
@DisplayName("Should import newest not-yet-imported Komoot activity via GPX and override metadata") @DisplayName("Should import a specific Komoot activity via GPX and override metadata")
void shouldImportNewestNotYetImportedActivity() { void shouldImportSpecificKomootActivity() {
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"); UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
UUID importedActivityId = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); UUID importedActivityId = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of()); when(activityRepository.findByUserIdAndKomootActivityId(userId, 2880957035L)).thenReturn(Optional.empty());
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("""
{
"_embedded": {
"tours": [
{
"id": 2880957035,
"name": "Latest Ride",
"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/2880957035?hl=en")) server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957035?hl=en"))
.andExpect(method(HttpMethod.GET)) .andExpect(method(HttpMethod.GET))
@ -276,13 +257,14 @@ class KomootImportServiceTest {
when(activityFileService.processActivityFile(any(), any(), any(), any(), any())).thenReturn(importedActivity); when(activityFileService.processActivityFile(any(), any(), any(), any(), any())).thenReturn(importedActivity);
when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0)); when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0));
KomootImportExecutionResponse response = service.importFirstNewActivity( KomootImportExecutionResponse response = service.importActivity(
new KomootImportRequest("user@example.com", "secret", "123456", null, null), new KomootActivityImportRequest("user@example.com", "secret", "123456", 2880957035L),
userId userId
); );
assertThat(response.importedActivityId()).isEqualTo(importedActivityId); assertThat(response.importedActivityId()).isEqualTo(importedActivityId);
assertThat(response.importedKomootActivityId()).isEqualTo(2880957035L); assertThat(response.importedKomootActivityId()).isEqualTo(2880957035L);
assertThat(response.status()).isEqualTo("IMPORTED");
assertThat(importedActivity.getKomootActivityId()).isEqualTo(2880957035L); assertThat(importedActivity.getKomootActivityId()).isEqualTo(2880957035L);
assertThat(importedActivity.getTitle()).isEqualTo("Latest Ride"); assertThat(importedActivity.getTitle()).isEqualTo("Latest Ride");
assertThat(importedActivity.getDescription()).isEqualTo("Imported from Komoot"); assertThat(importedActivity.getDescription()).isEqualTo("Imported from Komoot");
@ -294,88 +276,23 @@ class KomootImportServiceTest {
} }
@Test @Test
@DisplayName("Should respect date range when choosing Komoot import candidate") @DisplayName("Should skip already imported Komoot activity")
void shouldRespectDateRangeWhenImportingFirstNewActivity() { void shouldSkipAlreadyImportedKomootActivity() {
String authHeader = "Basic " + Base64.getEncoder()
.encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8));
UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
UUID importedActivityId = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd"); UUID existingActivityId = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd");
when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of()); when(activityRepository.findByUserIdAndKomootActivityId(userId, 3002L)).thenReturn(
Optional.of(Activity.builder().id(existingActivityId).userId(userId).komootActivityId(3002L).build())
);
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")) KomootImportExecutionResponse response = service.importActivity(
.andExpect(method(HttpMethod.GET)) new KomootActivityImportRequest("user@example.com", "secret", "123456", 3002L),
.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("""
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="komoot">
<trk><name>Inside Range Candidate</name></trk>
</gpx>
""", 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 userId
); );
assertThat(response.importedActivityId()).isNull();
assertThat(response.importedKomootActivityId()).isEqualTo(3002L); assertThat(response.importedKomootActivityId()).isEqualTo(3002L);
assertThat(importedActivity.getKomootActivityId()).isEqualTo(3002L); assertThat(response.status()).isEqualTo("SKIPPED_ALREADY_IMPORTED");
verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId);
server.verify();
} }
@Test @Test
@ -386,28 +303,7 @@ class KomootImportServiceTest {
UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
UUID importedActivityId = UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc"); UUID importedActivityId = UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc");
when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of()); when(activityRepository.findByUserIdAndKomootActivityId(userId, 2880957036L)).thenReturn(Optional.empty());
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("""
{
"_embedded": {
"tours": [
{
"id": 2880957036,
"name": "Unknown Sport",
"sport": "space_biking",
"status": "private",
"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/2880957036?hl=en")) server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957036?hl=en"))
.andExpect(method(HttpMethod.GET)) .andExpect(method(HttpMethod.GET))
@ -445,12 +341,13 @@ class KomootImportServiceTest {
when(activityFileService.processActivityFile(any(), any(), any(), any(), any())).thenReturn(importedActivity); when(activityFileService.processActivityFile(any(), any(), any(), any(), any())).thenReturn(importedActivity);
when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0)); when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0));
KomootImportExecutionResponse response = service.importFirstNewActivity( KomootImportExecutionResponse response = service.importActivity(
new KomootImportRequest("user@example.com", "secret", "123456", null, null), new KomootActivityImportRequest("user@example.com", "secret", "123456", 2880957036L),
userId userId
); );
assertThat(response.importedActivityId()).isEqualTo(importedActivityId); assertThat(response.importedActivityId()).isEqualTo(importedActivityId);
assertThat(response.status()).isEqualTo("IMPORTED");
assertThat(importedActivity.getActivityType()).isEqualTo(Activity.ActivityType.OTHER); assertThat(importedActivity.getActivityType()).isEqualTo(Activity.ActivityType.OTHER);
verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId); verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId);

View file

@ -0,0 +1 @@
mock-maker-subclass