fitpub/src/main/resources/templates/activities/komoot.html
Marcus Fihlon 9a9785973d
feat(komoot): show import progress count in button label
Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>
2026-04-29 15:10:24 +02:00

523 lines
24 KiB
HTML

<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout}">
<head>
<title>Komoot Import</title>
</head>
<body>
<div layout:fragment="content">
<div class="row justify-content-center">
<div class="col-lg-10">
<h2 class="mb-4">
<i class="bi bi-signpost-split text-success"></i>
Komoot Import
</h2>
<div class="alert alert-secondary">
<div class="fw-semibold mb-1">Important</div>
<div class="small mb-1">
Your Komoot credentials are only used for this request and are not stored in FitPub.
</div>
<div class="small mb-0">
The import currently runs in this browser tab. If you leave or reload the page, remaining activities will not continue importing automatically.
</div>
</div>
<ul class="small text-muted ps-3 mb-4">
<li>Import starts with the oldest new activities, so progress begins at the bottom of the list.</li>
<li>FitPub adds short delays between Komoot requests during loading and import to reduce rate limiting.</li>
<li>This integration depends on Komoot web endpoints and may stop working if Komoot changes them.</li>
</ul>
<div id="errorAlert" class="alert alert-danger d-none" role="alert"></div>
<div class="card shadow-sm">
<div class="card-body p-4">
<form id="komootImportForm">
<div class="row g-3">
<div class="col-md-6">
<label for="email" class="form-label">Komoot Email</label>
<input type="email" class="form-control" id="email" name="email" required autocomplete="username">
</div>
<div class="col-md-6">
<label for="password" class="form-label">Komoot Password</label>
<input type="password" class="form-control" id="password" name="password" required autocomplete="current-password">
</div>
<div class="col-md-6">
<label for="userId" class="form-label">Komoot ID</label>
<input type="text" class="form-control" id="userId" name="userId" required inputmode="numeric" pattern="[0-9]+">
<div class="form-text">You can find the Komoot ID in your Komoot account settings.</div>
</div>
<div class="col-md-6">
<div class="row g-3">
<div class="col-md-6">
<label for="startDate" class="form-label">Start Date</label>
<input type="date" class="form-control" id="startDate" name="startDate" th:value="${defaultStartDate}">
</div>
<div class="col-md-6">
<label for="endDate" class="form-label">End Date</label>
<input type="date" class="form-control" id="endDate" name="endDate" th:value="${defaultEndDate}">
</div>
</div>
<div class="form-text">Both dates must be set together. Inclusive, day-based filter.</div>
</div>
</div>
<div class="mt-4 d-flex justify-content-end gap-2 flex-wrap">
<button type="submit" class="btn btn-primary" id="loadActivitiesBtn">
<i class="bi bi-arrow-repeat"></i> Load Komoot Activities
</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>
<span id="importFirstProgressText">Importing...</span>
</span>
</button>
<button type="button" class="btn btn-outline-danger d-none" id="cancelImportBtn">
<i class="bi bi-stop-circle"></i> Stop After Current Activity
</button>
</div>
</form>
</div>
</div>
<div id="loadingIndicator" class="text-center py-5 d-none">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-2 text-muted mb-0">Loading Komoot activities...</p>
</div>
<div id="resultsSection" class="mt-4 d-none">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">
<i class="bi bi-list-check"></i>
Komoot Activities
</h4>
<span class="badge text-bg-secondary" id="resultCount">0</span>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th class="text-center" style="width: 1%;"></th>
<th>Name</th>
<th>Date</th>
<th>Type</th>
<th>Distance</th>
<th>Duration</th>
<th>Elevation</th>
<th class="text-center">Status</th>
</tr>
</thead>
<tbody id="resultsBody"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<th:block layout:fragment="scripts">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('komootImportForm');
const errorAlert = document.getElementById('errorAlert');
const loadingIndicator = document.getElementById('loadingIndicator');
const resultsSection = document.getElementById('resultsSection');
const resultsBody = document.getElementById('resultsBody');
const resultCount = document.getElementById('resultCount');
const loadActivitiesBtn = document.getElementById('loadActivitiesBtn');
const importFirstBtn = document.getElementById('importFirstBtn');
const importFirstText = document.getElementById('importFirstText');
const importFirstSpinner = document.getElementById('importFirstSpinner');
const importFirstProgressText = document.getElementById('importFirstProgressText');
const cancelImportBtn = document.getElementById('cancelImportBtn');
let currentActivities = [];
let importCancellationRequested = false;
let importInProgress = false;
function updateImportButtonState() {
importFirstBtn.disabled = importInProgress || currentActivities.length === 0;
}
function setLoading(loading) {
loadActivitiesBtn.disabled = loading;
loadingIndicator.classList.toggle('d-none', !loading);
}
function setImportLoading(loading) {
importInProgress = loading;
loadActivitiesBtn.disabled = loading;
updateImportButtonState();
importFirstText.classList.toggle('d-none', loading);
importFirstSpinner.classList.toggle('d-none', !loading);
cancelImportBtn.classList.toggle('d-none', !loading);
cancelImportBtn.disabled = !loading;
if (!loading) {
importFirstProgressText.textContent = 'Importing...';
}
}
function updateImportProgress(current, total) {
if (current == null || total == null || total <= 0) {
importFirstProgressText.textContent = 'Importing...';
return;
}
importFirstProgressText.textContent = `Importing ${current}/${total}...`;
}
function showError(message) {
errorAlert.textContent = message;
errorAlert.classList.remove('d-none');
}
function clearError() {
errorAlert.textContent = '';
errorAlert.classList.add('d-none');
}
function showStatus(message) {
errorAlert.textContent = message;
errorAlert.classList.remove('d-none');
errorAlert.classList.remove('alert-danger');
errorAlert.classList.add('alert-info');
}
function resetAlertToError() {
errorAlert.classList.remove('alert-info');
errorAlert.classList.add('alert-danger');
}
function formatDistance(meters) {
if (meters == null) {
return '-';
}
return (meters / 1000).toFixed(1) + ' km';
}
function formatDuration(seconds) {
if (seconds == null) {
return '-';
}
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60;
return [hours, minutes, remainingSeconds]
.map((value, index) => index === 0 ? String(value) : String(value).padStart(2, '0'))
.join(':');
}
function formatElevation(elevationUp) {
if (elevationUp == null) {
return '-';
}
return Math.round(elevationUp) + ' m';
}
function formatDate(value) {
if (!value) {
return '-';
}
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(value));
}
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.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 renderActivityTitle(activity) {
const title = escapeHtml(activity.name || 'Untitled activity');
if (activity.fitPubActivityId) {
return `<a href="/activities/${encodeURIComponent(activity.fitPubActivityId)}" class="fw-semibold text-decoration-none">${title}</a>`;
}
return `<div class="fw-semibold">${title}</div>`;
}
function renderImportStatus(activity) {
if (activity.uiImportStatus === 'queued') {
return '<i class="bi bi-hourglass-split text-warning" title="Queued for import" aria-label="Queued for import"></i>';
}
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 renderVisibilityIcon(activity) {
const status = String(activity.status || '').toLowerCase();
if (status === 'public') {
return '<i class="bi bi-globe2 visibility-public" title="Public" aria-label="Public"></i>';
}
if (status === 'friends' || status === 'followers' || status === 'close_friends') {
return '<i class="bi bi-people-fill visibility-followers" title="Followers" aria-label="Followers"></i>';
}
return '<i class="bi bi-lock-fill visibility-private" title="Private" aria-label="Private"></i>';
}
function renderActivities(activities) {
resultCount.textContent = activities.length;
if (activities.length === 0) {
resultsBody.innerHTML = '<tr><td colspan="8" class="text-center text-muted py-4">No completed activities found.</td></tr>';
resultsSection.classList.remove('d-none');
return;
}
resultsBody.innerHTML = activities.map(activity => `
<tr>
<td class="text-center">${renderVisibilityIcon(activity)}</td>
<td>${renderActivityTitle(activity)}</td>
<td>${formatDate(activity.date)}</td>
<td>${formatActivityTypeBadge(activity.mappedActivityType)}</td>
<td>${formatDistance(activity.distanceMeters)}</td>
<td>${formatDuration(activity.timeInMotionSeconds || activity.durationSeconds)}</td>
<td>${formatElevation(activity.elevationUp)}</td>
<td class="text-center">${renderImportStatus(activity)}</td>
</tr>
`).join('');
resultsSection.classList.remove('d-none');
}
function buildPayload() {
const formData = new FormData(form);
const startDate = formData.get('startDate');
const endDate = formData.get('endDate');
return {
email: formData.get('email'),
password: formData.get('password'),
userId: formData.get('userId'),
startDate: startDate || null,
endDate: endDate || null
};
}
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);
}
function resetQueuedActivities() {
for (const activity of currentActivities) {
if (activity.uiImportStatus === 'queued') {
activity.uiImportStatus = null;
activity.uiImportError = null;
}
}
}
form.addEventListener('submit', async function(event) {
event.preventDefault();
clearError();
resetAlertToError();
resultsSection.classList.add('d-none');
const payload = buildPayload();
if (hasIncompleteDateRange(payload)) {
showError('Start date and end date must either both be set or both be empty.');
return;
}
setLoading(true);
currentActivities = [];
updateImportButtonState();
try {
const response = await FitPubAuth.authenticatedFetch('/api/komoot-import/activities', {
method: 'POST',
body: payload
});
if (!response.ok) {
let message = 'Failed to load Komoot activities.';
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();
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.';
if (error instanceof Error && error.message === 'Authentication failed') {
return;
}
showError(message);
} finally {
setLoading(false);
}
});
importFirstBtn.addEventListener('click', async function() {
clearError();
resetAlertToError();
const payload = buildPayload();
if (hasIncompleteDateRange(payload)) {
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;
}
importCancellationRequested = false;
setImportLoading(true);
try {
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;
}
for (const activity of activitiesToImport) {
activity.uiImportStatus = 'queued';
activity.uiImportError = null;
}
renderActivities(currentActivities);
let importedCount = 0;
let failedCount = 0;
let cancelled = false;
const totalActivitiesToImport = activitiesToImport.length;
for (const [index, activity] of activitiesToImport.entries()) {
activity.uiImportStatus = 'importing';
activity.uiImportError = null;
updateImportProgress(index + 1, totalActivitiesToImport);
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.fitPubActivityId = data.importedActivityId || activity.fitPubActivityId;
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);
if (importCancellationRequested) {
cancelled = true;
resetQueuedActivities();
renderActivities(currentActivities);
break;
}
}
if (cancelled) {
showStatus(`Import stopped after the current activity. Imported ${importedCount} Komoot activit${importedCount === 1 ? 'y' : 'ies'}${failedCount > 0 ? `, ${failedCount} failed` : ''}.`);
} else {
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') {
return;
}
showError(message);
} finally {
setImportLoading(false);
importCancellationRequested = false;
}
});
cancelImportBtn.addEventListener('click', function() {
importCancellationRequested = true;
cancelImportBtn.disabled = true;
showStatus('Import will stop after the current activity finishes.');
});
updateImportButtonState();
});
</script>
</th:block>
</body>
</html>