use crate::domain::model::issue::{Issue, IssueView};
use crate::domain::model::status::StatusesConfig;
use crate::domain::model::temporal::iso_date::IsoDate;
use super::helpers::{close_date, creation_date, is_ongoing, is_terminal};
use super::Cadence;
pub(super) fn compute_cadence(
filtered: &IssueView<'_>,
statuses: &StatusesConfig,
today: &IsoDate,
weeks: u32,
) -> Option<Cadence> {
let window_start = today.minus_weeks(weeks);
let window_days = window_start.duration_until(today).as_days();
let window_weeks = weeks as f64;
let closed_in_window: Vec<&Issue> = filtered
.iter()
.filter(|i| i.status.terminal)
.filter(|i| close_date(i, statuses).is_some_and(|d| d > window_start && d <= *today))
.copied()
.collect();
let closed_count = closed_in_window.len() as u32;
let arrivals_count: u32 = filtered
.iter()
.filter(|i| {
let d = creation_date(i);
d > window_start && d <= *today
})
.count() as u32;
let week_labels: Vec<String> = (0..weeks)
.rev()
.map(|offset| today.minus_weeks(offset).iso_week_label())
.collect();
let mut closed_pw: std::collections::HashMap<String, i32> =
week_labels.iter().map(|l| (l.clone(), 0)).collect();
for issue in &closed_in_window {
if let Some(cd) = close_date(issue, statuses) {
let lbl = cd.iso_week_label();
if let Some(v) = closed_pw.get_mut(&lbl) {
*v += 1;
}
}
}
let mut started_pw: std::collections::HashMap<String, i32> =
week_labels.iter().map(|l| (l.clone(), 0)).collect();
for issue in filtered {
if let Some(ts) = issue.events.first_ongoing_timestamp(is_ongoing(statuses)) {
if let Ok(d) = IsoDate::new(&ts[..10]) {
if d > window_start && d <= *today {
let lbl = d.iso_week_label();
if let Some(v) = started_pw.get_mut(&lbl) {
*v += 1;
}
}
}
}
}
let net_flow_per_week = if week_labels.is_empty() {
None
} else {
let net_sum: i32 = week_labels
.iter()
.map(|l| {
closed_pw.get(l).copied().unwrap_or(0) - started_pw.get(l).copied().unwrap_or(0)
})
.sum();
Some(net_sum as f64 / week_labels.len() as f64)
};
let week_ends: Vec<IsoDate> = (0..weeks)
.rev()
.map(|offset| today.minus_weeks(offset))
.collect();
let avg_wip = if week_ends.is_empty() {
None
} else {
let snapshots: Vec<f64> = week_ends
.iter()
.map(|we| {
filtered
.iter()
.filter(|i| {
let started = i
.events
.first_ongoing_timestamp(is_ongoing(statuses))
.and_then(|ts| IsoDate::new(&ts[..10]).ok());
let started_ok = started.is_some_and(|d| d <= *we);
let closed_ok = close_date(i, statuses).is_none_or(|d| d > *we);
started_ok && closed_ok
})
.count() as f64
})
.collect();
Some(snapshots.iter().sum::<f64>() / snapshots.len() as f64)
};
let avg_lead_time_days = {
let lts: Vec<f64> = closed_in_window
.iter()
.filter_map(|i| {
i.events
.lead_time(&i.date, is_terminal(statuses))
.map(|d| d.as_days())
})
.collect();
if lts.is_empty() {
None
} else {
Some(lts.iter().sum::<f64>() / lts.len() as f64)
}
};
Some(Cadence {
weeks,
closed_count,
throughput_per_day: if window_days > 0.0 {
closed_count as f64 / window_days
} else {
0.0
},
throughput_per_week: closed_count as f64 / window_weeks,
arrivals_count,
arrivals_per_day: if window_days > 0.0 {
arrivals_count as f64 / window_days
} else {
0.0
},
arrivals_per_week: arrivals_count as f64 / window_weeks,
net_flow_per_week,
avg_wip,
avg_lead_time_days,
})
}
#[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 counts_closed_and_arrivals_within_window() {
scenario("2026-03-12")
.given(
feature("T1")
.status("closed")
.with_timestamped_event("2026-03-01T00:00:00Z", "created", None, None)
.with_timestamped_event(
"2026-03-09T00:00:00Z",
"status_changed",
Some("open"),
Some("closed"),
),
)
.when_cadence(8)
.then_weeks(8)
.then_closed_count(1)
.then_arrivals_count(1);
}
#[test]
fn issues_outside_window_are_excluded() {
scenario("2026-03-12")
.given(
feature("Old")
.status("closed")
.with_timestamped_event("2024-01-01T00:00:00Z", "created", None, None)
.with_timestamped_event(
"2024-01-10T00:00:00Z",
"status_changed",
Some("open"),
Some("closed"),
),
)
.when_cadence(8)
.then_closed_count(0)
.then_arrivals_count(0);
}
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_cadence(self, weeks: u32) -> CadenceOutcome {
let refs = IssueView::from_slice(&self.issues);
let result =
compute_cadence(&refs, &StatusesConfig::default_issue(), &self.today, weeks)
.expect("cadence should be computed");
CadenceOutcome { result }
}
}
struct CadenceOutcome {
result: Cadence,
}
impl CadenceOutcome {
fn then_weeks(self, expected: u32) -> Self {
assert_eq!(self.result.weeks, expected, "weeks mismatch");
self
}
fn then_closed_count(self, expected: u32) -> Self {
assert_eq!(self.result.closed_count, expected, "closed_count mismatch");
self
}
fn then_arrivals_count(self, expected: u32) -> Self {
assert_eq!(
self.result.arrivals_count, expected,
"arrivals_count mismatch"
);
self
}
}
}