1use crate::model::item::{Task, TaskStatus};
4use chrono::Utc; pub 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
13pub fn random_related_icon(uid1: &str, uid2: &str) -> char {
16 let (first, second) = if uid1 < uid2 {
18 (uid1, uid2)
19 } else {
20 (uid2, uid1)
21 };
22
23 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 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 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 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 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 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 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 s.push_str(&format!(" done:{}%", pc));
277 }
278
279 s
280 }
281}