cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
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};

    // ── Tests ─────────────────────────────────────────────────────────────────

    #[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);
    }

    // ── Scenario DSL ──────────────────────────────────────────────────────────

    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
        }
    }
}