Compare commits

...

2 Commits

Author SHA1 Message Date
Savanni D'Gerinel 24276d172b Introduce the RecordProvider interface
DayDetailViewModel needs testing. I've worked out an improved API, and a set of tests to go along with it, and those can be made more easily with a mockable RecordProvider. So, in addition to stubbing out a bunch of tests, I've also created RecordProvider, mocked it, and implemented it for App.
2024-02-01 10:12:35 -05:00
Savanni D'Gerinel 96317f5692 Finish removing the previous record grouping
Now that the DayDetailViewModel knows to retrieve its own records, the grouping functions, and passing groups of records around, no longer make sens.
2024-02-01 10:08:18 -05:00
6 changed files with 266 additions and 213 deletions

50
Cargo.lock generated
View File

@ -133,6 +133,17 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "async-trait"
version = "0.1.77"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c980ee35e870bd1a4d2c8294d4c04d0499e67bca1e4b5cefcc693c2fa00caea9"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.48",
]
[[package]] [[package]]
name = "atoi" name = "atoi"
version = "2.0.0" version = "2.0.0"
@ -420,7 +431,7 @@ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.41", "syn 2.0.48",
] ]
[[package]] [[package]]
@ -736,7 +747,7 @@ checksum = "487585f4d0c6655fe74905e2504d8ad6908e4db67f744eb140876906c2f3175d"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.41", "syn 2.0.48",
] ]
[[package]] [[package]]
@ -928,6 +939,7 @@ name = "fitnesstrax"
version = "0.3.0" version = "0.3.0"
dependencies = [ dependencies = [
"async-channel", "async-channel",
"async-trait",
"chrono", "chrono",
"chrono-tz", "chrono-tz",
"dimensioned 0.8.0", "dimensioned 0.8.0",
@ -1132,7 +1144,7 @@ checksum = "53b153fd91e4b0147f4aced87be237c98248656bb01050b96bf3ee89220a8ddb"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.41", "syn 2.0.48",
] ]
[[package]] [[package]]
@ -1367,7 +1379,7 @@ dependencies = [
"proc-macro-error", "proc-macro-error",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.41", "syn 2.0.48",
] ]
[[package]] [[package]]
@ -2443,7 +2455,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.41", "syn 2.0.48",
] ]
[[package]] [[package]]
@ -2648,7 +2660,7 @@ checksum = "4359fd9c9171ec6e8c62926d6faaf553a8dc3f64e1507e76da7911b4f6a04405"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.41", "syn 2.0.48",
] ]
[[package]] [[package]]
@ -2792,9 +2804,9 @@ dependencies = [
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.70" version = "1.0.78"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39278fbbf5fb4f646ce651690877f89d1c5811a3d4acb27700c1cb3cdb78fd3b" checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
@ -2836,9 +2848,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.33" version = "1.0.35"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
] ]
@ -3321,7 +3333,7 @@ checksum = "43576ca501357b9b071ac53cdc7da8ef0cbd9493d8df094cd821777ea6e894d3"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.41", "syn 2.0.48",
] ]
[[package]] [[package]]
@ -3720,9 +3732,9 @@ dependencies = [
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.41" version = "2.0.48"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c8b28c477cc3bf0e7966561e3460130e1255f7a1cf71931075f1c5e7a7e269" checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -3818,7 +3830,7 @@ checksum = "01742297787513b79cf8e29d1056ede1313e2420b7b3b15d0a768b4921f549df"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.41", "syn 2.0.48",
] ]
[[package]] [[package]]
@ -3942,7 +3954,7 @@ checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.41", "syn 2.0.48",
] ]
[[package]] [[package]]
@ -4063,7 +4075,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.41", "syn 2.0.48",
] ]
[[package]] [[package]]
@ -4433,7 +4445,7 @@ dependencies = [
"once_cell", "once_cell",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.41", "syn 2.0.48",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@ -4467,7 +4479,7 @@ checksum = "f0eb82fcb7930ae6219a7ecfd55b217f5f0893484b7a13022ebb2b2bf20b5283"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.41", "syn 2.0.48",
"wasm-bindgen-backend", "wasm-bindgen-backend",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@ -4708,7 +4720,7 @@ checksum = "b3c129550b3e6de3fd0ba67ba5c81818f9805e58b8d7fee80a3a59d2c9fc601a"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.41", "syn 2.0.48",
] ]
[[package]] [[package]]

