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 {
14 #[arg(long)]
16 start_date: Option<String>,
17 #[arg(long)]
19 end_date: Option<String>,
20 #[arg(long)]
22 assignee: Option<String>,
23 #[arg(long)]
25 task: Option<String>,
26 },
27 Get {
29 id: String,
31 },
32 Current,
34 Create {
36 #[arg(long)]
38 start: String,
39 #[arg(long)]
41 duration: String,
42 #[arg(long)]
44 task: Option<String>,
45 #[arg(long)]
47 description: Option<String>,
48 #[arg(long)]
50 billable: bool,
51 },
52 Update {
54 id: String,
56 #[arg(long)]
58 start: Option<String>,
59 #[arg(long)]
61 end: Option<String>,
62 #[arg(long)]
64 description: Option<String>,
65 #[arg(long)]
67 billable: Option<bool>,
68 },
69 Delete {
71 id: String,
73 },
74 Start {
76 #[arg(long)]
78 task: Option<String>,
79 #[arg(long)]
81 description: Option<String>,
82 #[arg(long)]
84 billable: bool,
85 },
86 Stop,
88 Tags,
90 AddTags {
92 #[arg(long)]
94 entry_id: String,
95 #[arg(long = "tag")]
97 tags: Vec<String>,
98 },
99 RemoveTags {
101 #[arg(long)]
103 entry_id: String,
104 #[arg(long = "tag")]
106 tags: Vec<String>,
107 },
108 RenameTag {
110 #[arg(long)]
112 name: String,
113 #[arg(long)]
115 new_name: String,
116 #[arg(long)]
118 tag_bg: String,
119 #[arg(long)]
121 tag_fg: String,
122 },
123 History {
125 id: String,
127 },
128}
129
130const TIME_FIELDS: &[&str] = &["id", "task", "duration", "start", "billable"];
131
132fn 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 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}