ridewithgps_client/
trips.rs

1//! Trip-related types and methods
2
3use crate::{PaginatedResponse, Photo, Polyline, Result, RideWithGpsClient, Visibility};
4use serde::{Deserialize, Serialize};
5
6/// Track point on a trip with telemetry data
7#[derive(Debug, Clone, Deserialize, Serialize)]
8pub struct TripTrackPoint {
9    /// Longitude
10    pub x: Option<f64>,
11
12    /// Latitude
13    pub y: Option<f64>,
14
15    /// Distance in meters
16    pub d: Option<f64>,
17
18    /// Elevation in meters
19    pub e: Option<f64>,
20
21    /// Unix timestamp
22    pub t: Option<i64>,
23
24    /// Speed in km/h
25    pub s: Option<f64>,
26
27    /// Temperature in Celsius
28    #[serde(rename = "T")]
29    pub temp: Option<f64>,
30
31    /// Heart rate in BPM
32    pub h: Option<f64>,
33
34    /// Cadence in RPM
35    pub c: Option<f64>,
36
37    /// Power in watts
38    pub p: Option<f64>,
39
40    /// Power balance (L/R percentage)
41    pub pb: Option<f64>,
42
43    /// Lap marker
44    pub lap: Option<bool>,
45
46    /// Exclude from metrics
47    pub k: Option<bool>,
48
49    /// User modified point
50    pub m: Option<bool>,
51}
52
53/// Gear/equipment used for a trip
54#[derive(Debug, Clone, Deserialize, Serialize)]
55pub struct Gear {
56    /// Gear ID
57    pub id: u64,
58
59    /// Make/brand
60    pub make: Option<String>,
61
62    /// Model
63    pub model: Option<String>,
64
65    /// Description
66    pub description: Option<String>,
67
68    /// Whether to exclude from totals
69    pub exclude_from_totals: Option<bool>,
70
71    /// Created timestamp
72    pub created_at: Option<String>,
73}
74
75/// A trip (recorded ride)
76#[derive(Debug, Clone, Deserialize, Serialize)]
77pub struct Trip {
78    /// Trip ID
79    pub id: u64,
80
81    /// Trip name
82    pub name: Option<String>,
83
84    /// Trip description
85    pub description: Option<String>,
86
87    /// Distance in meters
88    pub distance: Option<f64>,
89
90    /// Elevation gain in meters
91    pub elevation_gain: Option<f64>,
92
93    /// Elevation loss in meters
94    pub elevation_loss: Option<f64>,
95
96    /// Trip visibility
97    pub visibility: Option<Visibility>,
98
99    /// User ID of the trip owner
100    pub user_id: Option<u64>,
101
102    /// API URL
103    pub url: Option<String>,
104
105    /// HTML/web URL
106    pub web_url: Option<String>,
107
108    /// Departed at timestamp
109    pub departed_at: Option<String>,
110
111    /// Time zone
112    pub time_zone: Option<String>,
113
114    /// Created timestamp
115    pub created_at: Option<String>,
116
117    /// Updated timestamp
118    pub updated_at: Option<String>,
119
120    /// Duration in seconds
121    pub duration: Option<f64>,
122
123    /// Moving time in seconds
124    pub moving_time: Option<f64>,
125
126    /// Average speed in m/s
127    pub avg_speed: Option<f64>,
128
129    /// Maximum speed in m/s
130    pub max_speed: Option<f64>,
131
132    /// Average cadence (RPM)
133    pub avg_cad: Option<f64>,
134
135    /// Minimum cadence (RPM)
136    pub min_cad: Option<f64>,
137
138    /// Maximum cadence (RPM)
139    pub max_cad: Option<f64>,
140
141    /// Average heart rate (BPM)
142    pub avg_hr: Option<f64>,
143
144    /// Minimum heart rate (BPM)
145    pub min_hr: Option<f64>,
146
147    /// Maximum heart rate (BPM)
148    pub max_hr: Option<f64>,
149
150    /// Average power (watts)
151    pub avg_watts: Option<f64>,
152
153    /// Minimum power (watts)
154    pub min_watts: Option<f64>,
155
156    /// Maximum power (watts)
157    pub max_watts: Option<f64>,
158
159    /// Calories burned
160    pub calories: Option<f64>,
161
162    /// Recording device name
163    pub device: Option<String>,
164
165    /// Locality/location
166    pub locality: Option<String>,
167
168    /// Administrative area
169    pub administrative_area: Option<String>,
170
171    /// Country code
172    pub country_code: Option<String>,
173
174    /// Activity type
175    pub activity_type: Option<String>,
176
177    /// FIT file sport type
178    pub fit_sport: Option<i32>,
179
180    /// FIT file sub-sport type
181    pub fit_sub_sport: Option<i32>,
182
183    /// Whether the trip is stationary
184    pub stationary: Option<bool>,
185
186    /// Track type
187    pub track_type: Option<String>,
188
189    /// Terrain rating
190    pub terrain: Option<i32>,
191
192    /// Difficulty rating
193    pub difficulty: Option<i32>,
194
195    /// First point latitude
196    pub first_lat: Option<f64>,
197
198    /// First point longitude
199    pub first_lng: Option<f64>,
200
201    /// Last point latitude
202    pub last_lat: Option<f64>,
203
204    /// Last point longitude
205    pub last_lng: Option<f64>,
206
207    /// Southwest corner latitude (bounding box)
208    pub sw_lat: Option<f64>,
209
210    /// Southwest corner longitude (bounding box)
211    pub sw_lng: Option<f64>,
212
213    /// Northeast corner latitude (bounding box)
214    pub ne_lat: Option<f64>,
215
216    /// Northeast corner longitude (bounding box)
217    pub ne_lng: Option<f64>,
218
219    /// Track points with telemetry (included when fetching a specific trip)
220    pub track_points: Option<Vec<TripTrackPoint>>,
221
222    /// Gear/equipment used (included when fetching a specific trip)
223    pub gear: Option<Gear>,
224
225    /// Photos (included when fetching a specific trip)
226    pub photos: Option<Vec<Photo>>,
227}
228
229/// Parameters for listing trips
230#[derive(Debug, Clone, Default, Serialize)]
231pub struct ListTripsParams {
232    /// Filter by trip name
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub name: Option<String>,
235
236    /// Filter by visibility
237    #[serde(skip_serializing_if = "Option::is_none")]
238    pub visibility: Option<Visibility>,
239
240    /// Filter by minimum distance (meters)
241    #[serde(skip_serializing_if = "Option::is_none")]
242    pub min_distance: Option<f64>,
243
244    /// Filter by maximum distance (meters)
245    #[serde(skip_serializing_if = "Option::is_none")]
246    pub max_distance: Option<f64>,
247
248    /// Filter by minimum elevation gain (meters)
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub min_elevation_gain: Option<f64>,
251
252    /// Filter by maximum elevation gain (meters)
253    #[serde(skip_serializing_if = "Option::is_none")]
254    pub max_elevation_gain: Option<f64>,
255
256    /// Page number
257    #[serde(skip_serializing_if = "Option::is_none")]
258    pub page: Option<u32>,
259
260    /// Page size
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub page_size: Option<u32>,
263}
264
265impl RideWithGpsClient {
266    /// List trips for the authenticated user
267    ///
268    /// # Arguments
269    ///
270    /// * `params` - Optional parameters for filtering and pagination
271    ///
272    /// # Example
273    ///
274    /// ```rust,no_run
275    /// use ridewithgps_client::{RideWithGpsClient, ListTripsParams};
276    ///
277    /// let client = RideWithGpsClient::new(
278    ///     "https://ridewithgps.com",
279    ///     "your-api-key",
280    ///     Some("your-auth-token")
281    /// );
282    ///
283    /// let params = ListTripsParams {
284    ///     min_distance: Some(20000.0), // 20km
285    ///     ..Default::default()
286    /// };
287    ///
288    /// let trips = client.list_trips(Some(&params)).unwrap();
289    /// println!("Found {} trips", trips.results.len());
290    /// ```
291    pub fn list_trips(&self, params: Option<&ListTripsParams>) -> Result<PaginatedResponse<Trip>> {
292        let mut url = "/api/v1/trips.json".to_string();
293
294        if let Some(params) = params {
295            let query = serde_json::to_value(params)?;
296            if let Some(obj) = query.as_object() {
297                if !obj.is_empty() {
298                    let query_str = serde_urlencoded::to_string(obj).map_err(|e| {
299                        crate::Error::ApiError(format!("Failed to encode query: {}", e))
300                    })?;
301                    url.push('?');
302                    url.push_str(&query_str);
303                }
304            }
305        }
306
307        self.get(&url)
308    }
309
310    /// Get a specific trip by ID
311    ///
312    /// # Arguments
313    ///
314    /// * `id` - The trip ID
315    ///
316    /// # Example
317    ///
318    /// ```rust,no_run
319    /// use ridewithgps_client::RideWithGpsClient;
320    ///
321    /// let client = RideWithGpsClient::new(
322    ///     "https://ridewithgps.com",
323    ///     "your-api-key",
324    ///     Some("your-auth-token")
325    /// );
326    ///
327    /// let trip = client.get_trip(12345).unwrap();
328    /// println!("Trip: {:?}", trip);
329    /// ```
330    pub fn get_trip(&self, id: u64) -> Result<Trip> {
331        #[derive(Deserialize)]
332        struct TripWrapper {
333            trip: Trip,
334        }
335
336        let wrapper: TripWrapper = self.get(&format!("/api/v1/trips/{}.json", id))?;
337        Ok(wrapper.trip)
338    }
339
340    /// Get the polyline for a specific trip
341    ///
342    /// # Arguments
343    ///
344    /// * `id` - The trip ID
345    ///
346    /// # Example
347    ///
348    /// ```rust,no_run
349    /// use ridewithgps_client::RideWithGpsClient;
350    ///
351    /// let client = RideWithGpsClient::new(
352    ///     "https://ridewithgps.com",
353    ///     "your-api-key",
354    ///     None
355    /// );
356    ///
357    /// let polyline = client.get_trip_polyline(12345).unwrap();
358    /// println!("Polyline: {}", polyline.polyline);
359    /// ```
360    pub fn get_trip_polyline(&self, id: u64) -> Result<Polyline> {
361        self.get(&format!("/api/v1/trips/{}/polyline.json", id))
362    }
363
364    /// Delete a trip
365    ///
366    /// # Arguments
367    ///
368    /// * `id` - The trip ID
369    ///
370    /// # Example
371    ///
372    /// ```rust,no_run
373    /// use ridewithgps_client::RideWithGpsClient;
374    ///
375    /// let client = RideWithGpsClient::new(
376    ///     "https://ridewithgps.com",
377    ///     "your-api-key",
378    ///     Some("your-auth-token")
379    /// );
380    ///
381    /// client.delete_trip(12345).unwrap();
382    /// ```
383    pub fn delete_trip(&self, id: u64) -> Result<()> {
384        self.delete(&format!("/api/v1/trips/{}.json", id))
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    #[test]
393    fn test_trip_deserialization() {
394        let json = r#"{
395            "id": 456,
396            "name": "Morning Ride",
397            "distance": 25000.0,
398            "elevation_gain": 300.0,
399            "visibility": "private",
400            "duration": 3600.0,
401            "avg_speed": 6.94
402        }"#;
403
404        let trip: Trip = serde_json::from_str(json).unwrap();
405        assert_eq!(trip.id, 456);
406        assert_eq!(trip.name.as_deref(), Some("Morning Ride"));
407        assert_eq!(trip.distance, Some(25000.0));
408        assert_eq!(trip.visibility, Some(Visibility::Private));
409        assert_eq!(trip.duration, Some(3600.0));
410    }
411
412    #[test]
413    fn test_list_trips_params() {
414        let params = ListTripsParams {
415            name: Some("ride".to_string()),
416            visibility: Some(Visibility::Public),
417            min_distance: Some(10000.0),
418            page: Some(2),
419            ..Default::default()
420        };
421
422        let json = serde_json::to_value(&params).unwrap();
423        assert!(json.get("name").is_some());
424        assert!(json.get("visibility").is_some());
425        assert!(json.get("min_distance").is_some());
426        assert!(json.get("page").is_some());
427    }
428
429    #[test]
430    fn test_trip_wrapper_deserialization() {
431        let json = r#"{
432            "trip": {
433                "id": 789,
434                "name": "Wrapped Trip",
435                "distance": 30000.0,
436                "avg_hr": 145.5
437            }
438        }"#;
439
440        #[derive(Deserialize)]
441        struct TripWrapper {
442            trip: Trip,
443        }
444
445        let wrapper: TripWrapper = serde_json::from_str(json).unwrap();
446        assert_eq!(wrapper.trip.id, 789);
447        assert_eq!(wrapper.trip.name.as_deref(), Some("Wrapped Trip"));
448        assert_eq!(wrapper.trip.avg_hr, Some(145.5));
449    }
450
451    #[test]
452    fn test_trip_track_point_deserialization() {
453        let json = r#"{
454            "x": -122.4,
455            "y": 37.7,
456            "t": 1609459200,
457            "s": 5.5,
458            "h": 150.0,
459            "c": 90.0,
460            "p": 200.0,
461            "lap": true
462        }"#;
463
464        let track_point: TripTrackPoint = serde_json::from_str(json).unwrap();
465        assert_eq!(track_point.x, Some(-122.4));
466        assert_eq!(track_point.y, Some(37.7));
467        assert_eq!(track_point.t, Some(1609459200));
468        assert_eq!(track_point.s, Some(5.5));
469        assert_eq!(track_point.h, Some(150.0));
470        assert_eq!(track_point.c, Some(90.0));
471        assert_eq!(track_point.p, Some(200.0));
472        assert_eq!(track_point.lap, Some(true));
473    }
474
475    #[test]
476    fn test_gear_deserialization() {
477        let json = r#"{
478            "id": 42,
479            "make": "Trek",
480            "model": "Domane SL5",
481            "description": "Road Bike"
482        }"#;
483
484        let gear: Gear = serde_json::from_str(json).unwrap();
485        assert_eq!(gear.id, 42);
486        assert_eq!(gear.make.as_deref(), Some("Trek"));
487        assert_eq!(gear.model.as_deref(), Some("Domane SL5"));
488        assert_eq!(gear.description.as_deref(), Some("Road Bike"));
489    }
490
491    #[test]
492    fn test_trip_with_telemetry() {
493        let json = r#"{
494            "id": 555,
495            "name": "Power Training",
496            "avg_cad": 85.5,
497            "max_cad": 120.0,
498            "avg_hr": 155.0,
499            "max_hr": 180.0,
500            "avg_watts": 220.0,
501            "max_watts": 450.0,
502            "calories": 850.0,
503            "fit_sport": 2,
504            "fit_sub_sport": 10
505        }"#;
506
507        let trip: Trip = serde_json::from_str(json).unwrap();
508        assert_eq!(trip.id, 555);
509        assert_eq!(trip.avg_cad, Some(85.5));
510        assert_eq!(trip.max_cad, Some(120.0));
511        assert_eq!(trip.avg_hr, Some(155.0));
512        assert_eq!(trip.max_hr, Some(180.0));
513        assert_eq!(trip.avg_watts, Some(220.0));
514        assert_eq!(trip.calories, Some(850.0));
515        assert_eq!(trip.fit_sport, Some(2));
516        assert_eq!(trip.fit_sub_sport, Some(10));
517    }
518}