View File

@ -8,6 +8,7 @@ edition = "2021"
[dependencies] [dependencies]
adw = { version = "0.5", package = "libadwaita", features = [ "v1_4" ] } adw = { version = "0.5", package = "libadwaita", features = [ "v1_4" ] }
async-channel = { version = "2.1" } async-channel = { version = "2.1" }
async-trait = { version = "0.1" }
chrono = { version = "0.4" } chrono = { version = "0.4" }
chrono-tz = { version = "0.8" } chrono-tz = { version = "0.8" }
dimensioned = { version = "0.8", features = [ "serde" ] } dimensioned = { version = "0.8", features = [ "serde" ] }

View File

@ -14,6 +14,7 @@ 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/>. You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/ */
use async_trait::async_trait;
use chrono::NaiveDate; use chrono::NaiveDate;
use emseries::{time_range, Record, RecordId, Series, Timestamp}; use emseries::{time_range, Record, RecordId, Series, Timestamp};
use ft_core::TraxRecord; use ft_core::TraxRecord;
@ -34,6 +35,32 @@ pub enum AppError {
Unhandled, Unhandled,
} }
#[derive(Debug, Error)]
pub enum ReadError {
#[error("no database loaded")]
NoDatabase,
}
#[derive(Debug, Error)]
pub enum WriteError {
#[error("no database loaded")]
NoDatabase,
#[error("unhandled error")]
Unhandled,
}
#[async_trait]
pub trait RecordProvider: Send + Sync {
async fn records(
&self,
start: NaiveDate,
end: NaiveDate,
) -> Result<Vec<Record<TraxRecord>>, ReadError>;
async fn put_record(&self, record: TraxRecord) -> Result<RecordId, WriteError>;
async fn update_record(&self, record: Record<TraxRecord>) -> Result<(), WriteError>;
async fn delete_record(&self, id: RecordId) -> Result<(), WriteError>;
}
/// The real, headless application. This is where all of the logic will reside. /// The real, headless application. This is where all of the logic will reside.
#[derive(Clone)] #[derive(Clone)]
pub struct App { pub struct App {
@ -57,11 +84,26 @@ impl App {
} }
} }
pub async fn records( pub async fn open_db(&self, path: PathBuf) -> Result<(), AppError> {
let db_ref = self.database.clone();
self.runtime
.spawn_blocking(move || {
let db = Series::open(path).map_err(|_| AppError::FailedToOpenDatabase)?;
*db_ref.write().unwrap() = Some(db);
Ok(())
})
.await
.unwrap()
}
}
#[async_trait]
impl RecordProvider for App {
async fn records(
&self, &self,
start: NaiveDate, start: NaiveDate,
end: NaiveDate, end: NaiveDate,
) -> Result<Vec<Record<TraxRecord>>, AppError> { ) -> Result<Vec<Record<TraxRecord>>, ReadError> {
let db = self.database.clone(); let db = self.database.clone();
self.runtime self.runtime
.spawn_blocking(move || { .spawn_blocking(move || {
@ -77,14 +119,14 @@ impl App {
.collect::<Vec<Record<TraxRecord>>>(); .collect::<Vec<Record<TraxRecord>>>();
Ok(records) Ok(records)
} else { } else {
Err(AppError::NoDatabase) Err(ReadError::NoDatabase)
} }
}) })
.await .await
.unwrap() .unwrap()
} }
pub async fn put_record(&self, record: TraxRecord) -> Result<RecordId, AppError> { async fn put_record(&self, record: TraxRecord) -> Result<RecordId, WriteError> {
let db = self.database.clone(); let db = self.database.clone();
self.runtime self.runtime
.spawn_blocking(move || { .spawn_blocking(move || {
@ -97,10 +139,10 @@ impl App {
}) })
.await .await
.unwrap() .unwrap()
.map_err(|_| AppError::Unhandled) .map_err(|_| WriteError::Unhandled)
} }
pub async fn update_record(&self, record: Record<TraxRecord>) -> Result<(), AppError> { async fn update_record(&self, record: Record<TraxRecord>) -> Result<(), WriteError> {
let db = self.database.clone(); let db = self.database.clone();
self.runtime self.runtime
.spawn_blocking(move || { .spawn_blocking(move || {
@ -112,18 +154,10 @@ impl App {
}) })
.await .await
.unwrap() .unwrap()
.map_err(|_| AppError::Unhandled) .map_err(|_| WriteError::Unhandled)
} }
pub async fn open_db(&self, path: PathBuf) -> Result<(), AppError> { async fn delete_record(&self, id: RecordId) -> Result<(), WriteError> {
let db_ref = self.database.clone(); unimplemented!()
self.runtime
.spawn_blocking(move || {
let db = Series::open(path).map_err(|_| AppError::FailedToOpenDatabase)?;
*db_ref.write().unwrap() = Some(db);
Ok(())
})
.await
.unwrap()
} }
} }

