diff --git a/Cargo.lock b/Cargo.lock index ee934db9..0b40a2e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1112,6 +1112,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "object" version = "0.32.2" @@ -1244,6 +1253,7 @@ dependencies = [ "serde_derive", "serde_json", "similar-asserts", + "simplelog", "strum", "strum_macros", "tabled", @@ -1282,6 +1292,7 @@ dependencies = [ "eyre", "getset", "humantime", + "rstest", "serde", "serde_derive", "thiserror", @@ -1837,6 +1848,17 @@ dependencies = [ "similar", ] +[[package]] +name = "simplelog" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16257adbfaef1ee58b1363bdc0664c9b8e1e30aed86049635fb5f147d065a9c0" +dependencies = [ + "log", + "termcolor", + "time", +] + [[package]] name = "siphasher" version = "0.3.11" @@ -2067,7 +2089,9 @@ checksum = "c8248b6521bb14bc45b4067159b9b6ad792e2d6d754d6c41fb50e29fefe38749" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde", "time-core", @@ -2140,6 +2164,7 @@ version = "0.1.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", diff --git a/Cargo.toml b/Cargo.toml index 984d1bc8..58bf3757 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,6 +64,7 @@ tracing = "0.1.40" typed-builder = "0.18.1" ulid = "1.1.2" wildmatch = "2.3.3" +simplelog = "0.12.2" [package] name = "pace-rs" diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index cfda38e7..bfde47a1 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -63,6 +63,7 @@ insta = { workspace = true, features = ["toml", "redactions"] } pace_testing = { workspace = true } rstest = { workspace = true } similar-asserts = { workspace = true, features = ["serde"] } +simplelog = { workspace = true } [lints] workspace = true diff --git a/crates/core/src/commands/reflect.rs b/crates/core/src/commands/reflect.rs index 911012e4..fdfbf42c 100644 --- a/crates/core/src/commands/reflect.rs +++ b/crates/core/src/commands/reflect.rs @@ -81,14 +81,14 @@ pub struct ReflectCommandOptions { )] date_flags: Option, - /// Time zone to use for the activity, e.g., "Europe/Amsterdam" + /// Time zone to use for displaying the reflections, e.g., "Europe/Amsterdam" #[cfg_attr( feature = "clap", clap(long, value_name = "Time Zone", group = "tz", visible_alias = "tz") )] time_zone: Option, - /// Time zone offset to use for the activity, e.g., "+0200" or "-0500". Format: ±HHMM + /// Time zone offset to use to display the reflections, e.g., "+0200" or "-0500". Format: ±HHMM #[cfg_attr( feature = "clap", clap( diff --git a/crates/core/src/domain/activity.rs b/crates/core/src/domain/activity.rs index 2698b685..8642d6e9 100644 --- a/crates/core/src/domain/activity.rs +++ b/crates/core/src/domain/activity.rs @@ -482,7 +482,7 @@ impl Activity { begin: PaceDateTime, end: PaceDateTime, ) -> PaceResult<()> { - let end_opts = ActivityEndOptions::new(end, calculate_duration(&begin, end)?); + let end_opts = ActivityEndOptions::new(end, calculate_duration(&begin, &end)?); debug!( "Ending activity {} with duration calculations end_opts: {:?}", diff --git a/crates/core/src/storage/in_memory.rs b/crates/core/src/storage/in_memory.rs index 25ddd4d2..054ddea8 100644 --- a/crates/core/src/storage/in_memory.rs +++ b/crates/core/src/storage/in_memory.rs @@ -272,7 +272,7 @@ impl ActivityStateManagement for InMemoryActivityStorage { let end_opts = ActivityEndOptions::new( *end_opts.end_time(), - calculate_duration(&begin_time, *end_opts.end_time())?, + calculate_duration(&begin_time, end_opts.end_time())?, ); debug!("End options: {:?}", end_opts); diff --git a/crates/core/tests/journey/hold_resume.rs b/crates/core/tests/journey/hold_resume.rs index 118be146..f8c47884 100644 --- a/crates/core/tests/journey/hold_resume.rs +++ b/crates/core/tests/journey/hold_resume.rs @@ -2,13 +2,11 @@ use pace_core::prelude::{ Activity, ActivityQuerying, ActivityReadOps, ActivityStateManagement, HoldOptions, InMemoryActivityStorage, ResumeOptions, TestResult, }; -use pace_time::date_time::PaceDateTime; #[test] #[allow(clippy::too_many_lines)] fn test_hold_resume_journey_for_activities_passes() -> TestResult<()> { let storage = InMemoryActivityStorage::new(); - let _now = PaceDateTime::now(); let first_og_activity = Activity::builder().description("Test activity").build(); @@ -196,8 +194,3 @@ fn test_hold_resume_journey_for_activities_passes() -> TestResult<()> { Ok(()) } - -// #[test] -// fn test_begin_and_end_activity_in_different_time_zones_passes() -> TestResult<()> { -// todo!("Implement this test.") -// } diff --git a/crates/core/tests/journey/main.rs b/crates/core/tests/journey/main.rs index f7377778..5156a1aa 100644 --- a/crates/core/tests/journey/main.rs +++ b/crates/core/tests/journey/main.rs @@ -1 +1,10 @@ mod hold_resume; +mod start_finish_different_time_zone; + +// DEBUGGING: To make life easier, you can import SimpleLogger from simplelog crate +// +// `use simplelog::{Config, SimpleLogger};` +// +// and use it like this to initialize the logger in your tests: +// +// `SimpleLogger::init(tracing::log::LevelFilter::Debug, Config::default())?;` diff --git a/crates/core/tests/journey/start_finish_different_time_zone.rs b/crates/core/tests/journey/start_finish_different_time_zone.rs new file mode 100644 index 00000000..46f8d1d7 --- /dev/null +++ b/crates/core/tests/journey/start_finish_different_time_zone.rs @@ -0,0 +1,105 @@ +use chrono::FixedOffset; +use eyre::OptionExt; +use pace_core::prelude::{ + Activity, ActivityReadOps, ActivityStateManagement, EndOptions, InMemoryActivityStorage, + TestResult, +}; +use pace_time::{date_time::PaceDateTime, duration::PaceDuration}; + +#[test] +#[allow(clippy::too_many_lines)] +fn test_begin_and_end_activity_in_different_time_zones_passes() -> TestResult<()> { + let storage = InMemoryActivityStorage::new(); + + // We start an activity in our time zone + let now = PaceDateTime::now(); + + let first_og_activity = Activity::builder() + .description("Our time zone") + .begin(now) + .build(); + + let first_begin_activity = storage.begin_activity(first_og_activity.clone())?; + + let first_stored_activity = storage.read_activity(*first_begin_activity.guid())?; + + assert_eq!( + first_og_activity.begin(), + first_stored_activity.activity().begin(), + "Stored activity has not the same begin time as the original activity." + ); + + assert_eq!( + first_og_activity.description(), + first_stored_activity.activity().description(), + "Stored activity has not the same description as the original activity." + ); + + assert_eq!( + first_og_activity.kind(), + first_stored_activity.activity().kind(), + "Stored activity has not the same kind as the original activity." + ); + + assert_ne!( + first_og_activity.status(), + first_stored_activity.activity().status(), + "Stored activity has the same status as the original activity. Which can't be, because it should be active." + ); + + assert!( + first_stored_activity.activity().status().is_active(), + "Stored activity is not active." + ); + + assert!( + first_og_activity.status().is_inactive(), + "Original activity is not inactive." + ); + + // use 3 hours as duration + let artificial_duration = 3 * 60 * 60; + + // Now we end the activity in a different time zone + let end = PaceDateTime::now_with_offset("-0200".parse::()?) + .add_duration(PaceDuration::new(artificial_duration))?; + + let ended_activity = storage + .end_last_unfinished_activity(EndOptions::builder().end_time(end).build())? + .ok_or_eyre("Activity was not ended.")?; + + assert_eq!( + first_og_activity.begin(), + ended_activity.activity().begin(), + "Ended activity has not the same begin time as the original activity." + ); + + assert_eq!( + first_og_activity.description(), + ended_activity.activity().description(), + "Ended activity has not the same description as the original activity." + ); + + assert_eq!( + first_og_activity.kind(), + ended_activity.activity().kind(), + "Ended activity has not the same kind as the original activity." + ); + + assert!( + ended_activity.activity().status().is_ended(), + "Activity is not ended." + ); + + let read_ended_activity = storage.read_activity(*ended_activity.guid())?; + + dbg!(&read_ended_activity); + + // check activity lasted 3 hours + assert_eq!( + read_ended_activity.activity().duration()?, + PaceDuration::new(artificial_duration) + ); + + Ok(()) +} diff --git a/crates/time/Cargo.toml b/crates/time/Cargo.toml index 4941a834..7190e915 100644 --- a/crates/time/Cargo.toml +++ b/crates/time/Cargo.toml @@ -27,11 +27,12 @@ humantime = { workspace = true } serde = { workspace = true } serde_derive = { workspace = true } thiserror = { workspace = true } -tracing = { workspace = true } +tracing = { workspace = true, features = ["log"] } typed-builder = { workspace = true } [dev-dependencies] eyre = { workspace = true } +rstest = { workspace = true } [lints] workspace = true diff --git a/crates/time/src/date_time.rs b/crates/time/src/date_time.rs index 38537c60..980ab31c 100644 --- a/crates/time/src/date_time.rs +++ b/crates/time/src/date_time.rs @@ -4,8 +4,8 @@ use std::{ }; use chrono::{ - DateTime, FixedOffset, Local, LocalResult, NaiveDate, NaiveDateTime, NaiveTime, SubsecRound, - TimeZone, + DateTime, Duration, FixedOffset, Local, LocalResult, NaiveDate, NaiveDateTime, NaiveTime, + SubsecRound, TimeZone, }; use serde_derive::{Deserialize, Serialize}; @@ -13,6 +13,7 @@ use tracing::debug; use crate::{ date::PaceDate, + duration::PaceDuration, error::{PaceTimeErrorKind, PaceTimeResult}, time::PaceTime, time_zone::PaceTimeZoneKind, @@ -132,6 +133,38 @@ impl PaceDateTime { pace_date_time_from_date_and_time_and_tz(date, time, time_zone) } + /// Add a [`TimeDelta`] to the [`PaceDateTime`] and return a new [`PaceDateTime`] + /// + /// # Arguments + /// + /// * `rhs` - The [`TimeDelta`] to add + /// + /// # Errors + /// + /// Returns an error if the addition fails + /// + /// # Returns + /// + /// Returns the new [`PaceDateTime`] with the added [`TimeDelta`] + pub fn add_duration(self, rhs: PaceDuration) -> PaceTimeResult { + Ok(Self( + self.0 + .checked_add_signed( + Duration::new( + i64::try_from(rhs.inner()) + .map_err(PaceTimeErrorKind::FailedToConvertDurationToI64)?, + 0, + ) + .ok_or_else(|| { + PaceTimeErrorKind::ConversionToDurationFailed(format!("{rhs:?}")) + })?, + ) + .ok_or_else(|| { + PaceTimeErrorKind::AddingTimeDeltaFailed(format!("{self} + {rhs:?}")) + })?, + )) + } + // TODO! Implement this // pub fn with_date_and_time( // year: i32, @@ -239,6 +272,20 @@ impl PaceDateTime { pub fn now() -> Self { Self(Local::now().round_subsecs(0).fixed_offset()) } + + /// Create a new `PaceDateTime` with a [`FixedOffset`] + /// + /// # Arguments + /// + /// * `offset` - The [`FixedOffset`] to use + /// + /// # Returns + /// + /// Returns the new `PaceDateTime` with the given offset + #[must_use] + pub fn now_with_offset(offset: FixedOffset) -> Self { + Self(Local::now().round_subsecs(0).with_timezone(&offset)) + } } impl Validate for PaceDateTime { diff --git a/crates/time/src/duration.rs b/crates/time/src/duration.rs index ec04590f..7f2ab297 100644 --- a/crates/time/src/duration.rs +++ b/crates/time/src/duration.rs @@ -89,6 +89,11 @@ impl Display for PaceDuration { #[allow(clippy::trivially_copy_pass_by_ref)] impl PaceDuration { + #[must_use] + pub const fn inner(&self) -> u64 { + self.0 + } + #[must_use] pub const fn new(duration: u64) -> Self { Self(duration) @@ -218,10 +223,17 @@ impl std::ops::SubAssign for PaceDuration { /// /// Returns the duration of the activity #[tracing::instrument] -pub fn calculate_duration(begin: &PaceDateTime, end: PaceDateTime) -> PaceTimeResult { - let duration = end.inner().signed_duration_since(begin.inner()).to_std()?; - - debug!("Duration: {:?}", duration); +pub fn calculate_duration( + begin: &PaceDateTime, + end: &PaceDateTime, +) -> PaceTimeResult { + let duration = end + .inner() + .signed_duration_since(begin.inner()) + .abs() + .to_std()?; + + debug!("Duration: {duration:?}"); Ok(duration.into()) } @@ -234,6 +246,22 @@ mod tests { use chrono::{NaiveDate, NaiveTime, TimeDelta}; use eyre::{eyre, OptionExt, Result}; + use rstest::rstest; + + #[rstest] + #[case("2024-03-23T11:34:31+01:00".parse::()?, "2024-03-23T08:34:31-02:00".parse::()?, PaceDuration::new(0))] + #[case("2024-03-23T10:00:00+03:00".parse::()?, "2024-03-23T10:00:00+01:00".parse::()?, PaceDuration::new(7200))] + #[case("2024-03-23T10:00:00+01:00".parse::()?, "2024-03-23T10:00:00-03:00".parse::()?, PaceDuration::new(14400))] + fn calculate_duration_with_time_zone( + #[case] begin: PaceDateTime, + #[case] end: PaceDateTime, + #[case] expected: PaceDuration, + ) -> Result<()> { + assert_eq!(calculate_duration(&begin, &end)?, expected); + + Ok(()) + } + #[test] fn test_duration_to_str_passes() { let initial_time = Local::now(); @@ -253,7 +281,7 @@ mod tests { NaiveTime::from_hms_opt(0, 0, 1).ok_or(eyre!("Invalid date."))?, ))?; - let duration = calculate_duration(&begin, end)?; + let duration = calculate_duration(&begin, &end)?; assert_eq!(duration, Duration::from_secs(1).into()); Ok(()) @@ -271,7 +299,7 @@ mod tests { NaiveTime::from_hms_opt(0, 0, 0).ok_or(eyre!("Invalid date."))?, ))?; - let duration = calculate_duration(&begin, end); + let duration = calculate_duration(&begin, &end); assert!(duration.is_err()); diff --git a/crates/time/src/error.rs b/crates/time/src/error.rs index 615be16f..ceb4d391 100644 --- a/crates/time/src/error.rs +++ b/crates/time/src/error.rs @@ -1,3 +1,5 @@ +use std::num::TryFromIntError; + use chrono::OutOfRangeError; use displaydoc::Display; use thiserror::Error; @@ -31,10 +33,10 @@ pub enum PaceTimeErrorKind { /// Failed to parse date '{0}' ParsingDateFailed(String), - /// Invalid time range: Start {0} - End {1} + /// Invalid time range: Start '{0}' - End '{1}' InvalidTimeRange(String, String), - /// Invalid time zone: {0} + /// Invalid time zone: '{0}' InvalidTimeZone(String), /// Failed to parse fixed offset '{0}' from user input, please use the format ±HHMM @@ -63,4 +65,13 @@ pub enum PaceTimeErrorKind { /// Setting start of day failed SettingStartOfDayFailed, + + /// Adding time delta failed: '{0}' + AddingTimeDeltaFailed(String), + + /// Failed to convert duration to i64: '{0}' + FailedToConvertDurationToI64(TryFromIntError), + + /// Failed to convert PaceDuration to Standard Duration: '{0}' + ConversionToDurationFailed(String), } diff --git a/crates/time/src/flags.rs b/crates/time/src/flags.rs index 3fa64a55..88ad58c1 100644 --- a/crates/time/src/flags.rs +++ b/crates/time/src/flags.rs @@ -1,7 +1,7 @@ use chrono::NaiveDate; #[cfg(feature = "clap")] -use clap::{Parser, ValueEnum}; +use clap::{Args, ValueEnum}; use getset::{Getters, MutGetters, Setters}; use typed_builder::TypedBuilder; @@ -31,7 +31,7 @@ pub enum TimeFlags { #[derive(Debug, Getters, Default, TypedBuilder, Setters, MutGetters, Clone, Eq, PartialEq)] #[getset(get = "pub")] -#[cfg_attr(feature = "clap", derive(Parser))] +#[cfg_attr(feature = "clap", derive(Args))] #[cfg_attr( feature = "clap", clap(group = clap::ArgGroup::new("date-flag").multiple(true)))] pub struct DateFlags {