sync: import activities via AJAX with a per-row progress bar

'Alle sichtbaren importieren' used to POST a plain form, which
synchronously imported every visible activity server-side and only
then redirected via a meta refresh -- the browser just sat on a
blank reload for however long the whole batch took, with no
per-activity feedback.

Each activity row now carries data-athlete-id/data-activity-id plus
a progress bar and status line. Both the per-row 'Analysieren'
button and the 'Alle sichtbaren importieren' button now drive a
small JS loop that POSTs to the existing
/sync/{athlete}/activities/{activity}/import endpoint per activity
with Accept: application/json, animates that row's progress bar
while the request is in flight, and shows the resulting crossing
count (or an error) inline -- without a full page reload.

The endpoint itself now branches on the Accept header: JSON for the
AJAX path, the previous meta-refresh HTML for plain form submits
(so it still works without JavaScript). process_activity and
process_activity_with_client now return the crossing count so it
can be reported back to the row.
This commit is contained in:
Claude 2026-08-13 09:35:01 +00:00 committed by Jonas Rabenstein
commit 4475d156c8
2 changed files with 217 additions and 28 deletions

View file

@ -11,9 +11,10 @@ mod webhook;
use anyhow::{Context, Result, anyhow};
use axum::{
Router,
Json, Router,
extract::{Path, Query, State},
response::Html,
http::{HeaderMap, header::ACCEPT},
response::{Html, IntoResponse},
routing::{get, post},
};
use chrono::{DateTime, Duration, Utc};
@ -381,6 +382,44 @@ input[type="datetime-local"] {
background: #111827;
color: #f9fafb;
}
.row-progress {
display: none;
height: 6px;
width: 220px;
max-width: 100%;
margin-top: .5rem;
border-radius: 4px;
background: #374151;
overflow: hidden;
}
.row-progress.active {
display: block;
}
.row-progress-bar {
height: 100%;
width: 30%;
border-radius: 4px;
background: #2563eb;
animation: row-progress-indeterminate 1.1s ease-in-out infinite;
}
@keyframes row-progress-indeterminate {
0% { margin-left: -30%; }
100% { margin-left: 100%; }
}
.row-status {
margin-top: .3rem;
min-height: 1.1em;
font-size: .9rem;
}
.row-status.ok {
color: #4ade80;
}
.row-status.error {
color: #f87171;
}
.activity-row.done {
opacity: .6;
}
</style>
</head>
<body>
@ -451,7 +490,8 @@ input[type="datetime-local"] {
if !visible.is_empty() {
html.push_str(&format!(
r#"<div class="card">
<form method="post"
<form id="import-all-form"
method="post"
action="/sync/{}/import-visible">
<input type="hidden"
name="oldest"
@ -486,7 +526,11 @@ input[type="datetime-local"] {
.map(|m| format!("{:.1} km", m / 1000.0))
.unwrap_or_else(|| "".into());
html.push_str(r#"<div class="card row">"#);
html.push_str(&format!(
r#"<div class="card row activity-row" data-athlete-id="{}" data-activity-id="{}">"#,
html::escape(&athlete_id),
html::escape(&activity.id),
));
html.push_str(&format!(
r#"<div>
@ -494,6 +538,8 @@ input[type="datetime-local"] {
<div class="meta">
{} · {} · {}
</div>
<div class="row-progress"><div class="row-progress-bar"></div></div>
<div class="row-status small"></div>
</div>"#,
html::escape(&activity.name),
html::escape(&activity.activity_type),
@ -502,7 +548,8 @@ input[type="datetime-local"] {
));
html.push_str(&format!(
r#"<form method="post"
r#"<form class="import-form"
method="post"
action="/sync/{}/activities/{}/import">
<button class="button"
type="submit">
@ -519,6 +566,99 @@ input[type="datetime-local"] {
r#"<p>
<a href="/"> Leaderboard</a>
</p>
<script>
async function importActivity(athleteId, activityId, row) {
const progress = row.querySelector('.row-progress');
const bar = row.querySelector('.row-progress-bar');
const status = row.querySelector('.row-status');
const button = row.querySelector('.import-form button');
progress.classList.add('active');
status.textContent = '';
status.className = 'row-status small';
if (button) button.disabled = true;
try {
const url = '/sync/' + encodeURIComponent(athleteId) +
'/activities/' + encodeURIComponent(activityId) + '/import';
const response = await fetch(url, {
method: 'POST',
headers: { Accept: 'application/json' },
});
const data = await response.json().catch(() => ({}));
if (!response.ok || data.status !== 'ok') {
throw new Error(data.message || ('HTTP ' + response.status));
}
progress.classList.remove('active');
bar.style.animation = 'none';
bar.style.width = '100%';
const count = typeof data.crossings === 'number' ? data.crossings : null;
status.textContent = count === null
? 'Importiert.'
: ('Importiert · ' + count + (count === 1 ? ' Übertritt' : ' Übertritte'));
status.classList.add('ok');
row.classList.add('done');
if (button) button.remove();
return true;
} catch (error) {
progress.classList.remove('active');
status.textContent = 'Fehler: ' + error.message;
status.classList.add('error');
if (button) button.disabled = false;
return false;
}
}
document.querySelectorAll('.activity-row .import-form').forEach((form) => {
form.addEventListener('submit', (event) => {
event.preventDefault();
const row = form.closest('.activity-row');
importActivity(row.dataset.athleteId, row.dataset.activityId, row);
});
});
const importAllForm = document.getElementById('import-all-form');
if (importAllForm) {
importAllForm.addEventListener('submit', async (event) => {
event.preventDefault();
const button = importAllForm.querySelector('button');
const originalLabel = button ? button.textContent : '';
const rows = Array.from(
document.querySelectorAll('.activity-row:not(.done)')
);
if (button) button.disabled = true;
let done = 0;
for (const row of rows) {
const ok = await importActivity(
row.dataset.athleteId,
row.dataset.activityId,
row
);
if (ok) done += 1;
if (button) {
button.textContent =
'Importiere (' + done + '/' + rows.length + ')';
}
}
if (button) {
button.textContent =
rows.length > 0
? 'Fertig (' + done + '/' + rows.length + ')'
: originalLabel;
}
});
}
</script>
</body>
</html>"#,
);
@ -530,17 +670,26 @@ async fn sync_import_activity(
State(state): State<AppState>,
Path((athlete_id, activity_id)): Path<(String, String)>,
jar: axum_extra::extract::cookie::CookieJar,
) -> Result<Html<String>, (axum::http::StatusCode, String)> {
headers: HeaderMap,
) -> Result<axum::response::Response, (axum::http::StatusCode, String)> {
require_athlete_access(&state, &jar, &athlete_id)
.await
.map_err(http_error)?;
process_activity(state.clone(), athlete_id.clone(), activity_id.clone())
.await
.map_err(http_error)?;
let wants_json = wants_json_response(&headers);
Ok(Html(format!(
r#"<!doctype html>
match process_activity(state.clone(), athlete_id.clone(), activity_id.clone()).await {
Ok(crossings) => {
if wants_json {
Ok(Json(serde_json::json!({
"status": "ok",
"activity_id": activity_id,
"crossings": crossings,
}))
.into_response())
} else {
Ok(Html(format!(
r#"<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
@ -557,10 +706,43 @@ Aktivität {} wurde importiert.
</p>
</body>
</html>"#,
url_segment(&athlete_id),
html::escape(&activity_id),
url_segment(&athlete_id),
)))
url_segment(&athlete_id),
html::escape(&activity_id),
url_segment(&athlete_id),
))
.into_response())
}
}
Err(error) => {
if wants_json {
tracing::error!(
error = ?error,
activity_id = %activity_id,
"activity import failed"
);
Ok((
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"status": "error",
"message": error.to_string(),
})),
)
.into_response())
} else {
Err(http_error(error))
}
}
}
}
/// True if the client explicitly asked for a JSON response (used by the
/// AJAX import flow), rather than the classic full-page HTML fallback.
fn wants_json_response(headers: &HeaderMap) -> bool {
headers
.get(ACCEPT)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("application/json"))
}
#[derive(Debug, Deserialize)]
@ -743,7 +925,7 @@ pub async fn process_activity(
state: AppState,
intervals_athlete_id: String,
activity_id: String,
) -> Result<()> {
) -> Result<usize> {
let access_token: String = sqlx::query_scalar(
r#"
SELECT access_token
@ -781,7 +963,7 @@ pub async fn process_activity_with_client(
intervals_athlete_id: String,
activity_id: String,
client: &IntervalsClient,
) -> Result<()> {
) -> Result<usize> {
let athlete = sqlx::query(
r#"
SELECT id
@ -821,7 +1003,7 @@ pub async fn process_activity_with_client(
points = points.len(),
"activity has insufficient GPS data"
);
return Ok(());
return Ok(0);
}
let mut tx = state.db.begin().await?;
@ -863,7 +1045,7 @@ pub async fn process_activity_with_client(
"activity processed"
);
Ok(())
Ok(crossing_count)
}
fn parse_track(streams: &Value, start_time: DateTime<Utc>) -> Result<Vec<TrackPoint>> {

View file

@ -59,14 +59,21 @@ pub async fn receive(
let state_clone = state.clone();
tokio::spawn(async move {
if let Err(error) =
crate::process_activity(state_clone, athlete_id, activity_id.clone()).await
{
tracing::error!(
%error,
activity_id = %activity_id,
"failed to process webhook activity"
);
match crate::process_activity(state_clone, athlete_id, activity_id.clone()).await {
Ok(crossings) => {
tracing::info!(
activity_id = %activity_id,
crossings,
"webhook activity processed"
);
}
Err(error) => {
tracing::error!(
%error,
activity_id = %activity_id,
"failed to process webhook activity"
);
}
}
});