Skip to main content

kasl/libs/
inbox_filter.rs

1//! Slicing the inbox: which issues to show, and in what order.
2//!
3//! An inbox of two hundred open issues is not a list anyone reads top to
4//! bottom; it is a pile that gets useful only once it can be cut - what
5//! arrived this week, what scores above five, what is High or worse. The cuts
6//! live here as plain functions over already-loaded items: the table is small
7//! enough that SQL buys nothing, and a function is what the tests can hold.
8
9use crate::db::jira_inbox::JiraInboxItem;
10use anyhow::{Result, bail};
11use chrono::{Duration, NaiveDateTime};
12
13/// A time window such as `7d`, `12h`, `2w`, or a bare number of days.
14///
15/// The unit is one letter and the number is whole: this is typed on the
16/// command line by someone who wants "this week", not a duration grammar.
17pub 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/// A cut by priority rank. Lower rank is more urgent in Jira (`1` = Highest).
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum PriorityCut {
38    /// That priority only.
39    Exactly(i32),
40    /// That priority and everything more urgent - the `NAME+` form.
41    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/// What the inbox is cut by. Every set field must hold; unset fields pass everything.
54#[derive(Debug, Default, Clone, PartialEq)]
55pub struct InboxFilter {
56    /// First seen no earlier than this long ago.
57    pub since: Option<Duration>,
58    /// Visibly changed no earlier than this long ago.
59    pub changed: Option<Duration>,
60    /// Ranking field (e.g. Scoring) at least this; issues without a score fail.
61    pub min_score: Option<f64>,
62    /// Priority, exact or "and above".
63    pub priority: Option<PriorityCut>,
64    /// Status name, matched without regard to case.
65    pub status: Option<String>,
66}
67
68impl InboxFilter {
69    /// True when nothing is set and the list would come back whole.
70    pub fn is_empty(&self) -> bool {
71        *self == Self::default()
72    }
73
74    /// Whether one issue passes every set cut.
75    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    /// Keeps the issues that pass, in their incoming order.
107    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
112/// The cut behind a priority name, as the inbox has seen it.
113///
114/// Priority names belong to the Jira instance (a Russian Jira says
115/// «Высокий», not "High"), so they are looked up in the issues at hand rather
116/// than in a table of English defaults. A trailing `+` widens the cut to
117/// that priority and everything more urgent.
118pub 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/// What the list is ordered by. Pinned issues lead and gone issues trail whatever the key.
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
142pub enum InboxSort {
143    /// Ranking field descending, then priority - the inbox's own order.
144    #[default]
145    Score,
146    /// Most urgent first, then score.
147    Priority,
148    /// Most recently discovered first.
149    New,
150    /// Most recently changed first; issues never changed trail.
151    Changed,
152}
153
154/// Reorders in place. Stable, so equal keys keep the inbox's own order.
155pub 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/// One line of the answer to "why is this issue here?".
168#[derive(Debug, Clone, PartialEq)]
169pub struct Reason {
170    /// What is being explained: `score`, `priority`, `pinned`.
171    pub what: &'static str,
172    /// The value as the row carries it.
173    pub value: String,
174    /// Where it comes from and what it does to the order.
175    pub because: String,
176}
177
178/// Explains an issue's place in the list.
179///
180/// Deliberately not a decomposed score. kasl does not compute importance: the
181/// score is a number read straight out of a Jira field the user named, and the
182/// priority rank is Jira's own priority id. Inventing a local formula to
183/// decompose - "High +3, due tomorrow +2" - would put a second, disagreeing
184/// answer next to Jira's about which issue matters, and the user would have no
185/// way to tell which one is real. So this explains what the order is actually
186/// made of and where each part came from.
187///
188/// `score_label` is what the configured ranking field is called (typically
189/// `Scoring`); naming it is most of the answer, because the number means
190/// nothing without knowing which field it is.
191pub 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}