Skip to main content

singularity_cli/models/
task.rs

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        // 23:00 UTC = 01:00 next day in Kyiv (UTC+2 in winter)
451        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        // 09:00 UTC = 11:00 Kyiv (UTC+2)
489        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        // API uses strict >, so we send previous day 23:59:59 local → UTC
511        // 2026-02-27T23:59:59+02:00 = 2026-02-27T21:59:59Z
512        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}