feat(komoot): add completed activities preview import flow

Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>
This commit is contained in:
Marcus Fihlon 2026-04-28 12:18:02 +02:00
parent 9e529f8b99
commit 7ca09f0f27
Signed by: McPringle
GPG key ID: C6B7F469EE363E1F
11 changed files with 671 additions and 0 deletions

View file

@ -0,0 +1,246 @@
<!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">Phase 1: Preview only</div>
<div class="small mb-2">
Your Komoot credentials are only used for this request and are not stored in FitPub.
</div>
<div class="small mb-0">
Komoot does not provide a public API for this flow. This preview currently depends on the
same web API endpoints used by the Komoot website and may stop working if Komoot changes them.
</div>
</div>
<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>
<div class="mt-4 d-flex justify-content-end">
<button type="submit" class="btn btn-primary" id="loadActivitiesBtn">
<span id="loadActivitiesText">
<i class="bi bi-arrow-repeat"></i> Load Completed Activities
</span>
<span id="loadActivitiesSpinner" class="d-none">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
Loading...
</span>
</button>
</div>
</form>
</div>
</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>
Completed 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>Name</th>
<th>Date</th>
<th>Sport</th>
<th>Distance</th>
<th>Duration</th>
<th>Elevation</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 resultsSection = document.getElementById('resultsSection');
const resultsBody = document.getElementById('resultsBody');
const resultCount = document.getElementById('resultCount');
const loadActivitiesBtn = document.getElementById('loadActivitiesBtn');
const loadActivitiesText = document.getElementById('loadActivitiesText');
const loadActivitiesSpinner = document.getElementById('loadActivitiesSpinner');
function setLoading(loading) {
loadActivitiesBtn.disabled = loading;
loadActivitiesText.classList.toggle('d-none', loading);
loadActivitiesSpinner.classList.toggle('d-none', !loading);
}
function showError(message) {
errorAlert.textContent = message;
errorAlert.classList.remove('d-none');
}
function clearError() {
errorAlert.textContent = '';
errorAlert.classList.add('d-none');
}
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 renderActivities(activities) {
resultCount.textContent = activities.length;
if (activities.length === 0) {
resultsBody.innerHTML = '<tr><td colspan="6" 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>
<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>${escapeHtml(activity.sport || '-')}</td>
<td>${formatDistance(activity.distanceMeters)}</td>
<td>${formatDuration(activity.timeInMotionSeconds || activity.durationSeconds)}</td>
<td>${formatElevation(activity.elevationUp)}</td>
</tr>
`).join('');
resultsSection.classList.remove('d-none');
}
form.addEventListener('submit', async function(event) {
event.preventDefault();
clearError();
resultsSection.classList.add('d-none');
const formData = new FormData(form);
const payload = {
email: formData.get('email'),
password: formData.get('password'),
userId: formData.get('userId')
};
setLoading(true);
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();
renderActivities(data.activities || []);
form.querySelector('#password').value = '';
} 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);
}
});
});
</script>
</th:block>
</body>
</html>

View file

@ -97,6 +97,11 @@
<i class="bi bi-file-earmark-zip"></i> Batch Import
</a>
</li>
<li>
<a class="dropdown-item" th:href="@{/komoot-import}">
<i class="bi bi-signpost-split"></i> Komoot Import
</a>
</li>
</ul>
</li>