use crate::domain::model::issue::{Issue, IssueView};
use crate::domain::model::status::{Status, StatusesConfig};
use crate::domain::model::temporal::iso_date::IsoDate;
use super::helpers::is_ongoing;
use super::{FlowLoad, WipItem};
pub(super) fn compute_flow_load(
filtered: &IssueView<'_>,
statuses: &StatusesConfig,
today: &IsoDate,
cycle_time_p85: Option<f64>,
stale_after: u32,
detailed: bool,
) -> FlowLoad {
let wip_issues: Vec<(&Issue, u32)> = filtered
.iter()
.filter(|i| !i.status.terminal)
.filter_map(|i| {
let ts_str = i.events.first_ongoing_timestamp(is_ongoing(statuses))?;
let onset = IsoDate::new(&ts_str[..10]).ok()?;
let age = onset.duration_until(today);
if age.is_positive() || age.is_zero() {
Some((*i, age.as_whole_days() as u32))
} else {
None
}
})
.collect();
let wip_total = wip_issues.len() as u32;
let (by_status_wip, items) = if detailed {
let mut sm: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
for (issue, _) in &wip_issues {
*sm.entry(issue.status.as_str().to_string()).or_insert(0) += 1;
}
let mut by_st: Vec<(Status, u32)> = sm
.into_iter()
.map(|(s, c)| (Status::unresolved(s), c))
.collect();
by_st.sort_by_key(|b| std::cmp::Reverse(b.1));
let mut items_list: Vec<WipItem> = wip_issues
.iter()
.map(|(issue, age_days)| {
let at_risk = cycle_time_p85.is_some_and(|p85| *age_days as f64 > p85);
let last_activity_days = issue
.events
.last_activity_date(&issue.date)
.duration_until(today)
.as_whole_days()
.max(0) as u32;
let stale = last_activity_days > stale_after;
WipItem {
id: issue.id.clone(),
status: issue.status.clone(),
age_days: *age_days,
at_risk,
last_activity_days,
stale,
}
})
.collect();
items_list.sort_by_key(|b| std::cmp::Reverse(b.age_days));
(by_st, items_list)
} else {
(vec![], vec![])
};
FlowLoad {
total: wip_total,
by_status: by_status_wip,
items,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::model::status::StatusesConfig;
use crate::domain::model::temporal::iso_date::IsoDate;
use crate::domain::usecases::issue::tests::{enrich_issue, feature, ir, IssueFixture};
#[test]
fn only_issues_with_ongoing_transition_count_as_wip() {
scenario("2026-03-12")
.given(
feature("Started")
.status("in-progress")
.with_timestamped_event("2026-03-01T00:00:00Z", "created", None, None)
.with_timestamped_event(
"2026-03-05T00:00:00Z",
"status_changed",
Some("to-do"),
Some("in-progress"),
),
)
.given(feature("Backlog").status("to-do"))
.when_flow_load(false)
.then_total(1);
}
#[test]
fn detailed_populates_items_with_age_and_staleness() {
scenario("2026-03-12")
.given(
feature("WIP")
.status("in-progress")
.with_timestamped_event("2026-03-01T00:00:00Z", "created", None, None)
.with_timestamped_event(
"2026-03-05T00:00:00Z",
"status_changed",
Some("to-do"),
Some("in-progress"),
)
.with_timestamped_event("2026-03-09T00:00:00Z", "content_updated", None, None),
)
.when_flow_load(true)
.then_item_age_days(0, 7) .then_item_last_activity_days(0, 3) .then_item_not_stale(0); }
#[test]
fn item_is_stale_when_last_activity_exceeds_threshold() {
scenario("2026-03-12")
.given(
feature("Stale")
.status("in-progress")
.with_timestamped_event("2026-03-01T00:00:00Z", "created", None, None)
.with_timestamped_event(
"2026-03-05T00:00:00Z",
"status_changed",
Some("to-do"),
Some("in-progress"),
)
.with_timestamped_event("2026-03-08T00:00:00Z", "content_updated", None, None),
)
.when_flow_load(true)
.then_item_last_activity_days(0, 4)
.then_item_stale(0);
}
#[test]
fn non_detailed_returns_empty_items_and_by_status() {
scenario("2026-03-12")
.given(feature("WIP").status("in-progress").with_timestamped_event(
"2026-03-05T00:00:00Z",
"status_changed",
Some("to-do"),
Some("in-progress"),
))
.when_flow_load(false)
.then_total(1)
.then_items_empty()
.then_by_status_empty();
}
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_flow_load(self, detailed: bool) -> FlowLoadOutcome {
let refs = IssueView::from_slice(&self.issues);
let statuses = StatusesConfig::default_issue();
let result = compute_flow_load(&refs, &statuses, &self.today, None, 3, detailed);
FlowLoadOutcome { result }
}
}
struct FlowLoadOutcome {
result: FlowLoad,
}
impl FlowLoadOutcome {
fn then_total(self, expected: u32) -> Self {
assert_eq!(self.result.total, expected, "total mismatch");
self
}
fn then_items_empty(self) -> Self {
assert!(self.result.items.is_empty(), "expected items to be empty");
self
}
fn then_by_status_empty(self) -> Self {
assert!(
self.result.by_status.is_empty(),
"expected by_status to be empty"
);
self
}
fn then_item_age_days(self, idx: usize, expected: u32) -> Self {
let item = &self.result.items[idx];
assert_eq!(item.age_days, expected, "item[{idx}].age_days mismatch");
self
}
fn then_item_last_activity_days(self, idx: usize, expected: u32) -> Self {
let item = &self.result.items[idx];
assert_eq!(
item.last_activity_days, expected,
"item[{idx}].last_activity_days mismatch"
);
self
}
fn then_item_stale(self, idx: usize) -> Self {
assert!(self.result.items[idx].stale, "item[{idx}] should be stale");
self
}
fn then_item_not_stale(self, idx: usize) -> Self {
assert!(
!self.result.items[idx].stale,
"item[{idx}] should not be stale"
);
self
}
}
}