use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::capacity::resolve_cycle;
use crate::config::Config;
use crate::cycle_schedule::identity::{CycleReference, ScheduleIndex};
use crate::model::Issue;
use crate::workflow;
use super::diagnostics::{AnalyticsDiagnostic, AnalyticsDiagnosticKind};
use super::events::{AnalyticsScope, EventClass, EventIndex, ScopeKind};
pub const COUNT_FORMULA_VERSION: &str = "burndown.count.v1";
pub const ESTIMATE_FORMULA_VERSION: &str = "burndown.estimate.v1";
pub const MAX_SERIES_DAYS: usize = 100_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Mode {
Count,
Estimate,
}
impl Mode {
pub const ALL: [Mode; 2] = [Mode::Count, Mode::Estimate];
pub const fn as_str(self) -> &'static str {
match self {
Mode::Count => "count",
Mode::Estimate => "estimate",
}
}
pub fn parse(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"count" => Some(Mode::Count),
"estimate" => Some(Mode::Estimate),
_ => None,
}
}
pub const fn formula_version(self) -> &'static str {
match self {
Mode::Count => COUNT_FORMULA_VERSION,
Mode::Estimate => ESTIMATE_FORMULA_VERSION,
}
}
}
impl std::fmt::Display for Mode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Coverage {
Known,
Unknown,
CoverageLimited,
}
impl Coverage {
pub const ALL: [Coverage; 3] = [
Coverage::Known,
Coverage::Unknown,
Coverage::CoverageLimited,
];
pub const fn as_str(self) -> &'static str {
match self {
Coverage::Known => "known",
Coverage::Unknown => "unknown",
Coverage::CoverageLimited => "coverage_limited",
}
}
pub fn parse(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"known" => Some(Coverage::Known),
"unknown" => Some(Coverage::Unknown),
"coverage_limited" => Some(Coverage::CoverageLimited),
_ => None,
}
}
pub const fn is_known(self) -> bool {
matches!(self, Coverage::Known)
}
}
impl std::fmt::Display for Coverage {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DailyPoint {
pub date: String,
pub scope_total: Option<i64>,
pub remaining: Option<i64>,
pub completed: Option<i64>,
pub scope_added: Option<i64>,
pub completions: Option<i64>,
pub coverage: Coverage,
pub event_ids: Vec<String>,
}
impl DailyPoint {
pub fn is_known(&self) -> bool {
self.coverage.is_known()
}
pub fn event_ids(&self) -> &[String] {
&self.event_ids
}
fn values(&self) -> [Option<i64>; 5] {
[
self.scope_total,
self.remaining,
self.completed,
self.scope_added,
self.completions,
]
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct CoverageSummary {
pub known: usize,
pub unknown: usize,
pub coverage_limited: usize,
}
impl CoverageSummary {
pub const fn total(&self) -> usize {
self.known + self.unknown + self.coverage_limited
}
fn add(&mut self, coverage: Coverage) {
match coverage {
Coverage::Known => self.known += 1,
Coverage::Unknown => self.unknown += 1,
Coverage::CoverageLimited => self.coverage_limited += 1,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BurndownSeries {
pub scope: AnalyticsScope,
pub mode: Mode,
pub unit: Option<String>,
pub points: Vec<DailyPoint>,
pub diagnostics: Vec<AnalyticsDiagnostic>,
pub formula_version: String,
}
impl BurndownSeries {
fn empty(scope: &AnalyticsScope, mode: Mode, diagnostics: Vec<AnalyticsDiagnostic>) -> Self {
Self {
scope: scope.clone(),
mode,
unit: None,
points: Vec::new(),
diagnostics,
formula_version: mode.formula_version().to_string(),
}
}
pub fn is_empty(&self) -> bool {
self.points.is_empty()
}
pub fn len(&self) -> usize {
self.points.len()
}
pub fn coverage(&self) -> CoverageSummary {
let mut summary = CoverageSummary::default();
for point in &self.points {
summary.add(point.coverage);
}
summary
}
pub fn point_on(&self, date: &str) -> Option<&DailyPoint> {
self.points.iter().find(|point| point.date == date)
}
pub fn diagnostics_of(
&self,
kind: AnalyticsDiagnosticKind,
) -> impl Iterator<Item = &AnalyticsDiagnostic> {
self.diagnostics.iter().filter(move |d| d.kind == kind)
}
}
#[derive(Debug, Clone)]
pub struct ScopeSnapshot {
scope: AnalyticsScope,
index: EventIndex,
members: Vec<u32>,
completed_members: usize,
observed_days: BTreeSet<String>,
unplaced_events: usize,
visited: usize,
diagnostics: Vec<AnalyticsDiagnostic>,
}
impl ScopeSnapshot {
#[must_use]
pub fn build(
config: &Config,
index: &ScheduleIndex,
issues: &[Issue],
scope: &AnalyticsScope,
) -> Self {
let resolution = workflow::resolution(config, config.default_config(), "default");
let scope_key = match scope.kind {
ScopeKind::Cycle => Some(cycle_key(config, index, &scope.id)),
ScopeKind::Milestone => None,
};
let mut index_events = EventIndex::new(scope.clone());
let mut members: Vec<u32> = Vec::new();
let mut completed_members = 0usize;
let mut visited = 0usize;
for issue in issues {
visited += 1;
if !is_member(issue, scope, config, index, scope_key.as_deref()) {
continue;
}
members.push(issue.number);
if workflow::is_completed(issue, &resolution) {
completed_members += 1;
}
index_events.contribute_history(&issue.number.to_string(), &issue.history);
}
members.sort_unstable();
members.dedup();
let mut observed_days = BTreeSet::new();
let mut unplaced_events = 0usize;
for event in index_events.events() {
match event.date.as_deref() {
Some(date) => {
observed_days.insert(date.to_string());
}
None => unplaced_events += 1,
}
}
let mut diagnostics = Vec::new();
if let Some(snapshot_id) = scope.snapshot_id.as_deref() {
let start = scope.period_start().unwrap_or("open");
let end = scope.period_end().unwrap_or("open");
diagnostics.push(
AnalyticsDiagnostic::new(
AnalyticsDiagnosticKind::ClosedSnapshot,
scope.id.clone(),
format!(
"the series is bound to closed snapshot `{snapshot_id}`; the period the fold \
applied is the caller's `{start}..{end}`, and events recorded after it are \
not contributions"
),
)
.with_remedy(
"pass the snapshot's own recorded period — its `start_date`/`due_date`, or the \
day of its `closed_at` — so the exclusion ends where the cycle closed",
),
);
}
if unplaced_events > 0 {
diagnostics.push(AnalyticsDiagnostic::new(
AnalyticsDiagnosticKind::MissingHistory,
scope.id.clone(),
format!(
"{unplaced_events} recorded event(s) carry no readable calendar date and are \
placed on no day, so every day's change set is incomplete"
),
));
}
diagnostics.sort();
Self {
scope: scope.clone(),
index: index_events,
members,
completed_members,
observed_days,
unplaced_events,
visited,
diagnostics,
}
}
#[must_use]
pub fn scope(&self) -> &AnalyticsScope {
&self.scope
}
#[must_use]
pub fn members(&self) -> &[u32] {
&self.members
}
#[must_use]
pub fn event_index(&self) -> &EventIndex {
&self.index
}
#[must_use]
pub fn observed_days(&self) -> &BTreeSet<String> {
&self.observed_days
}
#[must_use]
pub fn unplaced_events(&self) -> usize {
self.unplaced_events
}
#[must_use]
pub fn issues_visited(&self) -> usize {
self.visited
}
#[must_use]
pub fn diagnostics(&self) -> &[AnalyticsDiagnostic] {
&self.diagnostics
}
#[must_use]
pub fn series(&self, mode: Mode) -> BurndownSeries {
match mode {
Mode::Count => self.count_series(),
Mode::Estimate => {
let mut diagnostics = self.diagnostics.clone();
diagnostics.push(AnalyticsDiagnostic::new(
AnalyticsDiagnosticKind::MissingConversion,
self.scope.id.clone(),
"estimate mode needs an explicit unit conversion, and no compatible unit is \
declared for this scope yet",
));
diagnostics.sort();
BurndownSeries::empty(&self.scope, mode, diagnostics)
}
}
}
#[allow(clippy::too_many_lines)]
fn count_series(&self) -> BurndownSeries {
let scope = &self.scope;
let mut diagnostics = self.diagnostics.clone();
let origin_known = scope.period.start.is_some();
let days = match self.series_days() {
Ok(days) => days,
Err(problem) => {
diagnostics.push(problem.into_diagnostic(&scope.id));
diagnostics.sort();
return BurndownSeries::empty(scope, Mode::Count, diagnostics);
}
};
let mut additions: BTreeMap<String, i64> = BTreeMap::new();
let mut completions: BTreeMap<String, i64> = BTreeMap::new();
let mut event_ids: BTreeMap<String, Vec<String>> = BTreeMap::new();
let mut weak_days: BTreeSet<String> = BTreeSet::new();
let mut unclassified = 0usize;
let mut unclassified_example: Option<String> = None;
let mut unquantified = 0usize;
let mut unquantified_example: Option<String> = None;
for event in self.index.events() {
let Some(date) = event.date.as_deref() else {
continue;
};
event_ids
.entry(date.to_string())
.or_default()
.push(event.event_id.clone());
match event.class {
EventClass::ScopeAddition | EventClass::Completion => match event.delta {
Some(delta) => {
let bucket = match event.class {
EventClass::ScopeAddition => &mut additions,
_ => &mut completions,
};
*bucket.entry(date.to_string()).or_default() += delta;
}
None => {
weak_days.insert(date.to_string());
unquantified += 1;
keep_smallest(&mut unquantified_example, &event.event_id);
}
},
EventClass::EstimateChange => {}
EventClass::Unclassified => {
unclassified += 1;
keep_smallest(&mut unclassified_example, &event.event_id);
}
}
}
let global_weak = self.unplaced_events > 0;
let mut points = Vec::with_capacity(days.len());
let mut scope_total = 0i64;
let mut completed = 0i64;
let mut first_unanswerable: Option<(&str, i64, i64)> = None;
for day in &days {
let observed = self.observed_days.contains(day);
let added = additions.get(day).copied();
let done = completions.get(day).copied();
if observed {
scope_total += added.unwrap_or(0);
completed += done.unwrap_or(0);
}
let (level_total, level_remaining, level_completed) = if origin_known {
let remaining = scope_total - completed;
if remaining < 0 {
first_unanswerable.get_or_insert((day, scope_total, completed));
(Some(scope_total), None, Some(completed))
} else {
(Some(scope_total), Some(remaining), Some(completed))
}
} else {
(None, None, None)
};
let point = DailyPoint {
date: day.clone(),
scope_total: level_total,
remaining: level_remaining,
completed: level_completed,
scope_added: observed.then(|| added.unwrap_or(0)),
completions: observed.then(|| done.unwrap_or(0)),
coverage: Coverage::Known,
event_ids: event_ids.get(day).cloned().unwrap_or_default(),
};
let weak = global_weak || weak_days.contains(day);
let coverage = coverage_of(&point, weak);
points.push(DailyPoint { coverage, ..point });
}
for point in &mut points {
point
.event_ids
.sort_by(|left, right| event_id_key(left).cmp(&event_id_key(right)));
}
if let Some((day, total, done)) = first_unanswerable {
diagnostics.push(
AnalyticsDiagnostic::new(
AnalyticsDiagnosticKind::MissingHistory,
scope.id.clone(),
format!(
"on {day} the period records {done} completion(s) but only {total} scope \
addition(s), so remaining work cannot be answered from the period alone"
),
)
.on(day),
);
}
if unclassified > 0 {
let mut diagnostic = AnalyticsDiagnostic::new(
AnalyticsDiagnosticKind::UnclassifiedEvent,
scope.id.clone(),
format!(
"{unclassified} recorded event(s) in the period carry no scope or completion \
meaning and move no line"
),
);
if let Some(event_id) = unclassified_example {
diagnostic = diagnostic.about_event(event_id);
}
diagnostics.push(diagnostic);
}
if unquantified > 0 {
let mut diagnostic = AnalyticsDiagnostic::new(
AnalyticsDiagnosticKind::MissingHistory,
scope.id.clone(),
format!(
"{unquantified} recorded scope/completion event(s) carry no count-level value, \
so the days they fall on are coverage-limited"
),
);
if let Some(event_id) = unquantified_example {
diagnostic = diagnostic.about_event(event_id);
}
diagnostics.push(diagnostic);
}
diagnostics.extend(self.gap_diagnostics(&points));
diagnostics.extend(self.baseline_diagnostic(origin_known, completed));
diagnostics.sort();
BurndownSeries {
scope: scope.clone(),
mode: Mode::Count,
unit: None,
points,
diagnostics,
formula_version: COUNT_FORMULA_VERSION.to_string(),
}
}
fn series_days(&self) -> Result<Vec<String>, PeriodProblem> {
let start = self
.scope
.period
.start
.clone()
.or_else(|| self.observed_days.iter().next().cloned());
let end = self
.scope
.period
.end
.clone()
.or_else(|| self.observed_days.iter().next_back().cloned());
let (Some(start), Some(end)) = (start, end) else {
return Err(PeriodProblem::Unbounded);
};
if start > end {
return Err(PeriodProblem::Reversed { start, end });
}
let mut days = Vec::new();
let mut cursor = start;
loop {
if days.len() >= MAX_SERIES_DAYS {
return Err(PeriodProblem::TooLong { from: cursor });
}
days.push(cursor.clone());
if cursor == end {
return Ok(days);
}
let Some(next) = next_day(&cursor) else {
return Err(PeriodProblem::Unsteppable { from: cursor });
};
cursor = next;
}
}
fn gap_diagnostics(&self, points: &[DailyPoint]) -> Vec<AnalyticsDiagnostic> {
let mut diagnostics = Vec::new();
let mut run: Option<(String, String, usize)> = None;
for point in points {
if self.observed_days.contains(&point.date) {
if let Some((first, last, days)) = run.take() {
diagnostics.push(self.gap_diagnostic(&first, &last, days));
}
} else {
run = match run {
Some((first, _, days)) => Some((first, point.date.clone(), days + 1)),
None => Some((point.date.clone(), point.date.clone(), 1)),
};
}
}
if let Some((first, last, days)) = run {
diagnostics.push(self.gap_diagnostic(&first, &last, days));
}
diagnostics
}
fn gap_diagnostic(&self, first: &str, last: &str, days: usize) -> AnalyticsDiagnostic {
let span = if first == last {
first.to_string()
} else {
format!("{first}..{last}")
};
AnalyticsDiagnostic::new(
AnalyticsDiagnosticKind::MissingHistory,
self.scope.id.clone(),
format!("{days} day(s) in {span} carry no recorded history"),
)
.on(first)
}
fn baseline_diagnostic(
&self,
origin_known: bool,
folded_completions: i64,
) -> Option<AnalyticsDiagnostic> {
if !origin_known || self.completed_members as i64 <= folded_completions {
return None;
}
let missing = self.completed_members as i64 - folded_completions;
let excluded = self.index.outside_period();
let (because, remedy) = if excluded > 0 {
(
format!(
"the period excluded {excluded} recorded event(s), so those {missing} \
completion(s) may lie before its start or after its end"
),
"pass a period that contains the members' completion events — for a closed cycle, \
the snapshot's own recorded period — or read each member's History to place them",
)
} else {
(
"the period excluded no recorded event at all, so those completions carry no \
transition inside the series' period"
.to_string(),
"record the completion through `tix mv` so the transition lands in the History this \
series folds",
)
};
Some(
AnalyticsDiagnostic::new(
AnalyticsDiagnosticKind::MissingHistory,
self.scope.id.clone(),
format!(
"the tree records {} of the scope's {} member(s) as complete, but the period \
holds only {folded_completions} completion event(s); {because}",
self.completed_members,
self.members.len()
),
)
.with_remedy(remedy),
)
}
}
enum PeriodProblem {
Unbounded,
Reversed {
start: String,
end: String,
},
TooLong {
from: String,
},
Unsteppable {
from: String,
},
}
impl PeriodProblem {
fn into_diagnostic(self, scope: &str) -> AnalyticsDiagnostic {
let (message, date) = match self {
PeriodProblem::Unbounded => (
"the period has no bound and the scope records no dated event, so there is no day \
to enumerate"
.to_string(),
None,
),
PeriodProblem::Reversed { start, end } => (
format!("the period starts at {start}, after its end {end}"),
None,
),
PeriodProblem::TooLong { from } => (
format!(
"the period spans more than {MAX_SERIES_DAYS} days, which is past any planning \
window and is refused rather than enumerated"
),
Some(from),
),
PeriodProblem::Unsteppable { from } => (
format!("`{from}` is not a calendar day this build can step from"),
Some(from),
),
};
let diagnostic =
AnalyticsDiagnostic::new(AnalyticsDiagnosticKind::InvalidPeriod, scope, message);
match date {
Some(date) => diagnostic.on(date),
None => diagnostic,
}
}
}
fn coverage_of(point: &DailyPoint, weak: bool) -> Coverage {
let values = point.values();
let known = values.iter().filter(|value| value.is_some()).count();
match known {
0 => Coverage::Unknown,
5 if !weak => Coverage::Known,
_ => Coverage::CoverageLimited,
}
}
fn cycle_key(config: &Config, index: &ScheduleIndex, reference: &str) -> String {
match resolve_cycle(index, reference) {
CycleReference::Generated { id, .. } => format!("cycle-id:{id}"),
CycleReference::Legacy { name } => format!("cycle-name:{name}"),
CycleReference::Unknown { .. } if config.cycles.contains_key(reference) => {
format!("cycle-name:{reference}")
}
CycleReference::Unknown { name } => format!("cycle-ref:{name}"),
}
}
fn is_member(
issue: &Issue,
scope: &AnalyticsScope,
config: &Config,
index: &ScheduleIndex,
scope_key: Option<&str>,
) -> bool {
match scope.kind {
ScopeKind::Milestone => issue
.milestones
.iter()
.flatten()
.any(|milestone| milestone == &scope.id),
ScopeKind::Cycle => scope_key.is_some_and(|key| {
issue
.cycles
.iter()
.flatten()
.any(|cycle| cycle_key(config, index, cycle) == key)
}),
}
}
pub(super) fn event_id_key(id: &str) -> (u8, u64, u64, &str) {
if let Some((source, index)) = id.split_once('#')
&& let Ok(number) = source.parse::<u64>()
{
return (0, number, index.parse().unwrap_or(u64::MAX), "");
}
(1, 0, 0, id)
}
fn keep_smallest(slot: &mut Option<String>, candidate: &str) {
let smaller = match slot.as_deref() {
Some(current) => event_id_key(candidate) < event_id_key(current),
None => true,
};
if smaller {
*slot = Some(candidate.to_string());
}
}
fn next_day(date: &str) -> Option<String> {
if !crate::model::validate_date(date) {
return None;
}
let year: i32 = date.get(0..4)?.parse().ok()?;
let month: u32 = date.get(5..7)?.parse().ok()?;
let mut day: u32 = date.get(8..10)?.parse::<u32>().ok()? + 1;
let mut month = month;
let mut year = year;
if day > days_in_month(year, month) {
day = 1;
month += 1;
if month > 12 {
month = 1;
year += 1;
}
}
Some(format!("{year:04}-{month:02}-{day:02}"))
}
fn days_in_month(year: i32, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if is_leap_year(year) => 29,
2 => 28,
_ => 0,
}
}
fn is_leap_year(year: i32) -> bool {
year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
#[test]
fn a_series_steps_days_across_month_year_and_leap_boundaries() {
assert_eq!(next_day("2026-01-05").as_deref(), Some("2026-01-06"));
assert_eq!(next_day("2026-01-31").as_deref(), Some("2026-02-01"));
assert_eq!(next_day("2026-02-28").as_deref(), Some("2026-03-01"));
assert_eq!(next_day("2028-02-28").as_deref(), Some("2028-02-29"));
assert_eq!(next_day("2028-02-29").as_deref(), Some("2028-03-01"));
assert_eq!(next_day("2026-12-31").as_deref(), Some("2027-01-01"));
assert_eq!(next_day("2000-02-28").as_deref(), Some("2000-02-29"));
assert_eq!(next_day("2100-02-28").as_deref(), Some("2100-03-01"));
}
#[test]
fn a_string_that_is_not_a_calendar_day_does_not_step() {
assert_eq!(next_day("2026-02-30"), None);
assert_eq!(next_day("not-a-day"), None);
assert_eq!(next_day("2026-2-3"), None);
}
#[test]
fn the_coverage_marker_describes_the_point() {
let base = DailyPoint {
date: "2026-01-05".to_string(),
scope_total: Some(3),
remaining: Some(2),
completed: Some(1),
scope_added: Some(1),
completions: Some(1),
coverage: Coverage::Known,
event_ids: vec!["1#0".to_string()],
};
assert_eq!(coverage_of(&base, false), Coverage::Known);
assert_eq!(
coverage_of(&base, true),
Coverage::CoverageLimited,
"a day the fold could not fully quantify is limited, not known"
);
let gap = DailyPoint {
scope_added: None,
completions: None,
..base.clone()
};
assert_eq!(coverage_of(&gap, false), Coverage::CoverageLimited);
let unanchored = DailyPoint {
scope_total: None,
remaining: None,
completed: None,
scope_added: None,
completions: None,
..base.clone()
};
assert_eq!(coverage_of(&unanchored, false), Coverage::Unknown);
}
}