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

@ -18,7 +18,7 @@
</h2>
<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">
Your Komoot credentials are only used for this request and are not stored in FitPub.
</div>
@ -63,15 +63,6 @@
</div>
<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">
<span id="loadActivitiesText">
<i class="bi bi-arrow-repeat"></i> Load Komoot Activities
@ -81,6 +72,15 @@
Loading...
</span>
</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>
</form>
</div>
@ -130,6 +130,11 @@
const importFirstBtn = document.getElementById('importFirstBtn');
const importFirstText = document.getElementById('importFirstText');
const importFirstSpinner = document.getElementById('importFirstSpinner');
let currentActivities = [];
function updateImportButtonState() {
importFirstBtn.disabled = currentActivities.length === 0;
}
function setLoading(loading) {
loadActivitiesBtn.disabled = loading;
@ -216,6 +221,23 @@
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) {
resultCount.textContent = activities.length;
@ -233,11 +255,7 @@
<td>${formatDistance(activity.distanceMeters)}</td>
<td>${formatDuration(activity.timeInMotionSeconds || activity.durationSeconds)}</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>
<td class="text-center">${renderImportStatus(activity)}</td>
</tr>
`).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) {
return Boolean(payload.startDate) !== Boolean(payload.endDate);
}
@ -274,6 +302,8 @@
}
setLoading(true);
currentActivities = [];
updateImportButtonState();
try {
const response = await FitPubAuth.authenticatedFetch('/api/komoot-import/activities', {
@ -293,8 +323,13 @@
}
const data = await response.json();
renderActivities(data.activities || []);
form.querySelector('#password').value = '';
currentActivities = (data.activities || []).map(activity => ({
...activity,
uiImportStatus: null,
uiImportError: null
}));
updateImportButtonState();
renderActivities(currentActivities);
} catch (error) {
let message = error instanceof Error ? error.message : 'Failed to load Komoot activities.';
@ -317,29 +352,66 @@
showError('Start date and end date must either both be set or both be empty.');
return;
}
if (currentActivities.length === 0) {
showError('Load Komoot activities before starting the import.');
return;
}
setImportLoading(true);
try {
const response = await FitPubAuth.authenticatedFetch('/api/komoot-import/activities/import-first', {
method: 'POST',
body: payload
});
const activitiesToImport = currentActivities
.filter(activity => !activity.imported)
.sort((left, right) => new Date(left.date).getTime() - new Date(right.date).getTime());
if (!response.ok) {
let message = 'Failed to import Komoot activity.';
try {
const body = await response.json();
message = body.error || message;
} catch (ignored) {
// Ignore parse errors and show the generic message.
}
throw new Error(message);
if (activitiesToImport.length === 0) {
showStatus('All listed Komoot activities are already imported.');
return;
}
const data = await response.json();
showStatus(data.message || 'Komoot activity imported.');
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',
body: buildImportPayload(activity.id)
});
if (!response.ok) {
let message = 'Failed to import Komoot activity.';
try {
const body = await response.json();
message = body.error || message;
} catch (ignored) {
// Ignore parse errors and show the generic message.
}
throw new Error(message);
}
const data = await response.json();
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) {
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 activity.';
let message = error instanceof Error ? error.message : 'Failed to import Komoot activities.';
if (error instanceof Error && error.message === 'Authentication failed') {
return;
@ -350,6 +422,8 @@
setImportLoading(false);
}
});
updateImportButtonState();
});
</script>
</th:block>