add map and clicks

This commit is contained in:
Jonas Rabenstein 2026-08-12 17:57:25 +02:00
commit 3cfd5bdd6c
8 changed files with 1195 additions and 992 deletions

View file

@ -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 No change is required to the working `src/intervals.rs`.
Garmin / Wahoo / ...
| The existing `src/webhook.rs` can keep calling:
v
Intervals.icu ```rust
| crate::process_activity(state_clone, athlete_id, activity_id.clone()).await
| ACTIVITY_ANALYZED ```
v
/webhooks/intervals `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.
|
v ## Database
Rust
| The migration is intentionally a complete replacement because the database can be reset during development.
+---- GET activity?intervals=true
| The new schema adds:
+---- GET streams.json
| time + latlng - `leaderboard_groups`
| - `leaderboard_group_members`
v - indexes for activity/crossing queries
PostgreSQL
+ PostGIS ## Leaderboard location matching
|
v Leaderboard rows are grouped by:
Landkreis-Grenze
| - 10-minute time bucket
v - source county
crossing_time - destination county
millisecond - spatial cluster with a 10 metre DBSCAN radius
|
v The crossing geometry is transformed to EPSG:3857 before the 10 metre clustering calculation so the distance is measured in metres rather than degrees.
icu_interval
| ## Frontend
v
10-Minuten-Bucket The frontend now provides:
|
v - Leaflet map for each leaderboard group
Leaderboard - 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

View file

@ -2,13 +2,10 @@ CREATE EXTENSION IF NOT EXISTS postgis;
CREATE TABLE athletes ( CREATE TABLE athletes (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
intervals_athlete_id TEXT NOT NULL UNIQUE, intervals_athlete_id TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL, display_name TEXT NOT NULL,
access_token TEXT NOT NULL, access_token TEXT NOT NULL,
scopes TEXT NOT NULL, scopes TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now() updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
@ -20,73 +17,53 @@ CREATE TABLE oauth_states (
CREATE TABLE sessions ( CREATE TABLE sessions (
token_hash BYTEA PRIMARY KEY, token_hash BYTEA PRIMARY KEY,
athlete_id BIGINT NOT NULL athlete_id BIGINT NOT NULL
REFERENCES athletes(id) REFERENCES athletes(id)
ON DELETE CASCADE, ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now() last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
CREATE INDEX sessions_athlete_idx CREATE INDEX sessions_athlete_idx ON sessions (athlete_id);
ON sessions (athlete_id);
CREATE TABLE activities ( CREATE TABLE activities (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
athlete_id BIGINT NOT NULL athlete_id BIGINT NOT NULL
REFERENCES athletes(id) REFERENCES athletes(id)
ON DELETE CASCADE, ON DELETE CASCADE,
intervals_activity_id TEXT NOT NULL, intervals_activity_id TEXT NOT NULL,
start_time TIMESTAMPTZ, start_time TIMESTAMPTZ,
processed_at TIMESTAMPTZ,
activity_json JSONB, activity_json JSONB,
processed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (athlete_id, intervals_activity_id) UNIQUE (athlete_id, intervals_activity_id)
); );
CREATE INDEX activities_start_time_idx CREATE INDEX activities_start_time_idx ON activities(start_time);
ON activities(start_time); CREATE INDEX activities_athlete_idx ON activities(athlete_id);
CREATE TABLE county_boundaries ( CREATE TABLE county_boundaries (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
geometry geometry(MultiPolygon, 4326) NOT NULL geometry geometry(MultiPolygon, 4326) NOT NULL
); );
CREATE INDEX county_boundaries_geometry_idx CREATE INDEX county_boundaries_geometry_idx
ON county_boundaries ON county_boundaries USING GIST (geometry);
USING GIST (geometry);
CREATE TABLE county_crossings ( CREATE TABLE county_crossings (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
activity_id BIGINT NOT NULL activity_id BIGINT NOT NULL
REFERENCES activities(id) REFERENCES activities(id)
ON DELETE CASCADE, ON DELETE CASCADE,
track_index BIGINT NOT NULL, track_index BIGINT NOT NULL,
crossing_time TIMESTAMPTZ(3) NOT NULL, crossing_time TIMESTAMPTZ(3) NOT NULL,
location geometry(Point, 4326) NOT NULL, location geometry(Point, 4326) NOT NULL,
from_county_id BIGINT NOT NULL from_county_id BIGINT NOT NULL
REFERENCES county_boundaries(id), REFERENCES county_boundaries(id),
to_county_id BIGINT NOT NULL to_county_id BIGINT NOT NULL
REFERENCES county_boundaries(id), REFERENCES county_boundaries(id),
intervals_interval JSONB, intervals_interval JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now() created_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
@ -94,12 +71,37 @@ CREATE INDEX county_crossings_time_idx
ON county_crossings(crossing_time); ON county_crossings(crossing_time);
CREATE INDEX county_crossings_location_idx CREATE INDEX county_crossings_location_idx
ON county_crossings ON county_crossings USING GIST (location);
USING GIST (location);
CREATE INDEX county_crossings_direction_time_idx CREATE INDEX county_crossings_direction_time_idx
ON county_crossings( ON county_crossings(from_county_id, to_county_id, crossing_time);
from_county_id,
to_county_id, CREATE INDEX county_crossings_activity_idx
crossing_time 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);

View file

@ -8,9 +8,7 @@ use crate::model::DetectedCrossing;
pub fn hash_token(token: &str) -> Vec<u8> { pub fn hash_token(token: &str) -> Vec<u8> {
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(token.as_bytes()); hasher.update(token.as_bytes());
hasher.finalize().to_vec() hasher.finalize().to_vec()
} }
@ -19,10 +17,7 @@ pub async fn create_session(db: &PgPool, athlete_id: i64, token: &str) -> Result
sqlx::query( sqlx::query(
r#" r#"
INSERT INTO sessions ( INSERT INTO sessions (token_hash, athlete_id)
token_hash,
athlete_id
)
VALUES ($1, $2) VALUES ($1, $2)
"#, "#,
) )
@ -39,12 +34,9 @@ pub async fn athlete_for_session(db: &PgPool, token: &str) -> Result<Option<(i64
let row = sqlx::query( let row = sqlx::query(
r#" r#"
SELECT SELECT a.id, a.display_name
a.id,
a.display_name
FROM sessions s FROM sessions s
JOIN athletes a JOIN athletes a ON a.id = s.athlete_id
ON a.id = s.athlete_id
WHERE s.token_hash = $1 WHERE s.token_hash = $1
"#, "#,
) )
@ -56,7 +48,6 @@ pub async fn athlete_for_session(db: &PgPool, token: &str) -> Result<Option<(i64
if let Some(row) = row { if let Some(row) = row {
let id: i64 = row.try_get("id")?; let id: i64 = row.try_get("id")?;
let name: String = row.try_get("display_name")?; let name: String = row.try_get("display_name")?;
sqlx::query( sqlx::query(
@ -105,17 +96,8 @@ pub async fn ensure_activity(
activity_json, activity_json,
processed_at processed_at
) )
VALUES ( VALUES ($1, $2, $3, $4, NULL)
$1, ON CONFLICT (athlete_id, intervals_activity_id)
$2,
$3,
$4,
NULL
)
ON CONFLICT (
athlete_id,
intervals_activity_id
)
DO UPDATE SET DO UPDATE SET
start_time = EXCLUDED.start_time, start_time = EXCLUDED.start_time,
activity_json = EXCLUDED.activity_json, activity_json = EXCLUDED.activity_json,
@ -131,7 +113,6 @@ pub async fn ensure_activity(
.await?; .await?;
use sqlx::Row; use sqlx::Row;
Ok(row.try_get("id")?) Ok(row.try_get("id")?)
} }
@ -173,10 +154,7 @@ pub async fn save_crossing(
$1, $1,
$2, $2,
$3, $3,
ST_SetSRID( ST_SetSRID(ST_MakePoint($4, $5), 4326),
ST_MakePoint($4, $5),
4326
),
$6, $6,
$7, $7,
$8 $8
@ -214,3 +192,14 @@ pub async fn mark_activity_processed(
Ok(()) Ok(())
} }
pub async fn delete_session(db: &PgPool, token: &str) -> Result<()> {
let hash = hash_token(token);
sqlx::query("DELETE FROM sessions WHERE token_hash = $1")
.bind(&hash)
.execute(db)
.await?;
Ok(())
}

View file

@ -1,4 +1,4 @@
use anyhow::{anyhow, Context, Result}; use anyhow::{Context, Result, anyhow};
use chrono::{DateTime, NaiveDateTime, Utc}; use chrono::{DateTime, NaiveDateTime, Utc};
use reqwest::Client; use reqwest::Client;
use serde_json::Value; use serde_json::Value;
@ -26,10 +26,7 @@ impl IntervalsClient {
/// Create a client using an explicitly supplied personal API key. /// Create a client using an explicitly supplied personal API key.
/// ///
/// Primarily used by /dev/sync/{api_key}/{athlete_id}. /// Primarily used by /dev/sync/{api_key}/{athlete_id}.
pub fn with_api_key( pub fn with_api_key(config: Config, api_key: impl Into<String>) -> Self {
config: Config,
api_key: impl Into<String>,
) -> Self {
Self { Self {
client: Client::new(), client: Client::new(),
config, config,
@ -38,17 +35,13 @@ impl IntervalsClient {
} }
fn base_url(&self) -> &str { fn base_url(&self) -> &str {
self.config self.config.intervals_base_url.trim_end_matches('/')
.intervals_base_url
.trim_end_matches('/')
} }
fn api_key(&self) -> Result<&str> { fn api_key(&self) -> Result<&str> {
self.api_key self.api_key
.as_deref() .as_deref()
.context( .context("Intervals.icu API key is not configured")
"Intervals.icu API key is not configured",
)
} }
fn api_url(&self, path: &str) -> String { fn api_url(&self, path: &str) -> String {
@ -59,54 +52,30 @@ impl IntervalsClient {
) )
} }
fn authenticated( fn authenticated(&self, request: reqwest::RequestBuilder) -> Result<reqwest::RequestBuilder> {
&self, Ok(request.basic_auth("API_KEY", Some(self.api_key()?)))
request: reqwest::RequestBuilder,
) -> Result<reqwest::RequestBuilder> {
Ok(
request.basic_auth(
"API_KEY",
Some(self.api_key()?),
)
)
} }
async fn get_json( async fn get_json(&self, url: String) -> Result<Value> {
&self,
url: String,
) -> Result<Value> {
let response = self let response = self
.authenticated(self.client.get(&url))? .authenticated(self.client.get(&url))?
.send() .send()
.await .await
.context( .context("Intervals.icu request failed")?;
"Intervals.icu request failed",
)?;
let status = response.status(); let status = response.status();
let body = response let body = response
.text() .text()
.await .await
.context( .context("cannot read Intervals.icu response")?;
"cannot read Intervals.icu response",
)?;
if !status.is_success() { if !status.is_success() {
return Err(anyhow!( return Err(anyhow!("Intervals.icu returned HTTP {}: {}", status, body));
"Intervals.icu returned HTTP {}: {}",
status,
body
));
} }
serde_json::from_str(&body) serde_json::from_str(&body)
.with_context(|| { .with_context(|| format!("invalid JSON returned by Intervals.icu: {}", body))
format!(
"invalid JSON returned by Intervals.icu: {}",
body
)
})
} }
/// Get the athlete belonging to the configured API key. /// Get the athlete belonging to the configured API key.
@ -119,13 +88,8 @@ impl IntervalsClient {
} }
/// Get a specific athlete. /// Get a specific athlete.
pub async fn athlete( pub async fn athlete(&self, athlete_id: &str) -> Result<Value> {
&self, let url = self.api_url(&format!("athlete/{athlete_id}"));
athlete_id: &str,
) -> Result<Value> {
let url = self.api_url(
&format!("athlete/{athlete_id}"),
);
self.get_json(url).await self.get_json(url).await
} }
@ -133,20 +97,13 @@ impl IntervalsClient {
/// Get a single activity. /// Get a single activity.
/// ///
/// GET /api/v1/activity/{id} /// GET /api/v1/activity/{id}
pub async fn activity( pub async fn activity(&self, activity_id: &str) -> Result<Value> {
&self,
activity_id: &str,
) -> Result<Value> {
/* /*
* Ask Intervals.icu to include interval information, * Ask Intervals.icu to include interval information,
* which is later used to associate county crossings * which is later used to associate county crossings
* with intervals. * with intervals.
*/ */
let url = self.api_url( let url = self.api_url(&format!("activity/{activity_id}?intervals=true"));
&format!(
"activity/{activity_id}?intervals=true"
),
);
self.get_json(url).await self.get_json(url).await
} }
@ -157,43 +114,27 @@ impl IntervalsClient {
/// ///
/// We explicitly request the streams needed for GPS /// We explicitly request the streams needed for GPS
/// processing. /// processing.
pub async fn streams( pub async fn streams(&self, activity_id: &str) -> Result<Value> {
&self, let url = self.api_url(&format!(
activity_id: &str, "activity/{activity_id}/streams.json?types=time,latlng"
) -> Result<Value> { ));
let url = self.api_url(
&format!(
"activity/{activity_id}/streams.json?types=time,latlng"
),
);
tracing::info!( tracing::info!(
activity_id = %activity_id, activity_id = %activity_id,
"requesting Intervals.icu streams" "requesting Intervals.icu streams"
); );
let value = let value = self.get_json(url).await?;
self.get_json(url).await?;
let stream_count = value let stream_count = value.as_array().map(|streams| streams.len()).unwrap_or(0);
.as_array()
.map(|streams| streams.len())
.unwrap_or(0);
let time_points = let time_points = Self::stream_values(&value, "time")
Self::stream_values(
&value,
"time",
)
.map(|values| values.len()) .map(|values| values.len())
.unwrap_or(0); .unwrap_or(0);
let (latitude_points, longitude_points) = let (latitude_points, longitude_points) = Self::latlng_values(&value)
Self::latlng_values(&value) .map(|(lat, lon)| (lat.len(), lon.len()))
.map(|(lat, lon)| { .unwrap_or((0, 0));
(lat.len(), lon.len())
})
.unwrap_or((0, 0));
tracing::info!( tracing::info!(
activity_id = %activity_id, activity_id = %activity_id,
@ -224,21 +165,11 @@ impl IntervalsClient {
/// ] /// ]
/// ///
/// Also tolerates a dictionary-like response. /// Also tolerates a dictionary-like response.
pub fn find_stream<'a>( pub fn find_stream<'a>(streams: &'a Value, stream_type: &str) -> Option<&'a Value> {
streams: &'a Value, if let Some(array) = streams.as_array() {
stream_type: &str, return array
) -> Option<&'a Value> { .iter()
if let Some(array) = .find(|stream| stream.get("type").and_then(Value::as_str) == Some(stream_type));
streams.as_array()
{
return array.iter().find(
|stream| {
stream
.get("type")
.and_then(Value::as_str)
== Some(stream_type)
},
);
} }
streams.get(stream_type) streams.get(stream_type)
@ -252,19 +183,10 @@ impl IntervalsClient {
/// "type": "time", /// "type": "time",
/// "data": [0, 1, 2, 3] /// "data": [0, 1, 2, 3]
/// } /// }
pub fn stream_values<'a>( pub fn stream_values<'a>(streams: &'a Value, stream_type: &str) -> Option<&'a Vec<Value>> {
streams: &'a Value, let stream = Self::find_stream(streams, stream_type)?;
stream_type: &str,
) -> Option<&'a Vec<Value>> {
let stream =
Self::find_stream(
streams,
stream_type,
)?;
stream stream.get("data").and_then(Value::as_array)
.get("data")
.and_then(Value::as_array)
} }
/// Get latitude and longitude arrays from the /// Get latitude and longitude arrays from the
@ -286,49 +208,24 @@ impl IntervalsClient {
/// It is NOT: /// It is NOT:
/// ///
/// data = [[lat, lon], [lat, lon], ...] /// data = [[lat, lon], [lat, lon], ...]
pub fn latlng_values<'a>( pub fn latlng_values<'a>(streams: &'a Value) -> Option<(&'a Vec<Value>, &'a Vec<Value>)> {
streams: &'a Value, let stream = Self::find_stream(streams, "latlng")?;
) -> Option<(
&'a Vec<Value>,
&'a Vec<Value>,
)> {
let stream =
Self::find_stream(
streams,
"latlng",
)?;
let latitudes = stream let latitudes = stream.get("data").and_then(Value::as_array)?;
.get("data")
.and_then(Value::as_array)?;
let longitudes = stream let longitudes = stream.get("data2").and_then(Value::as_array)?;
.get("data2")
.and_then(Value::as_array)?;
Some(( Some((latitudes, longitudes))
latitudes,
longitudes,
))
} }
/// Fetch an athlete's activities. /// Fetch an athlete's activities.
/// ///
/// `athlete_id = "0"` means the athlete belonging to /// `athlete_id = "0"` means the athlete belonging to
/// the API key. /// the API key.
pub async fn activities( pub async fn activities(&self, athlete_id: &str, oldest: &str, newest: &str) -> Result<Value> {
&self,
athlete_id: &str,
oldest: &str,
newest: &str,
) -> Result<Value> {
let url = format!( let url = format!(
"{}?oldest={}&newest={}", "{}?oldest={}&newest={}",
self.api_url( self.api_url(&format!("athlete/{athlete_id}/activities"),),
&format!(
"athlete/{athlete_id}/activities"
),
),
urlencoding::encode(oldest), urlencoding::encode(oldest),
urlencoding::encode(newest), urlencoding::encode(newest),
); );
@ -337,68 +234,39 @@ impl IntervalsClient {
} }
/// Extract the activity start timestamp. /// Extract the activity start timestamp.
pub fn activity_start( pub fn activity_start(activity: &Value) -> Result<DateTime<Utc>> {
activity: &Value,
) -> Result<DateTime<Utc>> {
let value = activity let value = activity
.get("start_date") .get("start_date")
.or_else(|| { .or_else(|| activity.get("start_date_local"))
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_time")
})
.ok_or_else(|| {
anyhow!(
"activity has no start timestamp"
)
})?;
let string = value let string = value
.as_str() .as_str()
.ok_or_else(|| { .ok_or_else(|| anyhow!("activity start timestamp is not a string"))?;
anyhow!(
"activity start timestamp is not a string"
)
})?;
parse_datetime(string) parse_datetime(string)
} }
/// Try to find an Intervals.icu interval corresponding /// Try to find an Intervals.icu interval corresponding
/// to a GPS stream index. /// to a GPS stream index.
pub fn matching_interval( pub fn matching_interval(activity: &Value, track_index: usize) -> Option<Value> {
activity: &Value, let intervals = activity.get("icu_intervals")?;
track_index: usize,
) -> Option<Value> {
let intervals =
activity.get("icu_intervals")?;
let array = let array = intervals.as_array()?;
intervals.as_array()?;
for interval in array { for interval in array {
let start = interval let start = interval
.get("start_index") .get("start_index")
.or_else(|| { .or_else(|| interval.get("start"));
interval.get("start")
});
let end = interval let end = interval.get("end_index").or_else(|| interval.get("end"));
.get("end_index")
.or_else(|| {
interval.get("end")
});
let start = let start = start.and_then(Value::as_u64)?;
start.and_then(Value::as_u64)?;
let end = let end = end.and_then(Value::as_u64)?;
end.and_then(Value::as_u64)?;
if (start as usize) <= track_index if (start as usize) <= track_index && track_index <= end as usize {
&& track_index <= end as usize
{
return Some(interval.clone()); return Some(interval.clone());
} }
} }
@ -407,25 +275,18 @@ impl IntervalsClient {
} }
/// OAuth methods retained for the normal multi-user flow. /// OAuth methods retained for the normal multi-user flow.
pub fn oauth_authorize_url( pub fn oauth_authorize_url(&self, state: &str) -> Result<String> {
&self,
state: &str,
) -> Result<String> {
let client_id = self let client_id = self
.config .config
.intervals_client_id .intervals_client_id
.as_deref() .as_deref()
.context( .context("INTERVALS_CLIENT_ID is not configured")?;
"INTERVALS_CLIENT_ID is not configured",
)?;
let redirect_uri = self let redirect_uri = self
.config .config
.intervals_redirect_uri .intervals_redirect_uri
.as_deref() .as_deref()
.context( .context("INTERVALS_REDIRECT_URI is not configured")?;
"INTERVALS_REDIRECT_URI is not configured",
)?;
Ok(format!( Ok(format!(
"{}/oauth/authorize?client_id={}&redirect_uri={}&scope={}&state={}", "{}/oauth/authorize?client_id={}&redirect_uri={}&scope={}&state={}",
@ -437,32 +298,22 @@ impl IntervalsClient {
)) ))
} }
pub async fn exchange_code( pub async fn exchange_code(&self, code: &str) -> Result<Value> {
&self,
code: &str,
) -> Result<Value> {
let client_id = self let client_id = self
.config .config
.intervals_client_id .intervals_client_id
.as_deref() .as_deref()
.context( .context("INTERVALS_CLIENT_ID is not configured")?;
"INTERVALS_CLIENT_ID is not configured",
)?;
let client_secret = self let client_secret = self
.config .config
.intervals_client_secret .intervals_client_secret
.as_deref() .as_deref()
.context( .context("INTERVALS_CLIENT_SECRET is not configured")?;
"INTERVALS_CLIENT_SECRET is not configured",
)?;
let response = self let response = self
.client .client
.post(format!( .post(format!("{}/api/oauth/token", self.base_url()))
"{}/api/oauth/token",
self.base_url()
))
.form(&[ .form(&[
("client_id", client_id), ("client_id", client_id),
("client_secret", client_secret), ("client_secret", client_secret),
@ -470,15 +321,11 @@ impl IntervalsClient {
]) ])
.send() .send()
.await .await
.context( .context("OAuth token request failed")?;
"OAuth token request failed",
)?;
let status = response.status(); let status = response.status();
let body = response let body = response.text().await?;
.text()
.await?;
if !status.is_success() { if !status.is_success() {
return Err(anyhow!( return Err(anyhow!(
@ -491,20 +338,11 @@ impl IntervalsClient {
Ok(serde_json::from_str(&body)?) Ok(serde_json::from_str(&body)?)
} }
pub fn token_data( pub fn token_data(response: &Value) -> Result<(String, String, String, String)> {
response: &Value,
) -> Result<(
String,
String,
String,
String,
)> {
let access_token = response let access_token = response
.get("access_token") .get("access_token")
.and_then(Value::as_str) .and_then(Value::as_str)
.context( .context("OAuth response has no access_token")?
"OAuth response has no access_token",
)?
.to_string(); .to_string();
let scope = response let scope = response
@ -515,9 +353,7 @@ impl IntervalsClient {
let athlete = response let athlete = response
.get("athlete") .get("athlete")
.context( .context("OAuth response has no athlete")?;
"OAuth response has no athlete",
)?;
let athlete_id = athlete let athlete_id = athlete
.get("id") .get("id")
@ -533,62 +369,27 @@ impl IntervalsClient {
let display_name = athlete let display_name = athlete
.get("name") .get("name")
.or_else(|| { .or_else(|| athlete.get("display_name"))
athlete.get("display_name")
})
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or( .unwrap_or("Intervals.icu athlete")
"Intervals.icu athlete",
)
.to_string(); .to_string();
Ok(( Ok((access_token, scope, athlete_id, display_name))
access_token,
scope,
athlete_id,
display_name,
))
} }
} }
fn parse_datetime( fn parse_datetime(value: &str) -> Result<DateTime<Utc>> {
value: &str, if let Ok(value) = DateTime::parse_from_rfc3339(value) {
) -> Result<DateTime<Utc>> { return Ok(value.with_timezone(&Utc));
if let Ok(value) =
DateTime::parse_from_rfc3339(value)
{
return Ok(
value.with_timezone(&Utc)
);
} }
if let Ok(value) = if let Ok(value) = DateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S%z") {
DateTime::parse_from_str( return Ok(value.with_timezone(&Utc));
value,
"%Y-%m-%dT%H:%M:%S%z",
)
{
return Ok(
value.with_timezone(&Utc)
);
} }
if let Ok(value) = if let Ok(value) = NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S") {
NaiveDateTime::parse_from_str( return Ok(DateTime::<Utc>::from_naive_utc_and_offset(value, Utc));
value,
"%Y-%m-%dT%H:%M:%S",
)
{
return Ok(
DateTime::<Utc>::from_naive_utc_and_offset(
value,
Utc,
)
);
} }
Err(anyhow!( Err(anyhow!("cannot parse activity timestamp: {}", value))
"cannot parse activity timestamp: {}",
value
))
} }

View file

@ -1,78 +1,100 @@
use anyhow::Result; use anyhow::Result;
use axum::{Json, extract::State}; use axum::{
Json,
extract::{Query, State},
};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::Deserialize;
use serde_json::Value; use serde_json::Value;
use sqlx::PgPool; use sqlx::PgPool;
use crate::{ use crate::{
AppState, AppState,
model::{LeaderboardGroup, LeaderboardRow}, model::{ActivityCrossing, LeaderboardGroup, LeaderboardRow},
}; };
#[derive(Debug, Deserialize, Default)]
pub struct LeaderboardQuery {
pub group_id: Option<i64>,
}
pub async fn api( pub async fn api(
State(state): State<AppState>, State(state): State<AppState>,
Query(query): Query<LeaderboardQuery>,
) -> Result<Json<Vec<LeaderboardGroup>>, (axum::http::StatusCode, String)> { ) -> Result<Json<Vec<LeaderboardGroup>>, (axum::http::StatusCode, String)> {
build(&state.db).await.map(Json).map_err(|error| { build(&state.db, query.group_id)
tracing::error!(%error, "leaderboard query failed"); .await
.map(Json)
( .map_err(|error| {
axum::http::StatusCode::INTERNAL_SERVER_ERROR, tracing::error!(%error, "leaderboard query failed");
error.to_string(), (
) axum::http::StatusCode::INTERNAL_SERVER_ERROR,
}) error.to_string(),
)
})
} }
pub async fn build(db: &PgPool) -> Result<Vec<LeaderboardGroup>> { pub async fn build(db: &PgPool, group_id: Option<i64>) -> Result<Vec<LeaderboardGroup>> {
let rows = sqlx::query( let rows = sqlx::query(
r#" r#"
WITH bucketed AS ( WITH bucketed AS (
SELECT SELECT
cc.id, cc.id,
cc.crossing_time, cc.crossing_time,
cc.track_index, cc.location,
cc.intervals_interval, cc.intervals_interval,
a.intervals_activity_id, a.intervals_activity_id,
ath.id AS athlete_id,
ath.display_name, ath.display_name,
f.name AS from_county, f.name AS from_county,
t.name AS to_county, t.name AS to_county,
cc.from_county_id,
cc.to_county_id,
to_timestamp( to_timestamp(
floor( floor(extract(epoch FROM cc.crossing_time) / 600.0) * 600
extract(epoch FROM cc.crossing_time)
/ 600.0
) * 600
) AS bucket_start ) AS bucket_start
FROM county_crossings cc FROM county_crossings cc
JOIN activities a ON a.id = cc.activity_id
JOIN activities a JOIN athletes ath ON ath.id = a.athlete_id
ON a.id = cc.activity_id JOIN county_boundaries f ON f.id = cc.from_county_id
JOIN county_boundaries t ON t.id = cc.to_county_id
JOIN athletes ath WHERE (
ON ath.id = a.athlete_id $1::BIGINT IS NULL
OR ath.id IN (
JOIN county_boundaries f SELECT athlete_id
ON f.id = cc.from_county_id FROM leaderboard_group_members
WHERE group_id = $1
JOIN county_boundaries t )
ON t.id = cc.to_county_id )
),
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 ( ranked AS (
SELECT SELECT
*, *,
row_number() OVER ( row_number() OVER (
PARTITION BY PARTITION BY
bucket_start, bucket_start,
from_county, from_county_id,
to_county to_county_id,
location_cluster
ORDER BY crossing_time, id ORDER BY crossing_time, id
) AS rank ) AS rank
FROM bucketed FROM clustered
) )
SELECT SELECT
rank, rank,
display_name, display_name,
@ -81,19 +103,20 @@ pub async fn build(db: &PgPool) -> Result<Vec<LeaderboardGroup>> {
bucket_start, bucket_start,
crossing_time, crossing_time,
intervals_activity_id, intervals_activity_id,
intervals_interval intervals_interval,
ST_Y(location) AS lat,
ST_X(location) AS lon
FROM ranked FROM ranked
WHERE rank <= 20 WHERE rank <= 20
ORDER BY ORDER BY
bucket_start DESC, bucket_start DESC,
from_county, from_county,
to_county, to_county,
crossing_time,
rank rank
"#, "#,
) )
.bind(group_id)
.fetch_all(db) .fetch_all(db)
.await?; .await?;
@ -103,9 +126,7 @@ pub async fn build(db: &PgPool) -> Result<Vec<LeaderboardGroup>> {
for row in rows { for row in rows {
let bucket_start: DateTime<Utc> = row.try_get("bucket_start")?; let bucket_start: DateTime<Utc> = row.try_get("bucket_start")?;
let from_county: String = row.try_get("from_county")?; let from_county: String = row.try_get("from_county")?;
let to_county: String = row.try_get("to_county")?; let to_county: String = row.try_get("to_county")?;
let group_index = groups.iter().position(|group| { let group_index = groups.iter().position(|group| {
@ -116,28 +137,26 @@ pub async fn build(db: &PgPool) -> Result<Vec<LeaderboardGroup>> {
let index = match group_index { let index = match group_index {
Some(index) => index, Some(index) => index,
None => { None => {
groups.push(LeaderboardGroup { groups.push(LeaderboardGroup {
bucket_start, bucket_start,
from_county: from_county.clone(), from_county: from_county.clone(),
to_county: to_county.clone(), to_county: to_county.clone(),
crossing_lat: row.try_get("lat")?,
crossing_lon: row.try_get("lon")?,
rows: Vec::new(), rows: Vec::new(),
}); });
groups.len() - 1 groups.len() - 1
} }
}; };
let rank: i64 = row.try_get("rank")?; let rank: i64 = row.try_get("rank")?;
let athlete: String = row.try_get("display_name")?; let athlete: String = row.try_get("display_name")?;
let crossing_time: DateTime<Utc> = row.try_get("crossing_time")?; let crossing_time: DateTime<Utc> = row.try_get("crossing_time")?;
let activity_id: String = row.try_get("intervals_activity_id")?; let activity_id: String = row.try_get("intervals_activity_id")?;
let interval: Option<Value> = row.try_get("intervals_interval")?; let interval: Option<Value> = row.try_get("intervals_interval")?;
let lat: f64 = row.try_get("lat")?;
let lon: f64 = row.try_get("lon")?;
let interval_id = interval let interval_id = interval
.as_ref() .as_ref()
@ -154,32 +173,156 @@ pub async fn build(db: &PgPool) -> Result<Vec<LeaderboardGroup>> {
.and_then(|v| v.get("average_heartrate")) .and_then(|v| v.get("average_heartrate"))
.and_then(Value::as_f64); .and_then(Value::as_f64);
let row_data = LeaderboardRow { groups[index].rows.push(LeaderboardRow {
rank, rank,
athlete, athlete,
from_county, from_county,
to_county, to_county,
crossing_time, crossing_time,
activity_id: activity_id.clone(), activity_id: activity_id.clone(),
activity_url: format!("/activity/{}", activity_id),
activity_url: format!("https://intervals.icu/activities/{}", activity_id),
interval_url: interval_id.map(|id| { interval_url: interval_id.map(|id| {
format!( format!(
"https://intervals.icu/activities/{}?interval={}", "https://intervals.icu/activities/{}?interval={}",
activity_id, id activity_id, id
) )
}), }),
average_watts, average_watts,
average_heartrate, average_heartrate,
}; lat,
lon,
groups[index].rows.push(row_data); });
} }
Ok(groups) Ok(groups)
} }
pub async fn activity_crossings(
db: &PgPool,
activity_id: &str,
group_id: Option<i64>,
) -> Result<Vec<ActivityCrossing>> {
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<Value> = 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)
}

View file

@ -8,24 +8,20 @@ mod model;
mod web; mod web;
mod webhook; mod webhook;
use anyhow::{anyhow, Context, Result}; use anyhow::{Context, Result, anyhow};
use axum::{ use axum::{
Router,
extract::{Path, State}, extract::{Path, State},
response::Json, response::Json,
routing::{get, post}, routing::{get, post},
Router,
}; };
use chrono::{DateTime, Duration, Utc}; use chrono::{DateTime, Duration, Utc};
use serde_json::{json, Value}; use serde_json::{Value, json};
use sqlx::PgPool; use sqlx::PgPool;
use tower_http::trace::TraceLayer; use tower_http::trace::TraceLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use crate::{ use crate::{config::Config, intervals::IntervalsClient, model::TrackPoint};
config::Config,
intervals::IntervalsClient,
model::TrackPoint,
};
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
@ -69,10 +65,9 @@ async fn main() -> Result<()> {
.route("/logout", get(auth::logout)) .route("/logout", get(auth::logout))
.route("/webhooks/intervals", post(webhook::receive)) .route("/webhooks/intervals", post(webhook::receive))
.route("/api/leaderboard", get(leaderboard::api)) .route("/api/leaderboard", get(leaderboard::api))
// Development-only activity synchronisation. .route("/activity/{activity_id}", get(web::activity))
// .route("/groups", get(web::groups).post(web::save_group))
// /dev/sync/{api_key} .route("/groups/delete", post(web::delete_group))
// /dev/sync/{api_key}/{athlete_id}
.route("/dev/sync/{api_key}", get(dev_sync_one)) .route("/dev/sync/{api_key}", get(dev_sync_one))
.route("/dev/sync/{api_key}/{athlete_id}", get(dev_sync)) .route("/dev/sync/{api_key}/{athlete_id}", get(dev_sync))
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
@ -86,7 +81,6 @@ async fn main() -> Result<()> {
); );
axum::serve(listener, app).await?; axum::serve(listener, app).await?;
Ok(()) Ok(())
} }
@ -100,19 +94,9 @@ fn redact_database_url(url: &str) -> String {
return format!("{prefix}@***"); return format!("{prefix}@***");
} }
} }
url.to_string() 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( async fn dev_sync(
State(state): State<AppState>, State(state): State<AppState>,
Path((api_key, athlete_id)): Path<(String, String)>, 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 { match dev_sync_inner(state, api_key, athlete_id).await {
Ok(result) => Ok(Json(result)), Ok(result) => Ok(Json(result)),
Err(error) => { Err(error) => {
tracing::error!( tracing::error!(error = %error, "development sync failed");
error = %error,
"development sync failed"
);
Err(axum::http::StatusCode::INTERNAL_SERVER_ERROR) 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( async fn dev_sync_one(
State(state): State<AppState>, State(state): State<AppState>,
Path(api_key): Path<String>, Path(api_key): Path<String>,
@ -144,21 +117,13 @@ async fn dev_sync_one(
match dev_sync_inner(state, api_key, "0".to_string()).await { match dev_sync_inner(state, api_key, "0".to_string()).await {
Ok(result) => Ok(Json(result)), Ok(result) => Ok(Json(result)),
Err(error) => { Err(error) => {
tracing::error!( tracing::error!(error = %error, "development sync failed");
error = %error,
"development sync failed"
);
Err(axum::http::StatusCode::INTERNAL_SERVER_ERROR) Err(axum::http::StatusCode::INTERNAL_SERVER_ERROR)
} }
} }
} }
async fn dev_sync_inner( async fn dev_sync_inner(state: AppState, api_key: String, athlete_id: String) -> Result<Value> {
state: AppState,
api_key: String,
athlete_id: String,
) -> Result<Value> {
if api_key.trim().is_empty() { if api_key.trim().is_empty() {
return Err(anyhow!("API key is empty")); return Err(anyhow!("API key is empty"));
} }
@ -169,24 +134,10 @@ async fn dev_sync_inner(
athlete_id athlete_id
}; };
/* let client = IntervalsClient::with_api_key(state.config.clone(), api_key);
* 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());
/*
* 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 resolved_athlete_id = if athlete_id == "0" {
let owner = client let owner = client.owner().await?;
.owner()
.await
.context("cannot resolve API-key owner")?;
owner owner
.get("id") .get("id")
@ -204,8 +155,7 @@ async fn dev_sync_inner(
}; };
let newest = Utc::now().date_naive(); 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 oldest_string = oldest.format("%Y-%m-%d").to_string();
let newest_string = newest.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" "starting development activity sync"
); );
/*
* Use the resolved athlete ID here, not the original "0".
*/
let activities = client let activities = client
.activities( .activities(&athlete_id, &oldest_string, &newest_string)
&resolved_athlete_id, .await?;
&oldest_string,
&newest_string,
)
.await
.context("cannot fetch development activities")?;
let activities_array = activities let activities_array = activities
.as_array() .as_array()
.context("Intervals.icu activities response is not an 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 processed = 0usize;
let mut skipped = 0usize; let mut skipped = 0usize;
let mut failed = 0usize; let mut failed = 0usize;
let mut results = Vec::new(); 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 { for activity in activities_array {
let Some(activity_id) = let Some(activity_id) = activity.get("id").and_then(Value::as_str) else {
activity.get("id").and_then(Value::as_str)
else {
skipped += 1; skipped += 1;
results.push(json!({"status": "skipped", "reason": "activity has no id"}));
results.push(json!({
"status": "skipped",
"reason": "activity has no id"
}));
continue; continue;
}; };
match process_activity( match process_activity_with_client(
state.clone(), state.clone(),
resolved_athlete_id.clone(), resolved_athlete_id.clone(),
activity_id.to_string(), activity_id.to_string(),
&client,
) )
.await .await
{ {
Ok(()) => { Ok(()) => {
processed += 1; processed += 1;
results.push(json!({"id": activity_id, "status": "processed"}));
results.push(json!({
"id": activity_id,
"status": "processed"
}));
} }
Err(error) => { Err(error) => {
failed += 1; failed += 1;
tracing::error!( tracing::error!(
activity_id = %activity_id, activity_id = %activity_id,
error = %error, error = %error,
"development activity processing failed" "development activity processing failed"
); );
results.push(json!({ results.push(json!({
"id": activity_id, "id": activity_id,
"status": "failed", "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( async fn ensure_dev_athlete(
state: &AppState, state: &AppState,
intervals_athlete_id: &str, intervals_athlete_id: &str,
client: &IntervalsClient, client: &IntervalsClient,
api_key: &str,
) -> Result<()> { ) -> Result<()> {
let athlete = client let athlete = client.athlete(intervals_athlete_id).await?;
.athlete(intervals_athlete_id)
.await
.with_context(|| {
format!(
"cannot fetch Intervals.icu athlete {}",
intervals_athlete_id
)
})?;
let display_name = athlete let display_name = athlete
.get("name") .get("name")
@ -349,61 +250,34 @@ async fn ensure_dev_athlete(
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or("Intervals.icu athlete"); .unwrap_or("Intervals.icu athlete");
/* let exists = sqlx::query(
* If this athlete already exists, update the development
* credential.
*/
let existing = sqlx::query(
r#" r#"
SELECT id SELECT id FROM athletes WHERE intervals_athlete_id = $1
FROM athletes
WHERE intervals_athlete_id = $1
"#, "#,
) )
.bind(intervals_athlete_id) .bind(intervals_athlete_id)
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await?; .await?;
if existing.is_some() { if exists.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")?;
return Ok(()); return Ok(());
} }
/*
* `scopes` is NOT NULL in the initial migration, so it must
* be supplied here.
*/
sqlx::query( sqlx::query(
r#" r#"
INSERT INTO athletes ( INSERT INTO athletes (
intervals_athlete_id, intervals_athlete_id,
display_name,
access_token, access_token,
display_name,
scopes scopes
) )
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4)
"#, "#,
) )
.bind(intervals_athlete_id) .bind(intervals_athlete_id)
.bind("")
.bind(display_name) .bind(display_name)
.bind(api_key) .bind("")
.bind("dev")
.execute(&state.db) .execute(&state.db)
.await .await
.context("cannot create local dev athlete")?; .context("cannot create local dev athlete")?;
@ -411,23 +285,54 @@ async fn ensure_dev_athlete(
Ok(()) Ok(())
} }
/// Process one activity. /// Normal processing entry point used by the webhook path.
/// /// The OAuth access token is loaded from the local athlete row.
/// 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.
pub async fn process_activity( pub async fn process_activity(
state: AppState, state: AppState,
intervals_athlete_id: String, intervals_athlete_id: String,
activity_id: String, activity_id: String,
) -> Result<()> { ) -> 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( let athlete = sqlx::query(
r#" r#"
SELECT SELECT id
id,
access_token
FROM athletes FROM athletes
WHERE intervals_athlete_id = $1 WHERE intervals_athlete_id = $1
"#, "#,
@ -436,6 +341,8 @@ pub async fn process_activity(
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await?; .await?;
use sqlx::Row;
let Some(athlete) = athlete else { let Some(athlete) = athlete else {
return Err(anyhow!( return Err(anyhow!(
"no local athlete for Intervals.icu athlete {}", "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 athlete_db_id: i64 = athlete.try_get("id")?;
let access_token: String = athlete let activity = client.activity(&activity_id).await?;
.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 start_time = IntervalsClient::activity_start(&activity)?; let start_time = IntervalsClient::activity_start(&activity)?;
let streams = client.streams(&activity_id).await?;
/*
* Fetch GPS/time streams.
*/
let streams = client
.streams(&activity_id)
.await
.with_context(|| {
format!(
"cannot fetch streams for Intervals.icu activity {}",
activity_id
)
})?;
let points = parse_track(&streams, start_time)?; let points = parse_track(&streams, start_time)?;
tracing::debug!(
activity_id = %activity_id,
points = points.len(),
"parsed activity GPS points"
);
if points.len() < 2 { if points.len() < 2 {
tracing::info!( tracing::info!(
activity_id = %activity_id, activity_id = %activity_id,
points = points.len(), points = points.len(),
"activity has insufficient GPS data" "activity has insufficient GPS data"
); );
return Ok(()); 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 mut tx = state.db.begin().await?;
let activity_db_id = let activity_db_id =
db::ensure_activity( db::ensure_activity(&mut tx, athlete_db_id, &activity_id, start_time, &activity).await?;
&mut tx,
athlete_db_id,
&activity_id,
start_time,
&activity,
)
.await?;
db::delete_activity_crossings( db::delete_activity_crossings(&mut tx, activity_db_id).await?;
&mut tx,
activity_db_id,
)
.await?;
let mut crossing_count = 0usize; let mut crossing_count = 0usize;
for pair in points.windows(2) { for pair in points.windows(2) {
let crossings = let crossings = geo::crossings_between(&state.db, &pair[0], &pair[1]).await?;
geo::crossings_between(
&state.db,
&pair[0],
&pair[1],
)
.await?;
for crossing in crossings { for crossing in crossings {
let interval = let interval = IntervalsClient::matching_interval(&activity, crossing.track_index);
IntervalsClient::matching_interval(
&activity,
crossing.track_index,
);
db::save_crossing( db::save_crossing(&mut tx, activity_db_id, &crossing, interval.as_ref()).await?;
&mut tx,
activity_db_id,
&crossing,
interval.as_ref(),
)
.await?;
crossing_count += 1; crossing_count += 1;
@ -564,22 +397,18 @@ pub async fn process_activity(
from = crossing.from_county_id, from = crossing.from_county_id,
to = crossing.to_county_id, to = crossing.to_county_id,
track_index = crossing.track_index, track_index = crossing.track_index,
lat = crossing.lat,
lon = crossing.lon,
"county crossing detected" "county crossing detected"
); );
} }
} }
db::mark_activity_processed( db::mark_activity_processed(&mut tx, activity_db_id).await?;
&mut tx,
activity_db_id,
)
.await?;
tx.commit().await?; tx.commit().await?;
tracing::info!( tracing::info!(
activity_id = %activity_id, activity_id = %activity_id,
points = points.len(),
crossings = crossing_count, crossings = crossing_count,
"activity processed" "activity processed"
); );
@ -587,66 +416,14 @@ pub async fn process_activity(
Ok(()) Ok(())
} }
/// Parse Intervals.icu GPS streams. fn parse_track(streams: &Value, start_time: DateTime<Utc>) -> Result<Vec<TrackPoint>> {
/// let times = IntervalsClient::stream_values(streams, "time")
/// Intervals.icu returns the streams as an array similar to: .ok_or_else(|| anyhow!("Intervals.icu response has no time stream"))?;
///
/// [
/// {
/// "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<Utc>,
) -> Result<Vec<TrackPoint>> {
let times = IntervalsClient::stream_values(
streams,
"time",
)
.ok_or_else(|| {
anyhow!(
"Intervals.icu response has no time stream"
)
})?;
let (latitudes, longitudes) = let latlng = IntervalsClient::stream_values(streams, "latlng")
IntervalsClient::latlng_values(streams) .ok_or_else(|| anyhow!("Intervals.icu response has no latlng stream"))?;
.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 length = times.len().min(latlng.len());
let mut result = Vec::with_capacity(length); let mut result = Vec::with_capacity(length);
for index in 0..length { for index in 0..length {
@ -654,25 +431,25 @@ fn parse_track(
continue; continue;
}; };
let Some(lat) = latitudes[index].as_f64() else { let Some(coordinate) = latlng[index].as_array() else {
continue; continue;
}; };
let Some(lon) = longitudes[index].as_f64() else { if coordinate.len() != 2 {
continue; continue;
}
let Some(lat) = coordinate[0].as_f64() else {
continue;
};
let Some(lon) = coordinate[1].as_f64() else {
continue;
}; };
if !seconds.is_finite() if !lat.is_finite() || !lon.is_finite() || !seconds.is_finite() {
|| !lat.is_finite()
|| !lon.is_finite()
{
continue; continue;
} }
/*
* Intervals.icu time values are seconds from the
* beginning of the activity.
*/
let millis = (seconds * 1000.0).round() as i64; let millis = (seconds * 1000.0).round() as i64;
result.push(TrackPoint { result.push(TrackPoint {
@ -683,106 +460,27 @@ fn parse_track(
}); });
} }
tracing::info!(
points = result.len(),
"finished parsing Intervals.icu GPS stream"
);
Ok(result) Ok(result)
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use chrono::{TimeZone, Utc};
use crate::geo::interpolate_time; use crate::geo::interpolate_time;
use chrono::{TimeZone, Utc};
#[test] #[test]
fn interpolation_is_millisecond_precise() { fn interpolation_is_millisecond_precise() {
let a = Utc.timestamp_millis_opt(1000).unwrap(); let a = Utc.timestamp_millis_opt(1000).unwrap();
let b = Utc.timestamp_millis_opt(2000).unwrap(); let b = Utc.timestamp_millis_opt(2000).unwrap();
let result = interpolate_time(a, b, 0.376); let result = interpolate_time(a, b, 0.376);
assert_eq!(result.timestamp_millis(), 1376);
assert_eq!(
result.timestamp_millis(),
1376
);
} }
#[test] #[test]
fn interpolation_rounds_to_nearest_ms() { fn interpolation_rounds_to_nearest_ms() {
let a = Utc.timestamp_millis_opt(1000).unwrap(); let a = Utc.timestamp_millis_opt(1000).unwrap();
let b = Utc.timestamp_millis_opt(2000).unwrap(); let b = Utc.timestamp_millis_opt(2000).unwrap();
let result = interpolate_time(a, b, 0.3764); let result = interpolate_time(a, b, 0.3764);
assert_eq!(result.timestamp_millis(), 1376);
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
);
} }
} }

View file

@ -30,42 +30,50 @@ pub struct WebhookEnvelope {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct WebhookEvent { pub struct WebhookEvent {
pub athlete_id: String, pub athlete_id: String,
#[serde(rename = "type")] #[serde(rename = "type")]
pub event_type: String, pub event_type: String,
pub timestamp: DateTime<Utc>, pub timestamp: DateTime<Utc>,
#[serde(default)] #[serde(default)]
pub activity: Option<Value>, pub activity: Option<Value>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, Clone)]
pub struct LeaderboardRow { pub struct LeaderboardRow {
pub rank: i64, pub rank: i64,
pub athlete: String, pub athlete: String,
pub from_county: String, pub from_county: String,
pub to_county: String, pub to_county: String,
pub crossing_time: DateTime<Utc>, pub crossing_time: DateTime<Utc>,
pub activity_id: String, pub activity_id: String,
pub activity_url: String, pub activity_url: String,
pub interval_url: Option<String>, pub interval_url: Option<String>,
pub average_watts: Option<f64>,
pub average_heartrate: Option<f64>,
pub lat: f64,
pub lon: f64,
}
#[derive(Debug, Serialize, Clone)]
pub struct LeaderboardGroup {
pub bucket_start: DateTime<Utc>,
pub from_county: String,
pub to_county: String,
pub crossing_lat: f64,
pub crossing_lon: f64,
pub rows: Vec<LeaderboardRow>,
}
#[derive(Debug, Serialize, Clone)]
pub struct ActivityCrossing {
pub crossing_time: DateTime<Utc>,
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<String>,
pub average_watts: Option<f64>, pub average_watts: Option<f64>,
pub average_heartrate: Option<f64>, pub average_heartrate: Option<f64>,
} }
#[derive(Debug, Serialize)]
pub struct LeaderboardGroup {
pub bucket_start: DateTime<Utc>,
pub from_county: String,
pub to_county: String,
pub rows: Vec<LeaderboardRow>,
}

View file

@ -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 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<AppState>, jar: CookieJar) -> Html<String> { #[derive(Debug, Deserialize, Default)]
let groups = leaderboard::build(&state.db).await.unwrap_or_default(); pub struct PageQuery {
pub group_id: Option<i64>,
}
let current_user = match jar.get("county_session") { #[derive(Debug, Deserialize)]
Some(cookie) => db::athlete_for_session(&state.db, cookie.value()) 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 .await
.ok() .ok()
.flatten() .flatten(),
.map(|(_, name)| name), _ => 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<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, None => None,
}; };
let mut html = String::from( let activity = sqlx::query(
r#"<!doctype html> r#"
<html lang="de"> SELECT
<head> a.intervals_activity_id,
<meta charset="utf-8"> a.start_time,
<meta name="viewport" content="width=device-width,initial-scale=1"> a.activity_json,
<title>Landkreis-Sprints</title> ath.id AS athlete_id,
<style> ath.display_name
body { FROM activities a
font-family: system-ui, sans-serif; JOIN athletes ath ON ath.id = a.athlete_id
max-width: 1100px; WHERE a.intervals_activity_id = $1
margin: 0 auto; AND (
padding: 1rem; ath.id = $2
background: #111827; OR (
color: #f9fafb; $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,
)))
} }
a { color: #93c5fd; }
header { pub async fn groups(
display: flex; State(state): State<AppState>,
justify-content: space-between; jar: CookieJar,
align-items: center; ) -> Result<Html<String>, StatusCode> {
gap: 1rem; 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)))
} }
.card {
background: #1f2937; pub async fn save_group(
border-radius: 12px; State(state): State<AppState>,
padding: 1rem; jar: CookieJar,
margin: 1rem 0; 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"))
} }
table {
width: 100%; pub async fn delete_group(
border-collapse: collapse; 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"))
} }
th, td {
text-align: left; async fn current_user(state: &AppState, jar: &CookieJar) -> Option<(i64, String)> {
padding: .55rem; let cookie = jar.get("county_session")?;
border-bottom: 1px solid #374151; db::athlete_for_session(&state.db, cookie.value())
.await
.ok()
.flatten()
} }
.rank {
font-weight: bold; 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")))
} }
.time {
font-family: ui-monospace, monospace; fn internal(error: impl std::fmt::Display) -> StatusCode {
tracing::error!(%error, "web request failed");
StatusCode::INTERNAL_SERVER_ERROR
} }
.small {
color: #9ca3af; fn render_index(
font-size: .9rem; groups: &[LeaderboardGroup],
} current: Option<&(i64, String)>,
button, .button { selected_group: Option<i64>,
background: #2563eb; available_groups: &[(i64, String)],
color: white; ) -> Html<String> {
border: 0; let mut html = String::from(BASE_HEAD);
border-radius: 8px;
padding: .6rem .9rem; html.push_str(
text-decoration: none; r#"<header>
}
</style>
</head>
<body>
<header>
<div> <div>
<h1>🚴 Landkreis-Sprints</h1> <h1>🚴 Landkreis-Sprints</h1>
<div class="small"> <div class="small">Grenzübertritte · Millisekunden · Intervals.icu</div>
Grenzübertritte · Millisekunden · Intervals.icu </div>"#,
</div>
</div>
"#,
); );
if let Some(name) = current_user { if let Some((_, name)) = current {
html.push_str(&format!( html.push_str(&format!(
"<div>Angemeldet als <strong>{}</strong> · \ r#"<div>Angemeldet als <strong>{}</strong><br>
<a class=\"button\" href=\"/logout\">Logout</a></div>", <a class="button secondary" href="/groups">Gruppen</a>
escape_html(&name) <a class="button" href="/logout">Logout</a></div>"#,
escape_html(name)
)); ));
} else { } else {
html.push_str( html.push_str(r#"<a class="button" href="/oauth/start">Mit Intervals.icu verbinden</a>"#);
r#"<a class="button" href="/oauth/start">
Mit Intervals.icu verbinden
</a>"#,
);
} }
html.push_str("</header>"); 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,
escape_html(name)
));
}
html.push_str("</select></form></div>");
}
if groups.is_empty() { if groups.is_empty() {
html.push_str( html.push_str(
r#"<div class="card"> r#"<div class="card">Noch keine Landkreisüberquerungen für diese Auswahl.</div>"#,
Noch keine Landkreisüberquerungen.
</div>"#,
); );
} }
for group in groups { for group in groups {
html.push_str("<div class=\"card\">"); html.push_str("<section class=\"card\">");
html.push_str(&format!( html.push_str(&format!(
"<h2>{} → {}</h2>", "<h2>{} → {}</h2>",
escape_html(&group.from_county), escape_html(&group.from_county),
escape_html(&group.to_county) escape_html(&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!( html.push_str(&format!(
"<div class=\"small\">10-Minuten-Fenster ab {}</div>", r#"<div class="group-map" data-lat="{}" data-lon="{}"></div>"#,
group.bucket_start.format("%Y-%m-%d %H:%M:%S UTC") group.crossing_lat, group.crossing_lon
)); ));
html.push_str( html.push_str(
r#"<table> r#"<table><thead><tr>
<thead> <th>#</th><th>Fahrer</th><th>Übertritt</th><th>Aktivität</th>
<tr> <th>Intervall</th><th>Power</th><th>HR</th><th>Karte</th>
<th>#</th> </tr></thead><tbody>"#,
<th>Fahrer</th>
<th>Übertritt</th>
<th>Intervall</th>
<th>Power</th>
<th>HR</th>
</tr>
</thead>
<tbody>"#,
); );
for row in group.rows { for row in &group.rows {
let interval = match row.interval_url { let interval = row
Some(url) => format!( .interval_url
"<a href=\"{}\" target=\"_blank\">Intervall</a>", .as_ref()
escape_html(&url) .map(|url| {
), format!(
"<a href=\"{}\" target=\"_blank\" rel=\"noreferrer\">Intervall</a>",
None => "<span class=\"small\">—</span>".into(), escape_html(url)
}; )
})
.unwrap_or_else(|| "<span class=\"small\">—</span>".into());
let power = row let power = row
.average_watts .average_watts
.map(|v| format!("{:.0} W", v)) .map(|v| format!("{:.0} W", v))
.unwrap_or_else(|| "".into()); .unwrap_or_else(|| "".into());
let hr = row let hr = row
.average_heartrate .average_heartrate
.map(|v| format!("{:.0} bpm", v)) .map(|v| format!("{:.0} bpm", v))
@ -160,38 +465,283 @@ button, .button {
html.push_str(&format!( html.push_str(&format!(
r#"<tr> r#"<tr>
<td class="rank">{}</td> <td class="rank">{}</td>
<td>{}</td> <td>{}</td>
<td class="time">{}</td> <td class="time" data-time="{}">{}</td>
<td>{}</td> <td><a href="{}">{}</a></td>
<td>{}</td> <td>{}</td><td>{}</td><td>{}</td>
<td>{}</td> <td><div class="mini-map" data-lat="{}" data-lon="{}"></div></td>
</tr>"#, </tr>"#,
row.rank, row.rank,
escape_html(&row.athlete), 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, interval,
row.lat,
row.lon,
power, power,
hr hr,
)); ));
} }
html.push_str("</tbody></table>"); html.push_str("</tbody></table></section>");
html.push_str("</div>");
} }
html.push_str( html.push_str(BASE_FOOT);
r#"<footer class="small">
Datenquelle: Intervals.icu · Landkreisgeometrien: VG250
</footer>
</body>
</html>"#,
);
Html(html) 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>"#,
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#"<div class="small activity-start" data-time="{}">Start: {}</div>"#,
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#"<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>",
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#"<tr><td>{}</td>
<td class="time" data-time="{}">{}</td>
<td>{} {}</td><td class="rank">{}</td>
<td>{}</td><td>{}</td><td>{}</td></tr>"#,
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("</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>"#,
escape_html(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,
escape_html(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,
escape_html(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,
escape_html(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: '&copy; 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;
});
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>
"#;
fn escape_html(value: &str) -> String { fn escape_html(value: &str) -> String {
value value
.replace('&', "&amp;") .replace('&', "&amp;")