This commit is contained in:
Jonas Rabenstein 2026-08-12 17:49:27 +02:00
commit 45bcc13788
3 changed files with 678 additions and 174 deletions

View file

@ -16,24 +16,55 @@ fi
: "${DATABASE_URL:?DATABASE_URL must be set}" : "${DATABASE_URL:?DATABASE_URL must be set}"
echo "Available layers:" echo "Available layers:"
ogrinfo "$INPUT" | sed -n '1,80p' ogrinfo "$INPUT" | sed -n '1,120p'
echo echo
echo "Importing vg250_krs..." echo "Inspecting vg250_krs..."
ogrinfo "$INPUT" vg250_krs -so
echo
echo "Importing only vg250_krs..."
# Remove an old failed import.
psql "$DATABASE_URL" <<'SQL'
DROP TABLE IF EXISTS vg250_krs_import CASCADE;
SQL
# IMPORTANT:
# The layer name is explicitly specified here.
ogr2ogr \ ogr2ogr \
-f PostgreSQL \ -f PostgreSQL \
"$DATABASE_URL" \ "$DATABASE_URL" \
"$INPUT" \ "$INPUT" \
vg250_krs \
-nln vg250_krs_import \ -nln vg250_krs_import \
-nlt PROMOTE_TO_MULTI \ -nlt PROMOTE_TO_MULTI \
-t_srs EPSG:4326 \ -t_srs EPSG:4326 \
-lco GEOMETRY_NAME=wkb_geometry \
-overwrite -overwrite
echo "Import completed." echo
echo "Source import completed."
echo
echo "Imported source columns:"
psql "$DATABASE_URL" <<'SQL'
SELECT
column_name,
data_type
FROM information_schema.columns
WHERE table_name = 'vg250_krs_import'
ORDER BY ordinal_position;
SQL
echo
echo "Creating county boundaries..."
psql "$DATABASE_URL" <<'SQL' psql "$DATABASE_URL" <<'SQL'
TRUNCATE county_boundaries RESTART IDENTITY; BEGIN;
TRUNCATE county_crossings;
TRUNCATE county_boundaries RESTART IDENTITY CASCADE;
INSERT INTO county_boundaries ( INSERT INTO county_boundaries (
name, name,
@ -41,9 +72,9 @@ INSERT INTO county_boundaries (
) )
SELECT SELECT
COALESCE( COALESCE(
NULLIF("GEN", ''), NULLIF(TRIM(gen), ''),
NULLIF("BEZ", ''), NULLIF(TRIM(bez), ''),
NULLIF("ARS", ''), NULLIF(TRIM(ars), ''),
'unknown' 'unknown'
) AS name, ) AS name,
@ -52,13 +83,29 @@ SELECT
ST_MakeValid(wkb_geometry), ST_MakeValid(wkb_geometry),
3 3
) )
)::geometry(MultiPolygon,4326) )::geometry(MultiPolygon, 4326) AS geometry
FROM vg250_krs_import FROM vg250_krs_import
WHERE wkb_geometry IS NOT NULL; WHERE
wkb_geometry IS NOT NULL
AND NOT ST_IsEmpty(wkb_geometry);
COMMIT;
SQL
echo
echo "Removing temporary import table..."
psql "$DATABASE_URL" <<'SQL'
DROP TABLE vg250_krs_import; DROP TABLE vg250_krs_import;
SQL
echo
echo "Recreating spatial index..."
psql "$DATABASE_URL" <<'SQL'
DROP INDEX IF EXISTS county_boundaries_geometry_idx;
CREATE INDEX county_boundaries_geometry_idx CREATE INDEX county_boundaries_geometry_idx
ON county_boundaries ON county_boundaries
@ -68,6 +115,32 @@ ANALYZE county_boundaries;
SQL SQL
echo echo
echo "Imported counties:" echo "=========================================="
psql "$DATABASE_URL" \ echo " Imported counties"
-c "SELECT id, name FROM county_boundaries ORDER BY name;" echo "=========================================="
psql "$DATABASE_URL" <<'SQL'
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE geometry IS NOT NULL) AS with_geometry,
COUNT(*) FILTER (WHERE ST_IsValid(geometry)) AS valid_geometry
FROM county_boundaries;
SQL
echo
echo "First 20 counties:"
echo
psql "$DATABASE_URL" <<'SQL'
SELECT
id,
name,
ST_GeometryType(geometry) AS geometry_type,
ST_SRID(geometry) AS srid
FROM county_boundaries
ORDER BY name
LIMIT 20;
SQL
echo
echo "Import completed successfully."

