diff --git a/fitnesstrax/app/src/components/day.rs b/fitnesstrax/app/src/components/day.rs index d8d547f..db0edf3 100644 --- a/fitnesstrax/app/src/components/day.rs +++ b/fitnesstrax/app/src/components/day.rs @@ -18,6 +18,7 @@ You should have received a copy of the GNU General Public License along with Fit // use ft_core::TraxRecord; use crate::{ components::{steps_editor, weight_field, ActionGroup, Steps, WeightLabel}, + types::WeightFormatter, view_models::DayDetailViewModel, }; use glib::Object; @@ -162,7 +163,7 @@ impl DayDetail { let top_row = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) .build(); - let weight_view = WeightLabel::new(view_model.weight()); + let weight_view = WeightLabel::new(view_model.weight().map(WeightFormatter::from)); top_row.append(&weight_view.widget()); let steps_view = Steps::new(view_model.steps()); @@ -293,10 +294,10 @@ impl DayEdit { .orientation(gtk::Orientation::Horizontal) .build(); top_row.append( - &weight_field(view_model.weight(), { + &weight_field(view_model.weight().map(WeightFormatter::from), { let view_model = view_model.clone(); move |w| match w { - Some(w) => view_model.set_weight(w), + Some(w) => view_model.set_weight(*w), None => eprintln!("have not implemented record delete"), } }) diff --git a/fitnesstrax/app/src/view_models/day_detail.rs b/fitnesstrax/app/src/view_models/day_detail.rs index 46aee59..2cd7224 100644 --- a/fitnesstrax/app/src/view_models/day_detail.rs +++ b/fitnesstrax/app/src/view_models/day_detail.rs @@ -14,13 +14,11 @@ General Public License for more details. You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see . */ -use crate::{ - app::{ReadError, RecordProvider, WriteError}, - types::WeightFormatter, -}; +use crate::app::{ReadError, RecordProvider, WriteError}; use chrono::NaiveDate; +use dimensioned::si; use emseries::{Record, RecordId, Recordable}; -use ft_core::TraxRecord; +use ft_core::{RecordType, TimeDistance, TimeDistanceWorkoutType, TraxRecord}; use std::{ collections::HashMap, ops::Deref, @@ -79,6 +77,17 @@ impl Deref for RecordState { } } +impl std::ops::DerefMut for RecordState { + fn deref_mut(&mut self) -> &mut Self::Target { + match self { + RecordState::Original(ref mut r) => &mut r.data, + RecordState::New(ref mut r) => r, + RecordState::Updated(ref mut r) => &mut r.data, + RecordState::Deleted(ref mut r) => &mut r.data, + } + } +} + #[derive(Clone)] pub struct DayDetailViewModel { provider: Arc, @@ -132,22 +141,20 @@ impl DayDetailViewModel { }) } - pub fn weight(&self) -> Option { - (*self.weight.read().unwrap()) - .as_ref() - .map(|w| WeightFormatter::from(w.weight)) + pub fn weight(&self) -> Option> { + (*self.weight.read().unwrap()).as_ref().map(|w| (*w).weight) } - pub fn set_weight(&self, new_weight: WeightFormatter) { + pub fn set_weight(&self, new_weight: si::Kilogram) { let mut record = self.weight.write().unwrap(); let new_record = match *record { Some(ref rstate) => rstate.clone().with_value(ft_core::Weight { date: self.date, - weight: *new_weight, + weight: new_weight, }), None => RecordState::New(ft_core::Weight { date: self.date, - weight: *new_weight, + weight: new_weight, }), }; *record = Some(new_record); @@ -172,72 +179,163 @@ impl DayDetailViewModel { *record = Some(new_record); } + pub fn new_time_distance(&self, type_: TimeDistanceWorkoutType) -> Record { + let id = RecordId::default(); + let workout = TimeDistance { + datetime: chrono::Local::now().into(), + distance: None, + duration: None, + comments: None, + }; + let tr = TraxRecord::from_time_distance(type_, workout.clone()); + self.records + .write() + .unwrap() + .insert(id.clone(), RecordState::New(tr)); + println!( + "records after new_time_distance: {:?}", + self.records.read().unwrap() + ); + Record { id, data: workout } + } + + pub fn update_time_distance(&self, workout: Record) { + let id = workout.id.clone(); + let data = workout.data.clone(); + + self.records + .write() + .unwrap() + .entry(id) + .and_modify(|r| match **r { + TraxRecord::BikeRide(ref mut v) => *v = data, + TraxRecord::Row(ref mut v) => *v = data, + TraxRecord::Run(ref mut v) => *v = data, + TraxRecord::Swim(ref mut v) => *v = data, + TraxRecord::Walk(ref mut v) => *v = data, + _ => {} + }); + } + + pub fn time_distance_records(&self, type_: RecordType) -> Vec> { + unimplemented!("time_distance_records") + } + + pub fn time_distance_summary( + &self, + type_: TimeDistanceWorkoutType, + ) -> (si::Meter, si::Second) { + self.records + .read() + .unwrap() + .iter() + .filter(|(_, workout_state)| workout_state.is_time_distance_type(type_)) + .filter_map(|(id, record_state)| match **record_state { + TraxRecord::BikeRide(ref workout) + | TraxRecord::Row(ref workout) + | TraxRecord::Run(ref workout) + | TraxRecord::Swim(ref workout) + | TraxRecord::Walk(ref workout) => Some(workout), + _ => None, + }) + .fold((0. * si::M, 0. * si::S), |(distance, duration), workout| { + println!("folding workout: {:?}", workout); + match (workout.distance, workout.duration) { + (Some(distance_), Some(duration_)) => { + (distance + distance_, duration + duration_) + } + (Some(distance_), None) => (distance + distance_, duration), + (None, Some(duration_)) => (distance, duration + duration_), + (None, None) => (distance, duration), + } + }) + } + + fn get_record(&self, id: &RecordId) -> Option> { + let record_set = self.records.read().unwrap(); + match record_set.get(&id) { + Some(record) => Some(Record { + id: id.clone(), + data: (**record).clone(), + }), + None => None, + } + } + + pub fn remove_record(&self, id: RecordId) { + unimplemented!("remove_record") + } + pub fn save(&self) { glib::spawn_future({ let s = self.clone(); - async move { - let weight_record = s.weight.read().unwrap().clone(); - match weight_record { - Some(RecordState::New(data)) => { - let _ = s.provider.put_record(TraxRecord::Weight(data)).await; - } - Some(RecordState::Original(_)) => {} - Some(RecordState::Updated(weight)) => { - let _ = s - .provider - .update_record(Record { - id: weight.id, - data: TraxRecord::Weight(weight.data), - }) - .await; - } - Some(RecordState::Deleted(_)) => {} - None => {} - } - - let steps_record = s.steps.read().unwrap().clone(); - match steps_record { - Some(RecordState::New(data)) => { - let _ = s.provider.put_record(TraxRecord::Steps(data)).await; - } - Some(RecordState::Original(_)) => {} - Some(RecordState::Updated(steps)) => { - let _ = s - .provider - .update_record(Record { - id: steps.id, - data: TraxRecord::Steps(steps.data), - }) - .await; - } - Some(RecordState::Deleted(_)) => {} - None => {} - } - - let records = s - .records - .write() - .unwrap() - .drain() - .map(|(_, record)| record) - .collect::>>(); - - for record in records { - match record { - RecordState::New(data) => { - let _ = s.provider.put_record(data).await; - } - RecordState::Original(_) => {} - RecordState::Updated(r) => { - let _ = s.provider.update_record(r.clone()).await; - } - RecordState::Deleted(_) => unimplemented!(), - } - } - } + async move { s.async_save().await } }); } + pub async fn async_save(&self) { + println!("async_save"); + let weight_record = self.weight.read().unwrap().clone(); + match weight_record { + Some(RecordState::New(data)) => { + let _ = self.provider.put_record(TraxRecord::Weight(data)).await; + } + Some(RecordState::Original(_)) => {} + Some(RecordState::Updated(weight)) => { + let _ = self + .provider + .update_record(Record { + id: weight.id, + data: TraxRecord::Weight(weight.data), + }) + .await; + } + Some(RecordState::Deleted(_)) => {} + None => {} + } + + let steps_record = self.steps.read().unwrap().clone(); + match steps_record { + Some(RecordState::New(data)) => { + let _ = self.provider.put_record(TraxRecord::Steps(data)).await; + } + Some(RecordState::Original(_)) => {} + Some(RecordState::Updated(steps)) => { + let _ = self + .provider + .update_record(Record { + id: steps.id, + data: TraxRecord::Steps(steps.data), + }) + .await; + } + Some(RecordState::Deleted(_)) => {} + None => {} + } + + let records = self + .records + .write() + .unwrap() + .drain() + .map(|(_, record)| record) + .collect::>>(); + + for record in records { + println!("saving record: {:?}", record); + match record { + RecordState::New(data) => { + let _ = self.provider.put_record(data).await; + } + RecordState::Original(_) => {} + RecordState::Updated(r) => { + let _ = self.provider.update_record(r.clone()).await; + } + RecordState::Deleted(_) => unimplemented!(), + } + } + } + pub fn revert(&self) { unimplemented!(); } @@ -247,11 +345,32 @@ impl DayDetailViewModel { mod test { use super::*; use async_trait::async_trait; + use chrono::{DateTime, FixedOffset, TimeZone}; use dimensioned::si; use emseries::Record; + #[derive(Clone, Debug)] struct MockProvider { - records: Vec>, + records: Arc>>>, + + put_records: Arc>>>, + updated_records: Arc>>>, + deleted_records: Arc>>, + } + + impl MockProvider { + fn new(records: Vec>) -> Self { + let record_map = records + .into_iter() + .map(|r| (r.id.clone(), r)) + .collect::>>(); + Self { + records: Arc::new(RwLock::new(record_map)), + put_records: Arc::new(RwLock::new(vec![])), + updated_records: Arc::new(RwLock::new(vec![])), + deleted_records: Arc::new(RwLock::new(vec![])), + } + } } #[async_trait] @@ -265,14 +384,24 @@ mod test { let end = emseries::Timestamp::Date(end); Ok(self .records + .read() + .unwrap() .iter() + .map(|(_, r)| r) .filter(|r| r.timestamp() >= start && r.timestamp() <= end) .cloned() .collect::>>()) } async fn put_record(&self, record: TraxRecord) -> Result { - Err(WriteError::NoDatabase) + let id = RecordId::default(); + let record = Record { + id: id.clone(), + data: record, + }; + self.put_records.write().unwrap().push(record.clone()); + self.records.write().unwrap().insert(id.clone(), record); + Ok(id) } async fn update_record(&self, record: Record) -> Result<(), WriteError> { @@ -284,89 +413,220 @@ mod test { } } - async fn with_view_model(test: TestFn) - where - TestFn: Fn(DayDetailViewModel), - { + async fn create_empty_view_model() -> (DayDetailViewModel, MockProvider) { + let provider = MockProvider::new(vec![]); + + let oct_13 = chrono::NaiveDate::from_ymd_opt(2023, 10, 13).unwrap(); + let model = DayDetailViewModel::new(oct_13, provider.clone()) + .await + .unwrap(); + (model, provider) + } + + async fn create_view_model() -> (DayDetailViewModel, MockProvider) { let oct_12 = chrono::NaiveDate::from_ymd_opt(2023, 10, 12).unwrap(); let oct_13 = chrono::NaiveDate::from_ymd_opt(2023, 10, 13).unwrap(); - let provider = MockProvider { - records: vec![ - Record { - id: RecordId::default(), - data: TraxRecord::Weight(ft_core::Weight { - date: oct_12.clone(), - weight: 93. * si::KG, - }), - }, - Record { - id: RecordId::default(), - data: TraxRecord::Weight(ft_core::Weight { - date: oct_13.clone(), - weight: 95. * si::KG, - }), - }, - Record { - id: RecordId::default(), - data: TraxRecord::Steps(ft_core::Steps { - date: oct_13.clone(), - count: 2500, - }), - }, - Record { - id: RecordId::default(), - data: TraxRecord::Weight(ft_core::Weight { - date: oct_13.clone(), - weight: 91. * si::KG, - }), - }, - Record { - id: RecordId::default(), - data: TraxRecord::Steps(ft_core::Steps { - date: oct_13.clone(), - count: 2750, - }), - }, - ], - }; - let model = DayDetailViewModel::new(oct_13, provider).await.unwrap(); - test(model) + let oct_13_am: DateTime = oct_13 + .clone() + .and_hms_opt(3, 28, 0) + .unwrap() + .and_utc() + .with_timezone(&FixedOffset::east_opt(5 * 3600).unwrap()); + let provider = MockProvider::new(vec![ + Record { + id: RecordId::default(), + data: TraxRecord::Weight(ft_core::Weight { + date: oct_12.clone(), + weight: 93. * si::KG, + }), + }, + Record { + id: RecordId::default(), + data: TraxRecord::Weight(ft_core::Weight { + date: oct_13.clone(), + weight: 95. * si::KG, + }), + }, + Record { + id: RecordId::default(), + data: TraxRecord::Steps(ft_core::Steps { + date: oct_13.clone(), + count: 2500, + }), + }, + Record { + id: RecordId::default(), + data: TraxRecord::Weight(ft_core::Weight { + date: oct_13.clone(), + weight: 91. * si::KG, + }), + }, + Record { + id: RecordId::default(), + data: TraxRecord::Steps(ft_core::Steps { + date: oct_13.clone(), + count: 2750, + }), + }, + Record { + id: RecordId::default(), + data: TraxRecord::BikeRide(ft_core::TimeDistance { + datetime: oct_13_am.clone(), + distance: Some(15000. * si::M), + duration: Some(3600. * si::S), + comments: Some("somecomments present".to_owned()), + }), + }, + ]); + let model = DayDetailViewModel::new(oct_13, provider.clone()) + .await + .unwrap(); + (model, provider) } #[tokio::test] async fn it_honors_only_the_first_weight_and_step_record() { - with_view_model(|view_model| { - assert_eq!(view_model.weight().map(|val| *val), Some(95. * si::KG)); - assert_eq!(view_model.steps(), Some(2500)); - }) - .await; + let (view_model, provider) = create_view_model().await; + assert_eq!(view_model.weight(), Some(95. * si::KG)); + assert_eq!(view_model.steps(), Some(2500)); } - #[test] - #[ignore] - fn it_enforces_one_weight_and_stepcount_per_day() {} + #[tokio::test] + async fn it_can_create_a_weight_and_stepcount() { + let (view_model, provider) = create_empty_view_model().await; + assert_eq!(view_model.weight(), None); + assert_eq!(view_model.steps(), None); - #[test] - #[ignore] - fn it_can_construct_new_records() {} + view_model.set_weight(95. * si::KG); + view_model.set_steps(250); - #[test] - #[ignore] - fn it_can_update_an_existing_record() {} + assert_eq!(view_model.weight(), Some(95. * si::KG)); + assert_eq!(view_model.steps(), Some(250)); - #[test] - #[ignore] - fn it_can_remove_a_new_record() {} + view_model.set_weight(93. * si::KG); + view_model.set_steps(255); - #[test] - #[ignore] - fn it_can_delete_an_existing_record() {} + assert_eq!(view_model.weight(), Some(93. * si::KG)); + assert_eq!(view_model.steps(), Some(255)); - #[test] - #[ignore] - fn it_retrieve_records_by_workout() {} + view_model.async_save().await; - #[test] + println!("provider: {:?}", provider); + assert_eq!(provider.put_records.read().unwrap().len(), 2); + assert_eq!(provider.updated_records.read().unwrap().len(), 0); + assert_eq!(provider.deleted_records.read().unwrap().len(), 0); + } + + #[tokio::test] + async fn it_can_construct_new_records() { + let (view_model, provider) = create_empty_view_model().await; + assert_eq!( + view_model.time_distance_summary(TimeDistanceWorkoutType::BikeRide), + (0. * si::M, 0. * si::S) + ); + + let mut record = view_model.new_time_distance(TimeDistanceWorkoutType::BikeRide); + record.data.duration = Some(60. * si::S); + view_model.async_save().await; + + assert_eq!(provider.put_records.read().unwrap().len(), 1); + assert_eq!(provider.updated_records.read().unwrap().len(), 0); + assert_eq!(provider.deleted_records.read().unwrap().len(), 0); + } + + #[tokio::test] + async fn it_can_update_a_new_record_before_saving() { + let (view_model, provider) = create_empty_view_model().await; + assert_eq!( + view_model.time_distance_summary(TimeDistanceWorkoutType::BikeRide), + (0. * si::M, 0. * si::S) + ); + + let mut record = view_model.new_time_distance(TimeDistanceWorkoutType::BikeRide); + record.data.duration = Some(60. * si::S); + view_model.update_time_distance(record.clone()); + let record = Record { + id: record.id, + data: TraxRecord::BikeRide(record.data), + }; + assert_eq!(view_model.get_record(&record.id), Some(record)); + assert_eq!( + view_model.time_distance_summary(TimeDistanceWorkoutType::BikeRide), + (0. * si::M, 60. * si::S) + ); + assert_eq!( + view_model.time_distance_summary(TimeDistanceWorkoutType::Run), + (0. * si::M, 0. * si::S) + ); + view_model.async_save().await; + + assert_eq!(provider.put_records.read().unwrap().len(), 1); + assert_eq!(provider.updated_records.read().unwrap().len(), 0); + assert_eq!(provider.deleted_records.read().unwrap().len(), 0); + } + + #[tokio::test] #[ignore] - fn it_summarizes_records_by_workout_type() {} + async fn it_can_update_an_existing_record() { + let (view_model, provider) = create_view_model().await; + let mut workout = view_model + .time_distance_records(RecordType::BikeRide) + .first() + .cloned() + .unwrap(); + + workout.data.duration = Some(1800. * si::S); + view_model.update_time_distance(workout.clone()); + + assert_eq!( + view_model.time_distance_summary(TimeDistanceWorkoutType::BikeRide), + (15000. * si::M, 1800. * si::S) + ); + + view_model.save(); + + assert_eq!(provider.put_records.read().unwrap().len(), 0); + assert_eq!(provider.updated_records.read().unwrap().len(), 1); + assert_eq!(provider.deleted_records.read().unwrap().len(), 0); + } + + #[tokio::test] + #[ignore] + async fn it_can_remove_a_new_record() { + let (view_model, provider) = create_empty_view_model().await; + assert_eq!( + view_model.time_distance_summary(TimeDistanceWorkoutType::BikeRide), + (0. * si::M, 0. * si::S) + ); + + let record = view_model.new_time_distance(TimeDistanceWorkoutType::BikeRide); + view_model.remove_record(record.id); + view_model.save(); + + assert_eq!(provider.put_records.read().unwrap().len(), 0); + assert_eq!(provider.updated_records.read().unwrap().len(), 0); + assert_eq!(provider.deleted_records.read().unwrap().len(), 0); + } + + #[tokio::test] + #[ignore] + async fn it_can_delete_an_existing_record() { + let (view_model, provider) = create_view_model().await; + let mut workout = view_model + .time_distance_records(RecordType::BikeRide) + .first() + .cloned() + .unwrap(); + + view_model.remove_record(workout.id); + assert_eq!( + view_model.time_distance_summary(TimeDistanceWorkoutType::BikeRide), + (0. * si::M, 0. * si::S) + ); + view_model.save(); + + assert_eq!(provider.put_records.read().unwrap().len(), 0); + assert_eq!(provider.updated_records.read().unwrap().len(), 0); + assert_eq!(provider.deleted_records.read().unwrap().len(), 1); + } } diff --git a/fitnesstrax/core/src/lib.rs b/fitnesstrax/core/src/lib.rs index 207f483..8e2144a 100644 --- a/fitnesstrax/core/src/lib.rs +++ b/fitnesstrax/core/src/lib.rs @@ -1,4 +1,4 @@ mod legacy; mod types; -pub use types::{RecordType, Steps, TimeDistance, TraxRecord, Weight}; +pub use types::{RecordType, Steps, TimeDistance, TimeDistanceWorkoutType, TraxRecord, Weight}; diff --git a/fitnesstrax/core/src/types.rs b/fitnesstrax/core/src/types.rs index b0959f7..a475dba 100644 --- a/fitnesstrax/core/src/types.rs +++ b/fitnesstrax/core/src/types.rs @@ -57,6 +57,16 @@ pub struct TimeDistance { pub comments: Option, } +impl Recordable for TimeDistance { + fn timestamp(&self) -> Timestamp { + Timestamp::DateTime(self.datetime) + } + + fn tags(&self) -> Vec { + vec![] + } +} + /// A singular daily weight measurement. Weight changes slowly enough that it seems unlikely to /// need to track more than a single weight in a day. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -86,6 +96,15 @@ pub enum RecordType { Weight, } +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum TimeDistanceWorkoutType { + BikeRide, + Row, + Run, + Swim, + Walk, +} + /// The unified data structure for all records that are part of the app. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum TraxRecord { @@ -99,6 +118,16 @@ pub enum TraxRecord { } impl TraxRecord { + pub fn from_time_distance(type_: TimeDistanceWorkoutType, workout: TimeDistance) -> Self { + match type_ { + TimeDistanceWorkoutType::BikeRide => Self::BikeRide(workout), + TimeDistanceWorkoutType::Run => Self::Run(workout), + TimeDistanceWorkoutType::Row => Self::Row(workout), + TimeDistanceWorkoutType::Swim => Self::Swim(workout), + TimeDistanceWorkoutType::Walk => Self::Walk(workout), + } + } + pub fn workout_type(&self) -> RecordType { match self { TraxRecord::BikeRide(_) => RecordType::BikeRide, @@ -118,6 +147,27 @@ impl TraxRecord { pub fn is_steps(&self) -> bool { matches!(self, TraxRecord::Steps(_)) } + + pub fn is_time_distance(&self) -> bool { + matches!( + self, + TraxRecord::BikeRide(_) + | TraxRecord::Row(_) + | TraxRecord::Run(_) + | TraxRecord::Swim(_) + | TraxRecord::Walk(_) + ) + } + + pub fn is_time_distance_type(&self, type_: TimeDistanceWorkoutType) -> bool { + match type_ { + TimeDistanceWorkoutType::BikeRide => matches!(self, TraxRecord::BikeRide(_)), + TimeDistanceWorkoutType::Row => matches!(self, TraxRecord::Row(_)), + TimeDistanceWorkoutType::Run => matches!(self, TraxRecord::Run(_)), + TimeDistanceWorkoutType::Swim => matches!(self, TraxRecord::Swim(_)), + TimeDistanceWorkoutType::Walk => matches!(self, TraxRecord::Walk(_)), + } + } } impl Recordable for TraxRecord {