This commit is contained in:
Jonas Rabenstein 2026-08-12 13:24:01 +02:00
commit a9d22e8312
18 changed files with 2308 additions and 0 deletions

223
src/web.rs Normal file
View file

@ -0,0 +1,223 @@
use axum::{
extract::State,
response::Html,
};
use axum_extra::extract::cookie::CookieJar;
use crate::{
AppState,
db,
leaderboard,
};
pub async fn index(
State(state): State<AppState>,
jar: CookieJar,
) -> Html<String> {
let groups =
leaderboard::build(&state.db)
.await
.unwrap_or_default();
let current_user =
match jar.get("county_session") {
Some(cookie) =>
db::athlete_for_session(
&state.db,
cookie.value(),
)
.await
.ok()
.flatten()
.map(|(_, name)| name),
None => None,
};
let mut html = String::from(
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>
<style>
body {
font-family: system-ui, sans-serif;
max-width: 1100px;
margin: 0 auto;
padding: 1rem;
background: #111827;
color: #f9fafb;
}
a { color: #93c5fd; }
header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 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;
}
.rank {
font-weight: bold;
}
.time {
font-family: ui-monospace, monospace;
}
.small {
color: #9ca3af;
font-size: .9rem;
}
button, .button {
background: #2563eb;
color: white;
border: 0;
border-radius: 8px;
padding: .6rem .9rem;
text-decoration: none;
}
</style>
</head>
<body>
<header>
<div>
<h1>🚴 Landkreis-Sprints</h1>
<div class="small">
Grenzübertritte · Millisekunden · Intervals.icu
</div>
</div>
"#,
);
if let Some(name) = current_user {
html.push_str(&format!(
"<div>Angemeldet als <strong>{}</strong> · \
<a class=\"button\" href=\"/logout\">Logout</a></div>",
escape_html(&name)
));
} else {
html.push_str(
r#"<a class="button" href="/oauth/start">
Mit Intervals.icu verbinden
</a>"#
);
}
html.push_str("</header>");
if groups.is_empty() {
html.push_str(
r#"<div class="card">
Noch keine Landkreisüberquerungen.
</div>"#
);
}
for group in groups {
html.push_str("<div class=\"card\">");
html.push_str(&format!(
"<h2>{} → {}</h2>",
escape_html(&group.from_county),
escape_html(&group.to_county)
));
html.push_str(&format!(
"<div class=\"small\">10-Minuten-Fenster ab {}</div>",
group.bucket_start.format("%Y-%m-%d %H:%M:%S UTC")
));
html.push_str(
r#"<table>
<thead>
<tr>
<th>#</th>
<th>Fahrer</th>
<th>Übertritt</th>
<th>Intervall</th>
<th>Power</th>
<th>HR</th>
</tr>
</thead>
<tbody>"#
);
for row in group.rows {
let interval =
match row.interval_url {
Some(url) =>
format!(
"<a href=\"{}\" target=\"_blank\">Intervall</a>",
escape_html(&url)
),
None =>
"<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">{}</td>
<td>{}</td>
<td>{}</td>
<td>{}</td>
</tr>"#,
row.rank,
escape_html(&row.athlete),
row.crossing_time.format("%H:%M:%S%.3f"),
interval,
power,
hr
));
}
html.push_str("</tbody></table>");
html.push_str("</div>");
}
html.push_str(
r#"<footer class="small">
Datenquelle: Intervals.icu · Landkreisgeometrien: VG250
</footer>
</body>
</html>"#
);
Html(html)
}
fn escape_html(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}