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 anyhow::{Context, Result, anyhow};
|
||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Json, Router,
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
response::Html,
|
http::{HeaderMap, header::ACCEPT},
|
||||||
|
response::{Html, IntoResponse},
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
};
|
};
|
||||||
use chrono::{DateTime, Duration, Utc};
|
use chrono::{DateTime, Duration, Utc};
|
||||||
|
|
@ -381,6 +382,44 @@ input[type="datetime-local"] {
|
||||||
background: #111827;
|
background: #111827;
|
||||||
color: #f9fafb;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -451,7 +490,8 @@ input[type="datetime-local"] {
|
||||||
if !visible.is_empty() {
|
if !visible.is_empty() {
|
||||||
html.push_str(&format!(
|
html.push_str(&format!(
|
||||||
r#"<div class="card">
|
r#"<div class="card">
|
||||||
<form method="post"
|
<form id="import-all-form"
|
||||||
|
method="post"
|
||||||
action="/sync/{}/import-visible">
|
action="/sync/{}/import-visible">
|
||||||
<input type="hidden"
|
<input type="hidden"
|
||||||
name="oldest"
|
name="oldest"
|
||||||
|
|
@ -486,7 +526,11 @@ input[type="datetime-local"] {
|
||||||
.map(|m| format!("{:.1} km", m / 1000.0))
|
.map(|m| format!("{:.1} km", m / 1000.0))
|
||||||
.unwrap_or_else(|| "—".into());
|
.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!(
|
html.push_str(&format!(
|
||||||
r#"<div>
|
r#"<div>
|
||||||
|
|
@ -494,6 +538,8 @@ input[type="datetime-local"] {
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
{} · {} · {}
|
{} · {} · {}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row-progress"><div class="row-progress-bar"></div></div>
|
||||||
|
<div class="row-status small"></div>
|
||||||
</div>"#,
|
</div>"#,
|
||||||
html::escape(&activity.name),
|
html::escape(&activity.name),
|
||||||
html::escape(&activity.activity_type),
|
html::escape(&activity.activity_type),
|
||||||
|
|
@ -502,7 +548,8 @@ input[type="datetime-local"] {
|
||||||
));
|
));
|
||||||
|
|
||||||
html.push_str(&format!(
|
html.push_str(&format!(
|
||||||
r#"<form method="post"
|
r#"<form class="import-form"
|
||||||
|
method="post"
|
||||||
action="/sync/{}/activities/{}/import">
|
action="/sync/{}/activities/{}/import">
|
||||||
<button class="button"
|
<button class="button"
|
||||||
type="submit">
|
type="submit">
|
||||||
|
|
@ -519,6 +566,99 @@ input[type="datetime-local"] {
|
||||||
r#"<p>
|
r#"<p>
|
||||||
<a href="/">← Leaderboard</a>
|
<a href="/">← Leaderboard</a>
|
||||||
</p>
|
</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>
|
</body>
|
||||||
</html>"#,
|
</html>"#,
|
||||||
);
|
);
|
||||||
|
|
@ -530,17 +670,26 @@ async fn sync_import_activity(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path((athlete_id, activity_id)): Path<(String, String)>,
|
Path((athlete_id, activity_id)): Path<(String, String)>,
|
||||||
jar: axum_extra::extract::cookie::CookieJar,
|
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)
|
require_athlete_access(&state, &jar, &athlete_id)
|
||||||
.await
|
.await
|
||||||
.map_err(http_error)?;
|
.map_err(http_error)?;
|
||||||
|
|
||||||
process_activity(state.clone(), athlete_id.clone(), activity_id.clone())
|
let wants_json = wants_json_response(&headers);
|
||||||
.await
|
|
||||||
.map_err(http_error)?;
|
|
||||||
|
|
||||||
Ok(Html(format!(
|
match process_activity(state.clone(), athlete_id.clone(), activity_id.clone()).await {
|
||||||
r#"<!doctype html>
|
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">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
|
|
@ -557,10 +706,43 @@ Aktivität {} wurde importiert.
|
||||||
</p>
|
</p>
|
||||||
</body>
|
</body>
|
||||||
</html>"#,
|
</html>"#,
|
||||||
url_segment(&athlete_id),
|
url_segment(&athlete_id),
|
||||||
html::escape(&activity_id),
|
html::escape(&activity_id),
|
||||||
url_segment(&athlete_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)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -743,7 +925,7 @@ pub async fn process_activity(
|
||||||
state: AppState,
|
state: AppState,
|
||||||
intervals_athlete_id: String,
|
intervals_athlete_id: String,
|
||||||
activity_id: String,
|
activity_id: String,
|
||||||
) -> Result<()> {
|
) -> Result<usize> {
|
||||||
let access_token: String = sqlx::query_scalar(
|
let access_token: String = sqlx::query_scalar(
|
||||||
r#"
|
r#"
|
||||||
SELECT access_token
|
SELECT access_token
|
||||||
|
|
@ -781,7 +963,7 @@ pub async fn process_activity_with_client(
|
||||||
intervals_athlete_id: String,
|
intervals_athlete_id: String,
|
||||||
activity_id: String,
|
activity_id: String,
|
||||||
client: &IntervalsClient,
|
client: &IntervalsClient,
|
||||||
) -> Result<()> {
|
) -> Result<usize> {
|
||||||
let athlete = sqlx::query(
|
let athlete = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT id
|
SELECT id
|
||||||
|
|
@ -821,7 +1003,7 @@ pub async fn process_activity_with_client(
|
||||||
points = points.len(),
|
points = points.len(),
|
||||||
"activity has insufficient GPS data"
|
"activity has insufficient GPS data"
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut tx = state.db.begin().await?;
|
let mut tx = state.db.begin().await?;
|
||||||
|
|
@ -863,7 +1045,7 @@ pub async fn process_activity_with_client(
|
||||||
"activity processed"
|
"activity processed"
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(crossing_count)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_track(streams: &Value, start_time: DateTime<Utc>) -> Result<Vec<TrackPoint>> {
|
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();
|
let state_clone = state.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(error) =
|
match crate::process_activity(state_clone, athlete_id, activity_id.clone()).await {
|
||||||
crate::process_activity(state_clone, athlete_id, activity_id.clone()).await
|
Ok(crossings) => {
|
||||||
{
|
tracing::info!(
|
||||||
tracing::error!(
|
activity_id = %activity_id,
|
||||||
%error,
|
crossings,
|
||||||
activity_id = %activity_id,
|
"webhook activity processed"
|
||||||
"failed to process webhook activity"
|
);
|
||||||
);
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::error!(
|
||||||
|
%error,
|
||||||
|
activity_id = %activity_id,
|
||||||
|
"failed to process webhook activity"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue