1use crate::db::jira_inbox::JiraInboxItem;
10use anyhow::{Result, bail};
11use chrono::{Duration, NaiveDateTime};
12
13pub fn parse_window(text: &str) -> Result<Duration> {
18 let text = text.trim();
19 let (digits, unit) = match text.char_indices().find(|(_, c)| !c.is_ascii_digit()) {
20 Some((at, _)) => text.split_at(at),
21 None => (text, "d"),
22 };
23 let amount: i64 = match digits.parse() {
24 Ok(n) if n > 0 => n,
25 _ => bail!("'{text}' is not a window; try 1d, 7d, 12h or 2w"),
26 };
27 match unit {
28 "h" => Ok(Duration::hours(amount)),
29 "d" => Ok(Duration::days(amount)),
30 "w" => Ok(Duration::weeks(amount)),
31 _ => bail!("'{text}' is not a window; the unit is h, d or w"),
32 }
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum PriorityCut {
38 Exactly(i32),
40 AtLeast(i32),
42}
43
44impl PriorityCut {
45 fn keeps(self, rank: i32) -> bool {
46 match self {
47 PriorityCut::Exactly(wanted) => rank == wanted,
48 PriorityCut::AtLeast(wanted) => rank <= wanted,
49 }
50 }
51}
52
53#[derive(Debug, Default, Clone, PartialEq)]
55pub struct InboxFilter {
56 pub since: Option<Duration>,
58 pub changed: Option<Duration>,
60 pub min_score: Option<f64>,
62 pub priority: Option<PriorityCut>,
64 pub status: Option<String>,
66}
67
68impl InboxFilter {
69 pub fn is_empty(&self) -> bool {
71 *self == Self::default()
72 }
73
74 pub fn keeps(&self, item: &JiraInboxItem, now: NaiveDateTime) -> bool {
76 let within = |at: Option<NaiveDateTime>, window: Duration| at.is_some_and(|at| now.signed_duration_since(at) <= window);
77 if let Some(window) = self.since
78 && !within(Some(item.first_seen), window)
79 {
80 return false;
81 }
82 if let Some(window) = self.changed
83 && !within(item.changed_at, window)
84 {
85 return false;
86 }
87 if let Some(floor) = self.min_score
88 && !item.sort_value.is_some_and(|score| score >= floor)
89 {
90 return false;
91 }
92 if let Some(cut) = self.priority
93 && !cut.keeps(item.priority_rank)
94 {
95 return false;
96 }
97 if let Some(status) = &self.status
98 && !item.status_name.eq_ignore_ascii_case(status)
99 && !item.status_id.as_deref().is_some_and(|id| id.eq_ignore_ascii_case(status))
100 {
101 return false;
102 }
103 true
104 }
105
106 pub fn apply(&self, items: Vec<JiraInboxItem>, now: NaiveDateTime) -> Vec<JiraInboxItem> {
108 items.into_iter().filter(|item| self.keeps(item, now)).collect()
109 }
110}
111
112pub fn priority_cut_named(items: &[JiraInboxItem], wanted: &str) -> Result<PriorityCut> {
119 let (name, and_above) = match wanted.strip_suffix('+') {
120 Some(name) => (name.trim(), true),
121 None => (wanted.trim(), false),
122 };
123 let rank = items
124 .iter()
125 .filter(|item| item.priority.as_deref().is_some_and(|p| p.eq_ignore_ascii_case(name)))
126 .map(|item| item.priority_rank)
127 .min();
128 let Some(rank) = rank else {
129 let mut known: Vec<&str> = items.iter().filter_map(|item| item.priority.as_deref()).collect();
130 known.sort_unstable();
131 known.dedup();
132 if known.is_empty() {
133 bail!("no priority named '{name}' in the inbox, and no issue carries a priority yet");
134 }
135 bail!("no priority named '{name}' in the inbox; known: {}", known.join(", "));
136 };
137 Ok(if and_above { PriorityCut::AtLeast(rank) } else { PriorityCut::Exactly(rank) })
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
142pub enum InboxSort {
143 #[default]
145 Score,
146 Priority,
148 New,
150 Changed,
152}
153
154pub fn sort(items: &mut [JiraInboxItem], by: InboxSort) {
156 items.sort_by(|a, b| {
157 let frame = a.gone_at.is_some().cmp(&b.gone_at.is_some()).then_with(|| b.pinned.cmp(&a.pinned));
158 frame.then_with(|| match by {
159 InboxSort::Score => std::cmp::Ordering::Equal,
160 InboxSort::Priority => a.priority_rank.cmp(&b.priority_rank),
161 InboxSort::New => b.first_seen.cmp(&a.first_seen),
162 InboxSort::Changed => b.changed_at.cmp(&a.changed_at),
163 })
164 });
165}
166
167#[derive(Debug, Clone, PartialEq)]
169pub struct Reason {
170 pub what: &'static str,
172 pub value: String,
174 pub because: String,
176}
177
178pub fn explain(item: &JiraInboxItem, score_label: Option<&str>, now: NaiveDateTime) -> Vec<Reason> {
192 let mut out = Vec::new();
193 let label = score_label.unwrap_or("the ranking field");
194
195 match item.sort_value {
196 Some(score) => out.push(Reason {
197 what: "score",
198 value: format!("{score}"),
199 because: format!("read from {label} in Jira; the list is ordered by it, highest first"),
200 }),
201 None => out.push(Reason {
202 what: "score",
203 value: "—".to_string(),
204 because: format!("{label} is empty on this issue in Jira; issues without a score sort below those with one"),
205 }),
206 }
207
208 out.push(Reason {
209 what: "priority",
210 value: item.priority.clone().unwrap_or_else(|| "—".to_string()),
211 because: match item.priority.as_deref() {
212 Some(_) => format!(
213 "Jira priority id {}, which breaks ties on equal scores - lower is more urgent",
214 item.priority_rank
215 ),
216 None => "no priority set in Jira, so this sorts last among equal scores".to_string(),
217 },
218 });
219
220 if item.pinned {
221 out.push(Reason {
222 what: "pinned",
223 value: "yes".to_string(),
224 because: "pinned issues lead the list whatever the order".to_string(),
225 });
226 }
227
228 if let Some(until) = item.snoozed_until
229 && until > now
230 {
231 out.push(Reason {
232 what: "snoozed",
233 value: until.format("%b %-d %H:%M").to_string(),
234 because: "asleep until then, so it is out of the list and out of the waiting count".to_string(),
235 });
236 }
237
238 if let Some(taken) = item.taken_at {
239 out.push(Reason {
240 what: "taken",
241 value: taken.format("%b %-d").to_string(),
242 because: "already a task; it stays in the list so what is in hand stays visible".to_string(),
243 });
244 }
245
246 if let Some(gone) = item.gone_at {
247 out.push(Reason {
248 what: "gone",
249 value: gone.format("%b %-d").to_string(),
250 because: "stopped appearing in the Jira poll - closed or reassigned; only `--all` shows it".to_string(),
251 });
252 }
253
254 if let (Some(change), Some(at)) = (&item.last_change, item.changed_at) {
255 out.push(Reason {
256 what: "changed",
257 value: change.clone(),
258 because: format!("seen on {}; the badge fades after a day", at.format("%b %-d %H:%M")),
259 });
260 }
261
262 out
263}