1use chrono::{Datelike, NaiveDate, NaiveDateTime, TimeZone, Utc};
2use chrono_tz::Tz;
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Deserialize)]
6pub struct TaskListResponse {
7 pub tasks: Vec<Task>,
8}
9
10#[derive(Debug, Deserialize)]
11pub struct Task {
12 pub id: String,
13 pub title: String,
14 pub priority: Option<i32>,
15 pub checked: Option<i32>,
16 pub note: Option<String>,
17 #[serde(rename = "projectId")]
18 pub project_id: Option<String>,
19 pub parent: Option<String>,
20 pub group: Option<String>,
21 pub start: Option<String>,
22 pub deadline: Option<String>,
23 #[serde(rename = "useTime")]
24 pub use_time: Option<bool>,
25 #[serde(rename = "timeLength")]
26 pub time_length: Option<i64>,
27 pub tags: Option<Vec<String>>,
28 #[serde(rename = "showInBasket")]
29 #[allow(dead_code)]
30 pub show_in_basket: Option<bool>,
31 #[serde(rename = "modificatedDate")]
32 #[allow(dead_code)]
33 pub modificated_date: Option<String>,
34 #[serde(rename = "isNote")]
35 #[allow(dead_code)]
36 pub is_note: Option<bool>,
37}
38
39#[derive(Debug, Deserialize)]
40#[serde(rename_all = "camelCase")]
41pub struct ChecklistItemListResponse {
42 pub checklist_items: Vec<ChecklistItem>,
43}
44
45#[derive(Debug, Deserialize)]
46#[serde(rename_all = "camelCase")]
47pub struct ChecklistItem {
48 #[allow(dead_code)]
49 pub id: String,
50 pub title: String,
51 pub done: Option<bool>,
52 #[allow(dead_code)]
53 pub parent_order: Option<f64>,
54}
55
56fn display_priority(p: &Option<i32>) -> String {
57 match p {
58 Some(0) => "high".to_string(),
59 Some(1) => "normal".to_string(),
60 Some(2) => "low".to_string(),
61 _ => "-".to_string(),
62 }
63}
64
65fn display_completed(c: &Option<i32>) -> String {
66 match c {
67 Some(1) => "true".to_string(),
68 _ => "false".to_string(),
69 }
70}
71
72fn parse_datetime(iso: &str) -> Option<NaiveDateTime> {
73 let trimmed = iso.trim_end_matches('Z');
74 NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S")
75 .or_else(|_| NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S%.f"))
76 .ok()
77}
78
79fn format_in_tz(naive: NaiveDateTime, tz: Option<Tz>, fmt: &str) -> String {
80 match tz {
81 Some(tz) => Utc
82 .from_utc_datetime(&naive)
83 .with_timezone(&tz)
84 .format(fmt)
85 .to_string(),
86 None => naive.format(fmt).to_string(),
87 }
88}
89
90pub(crate) fn format_date(iso: &str, tz: Option<Tz>) -> String {
91 parse_datetime(iso)
92 .map(|dt| format_in_tz(dt, tz, "%a, %b %d, %Y"))
93 .unwrap_or_else(|| iso.to_string())
94}
95
96fn format_duration(
97 iso: &str,
98 use_time: Option<bool>,
99 time_length: Option<i64>,
100 tz: Option<Tz>,
101) -> String {
102 if use_time != Some(true) {
103 return "All Day".to_string();
104 }
105 let parsed = match parse_datetime(iso) {
106 Some(dt) => dt,
107 None => return iso.to_string(),
108 };
109 let start_time = format_in_tz(parsed, tz, "%H:%M");
110 match time_length {
111 Some(minutes) if minutes > 0 => {
112 let end_dt = match tz {
113 Some(tz) => {
114 let local = Utc.from_utc_datetime(&parsed).with_timezone(&tz);
115 (local + chrono::Duration::minutes(minutes))
116 .format("%H:%M")
117 .to_string()
118 }
119 None => (parsed + chrono::Duration::minutes(minutes))
120 .format("%H:%M")
121 .to_string(),
122 };
123 format!("{} - {}", start_time, end_dt)
124 }
125 _ => format!("{} - ...", start_time),
126 }
127}
128
129pub fn resolve_date_keyword(keyword: &str, tz: Option<Tz>) -> anyhow::Result<(String, String)> {
130 let now = Utc::now();
131 let today = match tz {
132 Some(tz) => now.with_timezone(&tz).date_naive(),
133 None => now.date_naive(),
134 };
135 resolve_date_keyword_from(keyword, today)
136}
137
138fn resolve_date_keyword_from(
139 keyword: &str,
140 today: NaiveDate,
141) -> anyhow::Result<(String, String)> {
142 let (from, to) = match keyword {
143 "today" => (today, today),
144 "tomorrow" => {
145 let d = today + chrono::Duration::days(1);
146 (d, d)
147 }
148 "yesterday" => {
149 let d = today - chrono::Duration::days(1);
150 (d, d)
151 }
152 "week" => (today, today + chrono::Duration::days(6)),
153 "month" => {
154 let first = today.with_day(1).unwrap();
155 let last = if today.month() == 12 {
156 NaiveDate::from_ymd_opt(today.year() + 1, 1, 1).unwrap()
157 } else {
158 NaiveDate::from_ymd_opt(today.year(), today.month() + 1, 1).unwrap()
159 } - chrono::Duration::days(1);
160 (first, last)
161 }
162 _ => anyhow::bail!(
163 "unknown date keyword '{}'. Use: today, tomorrow, yesterday, week, month",
164 keyword
165 ),
166 };
167 Ok((
168 from.format("%Y-%m-%d").to_string(),
169 to.format("%Y-%m-%d").to_string(),
170 ))
171}
172
173pub fn convert_date_filter(value: &str, is_end: bool, tz: Option<Tz>) -> anyhow::Result<String> {
174 if value.contains('T') {
175 return Ok(value.to_string());
176 }
177 let naive_date = NaiveDate::parse_from_str(value, "%Y-%m-%d")
178 .map_err(|e| anyhow::anyhow!("invalid date '{}': {}", value, e))?;
179 let naive_dt = if is_end {
180 naive_date.and_hms_opt(23, 59, 59).unwrap()
181 } else {
182 (naive_date - chrono::Duration::days(1))
183 .and_hms_opt(23, 59, 59)
184 .unwrap()
185 };
186 match tz {
187 Some(tz) => {
188 let local_dt = tz.from_local_datetime(&naive_dt).single().ok_or_else(|| {
189 anyhow::anyhow!("ambiguous or invalid local time for date '{}'", value)
190 })?;
191 Ok(local_dt
192 .with_timezone(&Utc)
193 .format("%Y-%m-%dT%H:%M:%SZ")
194 .to_string())
195 }
196 None => {
197 if is_end {
198 Ok(format!("{}T23:59:59Z", value))
199 } else {
200 let prev = naive_date - chrono::Duration::days(1);
201 Ok(format!("{}T23:59:59Z", prev.format("%Y-%m-%d")))
202 }
203 }
204 }
205}
206
207impl Task {
208 pub fn display_detail(&self, checklist: &[ChecklistItem], tz: Option<Tz>) -> String {
209 let mut lines = vec![
210 format!("**ID:** {}", self.id),
211 format!("**Title:** {}", self.title),
212 ];
213 if let Some(ref v) = self.note {
214 lines.push(format!("**Note:** {}", v));
215 }
216 if !checklist.is_empty() {
217 lines.push("**Checklist:**".to_string());
218 for item in checklist {
219 let mark = if item.done == Some(true) { "x" } else { " " };
220 lines.push(format!(" [{}] {}", mark, item.title));
221 }
222 }
223 if let Some(ref v) = self.start {
224 lines.push(format!("**Date:** {}", format_date(v, tz)));
225 lines.push(format!(
226 "**Duration:** {}",
227 format_duration(v, self.use_time, self.time_length, tz)
228 ));
229 }
230 if let Some(ref v) = self.deadline {
231 lines.push(format!("**Deadline:** {}", format_date(v, tz)));
232 }
233 lines.push(format!(
234 "**Completed:** {}",
235 display_completed(&self.checked)
236 ));
237 lines.push(format!(
238 "**Priority:** {}",
239 display_priority(&self.priority)
240 ));
241 if let Some(ref v) = self.project_id {
242 lines.push(format!("**Project:** {}", v));
243 }
244 if let Some(ref v) = self.parent {
245 lines.push(format!("**Parent:** {}", v));
246 }
247 if let Some(ref v) = self.group {
248 lines.push(format!("**Group:** {}", v));
249 }
250 if let Some(ref v) = self.tags
251 && !v.is_empty()
252 {
253 lines.push(format!("**Tags:** {}", v.join(", ")));
254 }
255 lines.join("\n")
256 }
257
258 pub fn display_list_item(&self, checklist: &[ChecklistItem], tz: Option<Tz>) -> String {
259 let mut lines = vec![
260 format!("- ID: {}", self.id),
261 format!(" Task: {}", self.title),
262 ];
263 if let Some(ref v) = self.note {
264 lines.push(format!(" Note: {}", v));
265 }
266 if !checklist.is_empty() {
267 lines.push(" Checklist:".to_string());
268 for item in checklist {
269 let mark = if item.done == Some(true) { "x" } else { " " };
270 lines.push(format!(" [{}] {}", mark, item.title));
271 }
272 }
273 if let Some(ref v) = self.start {
274 lines.push(format!(" Date: {}", format_date(v, tz)));
275 lines.push(format!(
276 " Duration: {}",
277 format_duration(v, self.use_time, self.time_length, tz)
278 ));
279 }
280 if let Some(ref v) = self.deadline {
281 lines.push(format!(" Deadline: {}", format_date(v, tz)));
282 }
283 lines.push(format!(" Completed: {}", display_completed(&self.checked)));
284 lines.push(format!(" Priority: {}", display_priority(&self.priority)));
285 lines.join("\n")
286 }
287}
288
289#[derive(Debug, Serialize, Default)]
290pub struct TaskCreate {
291 pub title: String,
292 #[serde(skip_serializing_if = "Option::is_none")]
293 pub note: Option<String>,
294 #[serde(skip_serializing_if = "Option::is_none")]
295 pub priority: Option<i32>,
296 #[serde(skip_serializing_if = "Option::is_none", rename = "projectId")]
297 pub project_id: Option<String>,
298 #[serde(skip_serializing_if = "Option::is_none")]
299 pub parent: Option<String>,
300 #[serde(skip_serializing_if = "Option::is_none")]
301 pub group: Option<String>,
302 #[serde(skip_serializing_if = "Option::is_none")]
303 pub deadline: Option<String>,
304 #[serde(skip_serializing_if = "Option::is_none")]
305 pub start: Option<String>,
306 #[serde(skip_serializing_if = "Option::is_none")]
307 pub tags: Option<Vec<String>>,
308 #[serde(skip_serializing_if = "Option::is_none", rename = "isNote")]
309 pub is_note: Option<bool>,
310}
311
312#[derive(Debug, Serialize, Default)]
313pub struct TaskUpdate {
314 #[serde(skip_serializing_if = "Option::is_none")]
315 pub title: Option<String>,
316 #[serde(skip_serializing_if = "Option::is_none")]
317 pub note: Option<String>,
318 #[serde(skip_serializing_if = "Option::is_none")]
319 pub priority: Option<i32>,
320 #[serde(skip_serializing_if = "Option::is_none")]
321 pub checked: Option<i32>,
322 #[serde(skip_serializing_if = "Option::is_none", rename = "projectId")]
323 pub project_id: Option<String>,
324 #[serde(skip_serializing_if = "Option::is_none")]
325 pub parent: Option<String>,
326 #[serde(skip_serializing_if = "Option::is_none")]
327 pub group: Option<String>,
328 #[serde(skip_serializing_if = "Option::is_none")]
329 pub deadline: Option<String>,
330 #[serde(skip_serializing_if = "Option::is_none")]
331 pub start: Option<String>,
332 #[serde(skip_serializing_if = "Option::is_none")]
333 pub tags: Option<Vec<String>>,
334 #[serde(skip_serializing_if = "Option::is_none", rename = "isNote")]
335 pub is_note: Option<bool>,
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341
342 #[test]
343 fn deserialize_task_full() {
344 let json = r#"{
345 "id": "T-abc",
346 "title": "Do stuff",
347 "priority": 0,
348 "checked": 1,
349 "projectId": "P-123",
350 "tags": ["t1"],
351 "showInBasket": false,
352 "modificatedDate": "2025-01-01T00:00:00Z",
353 "isNote": false,
354 "useTime": true,
355 "timeLength": 90
356 }"#;
357 let t: Task = serde_json::from_str(json).unwrap();
358 assert_eq!(t.id, "T-abc");
359 assert_eq!(t.priority, Some(0));
360 assert_eq!(t.checked, Some(1));
361 assert_eq!(t.project_id.as_deref(), Some("P-123"));
362 assert_eq!(t.use_time, Some(true));
363 assert_eq!(t.time_length, Some(90));
364 }
365
366 #[test]
367 fn deserialize_task_minimal() {
368 let json = r#"{"id": "T-min", "title": "Minimal"}"#;
369 let t: Task = serde_json::from_str(json).unwrap();
370 assert_eq!(t.id, "T-min");
371 assert!(t.priority.is_none());
372 assert!(t.project_id.is_none());
373 }
374
375 #[test]
376 fn serialize_create_skips_none() {
377 let data = TaskCreate {
378 title: "Test task".to_string(),
379 ..Default::default()
380 };
381 let json = serde_json::to_value(&data).unwrap();
382 assert_eq!(json, serde_json::json!({"title": "Test task"}));
383 }
384
385 #[test]
386 fn serialize_create_camel_case_rename() {
387 let data = TaskCreate {
388 title: "T".to_string(),
389 project_id: Some("P-1".to_string()),
390 is_note: Some(true),
391 ..Default::default()
392 };
393 let json = serde_json::to_value(&data).unwrap();
394 assert_eq!(json["projectId"], "P-1");
395 assert_eq!(json["isNote"], true);
396 assert!(json.get("project_id").is_none());
397 }
398
399 #[test]
400 fn serialize_update_partial() {
401 let data = TaskUpdate {
402 checked: Some(1),
403 ..Default::default()
404 };
405 let json = serde_json::to_value(&data).unwrap();
406 assert_eq!(json, serde_json::json!({"checked": 1}));
407 }
408
409 #[test]
410 fn display_priority_values() {
411 assert_eq!(display_priority(&Some(0)), "high");
412 assert_eq!(display_priority(&Some(1)), "normal");
413 assert_eq!(display_priority(&Some(2)), "low");
414 assert_eq!(display_priority(&None), "-");
415 assert_eq!(display_priority(&Some(99)), "-");
416 }
417
418 #[test]
419 fn display_completed_values() {
420 assert_eq!(display_completed(&Some(0)), "false");
421 assert_eq!(display_completed(&Some(1)), "true");
422 assert_eq!(display_completed(&Some(2)), "false");
423 assert_eq!(display_completed(&None), "false");
424 }
425
426 #[test]
427 fn format_date_iso8601() {
428 assert_eq!(
429 format_date("2026-02-27T09:00:00Z", None),
430 "Fri, Feb 27, 2026"
431 );
432 }
433
434 #[test]
435 fn format_date_with_fractional_seconds() {
436 assert_eq!(
437 format_date("2026-02-27T09:00:00.000Z", None),
438 "Fri, Feb 27, 2026"
439 );
440 }
441
442 #[test]
443 fn format_date_invalid_fallback() {
444 assert_eq!(format_date("not-a-date", None), "not-a-date");
445 }
446
447 #[test]
448 fn format_date_with_timezone() {
449 let tz: Tz = "Europe/Kyiv".parse().unwrap();
450 assert_eq!(
452 format_date("2026-02-27T23:00:00Z", Some(tz)),
453 "Sat, Feb 28, 2026"
454 );
455 }
456
457 #[test]
458 fn format_date_without_timezone_unchanged() {
459 assert_eq!(
460 format_date("2026-02-27T23:00:00Z", None),
461 "Fri, Feb 27, 2026"
462 );
463 }
464
465 #[test]
466 fn format_duration_all_day() {
467 assert_eq!(
468 format_duration("2026-02-27T09:00:00Z", Some(false), Some(0), None),
469 "All Day"
470 );
471 assert_eq!(
472 format_duration("2026-02-27T09:00:00Z", None, None, None),
473 "All Day"
474 );
475 }
476
477 #[test]
478 fn format_duration_with_time_range() {
479 assert_eq!(
480 format_duration("2026-02-27T09:00:00Z", Some(true), Some(90), None),
481 "09:00 - 10:30"
482 );
483 }
484
485 #[test]
486 fn format_duration_with_timezone() {
487 let tz: Tz = "Europe/Kyiv".parse().unwrap();
488 assert_eq!(
490 format_duration("2026-02-27T09:00:00Z", Some(true), Some(90), Some(tz)),
491 "11:00 - 12:30"
492 );
493 }
494
495 #[test]
496 fn format_duration_open_ended() {
497 assert_eq!(
498 format_duration("2026-02-27T09:00:00Z", Some(true), Some(0), None),
499 "09:00 - ..."
500 );
501 assert_eq!(
502 format_duration("2026-02-27T09:00:00Z", Some(true), None, None),
503 "09:00 - ..."
504 );
505 }
506
507 #[test]
508 fn convert_date_filter_start_with_timezone() {
509 let tz: Tz = "Europe/Kyiv".parse().unwrap();
510 let result = convert_date_filter("2026-02-28", false, Some(tz)).unwrap();
513 assert_eq!(result, "2026-02-27T21:59:59Z");
514 }
515
516 #[test]
517 fn convert_date_filter_end_with_timezone() {
518 let tz: Tz = "Europe/Kyiv".parse().unwrap();
519 let result = convert_date_filter("2026-02-28", true, Some(tz)).unwrap();
520 assert_eq!(result, "2026-02-28T21:59:59Z");
521 }
522
523 #[test]
524 fn convert_date_filter_without_timezone() {
525 let result = convert_date_filter("2026-02-28", false, None).unwrap();
526 assert_eq!(result, "2026-02-27T23:59:59Z");
527 let result = convert_date_filter("2026-02-28", true, None).unwrap();
528 assert_eq!(result, "2026-02-28T23:59:59Z");
529 }
530
531 #[test]
532 fn convert_date_filter_passthrough_full_iso() {
533 let result = convert_date_filter("2026-02-28T00:00:00Z", false, None).unwrap();
534 assert_eq!(result, "2026-02-28T00:00:00Z");
535 }
536
537 #[test]
538 fn convert_date_filter_invalid_date() {
539 assert!(convert_date_filter("not-a-date", false, None).is_err());
540 }
541
542 #[test]
543 fn deserialize_checklist_item_list() {
544 let json = r#"{"checklistItems": [
545 {"id": "cl-1", "title": "Buy milk", "done": true, "parentOrder": 0.0},
546 {"id": "cl-2", "title": "Call dentist", "done": false, "parentOrder": 1.0}
547 ]}"#;
548 let resp: ChecklistItemListResponse = serde_json::from_str(json).unwrap();
549 assert_eq!(resp.checklist_items.len(), 2);
550 assert_eq!(resp.checklist_items[0].title, "Buy milk");
551 assert_eq!(resp.checklist_items[0].done, Some(true));
552 assert_eq!(resp.checklist_items[1].title, "Call dentist");
553 assert_eq!(resp.checklist_items[1].done, Some(false));
554 }
555
556 #[test]
557 fn deserialize_checklist_item_minimal() {
558 let json = r#"{"checklistItems": [{"id": "cl-1", "title": "Item"}]}"#;
559 let resp: ChecklistItemListResponse = serde_json::from_str(json).unwrap();
560 assert_eq!(resp.checklist_items.len(), 1);
561 assert!(resp.checklist_items[0].done.is_none());
562 assert!(resp.checklist_items[0].parent_order.is_none());
563 }
564
565 #[test]
566 fn resolve_date_keyword_today() {
567 let today = NaiveDate::from_ymd_opt(2026, 3, 15).unwrap();
568 let (from, to) = resolve_date_keyword_from("today", today).unwrap();
569 assert_eq!(from, "2026-03-15");
570 assert_eq!(to, "2026-03-15");
571 }
572
573 #[test]
574 fn resolve_date_keyword_tomorrow() {
575 let today = NaiveDate::from_ymd_opt(2026, 3, 15).unwrap();
576 let (from, to) = resolve_date_keyword_from("tomorrow", today).unwrap();
577 assert_eq!(from, "2026-03-16");
578 assert_eq!(to, "2026-03-16");
579 }
580
581 #[test]
582 fn resolve_date_keyword_yesterday() {
583 let today = NaiveDate::from_ymd_opt(2026, 3, 15).unwrap();
584 let (from, to) = resolve_date_keyword_from("yesterday", today).unwrap();
585 assert_eq!(from, "2026-03-14");
586 assert_eq!(to, "2026-03-14");
587 }
588
589 #[test]
590 fn resolve_date_keyword_week() {
591 let today = NaiveDate::from_ymd_opt(2026, 3, 15).unwrap();
592 let (from, to) = resolve_date_keyword_from("week", today).unwrap();
593 assert_eq!(from, "2026-03-15");
594 assert_eq!(to, "2026-03-21");
595 }
596
597 #[test]
598 fn resolve_date_keyword_month() {
599 let today = NaiveDate::from_ymd_opt(2026, 3, 15).unwrap();
600 let (from, to) = resolve_date_keyword_from("month", today).unwrap();
601 assert_eq!(from, "2026-03-01");
602 assert_eq!(to, "2026-03-31");
603 }
604
605 #[test]
606 fn resolve_date_keyword_month_february() {
607 let today = NaiveDate::from_ymd_opt(2026, 2, 10).unwrap();
608 let (from, to) = resolve_date_keyword_from("month", today).unwrap();
609 assert_eq!(from, "2026-02-01");
610 assert_eq!(to, "2026-02-28");
611 }
612
613 #[test]
614 fn resolve_date_keyword_month_december() {
615 let today = NaiveDate::from_ymd_opt(2026, 12, 5).unwrap();
616 let (from, to) = resolve_date_keyword_from("month", today).unwrap();
617 assert_eq!(from, "2026-12-01");
618 assert_eq!(to, "2026-12-31");
619 }
620
621 #[test]
622 fn resolve_date_keyword_invalid() {
623 let today = NaiveDate::from_ymd_opt(2026, 3, 15).unwrap();
624 assert!(resolve_date_keyword_from("invalid", today).is_err());
625 }
626}