780 lines
28 KiB
Rust
780 lines
28 KiB
Rust
use std::collections::{HashMap, HashSet};
|
|
|
|
use chrono::{DateTime, Utc};
|
|
|
|
use axum::{
|
|
extract::{Path, State},
|
|
http::StatusCode,
|
|
response::{Html, Redirect},
|
|
};
|
|
use axum_extra::extract::{Form, Query, cookie::CookieJar};
|
|
use serde::Deserialize;
|
|
use serde_json::Value;
|
|
use sqlx::Row;
|
|
|
|
use crate::{
|
|
AppState, db, html, leaderboard,
|
|
model::{ActivityCrossing, LeaderboardGroup},
|
|
};
|
|
|
|
#[derive(Debug, Deserialize, Default)]
|
|
pub struct PageQuery {
|
|
pub group_id: Option<i64>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct GroupForm {
|
|
pub group_id: Option<i64>,
|
|
pub name: String,
|
|
pub members: Option<Vec<i64>>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct DeleteGroupForm {
|
|
pub group_id: i64,
|
|
}
|
|
|
|
pub async fn index(
|
|
State(state): State<AppState>,
|
|
jar: CookieJar,
|
|
Query(query): Query<PageQuery>,
|
|
) -> Html<String> {
|
|
let current = current_user(&state, &jar).await;
|
|
let group_id = query.group_id;
|
|
|
|
let valid_group_id = match (current.as_ref(), group_id) {
|
|
(Some((owner_id, _)), Some(gid)) => group_belongs_to_user(&state, gid, *owner_id)
|
|
.await
|
|
.ok()
|
|
.flatten(),
|
|
_ => None,
|
|
};
|
|
|
|
let groups = Vec::new();
|
|
|
|
let available_groups = match current.as_ref() {
|
|
Some((id, _)) => futures_groups(&state, *id).await.unwrap_or_default(),
|
|
None => Vec::new(),
|
|
};
|
|
|
|
render_index(&groups, current.as_ref(), valid_group_id, &available_groups)
|
|
}
|
|
|
|
async fn futures_groups(
|
|
state: &AppState,
|
|
owner_id: i64,
|
|
) -> Result<Vec<(i64, String)>, anyhow::Error> {
|
|
let rows = sqlx::query(
|
|
r#"
|
|
SELECT id, name
|
|
FROM leaderboard_groups
|
|
WHERE owner_athlete_id = $1
|
|
ORDER BY name
|
|
"#,
|
|
)
|
|
.bind(owner_id)
|
|
.fetch_all(&state.db)
|
|
.await?;
|
|
|
|
Ok(rows
|
|
.into_iter()
|
|
.map(|row| (row.get("id"), row.get("name")))
|
|
.collect())
|
|
}
|
|
|
|
pub async fn activity(
|
|
State(state): State<AppState>,
|
|
jar: CookieJar,
|
|
Path(activity_id): Path<String>,
|
|
Query(query): Query<PageQuery>,
|
|
) -> Result<Html<String>, StatusCode> {
|
|
let Some((current_id, _)) = current_user(&state, &jar).await else {
|
|
return Err(StatusCode::UNAUTHORIZED);
|
|
};
|
|
|
|
let group_id = match query.group_id {
|
|
Some(gid) => group_belongs_to_user(&state, gid, current_id)
|
|
.await
|
|
.map_err(internal)?,
|
|
None => None,
|
|
};
|
|
|
|
let activity = sqlx::query(
|
|
r#"
|
|
SELECT
|
|
a.intervals_activity_id,
|
|
a.start_time,
|
|
a.activity_json,
|
|
ath.id AS athlete_id,
|
|
ath.display_name
|
|
FROM activities a
|
|
JOIN athletes ath ON ath.id = a.athlete_id
|
|
WHERE a.intervals_activity_id = $1
|
|
AND (
|
|
ath.id = $2
|
|
OR (
|
|
$3::BIGINT IS NOT NULL
|
|
AND ath.id IN (
|
|
SELECT athlete_id
|
|
FROM leaderboard_group_members
|
|
WHERE group_id = $3
|
|
)
|
|
)
|
|
)
|
|
ORDER BY a.id DESC
|
|
LIMIT 1
|
|
"#,
|
|
)
|
|
.bind(&activity_id)
|
|
.bind(current_id)
|
|
.bind(group_id)
|
|
.fetch_optional(&state.db)
|
|
.await
|
|
.map_err(internal)?;
|
|
|
|
let Some(activity) = activity else {
|
|
return Err(StatusCode::NOT_FOUND);
|
|
};
|
|
|
|
let start_time: Option<DateTime<Utc>> = activity.try_get("start_time").map_err(internal)?;
|
|
let activity_json: Option<Value> = activity.try_get("activity_json").map_err(internal)?;
|
|
let athlete_name: String = activity.try_get("display_name").map_err(internal)?;
|
|
let crossings = leaderboard::activity_crossings(&state.db, &activity_id, group_id)
|
|
.await
|
|
.map_err(internal)?;
|
|
|
|
Ok(Html(render_activity(
|
|
&activity_id,
|
|
athlete_name,
|
|
start_time,
|
|
activity_json.as_ref(),
|
|
&crossings,
|
|
group_id,
|
|
)))
|
|
}
|
|
|
|
pub async fn groups(
|
|
State(state): State<AppState>,
|
|
jar: CookieJar,
|
|
) -> Result<Html<String>, StatusCode> {
|
|
let Some((owner_id, owner_name)) = current_user(&state, &jar).await else {
|
|
return Err(StatusCode::UNAUTHORIZED);
|
|
};
|
|
|
|
let group_rows = sqlx::query(
|
|
r#"
|
|
SELECT
|
|
g.id AS group_id,
|
|
g.name AS group_name,
|
|
gm.athlete_id
|
|
FROM leaderboard_groups g
|
|
LEFT JOIN leaderboard_group_members gm
|
|
ON gm.group_id = g.id
|
|
WHERE g.owner_athlete_id = $1
|
|
ORDER BY g.name, gm.athlete_id
|
|
"#,
|
|
)
|
|
.bind(owner_id)
|
|
.fetch_all(&state.db)
|
|
.await
|
|
.map_err(internal)?;
|
|
|
|
let mut groups: Vec<(i64, String, HashSet<i64>)> = Vec::new();
|
|
for row in group_rows {
|
|
let group_id: i64 = row.get("group_id");
|
|
let group_name: String = row.get("group_name");
|
|
let athlete_id: Option<i64> = row.get("athlete_id");
|
|
|
|
if let Some((_, _, members)) = groups.iter_mut().find(|(id, _, _)| *id == group_id) {
|
|
if let Some(id) = athlete_id {
|
|
members.insert(id);
|
|
}
|
|
} else {
|
|
let mut members = HashSet::new();
|
|
if let Some(id) = athlete_id {
|
|
members.insert(id);
|
|
}
|
|
groups.push((group_id, group_name, members));
|
|
}
|
|
}
|
|
|
|
let athletes = sqlx::query(
|
|
r#"
|
|
SELECT id, display_name
|
|
FROM athletes
|
|
ORDER BY display_name, id
|
|
"#,
|
|
)
|
|
.fetch_all(&state.db)
|
|
.await
|
|
.map_err(internal)?;
|
|
|
|
let athlete_list: Vec<(i64, String)> = athletes
|
|
.into_iter()
|
|
.map(|row| (row.get("id"), row.get("display_name")))
|
|
.collect();
|
|
|
|
Ok(Html(render_groups(&owner_name, &groups, &athlete_list)))
|
|
}
|
|
|
|
pub async fn save_group(
|
|
State(state): State<AppState>,
|
|
jar: CookieJar,
|
|
Form(form): Form<GroupForm>,
|
|
) -> Result<Redirect, StatusCode> {
|
|
let Some((owner_id, _)) = current_user(&state, &jar).await else {
|
|
return Err(StatusCode::UNAUTHORIZED);
|
|
};
|
|
|
|
let name = form.name.trim();
|
|
if name.is_empty() {
|
|
return Err(StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
let group_id = match form.group_id {
|
|
Some(group_id) => {
|
|
let result = sqlx::query(
|
|
r#"
|
|
UPDATE leaderboard_groups
|
|
SET name = $1, updated_at = now()
|
|
WHERE id = $2 AND owner_athlete_id = $3
|
|
"#,
|
|
)
|
|
.bind(name)
|
|
.bind(group_id)
|
|
.bind(owner_id)
|
|
.execute(&state.db)
|
|
.await
|
|
.map_err(internal)?;
|
|
|
|
if result.rows_affected() != 1 {
|
|
return Err(StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
group_id
|
|
}
|
|
None => {
|
|
let row = sqlx::query(
|
|
r#"
|
|
INSERT INTO leaderboard_groups(owner_athlete_id, name)
|
|
VALUES ($1, $2)
|
|
RETURNING id
|
|
"#,
|
|
)
|
|
.bind(owner_id)
|
|
.bind(name)
|
|
.fetch_one(&state.db)
|
|
.await
|
|
.map_err(internal)?;
|
|
|
|
row.get("id")
|
|
}
|
|
};
|
|
|
|
sqlx::query("DELETE FROM leaderboard_group_members WHERE group_id = $1")
|
|
.bind(group_id)
|
|
.execute(&state.db)
|
|
.await
|
|
.map_err(internal)?;
|
|
|
|
if let Some(members) = form.members {
|
|
for athlete_id in members {
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO leaderboard_group_members(group_id, athlete_id)
|
|
SELECT $1, id
|
|
FROM athletes
|
|
WHERE id = $2
|
|
ON CONFLICT DO NOTHING
|
|
"#,
|
|
)
|
|
.bind(group_id)
|
|
.bind(athlete_id)
|
|
.execute(&state.db)
|
|
.await
|
|
.map_err(internal)?;
|
|
}
|
|
}
|
|
|
|
Ok(Redirect::to("/groups"))
|
|
}
|
|
|
|
pub async fn delete_group(
|
|
State(state): State<AppState>,
|
|
jar: CookieJar,
|
|
Form(form): Form<DeleteGroupForm>,
|
|
) -> Result<Redirect, StatusCode> {
|
|
let Some((owner_id, _)) = current_user(&state, &jar).await else {
|
|
return Err(StatusCode::UNAUTHORIZED);
|
|
};
|
|
|
|
sqlx::query(
|
|
r#"
|
|
DELETE FROM leaderboard_groups
|
|
WHERE id = $1 AND owner_athlete_id = $2
|
|
"#,
|
|
)
|
|
.bind(form.group_id)
|
|
.bind(owner_id)
|
|
.execute(&state.db)
|
|
.await
|
|
.map_err(internal)?;
|
|
|
|
Ok(Redirect::to("/groups"))
|
|
}
|
|
|
|
async fn current_user(state: &AppState, jar: &CookieJar) -> Option<(i64, String)> {
|
|
let cookie = jar.get("county_session")?;
|
|
db::athlete_for_session(&state.db, cookie.value())
|
|
.await
|
|
.ok()
|
|
.flatten()
|
|
}
|
|
|
|
async fn group_belongs_to_user(
|
|
state: &AppState,
|
|
group_id: i64,
|
|
owner_id: i64,
|
|
) -> Result<Option<i64>, anyhow::Error> {
|
|
let exists = sqlx::query(
|
|
r#"
|
|
SELECT id
|
|
FROM leaderboard_groups
|
|
WHERE id = $1 AND owner_athlete_id = $2
|
|
"#,
|
|
)
|
|
.bind(group_id)
|
|
.bind(owner_id)
|
|
.fetch_optional(&state.db)
|
|
.await?;
|
|
|
|
Ok(exists.map(|row| row.get("id")))
|
|
}
|
|
|
|
fn internal(error: impl std::fmt::Display) -> StatusCode {
|
|
tracing::error!(%error, "web request failed");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
}
|
|
|
|
fn render_index(
|
|
groups: &[LeaderboardGroup],
|
|
current: Option<&(i64, String)>,
|
|
selected_group: Option<i64>,
|
|
available_groups: &[(i64, String)],
|
|
) -> Html<String> {
|
|
let mut html = String::from(BASE_HEAD);
|
|
|
|
html.push_str(
|
|
r#"<header>
|
|
<div>
|
|
<h1>🚴 Landkreis-Sprints</h1>
|
|
<div class="small">Grenzübertritte · Millisekunden · Intervals.icu</div>
|
|
</div>"#,
|
|
);
|
|
|
|
if let Some((_, name)) = current {
|
|
html.push_str(&format!(
|
|
r#"<div>Angemeldet als <strong>{}</strong><br>
|
|
<a class="button secondary" href="/groups">Gruppen</a>
|
|
<a class="button" href="/logout">Logout</a></div>"#,
|
|
html::escape(name)
|
|
));
|
|
} else {
|
|
html.push_str(
|
|
r#"<div>
|
|
<a class="button" href="/oauth/start">Mit Intervals.icu verbinden</a>
|
|
<a class="button secondary" href="/api-key/start">Mit API-Key anmelden</a>
|
|
</div>"#,
|
|
);
|
|
}
|
|
|
|
html.push_str("</header>");
|
|
|
|
if !available_groups.is_empty() {
|
|
html.push_str(
|
|
r#"<div class="card filter-card"><form method="get" action="/">
|
|
<label for="group_id"><strong>Leaderboard-Gruppe</strong></label>
|
|
<select id="group_id" name="group_id" onchange="this.form.submit()">
|
|
<option value="">Alle Athleten</option>"#,
|
|
);
|
|
|
|
for (id, name) in available_groups {
|
|
let selected = if Some(*id) == selected_group {
|
|
" selected"
|
|
} else {
|
|
""
|
|
};
|
|
html.push_str(&format!(
|
|
"<option value=\"{}\"{}>{}</option>",
|
|
id,
|
|
selected,
|
|
html::escape(name)
|
|
));
|
|
}
|
|
|
|
html.push_str("</select></form></div>");
|
|
}
|
|
|
|
if groups.is_empty() {
|
|
html.push_str(
|
|
r#"<div id="leaderboard-status" class="card">Landkreisübertritte werden geladen …</div>"#,
|
|
);
|
|
}
|
|
|
|
html.push_str(r#"<div id="leaderboard-list"></div><div id="leaderboard-sentinel" aria-hidden="true"></div>"#);
|
|
|
|
for group in groups {
|
|
html.push_str("<section class=\"card\">");
|
|
html.push_str(&format!(
|
|
"<h2>{} → {}</h2>",
|
|
html::escape(&group.from_county),
|
|
html::escape(&group.to_county)
|
|
));
|
|
html.push_str(&format!(
|
|
"<div class=\"small\">10-Minuten-Fenster · {} · Standort ±10 m</div>",
|
|
group.bucket_start.to_rfc3339()
|
|
));
|
|
|
|
html.push_str(&format!(
|
|
r#"<div class="group-map" data-lat="{}" data-lon="{}"></div>"#,
|
|
group.crossing_lat, group.crossing_lon
|
|
));
|
|
|
|
html.push_str(
|
|
r#"<table><thead><tr>
|
|
<th>#</th><th>Fahrer</th><th>Übertritt</th><th>Aktivität</th>
|
|
<th>Intervall</th><th>Power</th><th>HR</th><th>Karte</th>
|
|
</tr></thead><tbody>"#,
|
|
);
|
|
|
|
for row in &group.rows {
|
|
let interval = row
|
|
.interval_url
|
|
.as_ref()
|
|
.map(|url| {
|
|
format!(
|
|
"<a href=\"{}\" target=\"_blank\" rel=\"noreferrer\">Intervall</a>",
|
|
html::escape(url)
|
|
)
|
|
})
|
|
.unwrap_or_else(|| "<span class=\"small\">—</span>".into());
|
|
|
|
let power = row
|
|
.average_watts
|
|
.map(|v| format!("{:.0} W", v))
|
|
.unwrap_or_else(|| "—".into());
|
|
let hr = row
|
|
.average_heartrate
|
|
.map(|v| format!("{:.0} bpm", v))
|
|
.unwrap_or_else(|| "—".into());
|
|
|
|
html.push_str(&format!(
|
|
r#"<tr>
|
|
<td class="rank">{}</td>
|
|
<td>{}</td>
|
|
<td class="time" data-time="{}">{}</td>
|
|
<td><a href="{}">{}</a></td>
|
|
<td>{}</td><td>{}</td><td>{}</td>
|
|
<td><div class="mini-map" data-lat="{}" data-lon="{}"></div></td>
|
|
</tr>"#,
|
|
row.rank,
|
|
html::escape(&row.athlete),
|
|
html::escape(&row.crossing_time.to_rfc3339()),
|
|
row.crossing_time.format("%H:%M:%S%.3f UTC"),
|
|
html::escape(&row.activity_url),
|
|
html::escape(&row.activity_id),
|
|
interval,
|
|
row.lat,
|
|
row.lon,
|
|
power,
|
|
hr,
|
|
));
|
|
}
|
|
|
|
html.push_str("</tbody></table></section>");
|
|
}
|
|
|
|
html.push_str(BASE_FOOT);
|
|
Html(html)
|
|
}
|
|
|
|
fn render_activity(
|
|
activity_id: &str,
|
|
athlete_name: String,
|
|
start_time: Option<chrono::DateTime<chrono::Utc>>,
|
|
activity_json: Option<&Value>,
|
|
crossings: &[ActivityCrossing],
|
|
group_id: Option<i64>,
|
|
) -> String {
|
|
let title = activity_json
|
|
.and_then(|v| v.get("name"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("Aktivität");
|
|
let sport = activity_json
|
|
.and_then(|v| v.get("type"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("");
|
|
|
|
let mut html = String::from(BASE_HEAD);
|
|
html.push_str(&format!(
|
|
r#"<header><div><a href="/">← Leaderboard</a>
|
|
<h1>{}</h1><div class="small">{} · {}</div></div></header>
|
|
<section class="card">
|
|
<h2>{}</h2>
|
|
<div class="small">Athlet: {} · Aktivität: {}</div>"#,
|
|
html::escape(title),
|
|
html::escape(&athlete_name),
|
|
html::escape(sport),
|
|
html::escape(title),
|
|
html::escape(&athlete_name),
|
|
html::escape(activity_id),
|
|
));
|
|
|
|
if let Some(start) = start_time {
|
|
html.push_str(&format!(
|
|
r#"<div class="small activity-start" data-time="{}">Start: {}</div>"#,
|
|
html::escape(&start.to_rfc3339()),
|
|
start.format("%Y-%m-%d %H:%M:%S UTC")
|
|
));
|
|
}
|
|
|
|
let points: Vec<_> = crossings
|
|
.iter()
|
|
.map(|c| {
|
|
serde_json::json!({
|
|
"lat": c.lat,
|
|
"lon": c.lon,
|
|
"time": c.crossing_time.to_rfc3339(),
|
|
"from": c.from_county,
|
|
"to": c.to_county,
|
|
"rank": c.rank,
|
|
"athlete": c.athlete,
|
|
})
|
|
})
|
|
.collect();
|
|
let points_json = html::escape(&serde_json::to_string(&points).unwrap_or_else(|_| "[]".into()));
|
|
|
|
html.push_str(&format!(
|
|
r#"<div id="activity-map" class="activity-map" data-points="{}"></div>"#,
|
|
points_json
|
|
));
|
|
|
|
if crossings.is_empty() {
|
|
html.push_str("<p class=\"small\">Keine Landkreisübertritte in dieser Aktivität.</p>");
|
|
} else {
|
|
html.push_str(r#"<table><thead><tr>
|
|
<th>#</th><th>Zeit</th><th>Landkreis</th><th>Rang</th><th>Power</th><th>HR</th><th>Intervall</th>
|
|
</tr></thead><tbody>"#);
|
|
|
|
for (index, crossing) in crossings.iter().enumerate() {
|
|
let interval = crossing
|
|
.interval_url
|
|
.as_ref()
|
|
.map(|url| {
|
|
format!(
|
|
"<a href=\"{}\" target=\"_blank\" rel=\"noreferrer\">Intervall</a>",
|
|
html::escape(url)
|
|
)
|
|
})
|
|
.unwrap_or_else(|| "—".into());
|
|
let power = crossing
|
|
.average_watts
|
|
.map(|v| format!("{:.0} W", v))
|
|
.unwrap_or_else(|| "—".into());
|
|
let hr = crossing
|
|
.average_heartrate
|
|
.map(|v| format!("{:.0} bpm", v))
|
|
.unwrap_or_else(|| "—".into());
|
|
|
|
html.push_str(&format!(
|
|
r#"<tr><td>{}</td>
|
|
<td class="time" data-time="{}">{}</td>
|
|
<td>{} → {}</td><td class="rank">{}</td>
|
|
<td>{}</td><td>{}</td><td>{}</td></tr>"#,
|
|
index + 1,
|
|
html::escape(&crossing.crossing_time.to_rfc3339()),
|
|
crossing.crossing_time.format("%H:%M:%S%.3f UTC"),
|
|
html::escape(&crossing.from_county),
|
|
html::escape(&crossing.to_county),
|
|
crossing.rank,
|
|
power,
|
|
hr,
|
|
interval,
|
|
));
|
|
}
|
|
html.push_str("</tbody></table>");
|
|
}
|
|
|
|
if let Some(group_id) = group_id {
|
|
html.push_str(&format!(
|
|
"<div class=\"small\">Leaderboard-Gruppe #{}</div>",
|
|
group_id
|
|
));
|
|
}
|
|
|
|
html.push_str("</section>");
|
|
html.push_str(BASE_FOOT);
|
|
html
|
|
}
|
|
|
|
fn render_groups(
|
|
owner_name: &str,
|
|
groups: &[(i64, String, HashSet<i64>)],
|
|
athletes: &[(i64, String)],
|
|
) -> String {
|
|
let mut html = String::from(BASE_HEAD);
|
|
html.push_str(&format!(
|
|
r#"<header><div><a href="/">← Leaderboard</a><h1>Leaderboard-Gruppen</h1>
|
|
<div class="small">Gruppen werden für {} verwaltet.</div></div></header>"#,
|
|
html::escape(owner_name)
|
|
));
|
|
|
|
html.push_str(
|
|
r#"<section class="card"><h2>Neue Gruppe</h2>
|
|
<form method="post" action="/groups">
|
|
<input type="text" name="name" placeholder="z. B. Trainingsgruppe" required>
|
|
<div class="members">"#,
|
|
);
|
|
for (id, name) in athletes {
|
|
html.push_str(&format!(
|
|
r#"<label><input type="checkbox" name="members" value="{}"> {}</label>"#,
|
|
id,
|
|
html::escape(name)
|
|
));
|
|
}
|
|
html.push_str(r#"</div><button type="submit">Gruppe anlegen</button></form></section>"#);
|
|
|
|
for (group_id, name, members) in groups {
|
|
html.push_str(&format!(
|
|
r#"<section class="card"><form method="post" action="/groups">
|
|
<input type="hidden" name="group_id" value="{}">
|
|
<input type="text" name="name" value="{}" required>
|
|
<div class="members">"#,
|
|
group_id,
|
|
html::escape(name)
|
|
));
|
|
|
|
for (id, athlete_name) in athletes {
|
|
let checked = if members.contains(id) { " checked" } else { "" };
|
|
html.push_str(&format!(
|
|
r#"<label><input type="checkbox" name="members" value="{}"{}> {}</label>"#,
|
|
id,
|
|
checked,
|
|
html::escape(athlete_name)
|
|
));
|
|
}
|
|
|
|
html.push_str(&format!(
|
|
r#"</div><button type="submit">Speichern</button></form>
|
|
<form method="post" action="/groups/delete" onsubmit="return confirm('Gruppe löschen?')">
|
|
<input type="hidden" name="group_id" value="{}"><button class="danger" type="submit">Löschen</button>
|
|
</form></section>"#,
|
|
group_id
|
|
));
|
|
}
|
|
|
|
html.push_str(BASE_FOOT);
|
|
html
|
|
}
|
|
|
|
const BASE_HEAD: &str = r#"<!doctype html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Landkreis-Sprints</title>
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
|
<style>
|
|
body{font-family:system-ui,sans-serif;max-width:1250px;margin:0 auto;padding:1rem;background:#111827;color:#f9fafb}
|
|
a{color:#93c5fd}.small{color:#9ca3af;font-size:.9rem}header{display:flex;justify-content:space-between;align-items:flex-start;gap:1rem;margin-bottom:1rem}.card{background:#1f2937;border-radius:12px;padding:1rem;margin:1rem 0}table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:.55rem;border-bottom:1px solid #374151;vertical-align:middle}.rank{font-weight:800}.time{font-family:ui-monospace,monospace;white-space:nowrap}.button,button{background:#2563eb;color:#fff;border:0;border-radius:8px;padding:.6rem .9rem;text-decoration:none;cursor:pointer;display:inline-block;margin:.15rem}.secondary{background:#374151}.danger{background:#991b1b}select,input[type=text]{background:#111827;color:#f9fafb;border:1px solid #4b5563;border-radius:8px;padding:.55rem}.filter-card form{display:flex;gap:.75rem;align-items:center}.group-map{height:240px;border-radius:10px;margin:.75rem 0}.mini-map{width:180px;height:120px;border-radius:8px}.activity-map{height:480px;border-radius:10px;margin:1rem 0}.members{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:.4rem;margin:1rem 0}.members label{padding:.4rem;background:#111827;border-radius:6px}.leaflet-container{background:#ddd}.activity-start{margin-top:.35rem}
|
|
@media(max-width:800px){table{font-size:.85rem}.mini-map{width:130px;height:100px}.group-map{height:200px}.activity-map{height:360px}th:nth-child(5),td:nth-child(5),th:nth-child(6),td:nth-child(6){display:none}}
|
|
</style>
|
|
</head><body>
|
|
"#;
|
|
|
|
const BASE_FOOT: &str = r#"
|
|
<footer class="small" style="margin:2rem 0">Datenquelle: Intervals.icu · Landkreisgeometrien: VG250 · Karten: OpenStreetMap</footer>
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
<script>
|
|
function tileLayer(map){
|
|
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
maxZoom: 19,
|
|
attribution: '© OpenStreetMap contributors'
|
|
}).addTo(map);
|
|
}
|
|
function fmtTime(value){
|
|
const d=new Date(value);
|
|
if(Number.isNaN(d.getTime())) return value;
|
|
return new Intl.DateTimeFormat(undefined,{dateStyle:'medium',timeStyle:'medium'}).format(d);
|
|
}
|
|
document.querySelectorAll('[data-time]').forEach(el=>{
|
|
el.textContent=fmtTime(el.dataset.time);
|
|
el.title=Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
});
|
|
function escapeHtml(value){return String(value).replace(/[&<>\"']/g,ch=>({'&':'&','<':'<','>':'>','\"':'"',"'":'''}[ch]));}
|
|
function formatPower(value){return value == null ? '—' : Number(value).toFixed(0)+' W';}
|
|
function formatHr(value){return value == null ? '—' : Number(value).toFixed(0)+' bpm';}
|
|
function leaderboardRowHtml(row){
|
|
const interval=row.interval_url ? '<a href="'+escapeHtml(row.interval_url)+'" target="_blank" rel="noreferrer">Intervall</a>' : '<span class="small">—</span>';
|
|
return '<tr><td class="rank">'+row.rank+'</td><td>'+escapeHtml(row.athlete)+'</td><td class="time" data-time="'+escapeHtml(row.crossing_time)+'">'+escapeHtml(row.crossing_time)+'</td><td><a href="'+escapeHtml(row.activity_url)+'">'+escapeHtml(row.activity_id)+'</a></td><td>'+interval+'</td><td>'+formatPower(row.average_watts)+'</td><td>'+formatHr(row.average_heartrate)+'</td><td><div class="mini-map" data-lat="'+row.lat+'" data-lon="'+row.lon+'"></div></td></tr>';
|
|
}
|
|
function leaderboardGroupKey(group){return group.bucket_start+'|'+group.from_county+'|'+group.to_county;}
|
|
function leaderboardGroupHtml(group){return '<section class="card leaderboard-group"><h2>'+escapeHtml(group.from_county)+' → '+escapeHtml(group.to_county)+'</h2><div class="small">10-Minuten-Fenster · '+escapeHtml(group.bucket_start)+' · Standort ±10 m</div><div class="group-map" data-lat="'+group.crossing_lat+'" data-lon="'+group.crossing_lon+'"></div><table><thead><tr><th>#</th><th>Fahrer</th><th>Übertritt</th><th>Aktivität</th><th>Intervall</th><th>Power</th><th>HR</th><th>Karte</th></tr></thead><tbody>'+group.rows.map(leaderboardRowHtml).join('')+'</tbody></table></section>';}
|
|
function initLeaderboardElements(root){
|
|
root.querySelectorAll('[data-time]').forEach(el=>{el.textContent=fmtTime(el.dataset.time);el.title=Intl.DateTimeFormat().resolvedOptions().timeZone;});
|
|
root.querySelectorAll('.group-map').forEach(el=>{if(el.dataset.mapReady)return;const lat=Number(el.dataset.lat),lon=Number(el.dataset.lon);if(!Number.isFinite(lat)||!Number.isFinite(lon))return;el.dataset.mapReady='1';const map=L.map(el,{scrollWheelZoom:false}).setView([lat,lon],15);tileLayer(map);L.circle([lat,lon],{radius:10}).addTo(map);L.marker([lat,lon]).addTo(map);});
|
|
root.querySelectorAll('.mini-map').forEach(el=>{if(el.dataset.mapReady)return;const lat=Number(el.dataset.lat),lon=Number(el.dataset.lon);if(!Number.isFinite(lat)||!Number.isFinite(lon))return;el.dataset.mapReady='1';const map=L.map(el,{zoomControl:false,dragging:false,scrollWheelZoom:false,doubleClickZoom:false,touchZoom:false}).setView([lat,lon],16);tileLayer(map);L.circle([lat,lon],{radius:10}).addTo(map);L.marker([lat,lon]).addTo(map);});
|
|
}
|
|
const leaderboardList=document.getElementById('leaderboard-list');
|
|
if(leaderboardList){
|
|
const status=document.getElementById('leaderboard-status'),sentinel=document.getElementById('leaderboard-sentinel'),groups=new Map();
|
|
let offset=0,loading=false,hasMore=true; const pageSize=50;
|
|
async function loadLeaderboard(){
|
|
if(loading||!hasMore)return; loading=true;
|
|
try{
|
|
const params=new URLSearchParams({offset:String(offset),limit:String(pageSize)}),selected=new URLSearchParams(location.search).get('group_id');
|
|
if(selected)params.set('group_id',selected);
|
|
const response=await fetch('/api/leaderboard?'+params,{headers:{Accept:'application/json'}}); if(!response.ok)throw new Error('HTTP '+response.status);
|
|
const page=await response.json();
|
|
if(offset===0&&page.groups.length===0){status.textContent='Noch keine Landkreisüberquerungen für diese Auswahl.';status.style.display='block';}else{status.style.display='none';}
|
|
for(const group of page.groups){const key=leaderboardGroupKey(group),existing=groups.get(key);if(existing){const tbody=existing.querySelector('tbody');tbody.insertAdjacentHTML('beforeend',group.rows.map(leaderboardRowHtml).join(''));initLeaderboardElements(tbody);}else{leaderboardList.insertAdjacentHTML('beforeend',leaderboardGroupHtml(group));const section=leaderboardList.lastElementChild;groups.set(key,section);initLeaderboardElements(section);}}
|
|
offset=page.next_offset;hasMore=page.has_more;if(!hasMore)sentinel.style.display='none';
|
|
}catch(error){console.error(error);status.textContent='Landkreisübertritte konnten nicht geladen werden.';status.style.display='block';}finally{loading=false;}
|
|
}
|
|
const observer=new IntersectionObserver(entries=>{if(entries.some(entry=>entry.isIntersecting))loadLeaderboard();},{rootMargin:'800px 0px'});observer.observe(sentinel);loadLeaderboard();
|
|
}
|
|
document.querySelectorAll('.group-map').forEach(el=>{
|
|
const lat=Number(el.dataset.lat),lon=Number(el.dataset.lon);
|
|
if(!Number.isFinite(lat)||!Number.isFinite(lon)) return;
|
|
const map=L.map(el,{scrollWheelZoom:false}).setView([lat,lon],15);
|
|
tileLayer(map);
|
|
L.circle([lat,lon],{radius:10}).addTo(map);
|
|
L.marker([lat,lon]).addTo(map);
|
|
});
|
|
document.querySelectorAll('.mini-map').forEach(el=>{
|
|
const lat=Number(el.dataset.lat),lon=Number(el.dataset.lon);
|
|
if(!Number.isFinite(lat)||!Number.isFinite(lon)) return;
|
|
const map=L.map(el,{zoomControl:false,dragging:false,scrollWheelZoom:false,doubleClickZoom:false,touchZoom:false}).setView([lat,lon],16);
|
|
tileLayer(map);
|
|
L.circle([lat,lon],{radius:10}).addTo(map);
|
|
L.marker([lat,lon]).addTo(map);
|
|
});
|
|
const activityMapEl=document.getElementById('activity-map');
|
|
if(activityMapEl){
|
|
const points=JSON.parse(activityMapEl.dataset.points || '[]');
|
|
if(points.length){
|
|
const map=L.map(activityMapEl).setView([points[0].lat,points[0].lon],14);
|
|
tileLayer(map);
|
|
const bounds=[];
|
|
points.forEach((p,i)=>{
|
|
const marker=L.marker([p.lat,p.lon]).addTo(map);
|
|
marker.bindPopup('<strong>#'+(i+1)+'</strong><br>'+p.from+' → '+p.to+'<br>'+fmtTime(p.time)+'<br>Rang: '+p.rank);
|
|
L.circle([p.lat,p.lon],{radius:10}).addTo(map);
|
|
bounds.push([p.lat,p.lon]);
|
|
});
|
|
map.fitBounds(bounds,{padding:[25,25]});
|
|
}
|
|
}
|
|
</script>
|
|
</body></html>
|
|
"#;
|