From 3cfd5bdd6c89ee3d69cc01c8f60e44a79abaf1d7 Mon Sep 17 00:00:00 2001 From: Jonas Rabenstein Date: Wed, 12 Aug 2026 17:57:25 +0200 Subject: [PATCH] add map and clicks --- README.md | 92 +++-- migrations/0001_initial.sql | 74 ++-- src/db.rs | 45 +- src/intervals.rs | 351 ++++------------ src/leaderboard.rs | 263 +++++++++--- src/main.rs | 514 +++++------------------ src/model.rs | 46 ++- src/web.rs | 798 ++++++++++++++++++++++++++++++------ 8 files changed, 1193 insertions(+), 990 deletions(-) diff --git a/README.md b/README.md index dd9f37b..72f96ab 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,55 @@ -# Landkreis-Sprints +# County Sprints frontend/leaderboard update -Automatische Auswertung von Landkreis-Sprints bei gemeinsamen Radausfahrten. +Replace these files in the project: -## Architektur +- `src/main.rs` +- `src/model.rs` +- `src/db.rs` +- `src/leaderboard.rs` +- `src/web.rs` +- `migrations/0001_initial.sql` -```text -Garmin / Wahoo / ... - | - v - Intervals.icu - | - | ACTIVITY_ANALYZED - v - /webhooks/intervals - | - v - Rust - | - +---- GET activity?intervals=true - | - +---- GET streams.json - | time + latlng - | - v - PostgreSQL - + PostGIS - | - v - Landkreis-Grenze - | - v - crossing_time - millisecond - | - v - icu_interval - | - v - 10-Minuten-Bucket - | - v - Leaderboard +No change is required to the working `src/intervals.rs`. + +The existing `src/webhook.rs` can keep calling: + +```rust +crate::process_activity(state_clone, athlete_id, activity_id.clone()).await +``` + +`process_activity()` now loads the athlete's OAuth access token itself. The development sync uses the explicit API-key client, so the personal development key is not written to the database. + +## Database + +The migration is intentionally a complete replacement because the database can be reset during development. + +The new schema adds: + +- `leaderboard_groups` +- `leaderboard_group_members` +- indexes for activity/crossing queries + +## Leaderboard location matching + +Leaderboard rows are grouped by: + +- 10-minute time bucket +- source county +- destination county +- spatial cluster with a 10 metre DBSCAN radius + +The crossing geometry is transformed to EPSG:3857 before the 10 metre clustering calculation so the distance is measured in metres rather than degrees. + +## Frontend + +The frontend now provides: + +- Leaflet map for each leaderboard group +- small map for every individual crossing +- activity detail page at `/activity/{activity_id}` +- activity map containing all crossings +- browser-local timestamp formatting using `Intl.DateTimeFormat` +- leaderboard group management at `/groups` +- group selection on the main leaderboard + +The map uses Leaflet and OpenStreetMap tiles. Leaflet's current stable 1.x documentation describes the same map/tile-layer APIs used here. citeturn0search2turn0search9 diff --git a/migrations/0001_initial.sql b/migrations/0001_initial.sql index 33df15d..afce885 100644 --- a/migrations/0001_initial.sql +++ b/migrations/0001_initial.sql @@ -2,13 +2,10 @@ CREATE EXTENSION IF NOT EXISTS postgis; CREATE TABLE athletes ( id BIGSERIAL PRIMARY KEY, - intervals_athlete_id TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, - access_token TEXT NOT NULL, - scopes TEXT NOT NULL, - + scopes TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); @@ -20,73 +17,53 @@ CREATE TABLE oauth_states ( CREATE TABLE sessions ( token_hash BYTEA PRIMARY KEY, - athlete_id BIGINT NOT NULL REFERENCES athletes(id) ON DELETE CASCADE, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE INDEX sessions_athlete_idx - ON sessions (athlete_id); +CREATE INDEX sessions_athlete_idx ON sessions (athlete_id); CREATE TABLE activities ( id BIGSERIAL PRIMARY KEY, - athlete_id BIGINT NOT NULL REFERENCES athletes(id) ON DELETE CASCADE, - intervals_activity_id TEXT NOT NULL, - start_time TIMESTAMPTZ, - processed_at TIMESTAMPTZ, - activity_json JSONB, - + processed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - UNIQUE (athlete_id, intervals_activity_id) ); -CREATE INDEX activities_start_time_idx - ON activities(start_time); +CREATE INDEX activities_start_time_idx ON activities(start_time); +CREATE INDEX activities_athlete_idx ON activities(athlete_id); CREATE TABLE county_boundaries ( id BIGSERIAL PRIMARY KEY, - name TEXT NOT NULL, - geometry geometry(MultiPolygon, 4326) NOT NULL ); CREATE INDEX county_boundaries_geometry_idx - ON county_boundaries - USING GIST (geometry); + ON county_boundaries USING GIST (geometry); CREATE TABLE county_crossings ( id BIGSERIAL PRIMARY KEY, - activity_id BIGINT NOT NULL REFERENCES activities(id) ON DELETE CASCADE, - track_index BIGINT NOT NULL, - crossing_time TIMESTAMPTZ(3) NOT NULL, - location geometry(Point, 4326) NOT NULL, - from_county_id BIGINT NOT NULL REFERENCES county_boundaries(id), - to_county_id BIGINT NOT NULL REFERENCES county_boundaries(id), - intervals_interval JSONB, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); @@ -94,12 +71,37 @@ CREATE INDEX county_crossings_time_idx ON county_crossings(crossing_time); CREATE INDEX county_crossings_location_idx - ON county_crossings - USING GIST (location); + ON county_crossings USING GIST (location); CREATE INDEX county_crossings_direction_time_idx - ON county_crossings( - from_county_id, - to_county_id, - crossing_time - ); + ON county_crossings(from_county_id, to_county_id, crossing_time); + +CREATE INDEX county_crossings_activity_idx + ON county_crossings(activity_id); + +CREATE TABLE leaderboard_groups ( + id BIGSERIAL PRIMARY KEY, + owner_athlete_id BIGINT NOT NULL + REFERENCES athletes(id) + ON DELETE CASCADE, + name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (owner_athlete_id, name) +); + +CREATE INDEX leaderboard_groups_owner_idx + ON leaderboard_groups(owner_athlete_id); + +CREATE TABLE leaderboard_group_members ( + group_id BIGINT NOT NULL + REFERENCES leaderboard_groups(id) + ON DELETE CASCADE, + athlete_id BIGINT NOT NULL + REFERENCES athletes(id) + ON DELETE CASCADE, + PRIMARY KEY (group_id, athlete_id) +); + +CREATE INDEX leaderboard_group_members_athlete_idx + ON leaderboard_group_members(athlete_id); diff --git a/src/db.rs b/src/db.rs index d1723ab..ae18974 100644 --- a/src/db.rs +++ b/src/db.rs @@ -8,9 +8,7 @@ use crate::model::DetectedCrossing; pub fn hash_token(token: &str) -> Vec { let mut hasher = Sha256::new(); - hasher.update(token.as_bytes()); - hasher.finalize().to_vec() } @@ -19,10 +17,7 @@ pub async fn create_session(db: &PgPool, athlete_id: i64, token: &str) -> Result sqlx::query( r#" - INSERT INTO sessions ( - token_hash, - athlete_id - ) + INSERT INTO sessions (token_hash, athlete_id) VALUES ($1, $2) "#, ) @@ -39,12 +34,9 @@ pub async fn athlete_for_session(db: &PgPool, token: &str) -> Result Result Result<()> { + let hash = hash_token(token); + + sqlx::query("DELETE FROM sessions WHERE token_hash = $1") + .bind(&hash) + .execute(db) + .await?; + + Ok(()) +} diff --git a/src/intervals.rs b/src/intervals.rs index 44083c4..782cf17 100644 --- a/src/intervals.rs +++ b/src/intervals.rs @@ -1,4 +1,4 @@ -use anyhow::{anyhow, Context, Result}; +use anyhow::{Context, Result, anyhow}; use chrono::{DateTime, NaiveDateTime, Utc}; use reqwest::Client; use serde_json::Value; @@ -26,10 +26,7 @@ impl IntervalsClient { /// Create a client using an explicitly supplied personal API key. /// /// Primarily used by /dev/sync/{api_key}/{athlete_id}. - pub fn with_api_key( - config: Config, - api_key: impl Into, - ) -> Self { + pub fn with_api_key(config: Config, api_key: impl Into) -> Self { Self { client: Client::new(), config, @@ -38,17 +35,13 @@ impl IntervalsClient { } fn base_url(&self) -> &str { - self.config - .intervals_base_url - .trim_end_matches('/') + self.config.intervals_base_url.trim_end_matches('/') } fn api_key(&self) -> Result<&str> { self.api_key .as_deref() - .context( - "Intervals.icu API key is not configured", - ) + .context("Intervals.icu API key is not configured") } fn api_url(&self, path: &str) -> String { @@ -59,54 +52,30 @@ impl IntervalsClient { ) } - fn authenticated( - &self, - request: reqwest::RequestBuilder, - ) -> Result { - Ok( - request.basic_auth( - "API_KEY", - Some(self.api_key()?), - ) - ) + fn authenticated(&self, request: reqwest::RequestBuilder) -> Result { + Ok(request.basic_auth("API_KEY", Some(self.api_key()?))) } - async fn get_json( - &self, - url: String, - ) -> Result { + async fn get_json(&self, url: String) -> Result { let response = self .authenticated(self.client.get(&url))? .send() .await - .context( - "Intervals.icu request failed", - )?; + .context("Intervals.icu request failed")?; let status = response.status(); let body = response .text() .await - .context( - "cannot read Intervals.icu response", - )?; + .context("cannot read Intervals.icu response")?; if !status.is_success() { - return Err(anyhow!( - "Intervals.icu returned HTTP {}: {}", - status, - body - )); + return Err(anyhow!("Intervals.icu returned HTTP {}: {}", status, body)); } serde_json::from_str(&body) - .with_context(|| { - format!( - "invalid JSON returned by Intervals.icu: {}", - body - ) - }) + .with_context(|| format!("invalid JSON returned by Intervals.icu: {}", body)) } /// Get the athlete belonging to the configured API key. @@ -119,13 +88,8 @@ impl IntervalsClient { } /// Get a specific athlete. - pub async fn athlete( - &self, - athlete_id: &str, - ) -> Result { - let url = self.api_url( - &format!("athlete/{athlete_id}"), - ); + pub async fn athlete(&self, athlete_id: &str) -> Result { + let url = self.api_url(&format!("athlete/{athlete_id}")); self.get_json(url).await } @@ -133,20 +97,13 @@ impl IntervalsClient { /// Get a single activity. /// /// GET /api/v1/activity/{id} - pub async fn activity( - &self, - activity_id: &str, - ) -> Result { + pub async fn activity(&self, activity_id: &str) -> Result { /* * Ask Intervals.icu to include interval information, * which is later used to associate county crossings * with intervals. */ - let url = self.api_url( - &format!( - "activity/{activity_id}?intervals=true" - ), - ); + let url = self.api_url(&format!("activity/{activity_id}?intervals=true")); self.get_json(url).await } @@ -157,43 +114,27 @@ impl IntervalsClient { /// /// We explicitly request the streams needed for GPS /// processing. - pub async fn streams( - &self, - activity_id: &str, - ) -> Result { - let url = self.api_url( - &format!( - "activity/{activity_id}/streams.json?types=time,latlng" - ), - ); + pub async fn streams(&self, activity_id: &str) -> Result { + let url = self.api_url(&format!( + "activity/{activity_id}/streams.json?types=time,latlng" + )); tracing::info!( activity_id = %activity_id, "requesting Intervals.icu streams" ); - let value = - self.get_json(url).await?; + let value = self.get_json(url).await?; - let stream_count = value - .as_array() - .map(|streams| streams.len()) - .unwrap_or(0); + let stream_count = value.as_array().map(|streams| streams.len()).unwrap_or(0); - let time_points = - Self::stream_values( - &value, - "time", - ) + let time_points = Self::stream_values(&value, "time") .map(|values| values.len()) .unwrap_or(0); - let (latitude_points, longitude_points) = - Self::latlng_values(&value) - .map(|(lat, lon)| { - (lat.len(), lon.len()) - }) - .unwrap_or((0, 0)); + let (latitude_points, longitude_points) = Self::latlng_values(&value) + .map(|(lat, lon)| (lat.len(), lon.len())) + .unwrap_or((0, 0)); tracing::info!( activity_id = %activity_id, @@ -224,21 +165,11 @@ impl IntervalsClient { /// ] /// /// Also tolerates a dictionary-like response. - pub fn find_stream<'a>( - streams: &'a Value, - stream_type: &str, - ) -> Option<&'a Value> { - if let Some(array) = - streams.as_array() - { - return array.iter().find( - |stream| { - stream - .get("type") - .and_then(Value::as_str) - == Some(stream_type) - }, - ); + pub fn find_stream<'a>(streams: &'a Value, stream_type: &str) -> Option<&'a Value> { + if let Some(array) = streams.as_array() { + return array + .iter() + .find(|stream| stream.get("type").and_then(Value::as_str) == Some(stream_type)); } streams.get(stream_type) @@ -252,19 +183,10 @@ impl IntervalsClient { /// "type": "time", /// "data": [0, 1, 2, 3] /// } - pub fn stream_values<'a>( - streams: &'a Value, - stream_type: &str, - ) -> Option<&'a Vec> { - let stream = - Self::find_stream( - streams, - stream_type, - )?; + pub fn stream_values<'a>(streams: &'a Value, stream_type: &str) -> Option<&'a Vec> { + let stream = Self::find_stream(streams, stream_type)?; - stream - .get("data") - .and_then(Value::as_array) + stream.get("data").and_then(Value::as_array) } /// Get latitude and longitude arrays from the @@ -286,49 +208,24 @@ impl IntervalsClient { /// It is NOT: /// /// data = [[lat, lon], [lat, lon], ...] - pub fn latlng_values<'a>( - streams: &'a Value, - ) -> Option<( - &'a Vec, - &'a Vec, - )> { - let stream = - Self::find_stream( - streams, - "latlng", - )?; + pub fn latlng_values<'a>(streams: &'a Value) -> Option<(&'a Vec, &'a Vec)> { + let stream = Self::find_stream(streams, "latlng")?; - let latitudes = stream - .get("data") - .and_then(Value::as_array)?; + let latitudes = stream.get("data").and_then(Value::as_array)?; - let longitudes = stream - .get("data2") - .and_then(Value::as_array)?; + let longitudes = stream.get("data2").and_then(Value::as_array)?; - Some(( - latitudes, - longitudes, - )) + Some((latitudes, longitudes)) } /// Fetch an athlete's activities. /// /// `athlete_id = "0"` means the athlete belonging to /// the API key. - pub async fn activities( - &self, - athlete_id: &str, - oldest: &str, - newest: &str, - ) -> Result { + pub async fn activities(&self, athlete_id: &str, oldest: &str, newest: &str) -> Result { let url = format!( "{}?oldest={}&newest={}", - self.api_url( - &format!( - "athlete/{athlete_id}/activities" - ), - ), + self.api_url(&format!("athlete/{athlete_id}/activities"),), urlencoding::encode(oldest), urlencoding::encode(newest), ); @@ -337,68 +234,39 @@ impl IntervalsClient { } /// Extract the activity start timestamp. - pub fn activity_start( - activity: &Value, - ) -> Result> { + pub fn activity_start(activity: &Value) -> Result> { let value = activity .get("start_date") - .or_else(|| { - activity.get("start_date_local") - }) - .or_else(|| { - activity.get("start_time") - }) - .ok_or_else(|| { - anyhow!( - "activity has no start timestamp" - ) - })?; + .or_else(|| activity.get("start_date_local")) + .or_else(|| activity.get("start_time")) + .ok_or_else(|| anyhow!("activity has no start timestamp"))?; let string = value .as_str() - .ok_or_else(|| { - anyhow!( - "activity start timestamp is not a string" - ) - })?; + .ok_or_else(|| anyhow!("activity start timestamp is not a string"))?; parse_datetime(string) } /// Try to find an Intervals.icu interval corresponding /// to a GPS stream index. - pub fn matching_interval( - activity: &Value, - track_index: usize, - ) -> Option { - let intervals = - activity.get("icu_intervals")?; + pub fn matching_interval(activity: &Value, track_index: usize) -> Option { + let intervals = activity.get("icu_intervals")?; - let array = - intervals.as_array()?; + let array = intervals.as_array()?; for interval in array { let start = interval .get("start_index") - .or_else(|| { - interval.get("start") - }); + .or_else(|| interval.get("start")); - let end = interval - .get("end_index") - .or_else(|| { - interval.get("end") - }); + let end = interval.get("end_index").or_else(|| interval.get("end")); - let start = - start.and_then(Value::as_u64)?; + let start = start.and_then(Value::as_u64)?; - let end = - end.and_then(Value::as_u64)?; + let end = end.and_then(Value::as_u64)?; - if (start as usize) <= track_index - && track_index <= end as usize - { + if (start as usize) <= track_index && track_index <= end as usize { return Some(interval.clone()); } } @@ -407,25 +275,18 @@ impl IntervalsClient { } /// OAuth methods retained for the normal multi-user flow. - pub fn oauth_authorize_url( - &self, - state: &str, - ) -> Result { + pub fn oauth_authorize_url(&self, state: &str) -> Result { let client_id = self .config .intervals_client_id .as_deref() - .context( - "INTERVALS_CLIENT_ID is not configured", - )?; + .context("INTERVALS_CLIENT_ID is not configured")?; let redirect_uri = self .config .intervals_redirect_uri .as_deref() - .context( - "INTERVALS_REDIRECT_URI is not configured", - )?; + .context("INTERVALS_REDIRECT_URI is not configured")?; Ok(format!( "{}/oauth/authorize?client_id={}&redirect_uri={}&scope={}&state={}", @@ -437,32 +298,22 @@ impl IntervalsClient { )) } - pub async fn exchange_code( - &self, - code: &str, - ) -> Result { + pub async fn exchange_code(&self, code: &str) -> Result { let client_id = self .config .intervals_client_id .as_deref() - .context( - "INTERVALS_CLIENT_ID is not configured", - )?; + .context("INTERVALS_CLIENT_ID is not configured")?; let client_secret = self .config .intervals_client_secret .as_deref() - .context( - "INTERVALS_CLIENT_SECRET is not configured", - )?; + .context("INTERVALS_CLIENT_SECRET is not configured")?; let response = self .client - .post(format!( - "{}/api/oauth/token", - self.base_url() - )) + .post(format!("{}/api/oauth/token", self.base_url())) .form(&[ ("client_id", client_id), ("client_secret", client_secret), @@ -470,15 +321,11 @@ impl IntervalsClient { ]) .send() .await - .context( - "OAuth token request failed", - )?; + .context("OAuth token request failed")?; let status = response.status(); - let body = response - .text() - .await?; + let body = response.text().await?; if !status.is_success() { return Err(anyhow!( @@ -491,20 +338,11 @@ impl IntervalsClient { Ok(serde_json::from_str(&body)?) } - pub fn token_data( - response: &Value, - ) -> Result<( - String, - String, - String, - String, - )> { + pub fn token_data(response: &Value) -> Result<(String, String, String, String)> { let access_token = response .get("access_token") .and_then(Value::as_str) - .context( - "OAuth response has no access_token", - )? + .context("OAuth response has no access_token")? .to_string(); let scope = response @@ -515,9 +353,7 @@ impl IntervalsClient { let athlete = response .get("athlete") - .context( - "OAuth response has no athlete", - )?; + .context("OAuth response has no athlete")?; let athlete_id = athlete .get("id") @@ -533,62 +369,27 @@ impl IntervalsClient { let display_name = athlete .get("name") - .or_else(|| { - athlete.get("display_name") - }) + .or_else(|| athlete.get("display_name")) .and_then(Value::as_str) - .unwrap_or( - "Intervals.icu athlete", - ) + .unwrap_or("Intervals.icu athlete") .to_string(); - Ok(( - access_token, - scope, - athlete_id, - display_name, - )) + Ok((access_token, scope, athlete_id, display_name)) } } -fn parse_datetime( - value: &str, -) -> Result> { - if let Ok(value) = - DateTime::parse_from_rfc3339(value) - { - return Ok( - value.with_timezone(&Utc) - ); +fn parse_datetime(value: &str) -> Result> { + if let Ok(value) = DateTime::parse_from_rfc3339(value) { + return Ok(value.with_timezone(&Utc)); } - if let Ok(value) = - DateTime::parse_from_str( - value, - "%Y-%m-%dT%H:%M:%S%z", - ) - { - return Ok( - value.with_timezone(&Utc) - ); + if let Ok(value) = DateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S%z") { + return Ok(value.with_timezone(&Utc)); } - if let Ok(value) = - NaiveDateTime::parse_from_str( - value, - "%Y-%m-%dT%H:%M:%S", - ) - { - return Ok( - DateTime::::from_naive_utc_and_offset( - value, - Utc, - ) - ); + if let Ok(value) = NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S") { + return Ok(DateTime::::from_naive_utc_and_offset(value, Utc)); } - Err(anyhow!( - "cannot parse activity timestamp: {}", - value - )) + Err(anyhow!("cannot parse activity timestamp: {}", value)) } diff --git a/src/leaderboard.rs b/src/leaderboard.rs index b06de6c..596d3fa 100644 --- a/src/leaderboard.rs +++ b/src/leaderboard.rs @@ -1,78 +1,100 @@ use anyhow::Result; -use axum::{Json, extract::State}; +use axum::{ + Json, + extract::{Query, State}, +}; use chrono::{DateTime, Utc}; +use serde::Deserialize; use serde_json::Value; use sqlx::PgPool; use crate::{ AppState, - model::{LeaderboardGroup, LeaderboardRow}, + model::{ActivityCrossing, LeaderboardGroup, LeaderboardRow}, }; +#[derive(Debug, Deserialize, Default)] +pub struct LeaderboardQuery { + pub group_id: Option, +} + pub async fn api( State(state): State, + Query(query): Query, ) -> Result>, (axum::http::StatusCode, String)> { - build(&state.db).await.map(Json).map_err(|error| { - tracing::error!(%error, "leaderboard query failed"); - - ( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - error.to_string(), - ) - }) + build(&state.db, query.group_id) + .await + .map(Json) + .map_err(|error| { + tracing::error!(%error, "leaderboard query failed"); + ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + error.to_string(), + ) + }) } -pub async fn build(db: &PgPool) -> Result> { +pub async fn build(db: &PgPool, group_id: Option) -> Result> { let rows = sqlx::query( r#" WITH bucketed AS ( SELECT cc.id, cc.crossing_time, - cc.track_index, + cc.location, cc.intervals_interval, - a.intervals_activity_id, + ath.id AS athlete_id, ath.display_name, - f.name AS from_county, t.name AS to_county, - + cc.from_county_id, + cc.to_county_id, to_timestamp( - floor( - extract(epoch FROM cc.crossing_time) - / 600.0 - ) * 600 + floor(extract(epoch FROM cc.crossing_time) / 600.0) * 600 ) AS bucket_start - FROM county_crossings cc - - JOIN activities a - ON a.id = cc.activity_id - - JOIN athletes ath - ON ath.id = a.athlete_id - - JOIN county_boundaries f - ON f.id = cc.from_county_id - - JOIN county_boundaries t - ON t.id = cc.to_county_id + JOIN activities a ON a.id = cc.activity_id + JOIN athletes ath ON ath.id = a.athlete_id + JOIN county_boundaries f ON f.id = cc.from_county_id + JOIN county_boundaries t ON t.id = cc.to_county_id + WHERE ( + $1::BIGINT IS NULL + OR ath.id IN ( + SELECT athlete_id + FROM leaderboard_group_members + WHERE group_id = $1 + ) + ) + ), + clustered AS ( + SELECT + *, + ST_ClusterDBSCAN( + ST_Transform(location, 3857), + 10.0, + 1 + ) OVER ( + PARTITION BY + bucket_start, + from_county_id, + to_county_id + ) AS location_cluster + FROM bucketed ), - ranked AS ( SELECT *, row_number() OVER ( PARTITION BY bucket_start, - from_county, - to_county + from_county_id, + to_county_id, + location_cluster ORDER BY crossing_time, id ) AS rank - FROM bucketed + FROM clustered ) - SELECT rank, display_name, @@ -81,19 +103,20 @@ pub async fn build(db: &PgPool) -> Result> { bucket_start, crossing_time, intervals_activity_id, - intervals_interval - + intervals_interval, + ST_Y(location) AS lat, + ST_X(location) AS lon FROM ranked - WHERE rank <= 20 - ORDER BY bucket_start DESC, from_county, to_county, + crossing_time, rank "#, ) + .bind(group_id) .fetch_all(db) .await?; @@ -103,9 +126,7 @@ pub async fn build(db: &PgPool) -> Result> { for row in rows { let bucket_start: DateTime = row.try_get("bucket_start")?; - let from_county: String = row.try_get("from_county")?; - let to_county: String = row.try_get("to_county")?; let group_index = groups.iter().position(|group| { @@ -116,28 +137,26 @@ pub async fn build(db: &PgPool) -> Result> { let index = match group_index { Some(index) => index, - None => { groups.push(LeaderboardGroup { bucket_start, from_county: from_county.clone(), to_county: to_county.clone(), + crossing_lat: row.try_get("lat")?, + crossing_lon: row.try_get("lon")?, rows: Vec::new(), }); - groups.len() - 1 } }; let rank: i64 = row.try_get("rank")?; - let athlete: String = row.try_get("display_name")?; - let crossing_time: DateTime = row.try_get("crossing_time")?; - let activity_id: String = row.try_get("intervals_activity_id")?; - let interval: Option = row.try_get("intervals_interval")?; + let lat: f64 = row.try_get("lat")?; + let lon: f64 = row.try_get("lon")?; let interval_id = interval .as_ref() @@ -154,32 +173,156 @@ pub async fn build(db: &PgPool) -> Result> { .and_then(|v| v.get("average_heartrate")) .and_then(Value::as_f64); - let row_data = LeaderboardRow { + groups[index].rows.push(LeaderboardRow { rank, athlete, - from_county, to_county, - crossing_time, - activity_id: activity_id.clone(), - - activity_url: format!("https://intervals.icu/activities/{}", activity_id), - + activity_url: format!("/activity/{}", activity_id), interval_url: interval_id.map(|id| { format!( "https://intervals.icu/activities/{}?interval={}", activity_id, id ) }), - average_watts, average_heartrate, - }; - - groups[index].rows.push(row_data); + lat, + lon, + }); } Ok(groups) } + +pub async fn activity_crossings( + db: &PgPool, + activity_id: &str, + group_id: Option, +) -> Result> { + let rows = sqlx::query( + r#" + WITH bucketed AS ( + SELECT + cc.id, + cc.crossing_time, + cc.location, + cc.intervals_interval, + a.intervals_activity_id, + ath.id AS athlete_id, + ath.display_name, + f.name AS from_county, + t.name AS to_county, + cc.from_county_id, + cc.to_county_id, + to_timestamp( + floor(extract(epoch FROM cc.crossing_time) / 600.0) * 600 + ) AS bucket_start + FROM county_crossings cc + JOIN activities a ON a.id = cc.activity_id + JOIN athletes ath ON ath.id = a.athlete_id + JOIN county_boundaries f ON f.id = cc.from_county_id + JOIN county_boundaries t ON t.id = cc.to_county_id + WHERE a.intervals_activity_id = $1 + AND ( + $2::BIGINT IS NULL + OR ath.id IN ( + SELECT athlete_id + FROM leaderboard_group_members + WHERE group_id = $2 + ) + ) + ), + clustered AS ( + SELECT + *, + ST_ClusterDBSCAN( + ST_Transform(location, 3857), + 10.0, + 1 + ) OVER ( + PARTITION BY + bucket_start, + from_county_id, + to_county_id + ) AS location_cluster + FROM bucketed + ), + ranked AS ( + SELECT + *, + row_number() OVER ( + PARTITION BY + bucket_start, + from_county_id, + to_county_id, + location_cluster + ORDER BY crossing_time, id + ) AS rank + FROM clustered + ) + SELECT + crossing_time, + from_county, + to_county, + ST_Y(location) AS lat, + ST_X(location) AS lon, + rank, + intervals_activity_id, + display_name, + intervals_interval + FROM ranked + WHERE intervals_activity_id = $1 + ORDER BY crossing_time, id + "#, + ) + .bind(activity_id) + .bind(group_id) + .fetch_all(db) + .await?; + + use sqlx::Row; + + let mut result = Vec::with_capacity(rows.len()); + + for row in rows { + let interval: Option = row.try_get("intervals_interval")?; + let interval_id = interval + .as_ref() + .and_then(|v| v.get("id")) + .and_then(Value::as_i64); + + let average_watts = interval + .as_ref() + .and_then(|v| v.get("average_watts")) + .and_then(Value::as_f64); + + let average_heartrate = interval + .as_ref() + .and_then(|v| v.get("average_heartrate")) + .and_then(Value::as_f64); + + result.push(ActivityCrossing { + crossing_time: row.try_get("crossing_time")?, + from_county: row.try_get("from_county")?, + to_county: row.try_get("to_county")?, + lat: row.try_get("lat")?, + lon: row.try_get("lon")?, + rank: row.try_get("rank")?, + activity_id: row.try_get("intervals_activity_id")?, + athlete: row.try_get("display_name")?, + interval_url: interval_id.map(|id| { + format!( + "https://intervals.icu/activities/{}?interval={}", + activity_id, id + ) + }), + average_watts, + average_heartrate, + }); + } + + Ok(result) +} diff --git a/src/main.rs b/src/main.rs index 434639b..9fefc82 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,24 +8,20 @@ mod model; mod web; mod webhook; -use anyhow::{anyhow, Context, Result}; +use anyhow::{Context, Result, anyhow}; use axum::{ + Router, extract::{Path, State}, response::Json, routing::{get, post}, - Router, }; use chrono::{DateTime, Duration, Utc}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use sqlx::PgPool; use tower_http::trace::TraceLayer; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; -use crate::{ - config::Config, - intervals::IntervalsClient, - model::TrackPoint, -}; +use crate::{config::Config, intervals::IntervalsClient, model::TrackPoint}; #[derive(Clone)] pub struct AppState { @@ -69,10 +65,9 @@ async fn main() -> Result<()> { .route("/logout", get(auth::logout)) .route("/webhooks/intervals", post(webhook::receive)) .route("/api/leaderboard", get(leaderboard::api)) - // Development-only activity synchronisation. - // - // /dev/sync/{api_key} - // /dev/sync/{api_key}/{athlete_id} + .route("/activity/{activity_id}", get(web::activity)) + .route("/groups", get(web::groups).post(web::save_group)) + .route("/groups/delete", post(web::delete_group)) .route("/dev/sync/{api_key}", get(dev_sync_one)) .route("/dev/sync/{api_key}/{athlete_id}", get(dev_sync)) .layer(TraceLayer::new_for_http()) @@ -86,7 +81,6 @@ async fn main() -> Result<()> { ); axum::serve(listener, app).await?; - Ok(()) } @@ -100,19 +94,9 @@ fn redact_database_url(url: &str) -> String { return format!("{prefix}@***"); } } - url.to_string() } -/// Development endpoint for importing/analyzing activities. -/// -/// Examples: -/// -/// GET /dev/sync/MY_API_KEY -/// GET /dev/sync/MY_API_KEY/0 -/// GET /dev/sync/MY_API_KEY/i123456 -/// -/// `0` means the owner of the supplied API key. async fn dev_sync( State(state): State, Path((api_key, athlete_id)): Path<(String, String)>, @@ -120,23 +104,12 @@ async fn dev_sync( match dev_sync_inner(state, api_key, athlete_id).await { Ok(result) => Ok(Json(result)), Err(error) => { - tracing::error!( - error = %error, - "development sync failed" - ); - + tracing::error!(error = %error, "development sync failed"); Err(axum::http::StatusCode::INTERNAL_SERVER_ERROR) } } } -/// One-segment variant: -/// -/// GET /dev/sync/{api_key} -/// -/// Equivalent to: -/// -/// GET /dev/sync/{api_key}/0 async fn dev_sync_one( State(state): State, Path(api_key): Path, @@ -144,21 +117,13 @@ async fn dev_sync_one( match dev_sync_inner(state, api_key, "0".to_string()).await { Ok(result) => Ok(Json(result)), Err(error) => { - tracing::error!( - error = %error, - "development sync failed" - ); - + tracing::error!(error = %error, "development sync failed"); Err(axum::http::StatusCode::INTERNAL_SERVER_ERROR) } } } -async fn dev_sync_inner( - state: AppState, - api_key: String, - athlete_id: String, -) -> Result { +async fn dev_sync_inner(state: AppState, api_key: String, athlete_id: String) -> Result { if api_key.trim().is_empty() { return Err(anyhow!("API key is empty")); } @@ -169,24 +134,10 @@ async fn dev_sync_inner( athlete_id }; - /* - * This client is explicitly authenticated using the API key - * supplied to the development endpoint. - */ - let client = - IntervalsClient::with_api_key(state.config.clone(), api_key.clone()); + let client = IntervalsClient::with_api_key(state.config.clone(), api_key); - /* - * Resolve athlete 0 through Intervals.icu. - * - * Intervals.icu supports athlete/0 for personal API keys; - * it refers to the athlete belonging to that key. - */ let resolved_athlete_id = if athlete_id == "0" { - let owner = client - .owner() - .await - .context("cannot resolve API-key owner")?; + let owner = client.owner().await?; owner .get("id") @@ -204,8 +155,7 @@ async fn dev_sync_inner( }; let newest = Utc::now().date_naive(); - let oldest = newest - Duration::days(state.config.dev_sync_days); - + let oldest = newest - chrono::Duration::days(state.config.dev_sync_days); let oldest_string = oldest.format("%Y-%m-%d").to_string(); let newest_string = newest.format("%Y-%m-%d").to_string(); @@ -216,81 +166,47 @@ async fn dev_sync_inner( "starting development activity sync" ); - /* - * Use the resolved athlete ID here, not the original "0". - */ let activities = client - .activities( - &resolved_athlete_id, - &oldest_string, - &newest_string, - ) - .await - .context("cannot fetch development activities")?; + .activities(&athlete_id, &oldest_string, &newest_string) + .await?; let activities_array = activities .as_array() .context("Intervals.icu activities response is not an array")?; + ensure_dev_athlete(&state, &resolved_athlete_id, &client).await?; + let mut processed = 0usize; let mut skipped = 0usize; let mut failed = 0usize; - let mut results = Vec::new(); - /* - * Make sure there is a corresponding local athlete row. - * - * The development API key is stored as access_token so that - * process_activity() can use exactly the same credential. - */ - ensure_dev_athlete( - &state, - &resolved_athlete_id, - &client, - &api_key, - ) - .await?; - for activity in activities_array { - let Some(activity_id) = - activity.get("id").and_then(Value::as_str) - else { + let Some(activity_id) = activity.get("id").and_then(Value::as_str) else { skipped += 1; - - results.push(json!({ - "status": "skipped", - "reason": "activity has no id" - })); - + results.push(json!({"status": "skipped", "reason": "activity has no id"})); continue; }; - match process_activity( + match process_activity_with_client( state.clone(), resolved_athlete_id.clone(), activity_id.to_string(), + &client, ) .await { Ok(()) => { processed += 1; - - results.push(json!({ - "id": activity_id, - "status": "processed" - })); + results.push(json!({"id": activity_id, "status": "processed"})); } - Err(error) => { failed += 1; - tracing::error!( activity_id = %activity_id, error = %error, "development activity processing failed" ); - results.push(json!({ "id": activity_id, "status": "failed", @@ -321,27 +237,12 @@ async fn dev_sync_inner( })) } -/// Make sure the athlete used by /dev/sync exists locally. -/// -/// If the athlete already exists, update its development API -/// credential so process_activity() can use it. -/// -/// We deliberately do not print the API key. async fn ensure_dev_athlete( state: &AppState, intervals_athlete_id: &str, client: &IntervalsClient, - api_key: &str, ) -> Result<()> { - let athlete = client - .athlete(intervals_athlete_id) - .await - .with_context(|| { - format!( - "cannot fetch Intervals.icu athlete {}", - intervals_athlete_id - ) - })?; + let athlete = client.athlete(intervals_athlete_id).await?; let display_name = athlete .get("name") @@ -349,61 +250,34 @@ async fn ensure_dev_athlete( .and_then(Value::as_str) .unwrap_or("Intervals.icu athlete"); - /* - * If this athlete already exists, update the development - * credential. - */ - let existing = sqlx::query( + let exists = sqlx::query( r#" - SELECT id - FROM athletes - WHERE intervals_athlete_id = $1 + SELECT id FROM athletes WHERE intervals_athlete_id = $1 "#, ) .bind(intervals_athlete_id) .fetch_optional(&state.db) .await?; - if existing.is_some() { - sqlx::query( - r#" - UPDATE athletes - SET - access_token = $2, - display_name = $3, - updated_at = now() - WHERE intervals_athlete_id = $1 - "#, - ) - .bind(intervals_athlete_id) - .bind(api_key) - .bind(display_name) - .execute(&state.db) - .await - .context("cannot update local dev athlete")?; - + if exists.is_some() { return Ok(()); } - /* - * `scopes` is NOT NULL in the initial migration, so it must - * be supplied here. - */ sqlx::query( r#" INSERT INTO athletes ( intervals_athlete_id, - display_name, access_token, + display_name, scopes ) VALUES ($1, $2, $3, $4) "#, ) .bind(intervals_athlete_id) + .bind("") .bind(display_name) - .bind(api_key) - .bind("dev") + .bind("") .execute(&state.db) .await .context("cannot create local dev athlete")?; @@ -411,23 +285,54 @@ async fn ensure_dev_athlete( Ok(()) } -/// Process one activity. -/// -/// The API credential is retrieved from the local athlete record. -/// This is important for development sync because the API key -/// supplied to /dev/sync/{api_key} is stored in access_token. +/// Normal processing entry point used by the webhook path. +/// The OAuth access token is loaded from the local athlete row. pub async fn process_activity( state: AppState, intervals_athlete_id: String, activity_id: String, ) -> Result<()> { - use sqlx::Row; + let access_token: String = sqlx::query_scalar( + r#" + SELECT access_token + FROM athletes + WHERE intervals_athlete_id = $1 + "#, + ) + .bind(&intervals_athlete_id) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| { + anyhow!( + "no local athlete for Intervals.icu athlete {}", + intervals_athlete_id + ) + })?; + if access_token.trim().is_empty() { + return Err(anyhow!( + "local athlete {} has no access token", + intervals_athlete_id + )); + } + + let client = IntervalsClient::with_api_key(state.config.clone(), access_token); + + process_activity_with_client(state, intervals_athlete_id, activity_id, &client).await +} + +/// Same processing pipeline, but with an explicitly supplied Intervals client. +/// The development endpoint uses this so its personal API key is never +/// written into the database. +pub async fn process_activity_with_client( + state: AppState, + intervals_athlete_id: String, + activity_id: String, + client: &IntervalsClient, +) -> Result<()> { let athlete = sqlx::query( r#" - SELECT - id, - access_token + SELECT id FROM athletes WHERE intervals_athlete_id = $1 "#, @@ -436,6 +341,8 @@ pub async fn process_activity( .fetch_optional(&state.db) .await?; + use sqlx::Row; + let Some(athlete) = athlete else { return Err(anyhow!( "no local athlete for Intervals.icu athlete {}", @@ -445,116 +352,42 @@ pub async fn process_activity( let athlete_db_id: i64 = athlete.try_get("id")?; - let access_token: String = athlete - .try_get("access_token") - .context("local athlete has no access token")?; - - /* - * IMPORTANT: - * - * Use the athlete's stored credential rather than creating - * IntervalsClient::new(), which would use only the globally - * configured INTERVALS_API_KEY. - */ - let client = IntervalsClient::with_api_key( - state.config.clone(), - access_token, - ); - - /* - * Fetch activity metadata. - */ - let activity = client - .activity(&activity_id) - .await - .with_context(|| { - format!( - "cannot fetch Intervals.icu activity {}", - activity_id - ) - })?; - + let activity = client.activity(&activity_id).await?; let start_time = IntervalsClient::activity_start(&activity)?; - - /* - * Fetch GPS/time streams. - */ - let streams = client - .streams(&activity_id) - .await - .with_context(|| { - format!( - "cannot fetch streams for Intervals.icu activity {}", - activity_id - ) - })?; - + let streams = client.streams(&activity_id).await?; let points = parse_track(&streams, start_time)?; + tracing::debug!( + activity_id = %activity_id, + points = points.len(), + "parsed activity GPS points" + ); + if points.len() < 2 { tracing::info!( activity_id = %activity_id, points = points.len(), "activity has insufficient GPS data" ); - return Ok(()); } - /* - * Re-processing is idempotent: - * - * - update activity - * - remove existing crossings - * - calculate crossings - * - insert crossings - * - mark processed - * - * All changes happen inside one transaction. - */ let mut tx = state.db.begin().await?; let activity_db_id = - db::ensure_activity( - &mut tx, - athlete_db_id, - &activity_id, - start_time, - &activity, - ) - .await?; + db::ensure_activity(&mut tx, athlete_db_id, &activity_id, start_time, &activity).await?; - db::delete_activity_crossings( - &mut tx, - activity_db_id, - ) - .await?; + db::delete_activity_crossings(&mut tx, activity_db_id).await?; let mut crossing_count = 0usize; for pair in points.windows(2) { - let crossings = - geo::crossings_between( - &state.db, - &pair[0], - &pair[1], - ) - .await?; + let crossings = geo::crossings_between(&state.db, &pair[0], &pair[1]).await?; for crossing in crossings { - let interval = - IntervalsClient::matching_interval( - &activity, - crossing.track_index, - ); + let interval = IntervalsClient::matching_interval(&activity, crossing.track_index); - db::save_crossing( - &mut tx, - activity_db_id, - &crossing, - interval.as_ref(), - ) - .await?; + db::save_crossing(&mut tx, activity_db_id, &crossing, interval.as_ref()).await?; crossing_count += 1; @@ -564,22 +397,18 @@ pub async fn process_activity( from = crossing.from_county_id, to = crossing.to_county_id, track_index = crossing.track_index, + lat = crossing.lat, + lon = crossing.lon, "county crossing detected" ); } } - db::mark_activity_processed( - &mut tx, - activity_db_id, - ) - .await?; - + db::mark_activity_processed(&mut tx, activity_db_id).await?; tx.commit().await?; tracing::info!( activity_id = %activity_id, - points = points.len(), crossings = crossing_count, "activity processed" ); @@ -587,66 +416,14 @@ pub async fn process_activity( Ok(()) } -/// Parse Intervals.icu GPS streams. -/// -/// Intervals.icu returns the streams as an array similar to: -/// -/// [ -/// { -/// "type": "time", -/// "data": [0, 1, 2, 3] -/// }, -/// { -/// "type": "latlng", -/// "data": [51.5, 51.5001, 51.5002, 51.5003], -/// "data2": [-0.1, -0.1001, -0.1002, -0.1003] -/// } -/// ] -/// -/// IMPORTANT: -/// -/// `latlng.data` is latitude. -/// -/// `latlng.data2` is longitude. -/// -/// It is NOT: -/// -/// `latlng.data = [[lat, lon], [lat, lon], ...]` -fn parse_track( - streams: &Value, - start_time: DateTime, -) -> Result> { - let times = IntervalsClient::stream_values( - streams, - "time", - ) - .ok_or_else(|| { - anyhow!( - "Intervals.icu response has no time stream" - ) - })?; +fn parse_track(streams: &Value, start_time: DateTime) -> Result> { + let times = IntervalsClient::stream_values(streams, "time") + .ok_or_else(|| anyhow!("Intervals.icu response has no time stream"))?; - let (latitudes, longitudes) = - IntervalsClient::latlng_values(streams) - .ok_or_else(|| { - anyhow!( - "Intervals.icu response has no usable latlng stream" - ) - })?; - - let length = times - .len() - .min(latitudes.len()) - .min(longitudes.len()); - - tracing::info!( - time_points = times.len(), - latitude_points = latitudes.len(), - longitude_points = longitudes.len(), - usable_points = length, - "parsing Intervals.icu GPS streams" - ); + let latlng = IntervalsClient::stream_values(streams, "latlng") + .ok_or_else(|| anyhow!("Intervals.icu response has no latlng stream"))?; + let length = times.len().min(latlng.len()); let mut result = Vec::with_capacity(length); for index in 0..length { @@ -654,25 +431,25 @@ fn parse_track( continue; }; - let Some(lat) = latitudes[index].as_f64() else { + let Some(coordinate) = latlng[index].as_array() else { continue; }; - let Some(lon) = longitudes[index].as_f64() else { + if coordinate.len() != 2 { continue; + } + + let Some(lat) = coordinate[0].as_f64() else { + continue; + }; + let Some(lon) = coordinate[1].as_f64() else { + continue; }; - if !seconds.is_finite() - || !lat.is_finite() - || !lon.is_finite() - { + if !lat.is_finite() || !lon.is_finite() || !seconds.is_finite() { continue; } - /* - * Intervals.icu time values are seconds from the - * beginning of the activity. - */ let millis = (seconds * 1000.0).round() as i64; result.push(TrackPoint { @@ -683,106 +460,27 @@ fn parse_track( }); } - tracing::info!( - points = result.len(), - "finished parsing Intervals.icu GPS stream" - ); - Ok(result) } #[cfg(test)] mod tests { - use chrono::{TimeZone, Utc}; - use crate::geo::interpolate_time; + use chrono::{TimeZone, Utc}; #[test] fn interpolation_is_millisecond_precise() { let a = Utc.timestamp_millis_opt(1000).unwrap(); let b = Utc.timestamp_millis_opt(2000).unwrap(); - let result = interpolate_time(a, b, 0.376); - - assert_eq!( - result.timestamp_millis(), - 1376 - ); + assert_eq!(result.timestamp_millis(), 1376); } #[test] fn interpolation_rounds_to_nearest_ms() { let a = Utc.timestamp_millis_opt(1000).unwrap(); let b = Utc.timestamp_millis_opt(2000).unwrap(); - let result = interpolate_time(a, b, 0.3764); - - assert_eq!( - result.timestamp_millis(), - 1376 - ); - } - - #[test] - fn parses_intervals_latlng_data_and_data2() { - let streams = serde_json::json!([ - { - "type": "time", - "data": [0, 1, 2, 3] - }, - { - "type": "latlng", - "data": [ - 51.5000, - 51.5001, - 51.5002, - 51.5003 - ], - "data2": [ - -0.1000, - -0.1001, - -0.1002, - -0.1003 - ] - } - ]); - - let start_time = Utc - .timestamp_opt(1_000_000, 0) - .unwrap(); - - let points = - super::parse_track( - &streams, - start_time, - ) - .unwrap(); - - assert_eq!(points.len(), 4); - - assert_eq!(points[0].index, 0); - - assert!( - (points[0].lat - 51.5000).abs() < 0.000001 - ); - - assert!( - (points[0].lon - (-0.1000)).abs() < 0.000001 - ); - - assert_eq!(points[1].index, 1); - - assert!( - (points[1].lat - 51.5001).abs() < 0.000001 - ); - - assert!( - (points[1].lon - (-0.1001)).abs() < 0.000001 - ); - - assert_eq!( - points[1].time.timestamp_millis(), - start_time.timestamp_millis() + 1000 - ); + assert_eq!(result.timestamp_millis(), 1376); } } diff --git a/src/model.rs b/src/model.rs index 946bf68..b7069af 100644 --- a/src/model.rs +++ b/src/model.rs @@ -30,42 +30,50 @@ pub struct WebhookEnvelope { #[derive(Debug, Deserialize)] pub struct WebhookEvent { pub athlete_id: String, - #[serde(rename = "type")] pub event_type: String, - pub timestamp: DateTime, - #[serde(default)] pub activity: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Clone)] pub struct LeaderboardRow { pub rank: i64, - pub athlete: String, - pub from_county: String, pub to_county: String, - pub crossing_time: DateTime, - pub activity_id: String, pub activity_url: String, - pub interval_url: Option, + pub average_watts: Option, + pub average_heartrate: Option, + pub lat: f64, + pub lon: f64, +} +#[derive(Debug, Serialize, Clone)] +pub struct LeaderboardGroup { + pub bucket_start: DateTime, + pub from_county: String, + pub to_county: String, + pub crossing_lat: f64, + pub crossing_lon: f64, + pub rows: Vec, +} + +#[derive(Debug, Serialize, Clone)] +pub struct ActivityCrossing { + pub crossing_time: DateTime, + pub from_county: String, + pub to_county: String, + pub lat: f64, + pub lon: f64, + pub rank: i64, + pub activity_id: String, + pub athlete: String, + pub interval_url: Option, pub average_watts: Option, pub average_heartrate: Option, } - -#[derive(Debug, Serialize)] -pub struct LeaderboardGroup { - pub bucket_start: DateTime, - - pub from_county: String, - pub to_county: String, - - pub rows: Vec, -} diff --git a/src/web.rs b/src/web.rs index 22feea8..dd327f7 100644 --- a/src/web.rs +++ b/src/web.rs @@ -1,158 +1,463 @@ -use axum::{extract::State, response::Html}; +use std::collections::{HashMap, HashSet}; + +use chrono::{DateTime, Utc}; + +use axum::{ + extract::{Form, Path, Query, State}, + http::StatusCode, + response::{Html, Redirect}, +}; use axum_extra::extract::cookie::CookieJar; +use serde::Deserialize; +use serde_json::Value; +use sqlx::Row; -use crate::{AppState, db, leaderboard}; +use crate::{ + AppState, db, leaderboard, + model::{ActivityCrossing, LeaderboardGroup}, +}; -pub async fn index(State(state): State, jar: CookieJar) -> Html { - let groups = leaderboard::build(&state.db).await.unwrap_or_default(); +#[derive(Debug, Deserialize, Default)] +pub struct PageQuery { + pub group_id: Option, +} - let current_user = match jar.get("county_session") { - Some(cookie) => db::athlete_for_session(&state.db, cookie.value()) +#[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() - .map(|(_, name)| name), + .flatten(), + _ => None, + }; + let groups = leaderboard::build(&state.db, valid_group_id) + .await + .unwrap_or_default(); + + 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 mut html = String::from( - r#" - - - - -Landkreis-Sprints - - - -
+ +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 -
-
-"#, +
Grenzübertritte · Millisekunden · Intervals.icu
+ "#, ); - if let Some(name) = current_user { + if let Some((_, name)) = current { html.push_str(&format!( - "
Angemeldet als {} · \ - Logout
", - escape_html(&name) + r#"
Angemeldet als {}
+ Gruppen + Logout
"#, + escape_html(name) )); } else { - html.push_str( - r#" - Mit Intervals.icu verbinden - "#, - ); + html.push_str(r#"Mit Intervals.icu verbinden"#); } html.push_str("
"); + if !available_groups.is_empty() { + html.push_str( + r#"
+ +
"); + } + if groups.is_empty() { html.push_str( - r#"
- Noch keine Landkreisüberquerungen. -
"#, + r#"
Noch keine Landkreisüberquerungen für diese Auswahl.
"#, ); } for group in groups { - html.push_str("
"); - + html.push_str("
"); html.push_str(&format!( "

