monorepo/fitnesstrax/app/src/view_models/day_detail.rs

373 lines
12 KiB
Rust
Raw Normal View History

/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::{
app::{ReadError, RecordProvider, WriteError},
types::WeightFormatter,
};
use chrono::NaiveDate;
use emseries::{Record, RecordId, Recordable};
use ft_core::TraxRecord;
use std::{
collections::HashMap,
ops::Deref,
sync::{Arc, RwLock},
};
#[derive(Clone, Debug)]
enum RecordState<T: Clone + Recordable> {
Original(Record<T>),
New(T),
Updated(Record<T>),
#[allow(unused)]
Deleted(Record<T>),
}
impl<T: Clone + emseries::Recordable> RecordState<T> {
#[allow(unused)]
fn id(&self) -> Option<&RecordId> {
match self {
RecordState::Original(ref r) => Some(&r.id),
RecordState::New(ref r) => None,
RecordState::Updated(ref r) => Some(&r.id),
RecordState::Deleted(ref r) => Some(&r.id),
}
}
fn with_value(self, value: T) -> RecordState<T> {
match self {
RecordState::Original(r) => RecordState::Updated(Record { data: value, ..r }),
RecordState::New(_) => RecordState::New(value),
RecordState::Updated(r) => RecordState::Updated(Record { data: value, ..r }),
RecordState::Deleted(r) => RecordState::Updated(Record { data: value, ..r }),
}
}
#[allow(unused)]
fn with_delete(self) -> Option<RecordState<T>> {
match self {
RecordState::Original(r) => Some(RecordState::Deleted(r)),
RecordState::New(r) => None,
RecordState::Updated(r) => Some(RecordState::Deleted(r)),
RecordState::Deleted(r) => Some(RecordState::Deleted(r)),
}
}
}
impl<T: Clone + emseries::Recordable> Deref for RecordState<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
match self {
RecordState::Original(ref r) => &r.data,
RecordState::New(ref r) => r,
RecordState::Updated(ref r) => &r.data,
RecordState::Deleted(ref r) => &r.data,
}
}
}
#[derive(Clone)]
pub struct DayDetailViewModel {
provider: Arc<dyn RecordProvider>,
pub date: chrono::NaiveDate,
weight: Arc<RwLock<Option<RecordState<ft_core::Weight>>>>,
steps: Arc<RwLock<Option<RecordState<ft_core::Steps>>>>,
records: Arc<RwLock<HashMap<RecordId, RecordState<TraxRecord>>>>,
}
impl DayDetailViewModel {
pub async fn new(
date: chrono::NaiveDate,
provider: impl RecordProvider + 'static,
) -> Result<Self, ReadError> {
let (weight_records, records): (Vec<Record<TraxRecord>>, Vec<Record<TraxRecord>>) =
provider
.records(date, date)
.await?
.into_iter()
.partition(|r| r.data.is_weight());
let (step_records, records): (Vec<Record<TraxRecord>>, Vec<Record<TraxRecord>>) =
records.into_iter().partition(|r| r.data.is_steps());
Ok(Self {
provider: Arc::new(provider),
date,
weight: Arc::new(RwLock::new(
weight_records
.first()
.and_then(|r| match r.data {
TraxRecord::Weight(ref w) => Some((r.id.clone(), w.clone())),
_ => None,
})
.map(|(id, w)| RecordState::Original(Record { id, data: w })),
)),
steps: Arc::new(RwLock::new(
step_records
.first()
.and_then(|r| match r.data {
TraxRecord::Steps(ref w) => Some((r.id.clone(), w.clone())),
_ => None,
})
.map(|(id, w)| RecordState::Original(Record { id, data: w })),
)),
records: Arc::new(RwLock::new(
records
.into_iter()
.map(|r| (r.id.clone(), RecordState::Original(r)))
.collect::<HashMap<RecordId, RecordState<TraxRecord>>>(),
)),
})
}
2024-01-31 13:38:17 +00:00
pub fn weight(&self) -> Option<WeightFormatter> {
(*self.weight.read().unwrap())
.as_ref()
2024-01-31 13:38:17 +00:00
.map(|w| WeightFormatter::from(w.weight))
}
2024-01-31 13:38:17 +00:00
pub fn set_weight(&self, new_weight: WeightFormatter) {
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,
}),
None => RecordState::New(ft_core::Weight {
date: self.date,
weight: *new_weight,
}),
};
*record = Some(new_record);
}
pub fn steps(&self) -> Option<u32> {
(*self.steps.read().unwrap()).as_ref().map(|w| w.count)
}
pub fn set_steps(&self, new_count: u32) {
let mut record = self.steps.write().unwrap();
let new_record = match *record {
Some(ref rstate) => rstate.clone().with_value(ft_core::Steps {
date: self.date,
count: new_count,
}),
None => RecordState::New(ft_core::Steps {
date: self.date,
count: new_count,
}),
};
*record = Some(new_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::<Vec<RecordState<TraxRecord>>>();
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!(),
}
}
}
});
}
pub fn revert(&self) {
unimplemented!();
}
}
#[cfg(test)]
mod test {
use super::*;
use async_trait::async_trait;
use dimensioned::si;
use emseries::Record;
struct MockProvider {
records: Vec<Record<TraxRecord>>,
}
#[async_trait]
impl RecordProvider for MockProvider {
async fn records(
&self,
start: NaiveDate,
end: NaiveDate,
) -> Result<Vec<Record<TraxRecord>>, ReadError> {
let start = emseries::Timestamp::Date(start);
let end = emseries::Timestamp::Date(end);
Ok(self
.records
.iter()
.filter(|r| r.timestamp() >= start && r.timestamp() <= end)
.cloned()
.collect::<Vec<Record<TraxRecord>>>())
}
async fn put_record(&self, record: TraxRecord) -> Result<RecordId, WriteError> {
Err(WriteError::NoDatabase)
}
async fn update_record(&self, record: Record<TraxRecord>) -> Result<(), WriteError> {
Err(WriteError::NoDatabase)
}
async fn delete_record(&self, id: RecordId) -> Result<(), WriteError> {
Err(WriteError::NoDatabase)
}
}
async fn with_view_model<TestFn>(test: TestFn)
where
TestFn: Fn(DayDetailViewModel),
{
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)
}
#[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;
}
#[test]
#[ignore]
fn it_enforces_one_weight_and_stepcount_per_day() {}
#[test]
#[ignore]
fn it_can_construct_new_records() {}
#[test]
#[ignore]
fn it_can_update_an_existing_record() {}
#[test]
#[ignore]
fn it_can_remove_a_new_record() {}
#[test]
#[ignore]
fn it_can_delete_an_existing_record() {}
#[test]
#[ignore]
fn it_retrieve_records_by_workout() {}
#[test]
#[ignore]
fn it_summarizes_records_by_workout_type() {}
}