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 },
117 History {
119 id: String,
121 },
122}
123
124const TIME_FIELDS: &[&str] = &["id", "task", "duration", "start", "billable"];
125
126fn 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}