Skip to main content

clickup_cli/commands/
time.rs

1use crate::client::ClickUpClient;
2use crate::commands::auth::resolve_token;
3use crate::commands::workspace::resolve_workspace;
4use crate::error::CliError;
5use crate::git;
6use crate::output::OutputConfig;
7use crate::Cli;
8use clap::Subcommand;
9
10#[derive(Subcommand)]
11pub enum TimeCommands {
12    /// List time entries
13    List {
14        /// Filter by start date (ISO 8601 or Unix ms)
15        #[arg(long)]
16        start_date: Option<String>,
17        /// Filter by end date (ISO 8601 or Unix ms)
18        #[arg(long)]
19        end_date: Option<String>,
20        /// Filter by assignee user ID
21        #[arg(long)]
22        assignee: Option<String>,
23        /// Filter by task ID
24        #[arg(long)]
25        task: Option<String>,
26    },
27    /// Get a time entry by ID
28    Get {
29        /// Time entry ID
30        id: String,
31    },
32    /// Get the currently running timer
33    Current,
34    /// Create a time entry
35    Create {
36        /// Start time (Unix ms)
37        #[arg(long)]
38        start: String,
39        /// Duration in milliseconds
40        #[arg(long)]
41        duration: String,
42        /// Task ID
43        #[arg(long)]
44        task: Option<String>,
45        /// Description
46        #[arg(long)]
47        description: Option<String>,
48        /// Mark as billable
49        #[arg(long)]
50        billable: bool,
51    },
52    /// Update a time entry
53    Update {
54        /// Time entry ID
55        id: String,
56        /// New start time (Unix ms)
57        #[arg(long)]
58        start: Option<String>,
59        /// New end time (Unix ms)
60        #[arg(long)]
61        end: Option<String>,
62        /// New description
63        #[arg(long)]
64        description: Option<String>,
65        /// Mark as billable
66        #[arg(long)]
67        billable: Option<bool>,
68    },
69    /// Delete a time entry
70    Delete {
71        /// Time entry ID
72        id: String,
73    },
74    /// Start a timer
75    Start {
76        /// Task ID to associate with the timer
77        #[arg(long)]
78        task: Option<String>,
79        /// Description
80        #[arg(long)]
81        description: Option<String>,
82        /// Mark as billable
83        #[arg(long)]
84        billable: bool,
85    },
86    /// Stop the currently running timer
87    Stop,
88    /// List time entry tags for workspace
89    Tags,
90    /// Add tags to a time entry
91    AddTags {
92        /// Time entry ID
93        #[arg(long)]
94        entry_id: String,
95        /// Tag name(s) to add
96        #[arg(long = "tag")]
97        tags: Vec<String>,
98    },
99    /// Remove tags from a time entry
100    RemoveTags {
101        /// Time entry ID
102        #[arg(long)]
103        entry_id: String,
104        /// Tag name(s) to remove
105        #[arg(long = "tag")]
106        tags: Vec<String>,
107    },
108    /// Rename a time entry tag
109    RenameTag {
110        /// Current tag name
111        #[arg(long)]
112        name: String,
113        /// New tag name
114        #[arg(long)]
115        new_name: String,
116    },
117    /// Get history for a time entry
118    History {
119        /// Time entry ID
120        id: String,
121    },
122}
123
124const TIME_FIELDS: &[&str] = &["id", "task", "duration", "start", "billable"];
125
126/// Flatten a time entry's "task" field from object to name string if needed.
127fn flatten_task_field(mut entry: serde_json::Value) -> serde_json::Value {
128    if let Some(obj) = entry.as_object_mut() {
129        if let Some(task_val) = obj.get("task").cloned() {
130            if let Some(name) = task_val.get("name").and_then(|n| n.as_str()) {
131                obj.insert(
132                    "task".to_string(),
133                    serde_json::Value::String(name.to_string()),
134                );
135            }
136        }
137    }
138    entry
139}
140
141pub async fn execute(command: TimeCommands, cli: &Cli) -> Result<(), CliError> {
142    let token = resolve_token(cli)?;
143    let client = ClickUpClient::new(&token, cli.timeout)?;
144    let output = OutputConfig::from_cli(&cli.output, &cli.fields, cli.no_header, cli.quiet);
145    let ws_id = resolve_workspace(cli)?;
146
147    match command {
148        TimeCommands::List {
149            start_date,
150            end_date,
151            assignee,
152            task,
153        } => {
154            let mut params = Vec::new();
155            if let Some(s) = start_date {
156                params.push(format!("start_date={}", s));
157            }
158            if let Some(e) = end_date {
159                params.push(format!("end_date={}", e));
160            }
161            if let Some(a) = assignee {
162                params.push(format!("assignee={}", a));
163            }
164            if let Some(t) = git::resolve_task(cli, task.as_deref(), true)? {
165                params.push(format!("task_id={}", t.id));
166            }
167            let query = if params.is_empty() {
168                String::new()
169            } else {
170                format!("?{}", params.join("&"))
171            };
172            let resp = client
173                .get(&format!("/v2/team/{}/time_entries{}", ws_id, query))
174                .await?;
175            let entries: Vec<serde_json::Value> = resp
176                .get("data")
177                .and_then(|d| d.as_array())
178                .cloned()
179                .unwrap_or_default()
180                .into_iter()
181                .map(flatten_task_field)
182                .collect();
183            output.print_items(&entries, TIME_FIELDS, "id");
184            Ok(())
185        }
186        TimeCommands::Get { id } => {
187            let resp = client
188                .get(&format!("/v2/team/{}/time_entries/{}", ws_id, id))
189                .await?;
190            let entry = resp
191                .get("data")
192                .cloned()
193                .map(flatten_task_field)
194                .unwrap_or(resp);
195            output.print_single(&entry, TIME_FIELDS, "id");
196            Ok(())
197        }
198        TimeCommands::Current => {
199            let resp = client
200                .get(&format!("/v2/team/{}/time_entries/current", ws_id))
201                .await?;
202            let entry = resp
203                .get("data")
204                .cloned()
205                .map(flatten_task_field)
206                .unwrap_or(resp);
207            output.print_single(&entry, TIME_FIELDS, "id");
208            Ok(())
209        }
210        TimeCommands::Create {
211            start,
212            duration,
213            task,
214            description,
215            billable,
216        } => {
217            let mut body = serde_json::json!({
218                "start": start,
219                "duration": duration,
220                "billable": billable,
221            });
222            if let Some(t) = git::resolve_task(cli, task.as_deref(), true)? {
223                body["tid"] = serde_json::Value::String(t.id);
224            }
225            if let Some(d) = description {
226                body["description"] = serde_json::Value::String(d);
227            }
228            let resp = client
229                .post(&format!("/v2/team/{}/time_entries", ws_id), &body)
230                .await?;
231            let entry = resp
232                .get("data")
233                .cloned()
234                .map(flatten_task_field)
235                .unwrap_or(resp);
236            output.print_single(&entry, TIME_FIELDS, "id");
237            Ok(())
238        }
239        TimeCommands::Update {
240            id,
241            start,
242            end,
243            description,
244            billable,
245        } => {
246            let mut body = serde_json::Map::new();
247            if let Some(s) = start {
248                body.insert("start".into(), serde_json::Value::String(s));
249            }
250            if let Some(e) = end {
251                body.insert("end".into(), serde_json::Value::String(e));
252            }
253            if let Some(d) = description {
254                body.insert("description".into(), serde_json::Value::String(d));
255            }
256            if let Some(b) = billable {
257                body.insert("billable".into(), serde_json::Value::Bool(b));
258            }
259            let resp = client
260                .put(
261                    &format!("/v2/team/{}/time_entries/{}", ws_id, id),
262                    &serde_json::Value::Object(body),
263                )
264                .await?;
265            let entry = resp
266                .get("data")
267                .cloned()
268                .map(flatten_task_field)
269                .unwrap_or(resp);
270            output.print_single(&entry, TIME_FIELDS, "id");
271            Ok(())
272        }
273        TimeCommands::Delete { id } => {
274            client
275                .delete(&format!("/v2/team/{}/time_entries/{}", ws_id, id))
276                .await?;
277            output.print_message(&format!("Time entry {} deleted", id));
278            Ok(())
279        }
280        TimeCommands::Start {
281            task,
282            description,
283            billable,
284        } => {
285            let mut body = serde_json::json!({ "billable": billable });
286            if let Some(t) = git::resolve_task(cli, task.as_deref(), true)? {
287                body["tid"] = serde_json::Value::String(t.id);
288            }
289            if let Some(d) = description {
290                body["description"] = serde_json::Value::String(d);
291            }
292            let resp = client
293                .post(&format!("/v2/team/{}/time_entries/start", ws_id), &body)
294                .await?;
295            let entry = resp
296                .get("data")
297                .cloned()
298                .map(flatten_task_field)
299                .unwrap_or(resp);
300            output.print_single(&entry, TIME_FIELDS, "id");
301            Ok(())
302        }
303        TimeCommands::Stop => {
304            let body = serde_json::json!({});
305            let resp = client
306                .post(&format!("/v2/team/{}/time_entries/stop", ws_id), &body)
307                .await?;
308            let entry = resp
309                .get("data")
310                .cloned()
311                .map(flatten_task_field)
312                .unwrap_or(resp);
313            output.print_single(&entry, TIME_FIELDS, "id");
314            Ok(())
315        }
316        TimeCommands::Tags => {
317            let resp = client
318                .get(&format!("/v2/team/{}/time_entries/tags", ws_id))
319                .await?;
320            let tags = resp
321                .get("data")
322                .and_then(|d| d.as_array())
323                .cloned()
324                .unwrap_or_default();
325            output.print_items(&tags, &["name"], "name");
326            Ok(())
327        }
328        TimeCommands::AddTags { entry_id, tags } => {
329            let tag_objects: Vec<serde_json::Value> = tags
330                .iter()
331                .map(|n| serde_json::json!({ "name": n }))
332                .collect();
333            let body = serde_json::json!({
334                "time_entry_ids": [entry_id],
335                "tags": tag_objects,
336            });
337            client
338                .post(&format!("/v2/team/{}/time_entries/tags", ws_id), &body)
339                .await?;
340            output.print_message("Tags added");
341            Ok(())
342        }
343        TimeCommands::RemoveTags { entry_id, tags } => {
344            let tag_objects: Vec<serde_json::Value> = tags
345                .iter()
346                .map(|n| serde_json::json!({ "name": n }))
347                .collect();
348            let body = serde_json::json!({
349                "time_entry_ids": [entry_id],
350                "tags": tag_objects,
351            });
352            client
353                .delete_with_body(&format!("/v2/team/{}/time_entries/tags", ws_id), &body)
354                .await?;
355            output.print_message("Tags removed");
356            Ok(())
357        }
358        TimeCommands::RenameTag { name, new_name } => {
359            let body = serde_json::json!({
360                "name": name,
361                "new_name": new_name,
362            });
363            client
364                .put(&format!("/v2/team/{}/time_entries/tags", ws_id), &body)
365                .await?;
366            output.print_message(&format!("Tag '{}' renamed to '{}'", name, new_name));
367            Ok(())
368        }
369        TimeCommands::History { id } => {
370            let resp = client
371                .get(&format!("/v2/team/{}/time_entries/{}/history", ws_id, id))
372                .await?;
373            let history = resp
374                .get("data")
375                .and_then(|d| d.as_array())
376                .cloned()
377                .unwrap_or_default();
378            output.print_items(&history, &["id", "date", "field", "before", "after"], "id");
379            Ok(())
380        }
381    }
382}