Skip to main content

clickup_cli/commands/
time.rs

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