use crate::domain::model::issue::IssueView;
use crate::domain::model::temporal::iso_date::IsoDate;
use super::helpers::creation_date;
use super::AgeBuckets;
pub(super) struct OpenAge {
pub avg_age_days: Option<f64>,
pub oldest_open_days: Option<u32>,
pub newest_open_days: Option<u32>,
pub created_last_7: u32,
pub created_last_30: u32,
pub created_last_90: u32,
pub open_ages_days: Vec<i64>,
}
pub(super) fn compute_open_age(filtered: &IssueView<'_>, today: &IsoDate) -> OpenAge {
let open_ages_days: Vec<i64> = filtered
.iter()
.filter(|i| !i.status.terminal)
.map(|i| creation_date(i).duration_until(today).as_whole_days())
.filter(|&d| d >= 0)
.collect();
let avg_age_days = if open_ages_days.is_empty() {
None
} else {
Some(open_ages_days.iter().sum::<i64>() as f64 / open_ages_days.len() as f64)
};
let oldest_open_days = open_ages_days.iter().max().map(|&d| d as u32);
let newest_open_days = open_ages_days.iter().min().map(|&d| d as u32);
let all_ages_days: Vec<i64> = filtered
.iter()
.map(|i| creation_date(i).duration_until(today).as_whole_days())
.filter(|&d| d >= 0)
.collect();
let created_last_7 = all_ages_days.iter().filter(|&&d| d <= 7).count() as u32;
let created_last_30 = all_ages_days.iter().filter(|&&d| d <= 30).count() as u32;
let created_last_90 = all_ages_days.iter().filter(|&&d| d <= 90).count() as u32;
OpenAge {
avg_age_days,
oldest_open_days,
newest_open_days,
created_last_7,
created_last_30,
created_last_90,
open_ages_days,
}
}
pub(super) fn compute_age_buckets(open_ages_days: &[i64]) -> AgeBuckets {
let mut b = AgeBuckets::default();
for &age in open_ages_days {
match age {
0..=7 => b.days_0_7 += 1,
8..=30 => b.days_8_30 += 1,
31..=90 => b.days_31_90 += 1,
_ => b.days_91_plus += 1,
}
}
b
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::model::issue::Issue;
use crate::domain::model::status::StatusesConfig;
use crate::domain::usecases::issue::tests::{enrich_issue, feature, ir, IssueFixture};
#[test]
fn uses_created_event_date_when_present() {
scenario("2026-03-12")
.given(feature("Story").status("open").with_timestamped_event(
"2026-03-02T00:00:00Z",
"created",
None,
None,
))
.when_open_age()
.then_oldest_open_days(Some(10))
.then_avg_age_days(10.0);
}
#[test]
fn falls_back_to_date_field_when_no_created_event() {
scenario("2026-03-12")
.given(feature("Story").status("open").date("2026-03-07"))
.when_open_age()
.then_oldest_open_days(Some(5));
}
#[test]
fn closed_issues_excluded_from_open_age() {
scenario("2026-03-12")
.given(feature("Closed").status("closed").date("2026-01-01"))
.given(feature("Open").status("open").date("2026-03-07"))
.when_open_age()
.then_oldest_open_days(Some(5))
.then_avg_age_days_less_than(10.0);
}
#[test]
fn created_last_n_days_counts_correctly() {
scenario("2026-03-12")
.given(feature("T1").date("2026-03-12")) .given(feature("T2").date("2026-03-06")) .given(feature("T3").date("2026-02-10")) .given(feature("T4").date("2025-12-03")) .when_open_age()
.then_created_last_n(2, 3, 3);
}
#[test]
fn age_buckets_distribute_correctly() {
let open_ages = vec![2i64, 15, 60, 120];
let buckets = compute_age_buckets(&open_ages);
assert_eq!(buckets.days_0_7, 1);
assert_eq!(buckets.days_8_30, 1);
assert_eq!(buckets.days_31_90, 1);
assert_eq!(buckets.days_91_plus, 1);
}
struct Scenario {
issues: Vec<Issue>,
today: IsoDate,
}
fn scenario(today: &str) -> Scenario {
Scenario {
issues: vec![],
today: IsoDate::new(today).unwrap(),
}
}
impl Scenario {
fn given(mut self, fixture: IssueFixture) -> Self {
let id = self.issues.len() as u64 + 1;
let mut issue = fixture.build(ir(id));
enrich_issue(&mut issue, &StatusesConfig::default_issue());
self.issues.push(issue);
self
}
fn when_open_age(self) -> OpenAgeOutcome {
let refs = IssueView::from_slice(&self.issues);
let result = compute_open_age(&refs, &self.today);
OpenAgeOutcome { result }
}
}
struct OpenAgeOutcome {
result: OpenAge,
}
impl OpenAgeOutcome {
fn then_oldest_open_days(self, expected: Option<u32>) -> Self {
assert_eq!(
self.result.oldest_open_days, expected,
"oldest_open_days mismatch"
);
self
}
fn then_avg_age_days(self, expected: f64) -> Self {
assert_eq!(
self.result.avg_age_days,
Some(expected),
"avg_age_days mismatch"
);
self
}
fn then_avg_age_days_less_than(self, bound: f64) -> Self {
let avg = self
.result
.avg_age_days
.expect("avg_age_days should be Some");
assert!(avg < bound, "expected avg_age_days < {bound}, got {avg}");
self
}
fn then_created_last_n(self, last7: u32, last30: u32, last90: u32) -> Self {
assert_eq!(self.result.created_last_7, last7, "created_last_7 mismatch");
assert_eq!(
self.result.created_last_30, last30,
"created_last_30 mismatch"
);
assert_eq!(
self.result.created_last_90, last90,
"created_last_90 mismatch"
);
self
}
}
}