Skip to main content

cfait/model/
display.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// File: ./src/model/display.rs
3use crate::model::item::{Task, TaskStatus};
4use chrono::Utc; // Import Utc for live calculation
5
6pub trait TaskDisplay {
7    fn to_smart_string(&self) -> String;
8    fn format_duration_short(&self) -> String;
9    fn checkbox_symbol(&self) -> &'static str;
10    fn is_paused(&self) -> bool;
11}
12
13/// Function to get a random relationship icon based on the relationship pair
14/// Takes both UIDs to ensure both sides of the relationship see the same icon
15pub fn random_related_icon(uid1: &str, uid2: &str) -> char {
16    // Sort UIDs to ensure consistent ordering regardless of direction
17    let (first, second) = if uid1 < uid2 {
18        (uid1, uid2)
19    } else {
20        (uid2, uid1)
21    };
22
23    // Hash the sorted pair
24    let hash: u32 = first
25        .bytes()
26        .chain(second.bytes())
27        .fold(0u32, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u32));
28
29    // Deterministic selection among three relationship icons
30    match hash % 3 {
31        0 => '\u{f0a5a}',
32        1 => '\u{f0a5e}',
33        _ => '\u{f02e8}',
34    }
35}
36
37impl TaskDisplay for Task {
38    fn is_paused(&self) -> bool {
39        // No longer relying exclusively on the 50% hack.
40        self.status == TaskStatus::NeedsAction
41            && ((self.percent_complete.unwrap_or(0) > 0
42                && self.percent_complete.unwrap_or(0) < 100)
43                || self.time_spent_seconds > 0
44                || !self.sessions.is_empty())
45    }
46
47    fn checkbox_symbol(&self) -> &'static str {
48        if self.is_paused() {
49            return "[‖]";
50        }
51        match self.status {
52            TaskStatus::Completed => "[✔]",
53            TaskStatus::Cancelled => "[✘]",
54            TaskStatus::InProcess => "[▶]",
55            TaskStatus::NeedsAction => "[ ]",
56        }
57    }
58
59    fn format_duration_short(&self) -> String {
60        // Calculate actual spent time (stored + current session)
61        let now_ts = Utc::now().timestamp();
62        let current_session = self
63            .last_started_at
64            .map(|start| (now_ts - start).max(0) as u64)
65            .unwrap_or(0);
66        let total_seconds = self.time_spent_seconds + current_session;
67        let total_mins = (total_seconds / 60) as u32;
68
69        let est_str = if let Some(min) = self.estimated_duration {
70            if let Some(max) = self.estimated_duration_max
71                && max > min
72            {
73                format!(
74                    "~{}-{}",
75                    crate::model::parser::format_duration_compact(min),
76                    crate::model::parser::format_duration_compact(max)
77                )
78            } else {
79                format!("~{}", crate::model::parser::format_duration_compact(min))
80            }
81        } else {
82            String::new()
83        };
84
85        let time_str = if total_mins > 0 || self.last_started_at.is_some() {
86            if !est_str.is_empty() {
87                format!(
88                    "{} / {}",
89                    crate::model::parser::format_duration_compact(total_mins),
90                    est_str
91                )
92            } else {
93                crate::model::parser::format_duration_compact(total_mins).to_string()
94            }
95        } else if !est_str.is_empty() {
96            est_str.to_string()
97        } else {
98            String::new()
99        };
100
101        // Only display percentage if the task is actively actionable (not completed/cancelled)
102        let pc_str = if !self.status.is_done() && self.percent_complete.unwrap_or(0) > 0 {
103            if let Some(pc) = self.percent_complete {
104                format!("{}%", pc)
105            } else {
106                String::new()
107            }
108        } else {
109            String::new()
110        };
111
112        if !pc_str.is_empty() && !time_str.is_empty() {
113            format!("[{}] | {}", pc_str, time_str)
114        } else if !pc_str.is_empty() {
115            format!("[{}]", pc_str)
116        } else if !time_str.is_empty() {
117            format!("[{}]", time_str)
118        } else {
119            String::new()
120        }
121    }
122
123    fn to_smart_string(&self) -> String {
124        use crate::model::item::AlarmTrigger;
125        use chrono::{Duration, Local};
126
127        let mut s = crate::model::parser::escape_summary(&self.summary);
128        if self.priority > 0 {
129            s.push_str(&format!(" !{}", self.priority));
130        }
131        if let Some(loc) = &self.location {
132            s.push_str(&format!(" @@{}", crate::model::parser::quote_value(loc)));
133        }
134        if let Some(u) = &self.url {
135            s.push_str(&format!(" url:{}", crate::model::parser::quote_value(u)));
136        }
137        if let Some(g) = &self.geo {
138            s.push_str(&format!(" geo:{}", crate::model::parser::quote_value(g)));
139        }
140        if let (Some(start), Some(due)) = (&self.dtstart, &self.due) {
141            if start == due {
142                s.push_str(&format!(" ^@{}", start.format_smart()));
143            } else if let (
144                crate::model::DateType::Specific(s_dt),
145                crate::model::DateType::Specific(d_dt),
146            ) = (start, due)
147            {
148                let s_loc = s_dt.with_timezone(&chrono::Local);
149                let d_loc = d_dt.with_timezone(&chrono::Local);
150                if s_loc.date_naive() == d_loc.date_naive() {
151                    s.push_str(&format!(
152                        " ^@{} {}-{}",
153                        s_loc.format("%Y-%m-%d"),
154                        s_loc.format("%H:%M"),
155                        d_loc.format("%H:%M")
156                    ));
157                } else {
158                    s.push_str(&format!(" ^{}", start.format_smart()));
159                    s.push_str(&format!(" @{}", due.format_smart()));
160                }
161            } else {
162                s.push_str(&format!(" ^{}", start.format_smart()));
163                s.push_str(&format!(" @{}", due.format_smart()));
164            }
165        } else {
166            if let Some(start) = &self.dtstart {
167                s.push_str(&format!(" ^{}", start.format_smart()));
168            }
169            if let Some(d) = &self.due {
170                s.push_str(&format!(" @{}", d.format_smart()));
171            }
172        }
173
174        if let Some(min) = self.estimated_duration {
175            let fmt_val = |m: u32| -> String {
176                if m.is_multiple_of(525600) {
177                    format!("{}y", m / 525600)
178                } else if m.is_multiple_of(43200) {
179                    format!("{}mo", m / 43200)
180                } else if m.is_multiple_of(10080) {
181                    format!("{}w", m / 10080)
182                } else if m.is_multiple_of(1440) {
183                    format!("{}d", m / 1440)
184                } else if m.is_multiple_of(60) {
185                    format!("{}h", m / 60)
186                } else {
187                    format!("{}m", m)
188                }
189            };
190
191            if let Some(max) = self.estimated_duration_max {
192                if max > min {
193                    s.push_str(&format!(" ~{}-{}", fmt_val(min), fmt_val(max)));
194                } else {
195                    s.push_str(&format!(" ~{}", fmt_val(min)));
196                }
197            } else {
198                s.push_str(&format!(" ~{}", fmt_val(min)));
199            }
200        }
201
202        if let Some(r) = &self.rrule {
203            let is_relative = self
204                .unmapped_properties
205                .iter()
206                .any(|p| p.key == "X-CFAIT-RECUR-FROM-COMPLETION");
207            let pretty = crate::model::parser::prettify_recurrence(r, is_relative);
208            s.push_str(&format!(" {}", pretty));
209        }
210
211        for ex in &self.exdates {
212            s.push_str(&format!(" except {}", ex.format_smart()));
213        }
214
215        for alarm in &self.alarms {
216            if alarm.is_snooze() || alarm.acknowledged.is_some() {
217                continue;
218            }
219            match alarm.trigger {
220                AlarmTrigger::Relative(offset) => {
221                    let mins = -offset;
222                    if mins > 0 {
223                        if mins % 10080 == 0 {
224                            s.push_str(&format!(" rem:{}w", mins / 10080));
225                        } else if mins % 1440 == 0 {
226                            s.push_str(&format!(" rem:{}d", mins / 1440));
227                        } else if mins % 60 == 0 {
228                            s.push_str(&format!(" rem:{}h", mins / 60));
229                        } else {
230                            s.push_str(&format!(" rem:{}m", mins));
231                        }
232                    } else {
233                        s.push_str(&format!(" rem:{}m", mins));
234                    }
235                }
236                AlarmTrigger::Absolute(dt) => {
237                    let local = dt.with_timezone(&Local);
238                    let now = Local::now();
239
240                    // Check if alarm date perfectly matches the task's own date
241                    let task_date = self
242                        .due
243                        .as_ref()
244                        .or(self.dtstart.as_ref())
245                        .map(|d| d.to_date_naive());
246
247                    if Some(local.date_naive()) == task_date
248                        || local.date_naive() == now.date_naive()
249                    {
250                        s.push_str(&format!(" rem:{}", local.format("%H:%M")));
251                    } else if local.date_naive() == now.date_naive() + Duration::days(1) {
252                        s.push_str(&format!(" rem:tomorrow {}", local.format("%H:%M")));
253                    } else {
254                        s.push_str(&format!(" rem:{}", local.format("%Y-%m-%d %H:%M")));
255                    }
256                }
257            }
258        }
259
260        for cat in &self.categories {
261            s.push_str(&format!(" #{}", crate::model::parser::quote_value(cat)));
262        }
263
264        if let Some(create_event) = self.create_event {
265            s.push_str(if create_event { " +cal" } else { " -cal" });
266        }
267
268        // Output completion date if present
269        if let Some(comp) = self.completion_date() {
270            let local = comp.with_timezone(&chrono::Local);
271            s.push_str(&format!(" done:{}", local.format("%Y-%m-%d %H:%M")));
272        } else if let Some(pc) = self.percent_complete
273            && pc > 0
274        {
275            // New partial completion syntax
276            s.push_str(&format!(" done:{}%", pc));
277        }
278
279        s
280    }
281}