this repo has no description
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}