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        /// New background colour as a hex string (required by ClickUp's spec, e.g. #000000)
117        #[arg(long)]
118        tag_bg: String,
119        /// New foreground colour as a hex string (required by ClickUp's spec, e.g. #FFFFFF)
120        #[arg(long)]
121        tag_fg: String,
122    },
123    /// Get history for a time entry
124    History {
125        /// Time entry ID
126        id: String,
127    },
128}
129
130const TIME_FIELDS: &[&str] = &["id", "task", "duration", "start", "billable"];
131
132/// Flatten a time entry's "task" field from object to name string if needed.
133fn flatten_task_field(mut entry: serde_json::Value) -> serde_json::Value {
134    if let Some(obj) = entry.as_object_mut() {
135        if let Some(task_val) = obj.get("task").cloned() {
136            if let Some(name) = task_val.get("name").and_then(|n| n.as_str()) {
137                obj.insert(
138                    "task".to_string(),
139                    serde_json::Value::String(name.to_string()),
140                );
141            }
142        }
143    }
144    entry
145}
146
147pub async fn execute(command: TimeCommands, cli: &Cli) -> Result<(), CliError> {
148    let token = resolve_token(cli)?;
149    let client = ClickUpClient::new(&token, cli.timeout)?;
150    let output = OutputConfig::from_cli(&cli.output, &cli.fields, cli.no_header, cli.quiet);
151    let ws_id = resolve_workspace(cli)?;
152
153    match command {
154        TimeCommands::List {
155            start_date,
156            end_date,
157            assignee,
158            task,
159        } => {
160            let mut params = Vec::new();
161            if let Some(s) = start_date {
162                params.push(format!("start_date={}", s));
163            }
164            if let Some(e) = end_date {
165                params.push(format!("end_date={}", e));
166            }
167            if let Some(a) = assignee {
168                params.push(format!("assignee={}", a));
169            }
170            if let Some(t) = git::resolve_task(cli, task.as_deref(), true)? {
171                params.push(format!("task_id={}", t.id));
172            }
173            let query = if params.is_empty() {
174                String::new()
175            } else {
176                format!("?{}", params.join("&"))
177            };
178            let resp = client
179                .get(&format!("/v2/team/{}/time_entries{}", ws_id, query))
180                .await?;
181            let entries: Vec<serde_json::Value> = resp
182                .get("data")
183                .and_then(|d| d.as_array())
184                .cloned()
185                .unwrap_or_default()
186                .into_iter()
187                .map(flatten_task_field)
188                .collect();
189            output.print_items(&entries, TIME_FIELDS, "id");
190            Ok(())
191        }
192        TimeCommands::Get { id } => {
193            let resp = client
194                .get(&format!("/v2/team/{}/time_entries/{}", ws_id, id))
195                .await?;
196            let entry = resp
197                .get("data")
198                .cloned()
199                .map(flatten_task_field)
200                .unwrap_or(resp);
201            output.print_single(&entry, TIME_FIELDS, "id");
202            Ok(())
203        }
204        TimeCommands::Current => {
205            let resp = client
206                .get(&format!("/v2/team/{}/time_entries/current", ws_id))
207                .await?;
208            let entry = resp
209                .get("data")
210                .cloned()
211                .map(flatten_task_field)
212                .unwrap_or(resp);
213            output.print_single(&entry, TIME_FIELDS, "id");
214            Ok(())
215        }
216        TimeCommands::Create {
217            start,
218            duration,
219            task,
220            description,
221            billable,
222        } => {
223            let mut body = serde_json::json!({
224                "start": start,
225                "duration": duration,
226                "billable": billable,
227            });
228            if let Some(t) = git::resolve_task(cli, task.as_deref(), true)? {
229                body["tid"] = serde_json::Value::String(t.id);
230            }
231            if let Some(d) = description {
232                body["description"] = serde_json::Value::String(d);
233            }
234            let resp = client
235                .post(&format!("/v2/team/{}/time_entries", ws_id), &body)
236                .await?;
237            let entry = resp
238                .get("data")
239                .cloned()
240                .map(flatten_task_field)
241                .unwrap_or(resp);
242            output.print_single(&entry, TIME_FIELDS, "id");
243            Ok(())
244        }
245        TimeCommands::Update {
246            id,
247            start,
248            end,
249            description,
250            billable,
251        } => {
252            let mut body = serde_json::Map::new();
253            if let Some(s) = start {
254                body.insert("start".into(), serde_json::Value::String(s));
255            }
256            if let Some(e) = end {
257                body.insert("end".into(), serde_json::Value::String(e));
258            }
259            if let Some(d) = description {
260                body.insert("description".into(), serde_json::Value::String(d));
261            }
262            if let Some(b) = billable {
263                body.insert("billable".into(), serde_json::Value::Bool(b));
264            }
265            let resp = client
266                .put(
267                    &format!("/v2/team/{}/time_entries/{}", ws_id, id),
268                    &serde_json::Value::Object(body),
269                )
270                .await?;
271            let entry = resp
272                .get("data")
273                .cloned()
274                .map(flatten_task_field)
275                .unwrap_or(resp);
276            output.print_single(&entry, TIME_FIELDS, "id");
277            Ok(())
278        }
279        TimeCommands::Delete { id } => {
280            client
281                .delete(&format!("/v2/team/{}/time_entries/{}", ws_id, id))
282                .await?;
283            output.print_message(&format!("Time entry {} deleted", id));
284            Ok(())
285        }
286        TimeCommands::Start {
287            task,
288            description,
289            billable,
290        } => {
291            let mut body = serde_json::json!({ "billable": billable });
292            if let Some(t) = git::resolve_task(cli, task.as_deref(), true)? {
293                body["tid"] = serde_json::Value::String(t.id);
294            }
295            if let Some(d) = description {
296                body["description"] = serde_json::Value::String(d);
297            }
298            let resp = client
299                .post(&format!("/v2/team/{}/time_entries/start", ws_id), &body)
300                .await?;
301            let entry = resp
302                .get("data")
303                .cloned()
304                .map(flatten_task_field)
305                .unwrap_or(resp);
306            output.print_single(&entry, TIME_FIELDS, "id");
307            Ok(())
308        }
309        TimeCommands::Stop => {
310            let body = serde_json::json!({});
311            let resp = client
312                .post(&format!("/v2/team/{}/time_entries/stop", ws_id), &body)
313                .await?;
314            let entry = resp
315                .get("data")
316                .cloned()
317                .map(flatten_task_field)
318                .unwrap_or(resp);
319            output.print_single(&entry, TIME_FIELDS, "id");
320            Ok(())
321        }
322        TimeCommands::Tags => {
323            let resp = client
324                .get(&format!("/v2/team/{}/time_entries/tags", ws_id))
325                .await?;
326            let tags = resp
327                .get("data")
328                .and_then(|d| d.as_array())
329                .cloned()
330                .unwrap_or_default();
331            output.print_items(&tags, &["name"], "name");
332            Ok(())
333        }
334        TimeCommands::AddTags { entry_id, tags } => {
335            let tag_objects: Vec<serde_json::Value> = tags
336                .iter()
337                .map(|n| serde_json::json!({ "name": n }))
338                .collect();
339            let body = serde_json::json!({
340                "time_entry_ids": [entry_id],
341                "tags": tag_objects,
342            });
343            client
344                .post(&format!("/v2/team/{}/time_entries/tags", ws_id), &body)
345                .await?;
346            output.print_message("Tags added");
347            Ok(())
348        }
349        TimeCommands::RemoveTags { entry_id, tags } => {
350            let tag_objects: Vec<serde_json::Value> = tags
351                .iter()
352                .map(|n| serde_json::json!({ "name": n }))
353                .collect();
354            let body = serde_json::json!({
355                "time_entry_ids": [entry_id],
356                "tags": tag_objects,
357            });
358            client
359                .delete_with_body(&format!("/v2/team/{}/time_entries/tags", ws_id), &body)
360                .await?;
361            output.print_message("Tags removed");
362            Ok(())
363        }
364        TimeCommands::RenameTag {
365            name,
366            new_name,
367            tag_bg,
368            tag_fg,
369        } => {
370            // ClickUp's spec for PUT /v2/team/{ws}/time_entries/tags marks
371            // tag_bg and tag_fg as required even on a pure rename.
372            let body = serde_json::json!({
373                "name": name,
374                "new_name": new_name,
375                "tag_bg": tag_bg,
376                "tag_fg": tag_fg,
377            });
378            client
379                .put(&format!("/v2/team/{}/time_entries/tags", ws_id), &body)
380                .await?;
381            output.print_message(&format!("Tag '{}' renamed to '{}'", name, new_name));
382            Ok(())
383        }
384        TimeCommands::History { id } => {
385            let resp = client
386                .get(&format!("/v2/team/{}/time_entries/{}/history", ws_id, id))
387                .await?;
388            let history = resp
389                .get("data")
390                .and_then(|d| d.as_array())
391                .cloned()
392                .unwrap_or_default();
393            output.print_items(&history, &["id", "date", "field", "before", "after"], "id");
394            Ok(())
395        }
396    }
397}