use crate::db::jira_inbox::JiraInboxItem;
use anyhow::{Result, bail};
use chrono::{Duration, NaiveDateTime};
pub fn parse_window(text: &str) -> Result<Duration> {
let text = text.trim();
let (digits, unit) = match text.char_indices().find(|(_, c)| !c.is_ascii_digit()) {
Some((at, _)) => text.split_at(at),
None => (text, "d"),
};
let amount: i64 = match digits.parse() {
Ok(n) if n > 0 => n,
_ => bail!("'{text}' is not a window; try 1d, 7d, 12h or 2w"),
};
match unit {
"h" => Ok(Duration::hours(amount)),
"d" => Ok(Duration::days(amount)),
"w" => Ok(Duration::weeks(amount)),
_ => bail!("'{text}' is not a window; the unit is h, d or w"),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PriorityCut {
Exactly(i32),
AtLeast(i32),
}
impl PriorityCut {
fn keeps(self, rank: i32) -> bool {
match self {
PriorityCut::Exactly(wanted) => rank == wanted,
PriorityCut::AtLeast(wanted) => rank <= wanted,
}
}
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct InboxFilter {
pub since: Option<Duration>,
pub changed: Option<Duration>,
pub min_score: Option<f64>,
pub priority: Option<PriorityCut>,
pub status: Option<String>,
}
impl InboxFilter {
pub fn is_empty(&self) -> bool {
*self == Self::default()
}
pub fn keeps(&self, item: &JiraInboxItem, now: NaiveDateTime) -> bool {
let within = |at: Option<NaiveDateTime>, window: Duration| at.is_some_and(|at| now.signed_duration_since(at) <= window);
if let Some(window) = self.since
&& !within(Some(item.first_seen), window)
{
return false;
}
if let Some(window) = self.changed
&& !within(item.changed_at, window)
{
return false;
}
if let Some(floor) = self.min_score
&& !item.sort_value.is_some_and(|score| score >= floor)
{
return false;
}
if let Some(cut) = self.priority
&& !cut.keeps(item.priority_rank)
{
return false;
}
if let Some(status) = &self.status
&& !item.status_name.eq_ignore_ascii_case(status)
&& !item.status_id.as_deref().is_some_and(|id| id.eq_ignore_ascii_case(status))
{
return false;
}
true
}
pub fn apply(&self, items: Vec<JiraInboxItem>, now: NaiveDateTime) -> Vec<JiraInboxItem> {
items.into_iter().filter(|item| self.keeps(item, now)).collect()
}
}
pub fn priority_cut_named(items: &[JiraInboxItem], wanted: &str) -> Result<PriorityCut> {
let (name, and_above) = match wanted.strip_suffix('+') {
Some(name) => (name.trim(), true),
None => (wanted.trim(), false),
};
let rank = items
.iter()
.filter(|item| item.priority.as_deref().is_some_and(|p| p.eq_ignore_ascii_case(name)))
.map(|item| item.priority_rank)
.min();
let Some(rank) = rank else {
let mut known: Vec<&str> = items.iter().filter_map(|item| item.priority.as_deref()).collect();
known.sort_unstable();
known.dedup();
if known.is_empty() {
bail!("no priority named '{name}' in the inbox, and no issue carries a priority yet");
}
bail!("no priority named '{name}' in the inbox; known: {}", known.join(", "));
};
Ok(if and_above { PriorityCut::AtLeast(rank) } else { PriorityCut::Exactly(rank) })
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum InboxSort {
#[default]
Score,
Priority,
New,
Changed,
}
pub fn sort(items: &mut [JiraInboxItem], by: InboxSort) {
items.sort_by(|a, b| {
let frame = a.gone_at.is_some().cmp(&b.gone_at.is_some()).then_with(|| b.pinned.cmp(&a.pinned));
frame.then_with(|| match by {
InboxSort::Score => std::cmp::Ordering::Equal,
InboxSort::Priority => a.priority_rank.cmp(&b.priority_rank),
InboxSort::New => b.first_seen.cmp(&a.first_seen),
InboxSort::Changed => b.changed_at.cmp(&a.changed_at),
})
});
}
#[derive(Debug, Clone, PartialEq)]
pub struct Reason {
pub what: &'static str,
pub value: String,
pub because: String,
}
pub fn explain(item: &JiraInboxItem, score_label: Option<&str>, now: NaiveDateTime) -> Vec<Reason> {
let mut out = Vec::new();
let label = score_label.unwrap_or("the ranking field");
match item.sort_value {
Some(score) => out.push(Reason {
what: "score",
value: format!("{score}"),
because: format!("read from {label} in Jira; the list is ordered by it, highest first"),
}),
None => out.push(Reason {
what: "score",
value: "—".to_string(),
because: format!("{label} is empty on this issue in Jira; issues without a score sort below those with one"),
}),
}
out.push(Reason {
what: "priority",
value: item.priority.clone().unwrap_or_else(|| "—".to_string()),
because: match item.priority.as_deref() {
Some(_) => format!(
"Jira priority id {}, which breaks ties on equal scores - lower is more urgent",
item.priority_rank
),
None => "no priority set in Jira, so this sorts last among equal scores".to_string(),
},
});
if item.pinned {
out.push(Reason {
what: "pinned",
value: "yes".to_string(),
because: "pinned issues lead the list whatever the order".to_string(),
});
}
if let Some(until) = item.snoozed_until
&& until > now
{
out.push(Reason {
what: "snoozed",
value: until.format("%b %-d %H:%M").to_string(),
because: "asleep until then, so it is out of the list and out of the waiting count".to_string(),
});
}
if let Some(taken) = item.taken_at {
out.push(Reason {
what: "taken",
value: taken.format("%b %-d").to_string(),
because: "already a task; it stays in the list so what is in hand stays visible".to_string(),
});
}
if let Some(gone) = item.gone_at {
out.push(Reason {
what: "gone",
value: gone.format("%b %-d").to_string(),
because: "stopped appearing in the Jira poll - closed or reassigned; only `--all` shows it".to_string(),
});
}
if let (Some(change), Some(at)) = (&item.last_change, item.changed_at) {
out.push(Reason {
what: "changed",
value: change.clone(),
because: format!("seen on {}; the badge fades after a day", at.format("%b %-d %H:%M")),
});
}
out
}