View file

@ -1,4 +1,4 @@
use anyhow::{Context, Result, anyhow}; use anyhow::{anyhow, Context, Result};
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,7 +26,10 @@ 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(config: Config, api_key: impl Into<String>) -> Self { pub fn with_api_key(
config: Config,
api_key: impl Into<String>,
) -> Self {
Self { Self {
client: Client::new(), client: Client::new(),
config, config,
@ -35,13 +38,17 @@ impl IntervalsClient {
} }
fn base_url(&self) -> &str { fn base_url(&self) -> &str {
self.config.intervals_base_url.trim_end_matches('/') self.config
.intervals_base_url
.trim_end_matches('/')
} }
fn api_key(&self) -> Result<&str> { fn api_key(&self) -> Result<&str> {
self.api_key self.api_key
.as_deref() .as_deref()
.context("Intervals.icu API key is not configured") .context(
"Intervals.icu API key is not configured",
)
} }
fn api_url(&self, path: &str) -> String { fn api_url(&self, path: &str) -> String {
@ -52,30 +59,54 @@ impl IntervalsClient {
) )
} }
fn authenticated(&self, request: reqwest::RequestBuilder) -> Result<reqwest::RequestBuilder> { fn authenticated(
Ok(request.basic_auth("API_KEY", Some(self.api_key()?))) &self,
request: reqwest::RequestBuilder,
) -> Result<reqwest::RequestBuilder> {
Ok(
request.basic_auth(
"API_KEY",
Some(self.api_key()?),
)
)
} }
async fn get_json(&self, url: String) -> Result<Value> { async fn get_json(
&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("Intervals.icu request failed")?; .context(
"Intervals.icu request failed",
)?;
let status = response.status(); let status = response.status();
let body = response let body = response
.text() .text()
.await .await
.context("cannot read Intervals.icu response")?; .context(
"cannot read Intervals.icu response",
)?;
if !status.is_success() { if !status.is_success() {
return Err(anyhow!("Intervals.icu returned HTTP {}: {}", status, body)); return Err(anyhow!(
"Intervals.icu returned HTTP {}: {}",
status,
body
));
} }
serde_json::from_str(&body) serde_json::from_str(&body)
.with_context(|| format!("invalid JSON returned by Intervals.icu: {}", body)) .with_context(|| {
format!(
"invalid JSON returned by Intervals.icu: {}",
body
)
})
} }
/// Get the athlete belonging to the configured API key. /// Get the athlete belonging to the configured API key.
@ -83,12 +114,18 @@ impl IntervalsClient {
/// GET /api/v1/athlete/0 /// GET /api/v1/athlete/0
pub async fn owner(&self) -> Result<Value> { pub async fn owner(&self) -> Result<Value> {
let url = self.api_url("athlete/0"); let url = self.api_url("athlete/0");
self.get_json(url).await self.get_json(url).await
} }
/// Get a specific athlete. /// Get a specific athlete.
pub async fn athlete(&self, athlete_id: &str) -> Result<Value> { pub async fn athlete(
let url = self.api_url(&format!("athlete/{athlete_id}")); &self,
athlete_id: &str,
) -> Result<Value> {
let url = self.api_url(
&format!("athlete/{athlete_id}"),
);
self.get_json(url).await self.get_json(url).await
} }
@ -96,8 +133,20 @@ impl IntervalsClient {
/// Get a single activity. /// Get a single activity.
/// ///
/// GET /api/v1/activity/{id} /// GET /api/v1/activity/{id}
pub async fn activity(&self, activity_id: &str) -> Result<Value> { pub async fn activity(
let url = self.api_url(&format!("activity/{activity_id}?intervals=true")); &self,
activity_id: &str,
) -> Result<Value> {
/*
* Ask Intervals.icu to include interval information,
* which is later used to associate county crossings
* with intervals.
*/
let url = self.api_url(
&format!(
"activity/{activity_id}?intervals=true"
),
);
self.get_json(url).await self.get_json(url).await
} }
@ -106,78 +155,180 @@ impl IntervalsClient {
/// ///
/// GET /api/v1/activity/{id}/streams.json /// GET /api/v1/activity/{id}/streams.json
/// ///
/// We explicitly request the streams needed for GPS processing. /// We explicitly request the streams needed for GPS
pub async fn streams(&self, activity_id: &str) -> Result<Value> { /// processing.
let url = self.api_url(&format!( pub async fn streams(
"activity/{activity_id}/streams.json?types=time,latlng" &self,
)); activity_id: &str,
) -> Result<Value> {
let url = self.api_url(
&format!(
"activity/{activity_id}/streams.json?types=time,latlng"
),
);
self.get_json(url).await tracing::info!(
activity_id = %activity_id,
"requesting Intervals.icu streams"
);
let value =
self.get_json(url).await?;
let stream_count = value
.as_array()
.map(|streams| streams.len())
.unwrap_or(0);
let time_points =
Self::stream_values(
&value,
"time",
)
.map(|values| values.len())
.unwrap_or(0);
let (latitude_points, longitude_points) =
Self::latlng_values(&value)
.map(|(lat, lon)| {
(lat.len(), lon.len())
})
.unwrap_or((0, 0));
tracing::info!(
activity_id = %activity_id,
stream_count,
time_points,
latitude_points,
longitude_points,
"received Intervals.icu streams"
);
Ok(value)
} }
/// Find a stream object by type. /// Find a stream object by type.
pub fn find_stream<'a>(streams: &'a Value, stream_type: &str) -> Option<&'a Value> { ///
// Normal API response: /// Normal Intervals.icu response:
// ///
// [ /// [
// { /// {
// "type": "time", /// "type": "time",
// "data": [...] /// "data": [...]
// }, /// },
// { /// {
// "type": "latlng", /// "type": "latlng",
// "data": [...], /// "data": [...],
// "data2": [...] /// "data2": [...]
// } /// }
// ] /// ]
if let Some(array) = streams.as_array() { ///
return array /// Also tolerates a dictionary-like response.
.iter() pub fn find_stream<'a>(
.find(|stream| stream.get("type").and_then(Value::as_str) == Some(stream_type)); streams: &'a Value,
stream_type: &str,
) -> Option<&'a Value> {
if let Some(array) =
streams.as_array()
{
return array.iter().find(
|stream| {
stream
.get("type")
.and_then(Value::as_str)
== Some(stream_type)
},
);
} }
// Also tolerate:
//
// {
// "time": {...},
// "latlng": {...}
// }
streams.get(stream_type) streams.get(stream_type)
} }
/// Get the `data` array of a normal stream. /// Get the `data` array of a normal stream.
pub fn stream_values<'a>(streams: &'a Value, stream_type: &str) -> Option<&'a Vec<Value>> { ///
let stream = Self::find_stream(streams, stream_type)?; /// For example:
///
/// {
/// "type": "time",
/// "data": [0, 1, 2, 3]
/// }
pub fn stream_values<'a>(
streams: &'a Value,
stream_type: &str,
) -> Option<&'a Vec<Value>> {
let stream =
Self::find_stream(
streams,
stream_type,
)?;
stream.get("data").and_then(Value::as_array) stream
.get("data")
.and_then(Value::as_array)
} }
/// Get the latitude and longitude arrays from the /// Get latitude and longitude arrays from the
/// Intervals.icu `latlng` stream. /// Intervals.icu `latlng` stream.
/// ///
/// Intervals.icu represents latlng as: /// Intervals.icu represents latlng as:
/// ///
/// {
/// "type": "latlng",
/// "data": [latitude, latitude, ...],
/// "data2": [longitude, longitude, ...]
/// }
///
/// Therefore:
///
/// data = latitude /// data = latitude
/// data2 = longitude /// data2 = longitude
/// ///
pub fn latlng_values<'a>(streams: &'a Value) -> Option<(&'a Vec<Value>, &'a Vec<Value>)> { /// It is NOT:
let stream = Self::find_stream(streams, "latlng")?; ///
/// data = [[lat, lon], [lat, lon], ...]
pub fn latlng_values<'a>(
streams: &'a Value,
) -> Option<(
&'a Vec<Value>,
&'a Vec<Value>,
)> {
let stream =
Self::find_stream(
streams,
"latlng",
)?;
let lat = stream.get("data")?.as_array()?; let latitudes = stream
.get("data")
.and_then(Value::as_array)?;
let lon = stream.get("data2")?.as_array()?; let longitudes = stream
.get("data2")
.and_then(Value::as_array)?;
Some((lat, lon)) Some((
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(&self, athlete_id: &str, oldest: &str, newest: &str) -> Result<Value> { pub async fn activities(
&self,
athlete_id: &str,
oldest: &str,
newest: &str,
) -> Result<Value> {
let url = format!( let url = format!(
"{}?oldest={}&newest={}", "{}?oldest={}&newest={}",
self.api_url(&format!("athlete/{athlete_id}/activities")), self.api_url(
&format!(
"athlete/{athlete_id}/activities"
),
),
urlencoding::encode(oldest), urlencoding::encode(oldest),
urlencoding::encode(newest), urlencoding::encode(newest),
); );
@ -186,39 +337,68 @@ impl IntervalsClient {
} }
/// Extract the activity start timestamp. /// Extract the activity start timestamp.
pub fn activity_start(activity: &Value) -> Result<DateTime<Utc>> { pub fn activity_start(
activity: &Value,
) -> Result<DateTime<Utc>> {
let value = activity let value = activity
.get("start_date") .get("start_date")
.or_else(|| activity.get("start_date_local")) .or_else(|| {
.or_else(|| activity.get("start_time")) activity.get("start_date_local")
.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(|| anyhow!("activity start timestamp is not a string"))?; .ok_or_else(|| {
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(activity: &Value, track_index: usize) -> Option<Value> { pub fn matching_interval(
let intervals = activity.get("icu_intervals")?; activity: &Value,
track_index: usize,
) -> Option<Value> {
let intervals =
activity.get("icu_intervals")?;
let array = intervals.as_array()?; let 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(|| interval.get("start")); .or_else(|| {
interval.get("start")
});
let end = interval.get("end_index").or_else(|| interval.get("end")); let end = interval
.get("end_index")
.or_else(|| {
interval.get("end")
});
let start = start.and_then(Value::as_u64)?; let start =
start.and_then(Value::as_u64)?;
let end = end.and_then(Value::as_u64)?; let end =
end.and_then(Value::as_u64)?;
if (start as usize) <= track_index && track_index <= end as usize { if (start as usize) <= track_index
&& track_index <= end as usize
{
return Some(interval.clone()); return Some(interval.clone());
} }
} }
@ -227,18 +407,25 @@ 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(&self, state: &str) -> Result<String> { pub fn oauth_authorize_url(
&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("INTERVALS_CLIENT_ID is not configured")?; .context(
"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("INTERVALS_REDIRECT_URI is not configured")?; .context(
"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={}",
@ -250,22 +437,32 @@ impl IntervalsClient {
)) ))
} }
pub async fn exchange_code(&self, code: &str) -> Result<Value> { pub async fn exchange_code(
&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("INTERVALS_CLIENT_ID is not configured")?; .context(
"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("INTERVALS_CLIENT_SECRET is not configured")?; .context(
"INTERVALS_CLIENT_SECRET is not configured",
)?;
let response = self let response = self
.client .client
.post(format!("{}/api/oauth/token", self.base_url())) .post(format!(
"{}/api/oauth/token",
self.base_url()
))
.form(&[ .form(&[
("client_id", client_id), ("client_id", client_id),
("client_secret", client_secret), ("client_secret", client_secret),
@ -273,11 +470,15 @@ impl IntervalsClient {
]) ])
.send() .send()
.await .await
.context("OAuth token request failed")?; .context(
"OAuth token request failed",
)?;
let status = response.status(); let status = response.status();
let body = response.text().await?; let body = response
.text()
.await?;
if !status.is_success() { if !status.is_success() {
return Err(anyhow!( return Err(anyhow!(
@ -290,11 +491,20 @@ impl IntervalsClient {
Ok(serde_json::from_str(&body)?) Ok(serde_json::from_str(&body)?)
} }
pub fn token_data(response: &Value) -> Result<(String, String, String, String)> { pub fn token_data(
response: &Value,
) -> Result<(
String,
String,
String,
String,
)> {
let access_token = response let access_token = response
.get("access_token") .get("access_token")
.and_then(Value::as_str) .and_then(Value::as_str)
.context("OAuth response has no access_token")? .context(
"OAuth response has no access_token",
)?
.to_string(); .to_string();
let scope = response let scope = response
@ -305,7 +515,9 @@ impl IntervalsClient {
let athlete = response let athlete = response
.get("athlete") .get("athlete")
.context("OAuth response has no athlete")?; .context(
"OAuth response has no athlete",
)?;
let athlete_id = athlete let athlete_id = athlete
.get("id") .get("id")
@ -321,27 +533,62 @@ impl IntervalsClient {
let display_name = athlete let display_name = athlete
.get("name") .get("name")
.or_else(|| athlete.get("display_name")) .or_else(|| {
athlete.get("display_name")
})
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or("Intervals.icu athlete") .unwrap_or(
"Intervals.icu athlete",
)
.to_string(); .to_string();
Ok((access_token, scope, athlete_id, display_name)) Ok((
access_token,
scope,
athlete_id,
display_name,
))
} }
} }
fn parse_datetime(value: &str) -> Result<DateTime<Utc>> { fn parse_datetime(
if let Ok(value) = DateTime::parse_from_rfc3339(value) { value: &str,
return Ok(value.with_timezone(&Utc)); ) -> Result<DateTime<Utc>> {
if let Ok(value) =
DateTime::parse_from_rfc3339(value)
{
return Ok(
value.with_timezone(&Utc)
);
} }
if let Ok(value) = DateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S%z") { if let Ok(value) =
return Ok(value.with_timezone(&Utc)); DateTime::parse_from_str(
value,
"%Y-%m-%dT%H:%M:%S%z",
)
{
return Ok(
value.with_timezone(&Utc)
);
} }
if let Ok(value) = NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S") { if let Ok(value) =
return Ok(DateTime::<Utc>::from_naive_utc_and_offset(value, Utc)); NaiveDateTime::parse_from_str(
value,
"%Y-%m-%dT%H:%M:%S",
)
{
return Ok(
DateTime::<Utc>::from_naive_utc_and_offset(
value,
Utc,
)
);
} }
Err(anyhow!("cannot parse activity timestamp: {}", value)) Err(anyhow!(
"cannot parse activity timestamp: {}",
value
))
} }

View file

@ -8,20 +8,24 @@ mod model;
mod web; mod web;
mod webhook; mod webhook;
use anyhow::{Context, Result, anyhow}; use anyhow::{anyhow, Context, Result};
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::{Value, json}; use serde_json::{json, Value};
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::{config::Config, intervals::IntervalsClient, model::TrackPoint}; use crate::{
config::Config,
intervals::IntervalsClient,
model::TrackPoint,
};
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
@ -115,7 +119,6 @@ async fn dev_sync(
) -> Result<Json<Value>, axum::http::StatusCode> { ) -> Result<Json<Value>, axum::http::StatusCode> {
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, error = %error,
@ -129,18 +132,17 @@ async fn dev_sync(
/// One-segment variant: /// One-segment variant:
/// ///
/// /dev/sync/{api_key} /// GET /dev/sync/{api_key}
/// ///
/// Equivalent to: /// Equivalent to:
/// ///
/// /dev/sync/{api_key}/0 /// 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>,
) -> Result<Json<Value>, axum::http::StatusCode> { ) -> Result<Json<Value>, axum::http::StatusCode> {
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, error = %error,
@ -152,7 +154,11 @@ async fn dev_sync_one(
} }
} }
async fn dev_sync_inner(state: AppState, api_key: String, athlete_id: String) -> Result<Value> { async fn dev_sync_inner(
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"));
} }
@ -167,7 +173,8 @@ async fn dev_sync_inner(state: AppState, api_key: String, athlete_id: String) ->
* This client is explicitly authenticated using the API key * This client is explicitly authenticated using the API key
* supplied to the development endpoint. * supplied to the development endpoint.
*/ */
let client = IntervalsClient::with_api_key(state.config.clone(), api_key.clone()); let client =
IntervalsClient::with_api_key(state.config.clone(), api_key.clone());
/* /*
* Resolve athlete 0 through Intervals.icu. * Resolve athlete 0 through Intervals.icu.
@ -197,11 +204,9 @@ async fn dev_sync_inner(state: AppState, api_key: String, athlete_id: String) ->
}; };
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 - 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();
tracing::info!( tracing::info!(
@ -212,13 +217,14 @@ async fn dev_sync_inner(state: AppState, api_key: String, athlete_id: String) ->
); );
/* /*
* IMPORTANT: * Use the resolved athlete ID here, not the original "0".
*
* Use the resolved athlete ID here, not the original
* "0" value.
*/ */
let activities = client let activities = client
.activities(&resolved_athlete_id, &oldest_string, &newest_string) .activities(
&resolved_athlete_id,
&oldest_string,
&newest_string,
)
.await .await
.context("cannot fetch development activities")?; .context("cannot fetch development activities")?;
@ -235,14 +241,21 @@ async fn dev_sync_inner(state: AppState, api_key: String, athlete_id: String) ->
/* /*
* Make sure there is a corresponding local athlete row. * Make sure there is a corresponding local athlete row.
* *
* We store the development API key as access_token because * The development API key is stored as access_token so that
* process_activity() uses the local athlete record and the * process_activity() can use exactly the same credential.
* existing activity-processing pipeline expects that field.
*/ */
ensure_dev_athlete(&state, &resolved_athlete_id, &client, &api_key).await?; 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) = activity.get("id").and_then(Value::as_str) else { let Some(activity_id) =
activity.get("id").and_then(Value::as_str)
else {
skipped += 1; skipped += 1;
results.push(json!({ results.push(json!({
@ -339,9 +352,6 @@ async fn ensure_dev_athlete(
/* /*
* If this athlete already exists, update the development * If this athlete already exists, update the development
* credential. * credential.
*
* This is particularly useful if the row was originally
* created by an earlier development sync with an empty token.
*/ */
let existing = sqlx::query( let existing = sqlx::query(
r#" r#"
@ -401,11 +411,18 @@ async fn ensure_dev_athlete(
Ok(()) Ok(())
} }
/// Process one activity.
///
/// The API credential is retrieved from the local athlete record.
/// This is important for development sync because the API key
/// supplied to /dev/sync/{api_key} is stored in access_token.
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 athlete = sqlx::query( let athlete = sqlx::query(
r#" r#"
SELECT SELECT
@ -419,8 +436,6 @@ 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 {}",
@ -430,14 +445,21 @@ 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
.try_get("access_token")
.context("local athlete has no access token")?;
/* /*
* The normal activity-processing path uses the configured * IMPORTANT:
* Intervals client.
* *
* The development endpoint has already ensured that the * Use the athlete's stored credential rather than creating
* athlete exists locally and that its credential is stored. * IntervalsClient::new(), which would use only the globally
* configured INTERVALS_API_KEY.
*/ */
let client = IntervalsClient::new(state.config.clone()); let client = IntervalsClient::with_api_key(
state.config.clone(),
access_token,
);
/* /*
* Fetch activity metadata. * Fetch activity metadata.
@ -445,19 +467,27 @@ pub async fn process_activity(
let activity = client let activity = client
.activity(&activity_id) .activity(&activity_id)
.await .await
.with_context(|| format!("cannot fetch Intervals.icu activity {}", activity_id))?; .with_context(|| {
format!(
"cannot fetch Intervals.icu activity {}",
activity_id
)
})?;
let start_time = IntervalsClient::activity_start(&activity)?; let start_time = IntervalsClient::activity_start(&activity)?;
/* /*
* Fetch GPS/time streams. * Fetch GPS/time streams.
*/ */
let streams = client.streams(&activity_id).await.with_context(|| { let streams = client
format!( .streams(&activity_id)
"cannot fetch streams for Intervals.icu activity {}", .await
activity_id .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)?;
@ -485,19 +515,46 @@ pub async fn process_activity(
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(&mut tx, athlete_db_id, &activity_id, start_time, &activity).await?; db::ensure_activity(
&mut tx,
athlete_db_id,
&activity_id,
start_time,
&activity,
)
.await?;
db::delete_activity_crossings(&mut tx, activity_db_id).await?; db::delete_activity_crossings(
&mut tx,
activity_db_id,
)
.await?;
let mut crossing_count = 0usize; let mut crossing_count = 0usize;
for pair in points.windows(2) { for pair in points.windows(2) {
let crossings = geo::crossings_between(&state.db, &pair[0], &pair[1]).await?; let crossings =
geo::crossings_between(
&state.db,
&pair[0],
&pair[1],
)
.await?;
for crossing in crossings { for crossing in crossings {
let interval = IntervalsClient::matching_interval(&activity, crossing.track_index); let interval =
IntervalsClient::matching_interval(
&activity,
crossing.track_index,
);
db::save_crossing(&mut tx, activity_db_id, &crossing, interval.as_ref()).await?; db::save_crossing(
&mut tx,
activity_db_id,
&crossing,
interval.as_ref(),
)
.await?;
crossing_count += 1; crossing_count += 1;
@ -512,12 +569,17 @@ pub async fn process_activity(
} }
} }
db::mark_activity_processed(&mut tx, activity_db_id).await?; db::mark_activity_processed(
&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"
); );
@ -525,14 +587,65 @@ pub async fn process_activity(
Ok(()) Ok(())
} }
fn parse_track(streams: &Value, start_time: DateTime<Utc>) -> Result<Vec<TrackPoint>> { /// Parse Intervals.icu GPS streams.
let times = IntervalsClient::stream_values(streams, "time") ///
.ok_or_else(|| anyhow!("Intervals.icu response has no time stream"))?; /// Intervals.icu returns the streams as an array similar to:
///
/// [
/// {
/// "type": "time",
/// "data": [0, 1, 2, 3]
/// },
/// {
/// "type": "latlng",
/// "data": [51.5, 51.5001, 51.5002, 51.5003],
/// "data2": [-0.1, -0.1001, -0.1002, -0.1003]
/// }
/// ]
///
/// IMPORTANT:
///
/// `latlng.data` is latitude.
///
/// `latlng.data2` is longitude.
///
/// It is NOT:
///
/// `latlng.data = [[lat, lon], [lat, lon], ...]`
fn parse_track(
streams: &Value,
start_time: DateTime<Utc>,
) -> Result<Vec<TrackPoint>> {
let times = IntervalsClient::stream_values(
streams,
"time",
)
.ok_or_else(|| {
anyhow!(
"Intervals.icu response has no time stream"
)
})?;
let latlng = IntervalsClient::stream_values(streams, "latlng") let (latitudes, longitudes) =
.ok_or_else(|| anyhow!("Intervals.icu response has no latlng stream"))?; IntervalsClient::latlng_values(streams)
.ok_or_else(|| {
anyhow!(
"Intervals.icu response has no usable latlng stream"
)
})?;
let length = times.len().min(latlng.len()); 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 mut result = Vec::with_capacity(length); let mut result = Vec::with_capacity(length);
@ -541,26 +654,25 @@ fn parse_track(streams: &Value, start_time: DateTime<Utc>) -> Result<Vec<TrackPo
continue; continue;
}; };
let Some(coordinate) = latlng[index].as_array() else { let Some(lat) = latitudes[index].as_f64() else {
continue; continue;
}; };
if coordinate.len() != 2 { let Some(lon) = longitudes[index].as_f64() else {
continue; continue;
}
let Some(lat) = coordinate[0].as_f64() else {
continue;
};
let Some(lon) = coordinate[1].as_f64() else {
continue;
}; };
if !lat.is_finite() || !lon.is_finite() || !seconds.is_finite() { if !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 {
@ -571,6 +683,11 @@ fn parse_track(streams: &Value, start_time: DateTime<Utc>) -> Result<Vec<TrackPo
}); });
} }
tracing::info!(
points = result.len(),
"finished parsing Intervals.icu GPS stream"
);
Ok(result) Ok(result)
} }
@ -583,22 +700,89 @@ mod tests {
#[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
);
} }
} }