use std::{ collections::{HashMap, HashSet}, path::PathBuf, }; use crate::{ api::auth, locations::Location, strava::{ActivityOwner, ActivityTrack}, }; pub mod api; pub mod config; pub mod locations; pub mod strava; pub const USER_AGENT: &str = concat!("DSD-Checkpoints-v", env!("CARGO_PKG_VERSION")); pub const HTTP_CLIENT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); #[derive(Clone, Debug)] pub struct AppState<'a> { pub config: config::Config, pub oauth_client: auth::OAuthClient, pub http_client: reqwest::Client, pub templates: minijinja::Environment<'a>, } impl<'a> ecsdb::Extension for AppState<'a> {} impl<'a> AppState<'a> { pub async fn acquire_db(&self) -> ecsdb::Ecs { tokio::task::block_in_place(|| self.acquire_db_sync()) } pub fn acquire_db_sync(&self) -> ecsdb::Ecs { ecsdb::Ecs::open(&self.config.database.database_path).unwrap() } } /// Generates an identicon-style SVG icon (128x128px) from a seed. /// The icon is deterministic and different seeds yield different icons. #[allow(clippy::needless_range_loop)] pub fn svg_avatar(seed: i64) -> String { use rand::rngs::StdRng; use rand::{Rng, SeedableRng}; use svg::Document; use svg::node::element::Rectangle; let mut rng = StdRng::seed_from_u64(seed as u64); // Grid size for identicon (e.g., 8x8) let grid_size = 8; let cell_size = 128 / grid_size; // Generate background and foreground colors using HSL let bg_h: i32 = rng.random_range(0..=359); let bg_s: i32 = rng.random_range(20..=40); let bg_l: i32 = rng.random_range(85..=95); // Foreground color (ensure contrast in hue and lightness) let fg_h: i32 = (bg_h + rng.random_range(90..=270)) % 360; let fg_s: i32 = rng.random_range(40..=60); let mut fg_l: i32 = rng.random_range(25..=45); // If lightness is too close, invert foreground lightness if (bg_l - fg_l).abs() < 30 { fg_l = 100 - fg_l; } // Build grid pattern: only fill left half, mirror to right let mut cells = vec![vec![false; grid_size]; grid_size]; for y in 0..grid_size { for x in 0..(grid_size / 2 + grid_size % 2) { // Use a random bit to decide fill let fill = rng.random_bool(0.5); cells[y][x] = fill; cells[y][grid_size - 1 - x] = fill; // mirror } } // Build SVG document let mut document = Document::new() .set("class", "avatar") .set("xmlns", "http://www.w3.org/2000/svg") .set("width", 128) .set("height", 128) .set("viewBox", (0, 0, 128, 128)); // Add background rectangle let bg_rect = Rectangle::new() .set("width", 128) .set("height", 128) .set("fill", format!("hsl({},{}%,{}%)", bg_h, bg_s, bg_l)); document = document.add(bg_rect); // Add filled cells for y in 0..grid_size { for x in 0..grid_size { if cells[y][x] { let rect = Rectangle::new() .set("x", x * cell_size) .set("y", y * cell_size) .set("width", cell_size) .set("height", cell_size) .set("fill", format!("hsl({},{}%,{}%)", fg_h, fg_s, fg_l)); document = document.add(rect); } } } document.to_string() } impl AppState<'_> { pub fn render_template(&self, name: &str, context: S) -> String { self.templates .get_template(name) .unwrap() .render(context) .unwrap() } } use ecsdb::Component; use geo::{ClosestPoint, Distance}; use serde::{Deserialize, Serialize}; use tracing::{debug, info, info_span, warn}; /// Contains a Strava Athlete's `.id`. /// /// Attached to the `Athlete` entity #[derive(Deserialize, Serialize, Debug, Component, Clone, Copy, PartialEq, Eq, Hash)] pub struct AthleteId(pub u64); #[derive(Deserialize, Serialize, Debug, Component, Clone)] pub struct AthleteUsername(pub String); #[derive(PartialEq, Eq, Clone, Serialize, Deserialize, Debug, Ord, Copy, PartialOrd)] pub struct DateTime(pub chrono::DateTime); impl DateTime { pub fn now() -> Self { Self(chrono::Utc::now().fixed_offset()) } pub fn min() -> Self { Self(chrono::DateTime::::MIN_UTC.fixed_offset()) } pub fn max() -> Self { Self(chrono::DateTime::::MAX_UTC.fixed_offset()) } pub fn as_str(&self) -> String { self.0.to_rfc3339() } pub fn elapsed_since(&self, other: Self) -> chrono::Duration { self.0.signed_duration_since(other.0) } } #[derive(Debug)] pub struct FileChange(pub PathBuf); impl ecsdb::schedule::SchedulingMode for FileChange { fn should_run(&self, ecs: &ecsdb::Ecs, system: &str) -> bool { let Some(entity) = ecs.system_entity(system) else { warn!("Missing System entity"); return true; }; let Some(ecsdb::LastRun(last_run)) = entity.component() else { debug!("No LastChanged recorded"); return true; }; let change_time = match std::fs::metadata(&self.0).and_then(|m| m.modified()) { Ok(mtime) => chrono::DateTime::::from(mtime), Err(error) if error.kind() == std::io::ErrorKind::NotFound => { debug!("File not found"); return false; } Err(error) => { warn!(%error, "Metadata::modified() returned error"); return false; } }; debug!(%change_time, %last_run); if change_time > last_run { debug!("modified"); true } else { debug!("not modified"); false } } } pub struct Condition(pub F); impl std::fmt::Debug for Condition { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_tuple("Condition").field(&"???").finish() } } impl ecsdb::schedule::SchedulingMode for Condition where F: Fn(&ecsdb::Ecs, &str) -> bool + 'static, { fn should_run(&self, ecs: &ecsdb::Ecs, system: &str) -> bool { let result = self.0(ecs, system); debug!(?result); result } } pub fn systems() -> ecsdb::Schedule { use ecsdb::schedule::*; let mut schedule = Schedule::default(); schedule.add( locations::location_loader_system, FileChange("locations.geojson".into()), ); schedule.add( strava::new_users_initial_import_system, Condition(|ecs: &ecsdb::Ecs, _system_name: &str| { ecs.query::)>() .next() .is_some() }), ); schedule.add( strava::activity_id_system, After::system(strava::new_users_initial_import_system), ); schedule.add( strava::activity_owner_system, After::system(strava::new_users_initial_import_system), ); schedule.add( strava::update_activities_system, Every(chrono::Duration::hours(1)), ); schedule.add( strava::activity_id_system, After::system(strava::update_activities_system), ); schedule.add( strava::activity_owner_system, After::system(strava::update_activities_system), ); schedule.add(strava::fetch_activity_track_system, Always); schedule.add(location_matching_system, Always); schedule.add( location_scoring_system, After::system(location_matching_system), ); schedule.add( athlete_scoring_system, After::system(location_matching_system), ); schedule } pub struct TokioRuntime(pub tokio::runtime::Handle); impl ecsdb::Extension for TokioRuntime {} #[tracing::instrument(skip_all, err)] pub fn tick(state: &AppState<'static>, schedule: &ecsdb::Schedule) -> Result<(), anyhow::Error> { info!("processing"); let mut db = state.acquire_db_sync(); db.register_extension(state.to_owned())?; if let Ok(handle) = tokio::runtime::Handle::try_current() { db.register_extension(TokioRuntime(handle))?; } schedule.tick(&db)?; Ok(()) } const THRESHOLD_METERS: u64 = 50; /// Set of visited locations. /// /// Usage: /// /// - Attached by `location_matching_system` to `strava::Activity` /// - Attached by `athlete_scoring_system` to `strava::Athlete` #[derive(Deserialize, Serialize, Debug, Component, Default)] pub struct VisitedLocations(HashSet); use ecsdb::query::*; pub fn location_matching_system( ecs: &ecsdb::Ecs, system: ecsdb::SystemEntity, locations: Query<(ecsdb::Entity, Location), ()>, tracks: Query<(ecsdb::Entity, ActivityTrack), Without>, ) -> Result<(), anyhow::Error> { #[derive(Debug, Serialize, Deserialize, Component, Default)] struct ProcessedLocations(HashSet); let ProcessedLocations(processed_locations) = system.component().unwrap_or_default(); if processed_locations != HashSet::from_iter(locations.entities().map(|e| e.id())) { info!("Locations changed. Recalculating all scores..."); for entity in ecs.query::() { debug!(%entity, "Removing VisitedLocations"); entity.detach::(); } } let locations = locations.iter().collect::>(); for (track_entity, track) in tracks.iter() { let _span = info_span!("track", entity = %track_entity).entered(); let mut visited = VisitedLocations(Default::default()); for (location_entity, location) in locations.iter() { debug!(?location, "checking"); let min_distance = track .0 .points() .map(|p| { let closest = location.geometry.closest_point(&p); match closest { geo::Closest::Intersection(_) => 0, geo::Closest::SinglePoint(c) => geo::Geodesic.distance(p, c) as u64, geo::Closest::Indeterminate => u64::MAX, } }) .min(); debug!(min_distance); if min_distance.is_some_and(|d| d <= THRESHOLD_METERS) { visited.0.insert(location_entity.id()); } } track_entity.attach(visited); } system.attach(ProcessedLocations(HashSet::from_iter( locations.iter().map(|(e, _)| e.id()), ))); Ok(()) } #[derive(Debug, Serialize, Deserialize, Component, Default)] pub struct Visitors { athletes: HashSet, rides: HashSet, } pub fn location_scoring_system( db: &ecsdb::Ecs, tracks: Query<(ecsdb::Entity, VisitedLocations, ActivityOwner)>, ) -> Result<(), anyhow::Error> { let mut visited_counts: HashMap = HashMap::new(); for (track_entity, visited_locations, athlete) in tracks.iter() { for eid in visited_locations.0 { let visitors = visited_counts.entry(eid).or_default(); visitors.athletes.insert(athlete.0); visitors.rides.insert(track_entity.id()); } } for (eid, visitors) in visited_counts { let Some(location) = db.find(eid).next() else { warn!(eid, "Couldn't find Location"); continue; }; location.attach(visitors); } Ok(()) } pub fn athlete_scoring_system( db: &ecsdb::Ecs, athletes: Query<(ecsdb::Entity, AthleteId)>, ) -> Result<(), anyhow::Error> { for (athlete, _) in athletes.iter() { let visited = VisitedLocations( db.query_filtered::(ActivityOwner(athlete.id())) .flat_map(|v| v.0) .collect(), ); athlete.attach(visited); } Ok(()) } impl AsRef> for DateTime { fn as_ref(&self) -> &chrono::DateTime { &self.0 } } impl TryFrom<&str> for DateTime { type Error = chrono::ParseError; fn try_from(value: &str) -> Result { chrono::DateTime::parse_from_rfc3339(value).map(Self) } } impl AsRef for AthleteId { fn as_ref(&self) -> &u64 { &self.0 } } impl AsRef for AthleteUsername { fn as_ref(&self) -> &str { &self.0 } } impl std::fmt::Display for AthleteId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.0.fmt(f) } } impl std::fmt::Display for AthleteUsername { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.0.fmt(f) } }