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, } #[derive(Debug, Deserialize)] pub struct GroupForm { pub group_id: Option, pub name: String, pub members: Option>, } #[derive(Debug, Deserialize)] pub struct DeleteGroupForm { pub group_id: i64, } pub async fn index( State(state): State, jar: CookieJar, Query(query): Query, ) -> Html { 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, 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, jar: CookieJar, Path(activity_id): Path, Query(query): Query, ) -> Result, 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> = activity.try_get("start_time").map_err(internal)?; let activity_json: Option = 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, jar: CookieJar, ) -> Result, 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)> = 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 = 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, jar: CookieJar, Form(form): Form, ) -> Result { 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, jar: CookieJar, Form(form): Form, ) -> Result { 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, 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, available_groups: &[(i64, String)], ) -> Html { let mut html = String::from(BASE_HEAD); html.push_str( r#"

🚮 Landkreis-Sprints

GrenzĂŒbertritte · Millisekunden · Intervals.icu
"#, ); if let Some((_, name)) = current { html.push_str(&format!( r#"
Angemeldet als {}
Gruppen Logout
"#, html::escape(name) )); } else { html.push_str( r#""#, ); } html.push_str("
"); if !available_groups.is_empty() { html.push_str( r#"
"); } if groups.is_empty() { html.push_str( r#"
LandkreisĂŒbertritte werden geladen 

"#, ); } html.push_str(r#"
"#); for group in groups { html.push_str("
"); html.push_str(&format!( "

{} → {}

", html::escape(&group.from_county), html::escape(&group.to_county) )); html.push_str(&format!( "
10-Minuten-Fenster · {} · Standort ±10 m
", group.bucket_start.to_rfc3339() )); html.push_str(&format!( r#"
"#, group.crossing_lat, group.crossing_lon )); html.push_str( r#""#, ); for row in &group.rows { let interval = row .interval_url .as_ref() .map(|url| { format!( "Intervall", html::escape(url) ) }) .unwrap_or_else(|| "—".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#""#, 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("
#FahrerÜbertrittAktivitĂ€t IntervallPowerHRKarte
{} {} {} {} {}{}{}
"); } html.push_str(BASE_FOOT); Html(html) } fn render_activity( activity_id: &str, athlete_name: String, start_time: Option>, activity_json: Option<&Value>, crossings: &[ActivityCrossing], group_id: Option, ) -> 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#"
← Leaderboard

{}

{} · {}

{}

Athlet: {} · AktivitÀt: {}
"#, 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#"
Start: {}
"#, 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#"
"#, points_json )); if crossings.is_empty() { html.push_str("

Keine LandkreisĂŒbertritte in dieser AktivitĂ€t.

"); } else { html.push_str(r#""#); for (index, crossing) in crossings.iter().enumerate() { let interval = crossing .interval_url .as_ref() .map(|url| { format!( "Intervall", 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#""#, 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("
#ZeitLandkreisRangPowerHRIntervall
{} {} {} → {}{} {}{}{}
"); } if let Some(group_id) = group_id { html.push_str(&format!( "
Leaderboard-Gruppe #{}
", group_id )); } html.push_str("
"); html.push_str(BASE_FOOT); html } fn render_groups( owner_name: &str, groups: &[(i64, String, HashSet)], athletes: &[(i64, String)], ) -> String { let mut html = String::from(BASE_HEAD); html.push_str(&format!( r#"
← Leaderboard

Leaderboard-Gruppen

Gruppen werden fĂŒr {} verwaltet.
"#, html::escape(owner_name) )); html.push_str( r#"

Neue Gruppe

"#, ); for (id, name) in athletes { html.push_str(&format!( r#""#, id, html::escape(name) )); } html.push_str(r#"
"#); for (group_id, name, members) in groups { html.push_str(&format!( r#"
"#, group_id, html::escape(name) )); for (id, athlete_name) in athletes { let checked = if members.contains(id) { " checked" } else { "" }; html.push_str(&format!( r#""#, id, checked, html::escape(athlete_name) )); } html.push_str(&format!( r#"
"#, group_id )); } html.push_str(BASE_FOOT); html } const BASE_HEAD: &str = r#" Landkreis-Sprints "#; const BASE_FOOT: &str = r#"
Datenquelle: Intervals.icu · Landkreisgeometrien: VG250 · Karten: OpenStreetMap
"#;