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:
parent
0622edfdcd
commit
4475d156c8
2 changed files with 217 additions and 28 deletions
220
src/main.rs
220
src/main.rs
|
|
@ -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>> {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue