show all known athletes for session on sync page

This commit is contained in:
OpenAI 2026-08-13 10:06:11 +00:00 committed by Jonas Rabenstein
commit 50cd57417d
2 changed files with 113 additions and 29 deletions

View file

@ -122,6 +122,54 @@ pub async fn session_athlete(db: &PgPool, token: &str) -> Result<Option<SessionA
}))
}
/// Return all locally known athletes that use the same credential as the
/// current session.
///
/// Intervals.icu does not expose an API endpoint that enumerates every
/// athlete a credential can access. Therefore this is the set of athletes
/// already known to this application for the credential. The owner of the
/// credential is included naturally because the login creates that row.
pub async fn session_accessible_athletes(
db: &PgPool,
token: &str,
) -> Result<Vec<SessionAthlete>> {
let hash = hash_token(token);
let rows = sqlx::query(
r#"
SELECT DISTINCT
a.id,
a.intervals_athlete_id,
a.display_name,
a.access_token
FROM sessions s
JOIN athletes current_athlete
ON current_athlete.id = s.athlete_id
JOIN athletes a
ON a.access_token = current_athlete.access_token
WHERE s.token_hash = $1
AND s.created_at > now() - interval '1 day'
ORDER BY a.display_name, a.intervals_athlete_id
"#,
)
.bind(&hash)
.fetch_all(db)
.await?;
use sqlx::Row;
rows.into_iter()
.map(|row| {
Ok(SessionAthlete {
id: row.try_get("id")?,
intervals_athlete_id: row.try_get("intervals_athlete_id")?,
display_name: row.try_get("display_name")?,
access_token: row.try_get("access_token")?,
})
})
.collect()
}
/// Return the start time of the most recently imported activity
/// for an athlete.
///

View file

@ -161,11 +161,18 @@ async fn sync_index(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
) -> Result<Html<String>, (axum::http::StatusCode, String)> {
let session = current_session_athlete(&state, &jar)
.await
.map_err(http_error)?;
let cookie = jar
.get("county_session")
.ok_or_else(|| http_error(anyhow!("not authenticated")))?;
let html = format!(
let athletes = db::session_accessible_athletes(
&state.db,
cookie.value(),
)
.await
.map_err(http_error)?;
let mut html = String::from(
r#"<!doctype html>
<html lang="de">
<head>
@ -174,63 +181,92 @@ async fn sync_index(
content="width=device-width,initial-scale=1">
<title>Sync · Landkreis-Sprints</title>
<style>
body {{
body {
font-family: system-ui, sans-serif;
max-width: 900px;
max-width: 1000px;
margin: 0 auto;
padding: 1rem;
background: #111827;
color: #f9fafb;
}}
a {{
}
a {
color: #93c5fd;
}}
.card {{
}
.card {
background: #1f2937;
border-radius: 12px;
padding: 1rem;
margin: 1rem 0;
}}
.button {{
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1rem;
}
.button {
display: inline-block;
background: #2563eb;
color: white;
border-radius: 8px;
padding: .6rem .9rem;
text-decoration: none;
border: 0;
cursor: pointer;
}}
.small {{
}
.small {
color: #9ca3af;
}}
}
.notice {
background: #3b2f12;
border: 1px solid #6b551c;
}
</style>
</head>
<body>
<h1>Synchronisation</h1>
"#,
);
<div class="card">
if athletes.is_empty() {
html.push_str(
r#"<div class="card notice">
Keine für diesen Authentication-Token bekannten Athleten.
</div>"#,
);
} else {
html.push_str(&format!(
r#"<div class="card small">
{} Athlet{} für diesen Authentication-Token bekannt.
</div>
<div class="grid">"#,
athletes.len(),
if athletes.len() == 1 { "" } else { "en" },
));
for athlete in athletes {
html.push_str(&format!(
r#"<div class="card">
<h2>{}</h2>
<div class="small">
Intervals.icu Athlete: {}
</div>
<div class="small">Intervals.icu: {}</div>
<p>
<a class="button"
href="/sync/{}">
<a class="button" href="/sync/{}">
Aktivitäten anzeigen
</a>
</p>
</div>
</div>"#,
html::escape(&athlete.display_name),
html::escape(&athlete.intervals_athlete_id),
url_segment(&athlete.intervals_athlete_id),
));
}
<p>
html.push_str("</div>");
}
html.push_str(
r#"<p>
<a href="/"> Leaderboard</a>
</p>
</body>
</html>"#,
html::escape(&session.display_name),
html::escape(&session.intervals_athlete_id),
url_segment(&session.intervals_athlete_id),
);
Ok(Html(html))