View File

@ -15,7 +15,8 @@ You should have received a copy of the GNU General Public License along with Fit
*/ */
use crate::{ use crate::{
app::App, app::{App, RecordProvider},
types::DayInterval,
view_models::DayDetailViewModel, view_models::DayDetailViewModel,
views::{DayDetailView, HistoricalView, PlaceholderView, View, WelcomeView}, views::{DayDetailView, HistoricalView, PlaceholderView, View, WelcomeView},
}; };
@ -133,15 +134,15 @@ impl AppWindow {
self.swap_main(view); self.swap_main(view);
} }
fn show_historical_view(&self, records: Vec<Record<TraxRecord>>) { fn show_historical_view(&self, interval: DayInterval) {
let view = View::Historical(HistoricalView::new(self.app.clone(), records, { let view = View::Historical(HistoricalView::new(self.app.clone(), interval, {
let s = self.clone(); let s = self.clone();
Rc::new(move |date, _records| { Rc::new(move |date| {
let s = s.clone(); let s = s.clone();
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
let layout = gtk::Box::new(gtk::Orientation::Vertical, 0); let layout = gtk::Box::new(gtk::Orientation::Vertical, 0);
layout.append(&adw::HeaderBar::new()); layout.append(&adw::HeaderBar::new());
let view_model = DayDetailViewModel::new(date, s.app.clone()).await; let view_model = DayDetailViewModel::new(date, s.app.clone()).await.unwrap();
layout.append(&DayDetailView::new(view_model)); layout.append(&DayDetailView::new(view_model));
let page = &adw::NavigationPage::builder() let page = &adw::NavigationPage::builder()
.title(date.format("%Y-%m-%d").to_string()) .title(date.format("%Y-%m-%d").to_string())
@ -160,10 +161,7 @@ impl AppWindow {
async move { async move {
let end = Local::now().date_naive(); let end = Local::now().date_naive();
let start = end - Duration::days(7); let start = end - Duration::days(7);
match s.app.records(start, end).await { s.show_historical_view(DayInterval { start, end });
Ok(records) => s.show_historical_view(records),
Err(_) => s.show_welcome_view(),
}
} }
}); });
} }

View File

@ -14,7 +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 <https://www.gnu.org/licenses/>. 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::App, types::WeightFormatter}; use crate::{
app::{ReadError, RecordProvider, WriteError},
types::WeightFormatter,
};
use chrono::NaiveDate;
use emseries::{Record, RecordId, Recordable}; use emseries::{Record, RecordId, Recordable};
use ft_core::TraxRecord; use ft_core::TraxRecord;
use std::{ use std::{
@ -77,7 +81,7 @@ impl<T: Clone + emseries::Recordable> Deref for RecordState<T> {
#[derive(Clone)] #[derive(Clone)]
pub struct DayDetailViewModel { pub struct DayDetailViewModel {
app: App, provider: Arc<dyn RecordProvider>,
pub date: chrono::NaiveDate, pub date: chrono::NaiveDate,
weight: Arc<RwLock<Option<RecordState<ft_core::Weight>>>>, weight: Arc<RwLock<Option<RecordState<ft_core::Weight>>>>,
steps: Arc<RwLock<Option<RecordState<ft_core::Steps>>>>, steps: Arc<RwLock<Option<RecordState<ft_core::Steps>>>>,
@ -85,15 +89,20 @@ pub struct DayDetailViewModel {
} }
impl DayDetailViewModel { impl DayDetailViewModel {
pub async fn new(date: chrono::NaiveDate, app: App) -> Self { pub async fn new(
let records = app.records(date, date).await.unwrap(); date: chrono::NaiveDate,
provider: impl RecordProvider + 'static,
) -> Result<Self, ReadError> {
let (weight_records, records): (Vec<Record<TraxRecord>>, Vec<Record<TraxRecord>>) = let (weight_records, records): (Vec<Record<TraxRecord>>, Vec<Record<TraxRecord>>) =
records.into_iter().partition(|r| r.data.is_weight()); provider
.records(date, date)
.await?
.into_iter()
.partition(|r| r.data.is_weight());
let (step_records, records): (Vec<Record<TraxRecord>>, Vec<Record<TraxRecord>>) = let (step_records, records): (Vec<Record<TraxRecord>>, Vec<Record<TraxRecord>>) =
records.into_iter().partition(|r| r.data.is_steps()); records.into_iter().partition(|r| r.data.is_steps());
Self { Ok(Self {
app, provider: Arc::new(provider),
date, date,
weight: Arc::new(RwLock::new( weight: Arc::new(RwLock::new(
weight_records weight_records
@ -120,7 +129,7 @@ impl DayDetailViewModel {
.map(|r| (r.id.clone(), RecordState::Original(r))) .map(|r| (r.id.clone(), RecordState::Original(r)))
.collect::<HashMap<RecordId, RecordState<TraxRecord>>>(), .collect::<HashMap<RecordId, RecordState<TraxRecord>>>(),
)), )),
} })
} }
pub fn weight(&self) -> Option<WeightFormatter> { pub fn weight(&self) -> Option<WeightFormatter> {
@ -170,12 +179,12 @@ impl DayDetailViewModel {
let weight_record = s.weight.read().unwrap().clone(); let weight_record = s.weight.read().unwrap().clone();
match weight_record { match weight_record {
Some(RecordState::New(data)) => { Some(RecordState::New(data)) => {
let _ = s.app.put_record(TraxRecord::Weight(data)).await; let _ = s.provider.put_record(TraxRecord::Weight(data)).await;
} }
Some(RecordState::Original(_)) => {} Some(RecordState::Original(_)) => {}
Some(RecordState::Updated(weight)) => { Some(RecordState::Updated(weight)) => {
let _ = s let _ = s
.app .provider
.update_record(Record { .update_record(Record {
id: weight.id, id: weight.id,
data: TraxRecord::Weight(weight.data), data: TraxRecord::Weight(weight.data),
@ -189,12 +198,12 @@ impl DayDetailViewModel {
let steps_record = s.steps.read().unwrap().clone(); let steps_record = s.steps.read().unwrap().clone();
match steps_record { match steps_record {
Some(RecordState::New(data)) => { Some(RecordState::New(data)) => {
let _ = s.app.put_record(TraxRecord::Steps(data)).await; let _ = s.provider.put_record(TraxRecord::Steps(data)).await;
} }
Some(RecordState::Original(_)) => {} Some(RecordState::Original(_)) => {}
Some(RecordState::Updated(steps)) => { Some(RecordState::Updated(steps)) => {
let _ = s let _ = s
.app .provider
.update_record(Record { .update_record(Record {
id: steps.id, id: steps.id,
data: TraxRecord::Steps(steps.data), data: TraxRecord::Steps(steps.data),
@ -216,11 +225,11 @@ impl DayDetailViewModel {
for record in records { for record in records {
match record { match record {
RecordState::New(data) => { RecordState::New(data) => {
let _ = s.app.put_record(data).await; let _ = s.provider.put_record(data).await;
} }
RecordState::Original(_) => {} RecordState::Original(_) => {}
RecordState::Updated(r) => { RecordState::Updated(r) => {
let _ = s.app.update_record(r.clone()).await; let _ = s.provider.update_record(r.clone()).await;
} }
RecordState::Deleted(_) => unimplemented!(), RecordState::Deleted(_) => unimplemented!(),
} }
@ -233,3 +242,131 @@ impl DayDetailViewModel {
unimplemented!(); 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() {}
}

View File

@ -62,11 +62,11 @@ impl ObjectSubclass for HistoricalViewPrivate {
let app = app.clone(); let app = app.clone();
let list_item = list_item.clone(); let list_item = list_item.clone();
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
let records = list_item let date = list_item
.downcast_ref::<gtk::ListItem>() .downcast_ref::<gtk::ListItem>()
.expect("should be a ListItem") .expect("should be a ListItem")
.item() .item()
.and_downcast::<DayRecords>() .and_downcast::<Date>()
.expect("should be a DaySummary"); .expect("should be a DaySummary");
let summary = list_item let summary = list_item
@ -77,8 +77,11 @@ impl ObjectSubclass for HistoricalViewPrivate {
.expect("should be a DaySummary"); .expect("should be a DaySummary");
if let Some(app) = app.borrow().clone() { if let Some(app) = app.borrow().clone() {
let view_model = DayDetailViewModel::new(records.date(), app.clone()).await; glib::spawn_future_local(async move {
summary.set_data(view_model); let view_model =
DayDetailViewModel::new(date.date(), app).await.unwrap();
summary.set_data(view_model);
});
} }
}); });
} }
@ -97,13 +100,9 @@ glib::wrapper! {
} }
impl HistoricalView { impl HistoricalView {
pub fn new<SelectFn>( pub fn new<SelectFn>(app: App, interval: DayInterval, on_select_day: Rc<SelectFn>) -> Self
app: App,
records: Vec<Record<TraxRecord>>,
on_select_day: Rc<SelectFn>,
) -> Self
where where
SelectFn: Fn(chrono::NaiveDate, Vec<Record<TraxRecord>>) + 'static, SelectFn: Fn(chrono::NaiveDate) + 'static,
{ {
let s: Self = Object::builder().build(); let s: Self = Object::builder().build();
s.set_orientation(gtk::Orientation::Vertical); s.set_orientation(gtk::Orientation::Vertical);
@ -111,11 +110,8 @@ impl HistoricalView {
*s.imp().app.borrow_mut() = Some(app); *s.imp().app.borrow_mut() = Some(app);
let grouped_records = let mut model = gio::ListStore::new::<Date>();
GroupedRecords::new((*s.imp().time_window.borrow()).clone()).with_data(records); model.extend(interval.days().map(Date::new));
let mut model = gio::ListStore::new::<DayRecords>();
model.extend(grouped_records.items());
s.imp() s.imp()
.list_view .list_view
.set_model(Some(&gtk::NoSelection::new(Some(model)))); .set_model(Some(&gtk::NoSelection::new(Some(model))));
@ -127,8 +123,8 @@ impl HistoricalView {
// actually want to do here is to open a modal dialog that shows all of the details of // actually want to do here is to open a modal dialog that shows all of the details of
// the day and which allows the user to edit items within that dialog. // the day and which allows the user to edit items within that dialog.
let item = s.model().unwrap().item(idx).unwrap(); let item = s.model().unwrap().item(idx).unwrap();
let records = item.downcast_ref::<DayRecords>().unwrap(); let date = item.downcast_ref::<Date>().unwrap();
on_select_day(records.date(), records.records()); on_select_day(date.date());
} }
}); });
@ -137,12 +133,9 @@ impl HistoricalView {
s s
} }
pub fn set_records(&self, records: Vec<Record<TraxRecord>>) { pub fn set_interval(&self, interval: DayInterval) {
println!("set_records: {:?}", records); let mut model = gio::ListStore::new::<Date>();
let grouped_records = model.extend(interval.days().map(Date::new));
GroupedRecords::new((self.imp().time_window.borrow()).clone()).with_data(records);
let mut model = gio::ListStore::new::<DayRecords>();
model.extend(grouped_records.items());
self.imp() self.imp()
.list_view .list_view
.set_model(Some(&gtk::NoSelection::new(Some(model)))); .set_model(Some(&gtk::NoSelection::new(Some(model))));
@ -154,152 +147,30 @@ impl HistoricalView {
} }
#[derive(Default)] #[derive(Default)]
pub struct DayRecordsPrivate { pub struct DatePrivate {
date: RefCell<chrono::NaiveDate>, date: RefCell<chrono::NaiveDate>,
records: RefCell<Vec<Record<TraxRecord>>>,
} }
#[glib::object_subclass] #[glib::object_subclass]
impl ObjectSubclass for DayRecordsPrivate { impl ObjectSubclass for DatePrivate {
const NAME: &'static str = "DayRecords"; const NAME: &'static str = "Date";
type Type = DayRecords; type Type = Date;
} }
impl ObjectImpl for DayRecordsPrivate {} impl ObjectImpl for DatePrivate {}
glib::wrapper! { glib::wrapper! {
pub struct DayRecords(ObjectSubclass<DayRecordsPrivate>); pub struct Date(ObjectSubclass<DatePrivate>);
} }
impl DayRecords { impl Date {
pub fn new(date: chrono::NaiveDate, records: Vec<Record<TraxRecord>>) -> Self { pub fn new(date: chrono::NaiveDate) -> Self {
let s: Self = Object::builder().build(); let s: Self = Object::builder().build();
*s.imp().date.borrow_mut() = date; *s.imp().date.borrow_mut() = date;
*s.imp().records.borrow_mut() = records;
s s
} }
pub fn date(&self) -> chrono::NaiveDate { pub fn date(&self) -> chrono::NaiveDate {
*self.imp().date.borrow() *self.imp().date.borrow()
} }
pub fn records(&self) -> Vec<Record<TraxRecord>> {
self.imp().records.borrow().clone()
}
pub fn add_record(&self, record: Record<TraxRecord>) {
self.imp().records.borrow_mut().push(record);
}
}
// This isn't feeling quite right. DayRecords is a glib object, but I'm not sure that I want to
// really be passing that around. It seems not generic enough. I feel like this whole grouped
// records thing can be made more generic.
struct GroupedRecords {
interval: DayInterval,
data: HashMap<NaiveDate, DayRecords>,
}
impl GroupedRecords {
fn new(interval: DayInterval) -> Self {
let mut s = Self {
interval: interval.clone(),
data: HashMap::new(),
};
interval.days().for_each(|date| {
let _ = s.data.insert(date, DayRecords::new(date, vec![]));
});
s
}
fn with_data(mut self, records: Vec<Record<TraxRecord>>) -> Self {
records.into_iter().for_each(|record| {
self.data
.entry(record.date())
.and_modify(|entry: &mut DayRecords| (*entry).add_record(record.clone()))
.or_insert(DayRecords::new(record.date(), vec![record]));
});
self
}
fn items(&self) -> impl Iterator<Item = DayRecords> + '_ {
self.interval.days().map(|date| {
self.data
.get(&date)
.cloned()
.unwrap_or(DayRecords::new(date, vec![]))
})
}
}
#[cfg(test)]
mod test {
use super::GroupedRecords;
use crate::types::DayInterval;
use chrono::{FixedOffset, NaiveDate, TimeZone};
use dimensioned::si::{KG, M, S};
use emseries::{Record, RecordId};
use ft_core::{Steps, TimeDistance, TraxRecord, Weight};
#[test]
fn groups_records() {
let records = vec![
Record {
id: RecordId::default(),
data: TraxRecord::Steps(Steps {
date: NaiveDate::from_ymd_opt(2023, 10, 13).unwrap(),
count: 1500,
}),
},
Record {
id: RecordId::default(),
data: TraxRecord::Weight(Weight {
date: NaiveDate::from_ymd_opt(2023, 10, 13).unwrap(),
weight: 85. * KG,
}),
},
Record {
id: RecordId::default(),
data: TraxRecord::Weight(Weight {
date: NaiveDate::from_ymd_opt(2023, 10, 14).unwrap(),
weight: 86. * KG,
}),
},
Record {
id: RecordId::default(),
data: TraxRecord::BikeRide(TimeDistance {
datetime: FixedOffset::west_opt(10 * 60 * 60)
.unwrap()
.with_ymd_and_hms(2019, 6, 15, 12, 0, 0)
.unwrap(),
distance: Some(1000. * M),
duration: Some(150. * S),
comments: Some("Test Comments".to_owned()),
}),
},
Record {
id: RecordId::default(),
data: TraxRecord::BikeRide(TimeDistance {
datetime: FixedOffset::west_opt(10 * 60 * 60)
.unwrap()
.with_ymd_and_hms(2019, 6, 15, 23, 0, 0)
.unwrap(),
distance: Some(1000. * M),
duration: Some(150. * S),
comments: Some("Test Comments".to_owned()),
}),
},
];
let groups = GroupedRecords::new(DayInterval {
start: NaiveDate::from_ymd_opt(2023, 10, 14).unwrap(),
end: NaiveDate::from_ymd_opt(2023, 10, 14).unwrap(),
})
.with_data(records)
.data;
assert_eq!(groups.len(), 3);
}
} }