{} → {}

", escape_html(&group.from_county), escape_html(&group.to_county) )); + html.push_str(&format!( + "
10-Minuten-Fenster · {} · Standort ±10 m
", + group.bucket_start.to_rfc3339() + )); html.push_str(&format!( - "
10-Minuten-Fenster ab {}
", - group.bucket_start.format("%Y-%m-%d %H:%M:%S UTC") + r#"
"#, + group.crossing_lat, group.crossing_lon )); html.push_str( - r#" - - - - - - - - - - -"#, + r#"
#FahrerÜbertrittIntervallPowerHR
+ + + "#, ); - for row in group.rows { - let interval = match row.interval_url { - Some(url) => format!( - "Intervall", - escape_html(&url) - ), - - None => "".into(), - }; + for row in &group.rows { + let interval = row + .interval_url + .as_ref() + .map(|url| { + format!( + "Intervall", + escape_html(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)) @@ -160,38 +465,283 @@ button, .button { html.push_str(&format!( r#" - - - - - - -"#, + + + + + + + "#, row.rank, escape_html(&row.athlete), - row.crossing_time.format("%H:%M:%S%.3f"), + escape_html(&row.crossing_time.to_rfc3339()), + row.crossing_time.format("%H:%M:%S%.3f UTC"), + escape_html(&row.activity_url), + escape_html(&row.activity_id), interval, + row.lat, + row.lon, power, - hr + hr, )); } - html.push_str("
#FahrerÜbertrittAktivitätIntervallPowerHRKarte
{}{}{}{}{}{}
{}{}{}{}{}{}{}
"); - - html.push_str("
"); + html.push_str(""); } - html.push_str( - r#"
- Datenquelle: Intervals.icu · Landkreisgeometrien: VG250 -
- -"#, - ); - + 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: {}
"#, + escape_html(title), + escape_html(&athlete_name), + escape_html(sport), + escape_html(title), + escape_html(&athlete_name), + escape_html(activity_id), + )); + + if let Some(start) = start_time { + html.push_str(&format!( + r#"
Start: {}
"#, + escape_html(&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 = escape_html(&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", + escape_html(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, + escape_html(&crossing.crossing_time.to_rfc3339()), + crossing.crossing_time.format("%H:%M:%S%.3f UTC"), + escape_html(&crossing.from_county), + escape_html(&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.
"#, + escape_html(owner_name) + )); + + html.push_str( + r#"

Neue Gruppe

+
+ +
"#, + ); + for (id, name) in athletes { + html.push_str(&format!( + r#""#, + id, + escape_html(name) + )); + } + html.push_str(r#"
"#); + + for (group_id, name, members) in groups { + html.push_str(&format!( + r#"
+ + +
"#, + group_id, + escape_html(name) + )); + + for (id, athlete_name) in athletes { + let checked = if members.contains(id) { " checked" } else { "" }; + html.push_str(&format!( + r#""#, + id, + checked, + escape_html(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
+ + + +"#; + fn escape_html(value: &str) -> String { value .replace('&', "&")