add map and clicks
This commit is contained in:
parent
45bcc13788
commit
3cfd5bdd6c
8 changed files with 1195 additions and 992 deletions
92
README.md
92
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
|
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. citeturn0search2turn0search9
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
45
src/db.rs
45
src/db.rs
|
|
@ -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(())
|
||||||
|
}
|
||||||
|
|
|
||||||
351
src/intervals.rs
351
src/intervals.rs
|
|
@ -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
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
514
src/main.rs
514
src/main.rs
|
|
@ -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
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
46
src/model.rs
46
src/model.rs
|
|
@ -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>,
|
|
||||||
}
|
|
||||||
|
|
|
||||||
798
src/web.rs
798
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 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: '© 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('&', "&")
|
.replace('&', "&")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue