at main 13 kB view raw
1use std::{ 2 collections::{HashMap, HashSet}, 3 path::PathBuf, 4}; 5 6use crate::{ 7 api::auth, 8 locations::Location, 9 strava::{ActivityOwner, ActivityTrack}, 10}; 11 12pub mod api; 13pub mod config; 14pub mod locations; 15pub mod strava; 16 17pub const USER_AGENT: &str = concat!("DSD-Checkpoints-v", env!("CARGO_PKG_VERSION")); 18pub const HTTP_CLIENT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); 19 20#[derive(Clone, Debug)] 21pub struct AppState<'a> { 22 pub config: config::Config, 23 pub oauth_client: auth::OAuthClient, 24 pub http_client: reqwest::Client, 25 26 pub templates: minijinja::Environment<'a>, 27} 28 29impl<'a> ecsdb::Extension for AppState<'a> {} 30 31impl<'a> AppState<'a> { 32 pub async fn acquire_db(&self) -> ecsdb::Ecs { 33 tokio::task::block_in_place(|| self.acquire_db_sync()) 34 } 35 36 pub fn acquire_db_sync(&self) -> ecsdb::Ecs { 37 ecsdb::Ecs::open(&self.config.database.database_path).unwrap() 38 } 39} 40 41/// Generates an identicon-style SVG icon (128x128px) from a seed. 42/// The icon is deterministic and different seeds yield different icons. 43#[allow(clippy::needless_range_loop)] 44pub fn svg_avatar(seed: i64) -> String { 45 use rand::rngs::StdRng; 46 use rand::{Rng, SeedableRng}; 47 use svg::Document; 48 use svg::node::element::Rectangle; 49 50 let mut rng = StdRng::seed_from_u64(seed as u64); 51 52 // Grid size for identicon (e.g., 8x8) 53 let grid_size = 8; 54 let cell_size = 128 / grid_size; 55 56 // Generate background and foreground colors using HSL 57 let bg_h: i32 = rng.random_range(0..=359); 58 let bg_s: i32 = rng.random_range(20..=40); 59 let bg_l: i32 = rng.random_range(85..=95); 60 61 // Foreground color (ensure contrast in hue and lightness) 62 let fg_h: i32 = (bg_h + rng.random_range(90..=270)) % 360; 63 let fg_s: i32 = rng.random_range(40..=60); 64 let mut fg_l: i32 = rng.random_range(25..=45); 65 66 // If lightness is too close, invert foreground lightness 67 if (bg_l - fg_l).abs() < 30 { 68 fg_l = 100 - fg_l; 69 } 70 71 // Build grid pattern: only fill left half, mirror to right 72 let mut cells = vec![vec![false; grid_size]; grid_size]; 73 for y in 0..grid_size { 74 for x in 0..(grid_size / 2 + grid_size % 2) { 75 // Use a random bit to decide fill 76 let fill = rng.random_bool(0.5); 77 cells[y][x] = fill; 78 cells[y][grid_size - 1 - x] = fill; // mirror 79 } 80 } 81 82 // Build SVG document 83 let mut document = Document::new() 84 .set("class", "avatar") 85 .set("xmlns", "http://www.w3.org/2000/svg") 86 .set("width", 128) 87 .set("height", 128) 88 .set("viewBox", (0, 0, 128, 128)); 89 90 // Add background rectangle 91 let bg_rect = Rectangle::new() 92 .set("width", 128) 93 .set("height", 128) 94 .set("fill", format!("hsl({},{}%,{}%)", bg_h, bg_s, bg_l)); 95 document = document.add(bg_rect); 96 97 // Add filled cells 98 for y in 0..grid_size { 99 for x in 0..grid_size { 100 if cells[y][x] { 101 let rect = Rectangle::new() 102 .set("x", x * cell_size) 103 .set("y", y * cell_size) 104 .set("width", cell_size) 105 .set("height", cell_size) 106 .set("fill", format!("hsl({},{}%,{}%)", fg_h, fg_s, fg_l)); 107 document = document.add(rect); 108 } 109 } 110 } 111 112 document.to_string() 113} 114 115impl AppState<'_> { 116 pub fn render_template<S: Serialize>(&self, name: &str, context: S) -> String { 117 self.templates 118 .get_template(name) 119 .unwrap() 120 .render(context) 121 .unwrap() 122 } 123} 124 125use ecsdb::Component; 126use geo::{ClosestPoint, Distance}; 127use serde::{Deserialize, Serialize}; 128use tracing::{debug, info, info_span, warn}; 129 130/// Contains a Strava Athlete's `.id`. 131/// 132/// Attached to the `Athlete` entity 133#[derive(Deserialize, Serialize, Debug, Component, Clone, Copy, PartialEq, Eq, Hash)] 134pub struct AthleteId(pub u64); 135 136#[derive(Deserialize, Serialize, Debug, Component, Clone)] 137pub struct AthleteUsername(pub String); 138 139#[derive(PartialEq, Eq, Clone, Serialize, Deserialize, Debug, Ord, Copy, PartialOrd)] 140pub struct DateTime(pub chrono::DateTime<chrono::FixedOffset>); 141 142impl DateTime { 143 pub fn now() -> Self { 144 Self(chrono::Utc::now().fixed_offset()) 145 } 146 147 pub fn min() -> Self { 148 Self(chrono::DateTime::<chrono::FixedOffset>::MIN_UTC.fixed_offset()) 149 } 150 151 pub fn max() -> Self { 152 Self(chrono::DateTime::<chrono::FixedOffset>::MAX_UTC.fixed_offset()) 153 } 154 155 pub fn as_str(&self) -> String { 156 self.0.to_rfc3339() 157 } 158 159 pub fn elapsed_since(&self, other: Self) -> chrono::Duration { 160 self.0.signed_duration_since(other.0) 161 } 162} 163 164#[derive(Debug)] 165pub struct FileChange(pub PathBuf); 166 167impl ecsdb::schedule::SchedulingMode for FileChange { 168 fn should_run(&self, ecs: &ecsdb::Ecs, system: &str) -> bool { 169 let Some(entity) = ecs.system_entity(system) else { 170 warn!("Missing System entity"); 171 return true; 172 }; 173 174 let Some(ecsdb::LastRun(last_run)) = entity.component() else { 175 debug!("No LastChanged recorded"); 176 return true; 177 }; 178 179 let change_time = match std::fs::metadata(&self.0).and_then(|m| m.modified()) { 180 Ok(mtime) => chrono::DateTime::<chrono::Utc>::from(mtime), 181 Err(error) if error.kind() == std::io::ErrorKind::NotFound => { 182 debug!("File not found"); 183 return false; 184 } 185 Err(error) => { 186 warn!(%error, "Metadata::modified() returned error"); 187 return false; 188 } 189 }; 190 191 debug!(%change_time, %last_run); 192 193 if change_time > last_run { 194 debug!("modified"); 195 true 196 } else { 197 debug!("not modified"); 198 false 199 } 200 } 201} 202 203pub struct Condition<F>(pub F); 204 205impl<F> std::fmt::Debug for Condition<F> { 206 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 207 f.debug_tuple("Condition").field(&"???").finish() 208 } 209} 210 211impl<F> ecsdb::schedule::SchedulingMode for Condition<F> 212where 213 F: Fn(&ecsdb::Ecs, &str) -> bool + 'static, 214{ 215 fn should_run(&self, ecs: &ecsdb::Ecs, system: &str) -> bool { 216 let result = self.0(ecs, system); 217 debug!(?result); 218 result 219 } 220} 221 222pub fn systems() -> ecsdb::Schedule { 223 use ecsdb::schedule::*; 224 let mut schedule = Schedule::default(); 225 226 schedule.add( 227 locations::location_loader_system, 228 FileChange("locations.geojson".into()), 229 ); 230 231 schedule.add( 232 strava::new_users_initial_import_system, 233 Condition(|ecs: &ecsdb::Ecs, _system_name: &str| { 234 ecs.query::<ecsdb::EntityId, (strava::Athlete, Without<strava::InitialImportDone>)>() 235 .next() 236 .is_some() 237 }), 238 ); 239 schedule.add( 240 strava::activity_id_system, 241 After::system(strava::new_users_initial_import_system), 242 ); 243 schedule.add( 244 strava::activity_owner_system, 245 After::system(strava::new_users_initial_import_system), 246 ); 247 248 schedule.add( 249 strava::update_activities_system, 250 Every(chrono::Duration::hours(1)), 251 ); 252 schedule.add( 253 strava::activity_id_system, 254 After::system(strava::update_activities_system), 255 ); 256 schedule.add( 257 strava::activity_owner_system, 258 After::system(strava::update_activities_system), 259 ); 260 261 schedule.add(strava::fetch_activity_track_system, Always); 262 263 schedule.add(location_matching_system, Always); 264 schedule.add( 265 location_scoring_system, 266 After::system(location_matching_system), 267 ); 268 schedule.add( 269 athlete_scoring_system, 270 After::system(location_matching_system), 271 ); 272 273 schedule 274} 275 276pub struct TokioRuntime(pub tokio::runtime::Handle); 277impl ecsdb::Extension for TokioRuntime {} 278 279#[tracing::instrument(skip_all, err)] 280pub fn tick(state: &AppState<'static>, schedule: &ecsdb::Schedule) -> Result<(), anyhow::Error> { 281 info!("processing"); 282 let mut db = state.acquire_db_sync(); 283 db.register_extension(state.to_owned())?; 284 285 if let Ok(handle) = tokio::runtime::Handle::try_current() { 286 db.register_extension(TokioRuntime(handle))?; 287 } 288 289 schedule.tick(&db)?; 290 291 Ok(()) 292} 293 294const THRESHOLD_METERS: u64 = 50; 295 296/// Set of visited locations. 297/// 298/// Usage: 299/// 300/// - Attached by `location_matching_system` to `strava::Activity` 301/// - Attached by `athlete_scoring_system` to `strava::Athlete` 302#[derive(Deserialize, Serialize, Debug, Component, Default)] 303pub struct VisitedLocations(HashSet<ecsdb::EntityId>); 304 305use ecsdb::query::*; 306pub fn location_matching_system( 307 ecs: &ecsdb::Ecs, 308 system: ecsdb::SystemEntity, 309 locations: Query<(ecsdb::Entity, Location), ()>, 310 tracks: Query<(ecsdb::Entity, ActivityTrack), Without<VisitedLocations>>, 311) -> Result<(), anyhow::Error> { 312 #[derive(Debug, Serialize, Deserialize, Component, Default)] 313 struct ProcessedLocations(HashSet<ecsdb::EntityId>); 314 let ProcessedLocations(processed_locations) = system.component().unwrap_or_default(); 315 if processed_locations != HashSet::from_iter(locations.entities().map(|e| e.id())) { 316 info!("Locations changed. Recalculating all scores..."); 317 for entity in ecs.query::<ecsdb::Entity, VisitedLocations>() { 318 debug!(%entity, "Removing VisitedLocations"); 319 entity.detach::<VisitedLocations>(); 320 } 321 } 322 323 let locations = locations.iter().collect::<Vec<_>>(); 324 for (track_entity, track) in tracks.iter() { 325 let _span = info_span!("track", entity = %track_entity).entered(); 326 327 let mut visited = VisitedLocations(Default::default()); 328 329 for (location_entity, location) in locations.iter() { 330 debug!(?location, "checking"); 331 332 let min_distance = track 333 .0 334 .points() 335 .map(|p| { 336 let closest = location.geometry.closest_point(&p); 337 match closest { 338 geo::Closest::Intersection(_) => 0, 339 geo::Closest::SinglePoint(c) => geo::Geodesic.distance(p, c) as u64, 340 geo::Closest::Indeterminate => u64::MAX, 341 } 342 }) 343 .min(); 344 345 debug!(min_distance); 346 347 if min_distance.is_some_and(|d| d <= THRESHOLD_METERS) { 348 visited.0.insert(location_entity.id()); 349 } 350 } 351 352 track_entity.attach(visited); 353 } 354 355 system.attach(ProcessedLocations(HashSet::from_iter( 356 locations.iter().map(|(e, _)| e.id()), 357 ))); 358 359 Ok(()) 360} 361 362#[derive(Debug, Serialize, Deserialize, Component, Default)] 363pub struct Visitors { 364 athletes: HashSet<ecsdb::EntityId>, 365 rides: HashSet<ecsdb::EntityId>, 366} 367 368pub fn location_scoring_system( 369 db: &ecsdb::Ecs, 370 tracks: Query<(ecsdb::Entity, VisitedLocations, ActivityOwner)>, 371) -> Result<(), anyhow::Error> { 372 let mut visited_counts: HashMap<ecsdb::EntityId, Visitors> = HashMap::new(); 373 374 for (track_entity, visited_locations, athlete) in tracks.iter() { 375 for eid in visited_locations.0 { 376 let visitors = visited_counts.entry(eid).or_default(); 377 visitors.athletes.insert(athlete.0); 378 visitors.rides.insert(track_entity.id()); 379 } 380 } 381 382 for (eid, visitors) in visited_counts { 383 let Some(location) = db.find(eid).next() else { 384 warn!(eid, "Couldn't find Location"); 385 continue; 386 }; 387 388 location.attach(visitors); 389 } 390 391 Ok(()) 392} 393 394pub fn athlete_scoring_system( 395 db: &ecsdb::Ecs, 396 athletes: Query<(ecsdb::Entity, AthleteId)>, 397) -> Result<(), anyhow::Error> { 398 for (athlete, _) in athletes.iter() { 399 let visited = VisitedLocations( 400 db.query_filtered::<VisitedLocations, ActivityOwner>(ActivityOwner(athlete.id())) 401 .flat_map(|v| v.0) 402 .collect(), 403 ); 404 405 athlete.attach(visited); 406 } 407 408 Ok(()) 409} 410 411impl AsRef<chrono::DateTime<chrono::FixedOffset>> for DateTime { 412 fn as_ref(&self) -> &chrono::DateTime<chrono::FixedOffset> { 413 &self.0 414 } 415} 416 417impl TryFrom<&str> for DateTime { 418 type Error = chrono::ParseError; 419 420 fn try_from(value: &str) -> Result<Self, Self::Error> { 421 chrono::DateTime::parse_from_rfc3339(value).map(Self) 422 } 423} 424 425impl AsRef<u64> for AthleteId { 426 fn as_ref(&self) -> &u64 { 427 &self.0 428 } 429} 430 431impl AsRef<str> for AthleteUsername { 432 fn as_ref(&self) -> &str { 433 &self.0 434 } 435} 436 437impl std::fmt::Display for AthleteId { 438 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 439 self.0.fmt(f) 440 } 441} 442 443impl std::fmt::Display for AthleteUsername { 444 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 445 self.0.fmt(f) 446 } 447}