use std::collections::{BTreeMap, BTreeSet};
use crate::capacity::estimate::EstimateValue;
use crate::config::Config;
use crate::model::Issue;
use crate::wip::WipConfig;
use super::diagnostics::{AnalyticsDiagnostic, AnalyticsDiagnosticKind};
use super::events::{AnalyticsEvent, AnalyticsScope, EventClass};
use super::series::{
BurndownSeries, Coverage, DailyPoint, ESTIMATE_FORMULA_VERSION, Mode, ScopeSnapshot,
event_id_key,
};
#[must_use]
pub fn declared_units(config: &Config) -> Vec<String> {
config.default_config().wip.units.keys().cloned().collect()
}
#[derive(Debug, Clone)]
pub struct EstimateConversion {
registry: WipConfig,
unit: String,
}
impl EstimateConversion {
#[must_use]
pub fn resolve(config: &Config, unit: &str) -> Option<Self> {
let registry = config.default_config().wip.clone();
let unit = unit.trim();
registry.factor(unit)?;
Some(Self {
registry,
unit: unit.to_string(),
})
}
#[must_use]
pub fn unit(&self) -> &str {
&self.unit
}
#[must_use]
pub fn value_of(&self, number: u32, stated: Option<&str>) -> EstimateValue {
match stated {
Some(raw) if !raw.trim().is_empty() => {
EstimateValue::parse(number, raw).converted_to(number, &self.registry, &self.unit)
}
_ => EstimateValue::unestimated(),
}
}
fn size_of(&self, number: u32, stated: Option<&str>) -> MemberSize {
let value = self.value_of(number, stated);
if let Some(size) = value.converted_value {
return MemberSize::Sized(i64::from(size));
}
if value.is_unestimated() {
return MemberSize::Unestimated;
}
let Some(finding) = value.diagnostics().first() else {
return MemberSize::Refused(Refused {
key: RefusalKey {
kind: "missing_conversion",
spelled: value.unit().map(str::to_string),
},
reason: format!("issue #{number} states `{}`", value.render()),
remedy: None,
});
};
MemberSize::Refused(Refused {
key: RefusalKey {
kind: finding.kind.as_str(),
spelled: value.unit().map(str::to_string),
},
reason: finding.message.clone(),
remedy: finding.because.clone(),
})
}
}
#[must_use]
pub fn estimate_series(
snapshot: &ScopeSnapshot,
issues: &[Issue],
config: &Config,
unit: &str,
) -> BurndownSeries {
let scope = snapshot.scope();
let Some(conversion) = EstimateConversion::resolve(config, unit) else {
let mut diagnostics = snapshot.diagnostics().to_vec();
diagnostics.push(undeclared_unit(scope, unit.trim(), config));
diagnostics.sort();
return empty(scope, unit.trim(), diagnostics);
};
let days = snapshot.series(Mode::Count);
if days.is_empty() {
return empty(scope, conversion.unit(), days.diagnostics);
}
let sizes = size_members(snapshot, issues, &conversion);
let valued = value_days(snapshot, &sizes);
let origin_known = scope.period_start().is_some();
let global_weak = snapshot.unplaced_events() > 0;
let mut points = Vec::with_capacity(days.points.len());
let mut scope_total = 0i64;
let mut completed = 0i64;
let mut first_unanswerable: Option<(&str, i64, i64)> = None;
let mut level_overflow: Option<&str> = None;
for covered in &days.points {
let date = covered.date.as_str();
let observed = snapshot.observed_days().contains(date);
let added = valued.added.get(date).copied();
let done = valued.completed.get(date).copied();
if observed
&& level_overflow.is_none()
&& (!super::series::carry_level(&mut scope_total, added.unwrap_or(0))
|| !super::series::carry_level(&mut completed, done.unwrap_or(0)))
{
level_overflow = Some(date);
}
let (total, remaining, done_level) = if level_overflow.is_some() {
(None, None, None)
} else if origin_known {
let remaining = scope_total - completed;
if remaining < 0 {
first_unanswerable.get_or_insert((date, scope_total, completed));
(Some(scope_total), None, Some(completed))
} else {
(Some(scope_total), Some(remaining), Some(completed))
}
} else {
(None, None, None)
};
let mut event_ids = valued.event_ids.get(date).cloned().unwrap_or_default();
event_ids.sort_by(|left, right| event_id_key(left).cmp(&event_id_key(right)));
let point = DailyPoint {
date: date.to_string(),
scope_total: total,
remaining,
completed: done_level,
scope_added: observed.then(|| added.unwrap_or(0)),
completions: observed.then(|| done.unwrap_or(0)),
coverage: Coverage::Known,
event_ids,
};
let weak = global_weak || valued.weak.contains(date);
points.push(DailyPoint {
coverage: coverage_of(&point, weak),
..point
});
}
let mut diagnostics = snapshot.diagnostics().to_vec();
diagnostics.extend(gap_findings(scope, &points, snapshot.observed_days()));
diagnostics.extend(valued.findings(scope, conversion.unit()));
if let Some((day, total, done)) = first_unanswerable {
diagnostics.push(level_finding(scope, conversion.unit(), day, total, done));
}
if let Some(day) = level_overflow {
diagnostics.push(
AnalyticsDiagnostic::new(
AnalyticsDiagnosticKind::MissingHistory,
scope.id.clone(),
format!(
"on {day} the carried estimate level exceeded the i64 range, so remaining \
work cannot be answered without wrapping or inventing a value"
),
)
.on(day)
.with_remedy(
"reduce the synthetic fixture: persisted estimates use u32 values, so a \
realistic project is orders of magnitude below this bound",
),
);
}
diagnostics.sort();
BurndownSeries {
scope: scope.clone(),
mode: Mode::Estimate,
unit: Some(conversion.unit().to_string()),
points,
diagnostics,
formula_version: ESTIMATE_FORMULA_VERSION.to_string(),
}
}
fn empty(
scope: &AnalyticsScope,
unit: &str,
diagnostics: Vec<AnalyticsDiagnostic>,
) -> BurndownSeries {
BurndownSeries {
scope: scope.clone(),
mode: Mode::Estimate,
unit: Some(unit.to_string()),
points: Vec::new(),
diagnostics,
formula_version: ESTIMATE_FORMULA_VERSION.to_string(),
}
}
fn undeclared_unit(scope: &AnalyticsScope, unit: &str, config: &Config) -> AnalyticsDiagnostic {
let declared = declared_units(config);
let table = if declared.is_empty() {
"the board declares no estimate unit".to_string()
} else {
format!(
"the board declares {}",
declared
.iter()
.map(|unit| format!("`{unit}`"))
.collect::<Vec<_>>()
.join(", ")
)
};
AnalyticsDiagnostic::new(
AnalyticsDiagnosticKind::MissingConversion,
scope.id.clone(),
format!(
"estimate mode cannot count in `{unit}`: {table}, so no day is folded and no \
estimate total is fabricated"
),
)
.with_remedy(
"choose `--mode count`, or declare the unit under `[kanban.<board>.wip.units]` and \
state the estimates in it",
)
}
fn gap_findings(
scope: &AnalyticsScope,
points: &[DailyPoint],
observed: &BTreeSet<String>,
) -> Vec<AnalyticsDiagnostic> {
let mut findings = Vec::new();
let mut run: Option<(String, String, usize)> = None;
for point in points {
if observed.contains(&point.date) {
if let Some((first, last, days)) = run.take() {
findings.push(gap_finding(scope, &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 {
findings.push(gap_finding(scope, &first, &last, days));
}
findings
}
fn gap_finding(
scope: &AnalyticsScope,
first: &str,
last: &str,
days: usize,
) -> AnalyticsDiagnostic {
let span = if first == last {
first.to_string()
} else {
format!("{first}..{last}")
};
AnalyticsDiagnostic::new(
AnalyticsDiagnosticKind::MissingHistory,
scope.id.clone(),
format!("{days} day(s) in {span} carry no recorded history"),
)
.on(first)
}
fn level_finding(
scope: &AnalyticsScope,
unit: &str,
day: &str,
total: i64,
done: i64,
) -> AnalyticsDiagnostic {
AnalyticsDiagnostic::new(
AnalyticsDiagnosticKind::MissingHistory,
scope.id.clone(),
format!(
"on {day} the period records `{done}` `{unit}` of completion but only `{total}` \
`{unit}` of scope addition, so the remaining work cannot be answered from the \
period alone"
),
)
.on(day)
}
fn coverage_of(point: &DailyPoint, weak: bool) -> Coverage {
let values = [
point.scope_total,
point.remaining,
point.completed,
point.scope_added,
point.completions,
];
let known = values.iter().filter(|value| value.is_some()).count();
match known {
0 => Coverage::Unknown,
5 if !weak => Coverage::Known,
_ => Coverage::CoverageLimited,
}
}
fn size_members(
snapshot: &ScopeSnapshot,
issues: &[Issue],
conversion: &EstimateConversion,
) -> BTreeMap<u32, MemberSize> {
let records: BTreeMap<u32, &Issue> = issues.iter().map(|issue| (issue.number, issue)).collect();
snapshot
.members()
.iter()
.map(|&number| match records.get(&number) {
Some(issue) => (
number,
conversion.size_of(number, issue.estimate.as_deref()),
),
None => (number, MemberSize::Unrecorded),
})
.collect()
}
#[derive(Debug, Default)]
struct ValuedDays {
added: BTreeMap<String, i64>,
completed: BTreeMap<String, i64>,
event_ids: BTreeMap<String, Vec<String>>,
weak: BTreeSet<String>,
refusals: BTreeMap<(Field, RefusalKey), Tally>,
unestimated: BTreeMap<Field, Tally>,
unattributed: BTreeMap<Field, Tally>,
unquantified: BTreeMap<Field, Tally>,
rescoped: Tally,
unclassified: Tally,
}
impl ValuedDays {
fn findings(&self, scope: &AnalyticsScope, unit: &str) -> Vec<AnalyticsDiagnostic> {
let mut diagnostics = Vec::new();
for ((field, key), tally) in &self.refusals {
let reason = tally
.reason
.as_deref()
.unwrap_or("the shared conversion refused it");
let mut diagnostic = AnalyticsDiagnostic::new(
AnalyticsDiagnosticKind::MissingConversion,
scope.id.clone(),
format!(
"{} recorded `{}` contribution(s) are left out of the estimate series in \
`{unit}`: {reason}",
tally.count,
field.as_str()
),
);
if let Some(event) = tally.first_event.as_deref() {
diagnostic = diagnostic.about_event(event);
}
diagnostic = match tally.remedy.as_deref() {
Some(remedy) => diagnostic.with_remedy(remedy),
None if key.kind == "malformed_estimate" => diagnostic.with_remedy(
"rewrite the issue's `estimate:` as `<number><unit>`, or run `tix lint`",
),
None => diagnostic,
};
diagnostics.push(diagnostic);
}
for (field, tally) in &self.unestimated {
diagnostics.push(sized_finding(
scope,
tally,
format!(
"{} recorded `{}` contribution(s) belong to an unestimated issue, so \
their size in `{unit}` is unknown and their days are coverage-limited",
tally.count,
field.as_str()
),
"state the estimate on the issue (`tix edit <n> --estimate <number><unit>`) \
so its work can be sized",
));
}
for (field, tally) in &self.unattributed {
diagnostics.push(sized_finding(
scope,
tally,
format!(
"{} recorded `{}` contribution(s) name no issue this series can size (a \
closed snapshot's contribution carries no issue), so they are not valued in \
`{unit}`",
tally.count,
field.as_str()
),
"read the day as coverage-limited: only the recorded per-issue contributions can \
be sized",
));
}
for (field, tally) in &self.unquantified {
diagnostics.push(sized_finding(
scope,
tally,
format!(
"{} recorded `{}` contribution(s) carry no count-level value, so their size in \
`{unit}` is unknown and their days are coverage-limited",
tally.count,
field.as_str()
),
"read the day as coverage-limited: the recorded detail names the change but not \
its size in this unit",
));
}
if self.rescoped.count > 0 {
let tally = &self.rescoped;
let mut diagnostic = AnalyticsDiagnostic::new(
AnalyticsDiagnosticKind::MissingHistory,
scope.id.clone(),
format!(
"{} recorded estimate change(s) inside the period re-sized an issue; the series \
values every contribution at the estimate the issue states now, so a level \
before such a change is not the level the record held then",
tally.count
),
);
if let Some(event) = tally.first_event.as_deref() {
diagnostic = diagnostic.about_event(event);
}
diagnostics.push(diagnostic);
}
if self.unclassified.count > 0 {
let tally = &self.unclassified;
let mut diagnostic = AnalyticsDiagnostic::new(
AnalyticsDiagnosticKind::UnclassifiedEvent,
scope.id.clone(),
format!(
"{} recorded event(s) in the period carry no scope, completion or estimate \
meaning and move no line",
tally.count
),
);
if let Some(event) = tally.first_event.as_deref() {
diagnostic = diagnostic.about_event(event);
}
diagnostics.push(diagnostic);
}
diagnostics
}
}
fn sized_finding(
scope: &AnalyticsScope,
tally: &Tally,
message: String,
remedy: &str,
) -> AnalyticsDiagnostic {
let mut diagnostic = AnalyticsDiagnostic::new(
AnalyticsDiagnosticKind::MissingHistory,
scope.id.clone(),
message,
)
.with_remedy(remedy);
if let Some(event) = tally.first_event.as_deref() {
diagnostic = diagnostic.about_event(event);
}
diagnostic
}
fn value_days(snapshot: &ScopeSnapshot, sizes: &BTreeMap<u32, MemberSize>) -> ValuedDays {
let mut valued = ValuedDays::default();
for event in snapshot.event_index().events() {
let Some(date) = event.date.as_deref() else {
continue;
};
valued
.event_ids
.entry(date.to_string())
.or_default()
.push(event.event_id.clone());
match event.class {
EventClass::ScopeAddition | EventClass::Completion => {
let field = match event.class {
EventClass::ScopeAddition => Field::Added,
_ => Field::Completed,
};
if event.delta.is_none() {
valued.weak.insert(date.to_string());
valued.unquantified.entry(field).or_default().record(event);
continue;
}
match event.issue.and_then(|number| sizes.get(&number)) {
Some(MemberSize::Sized(size)) => {
let bucket = match field {
Field::Added => &mut valued.added,
Field::Completed => &mut valued.completed,
};
*bucket.entry(date.to_string()).or_default() += size;
}
Some(MemberSize::Unestimated) => {
valued.weak.insert(date.to_string());
valued.unestimated.entry(field).or_default().record(event);
}
Some(MemberSize::Refused(refusal)) => {
valued.weak.insert(date.to_string());
valued
.refusals
.entry((field, refusal.key.clone()))
.or_default()
.record_refusal(event, refusal);
}
Some(MemberSize::Unrecorded) | None => {
valued.weak.insert(date.to_string());
valued.unattributed.entry(field).or_default().record(event);
}
}
}
EventClass::EstimateChange => {
valued.weak.insert(date.to_string());
valued.rescoped.record(event);
}
EventClass::Unclassified => valued.unclassified.record(event),
}
}
valued
}
#[derive(Debug, Clone)]
enum MemberSize {
Sized(i64),
Unestimated,
Refused(Refused),
Unrecorded,
}
#[derive(Debug, Clone)]
struct Refused {
key: RefusalKey,
reason: String,
remedy: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct RefusalKey {
kind: &'static str,
spelled: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum Field {
Added,
Completed,
}
impl Field {
const fn as_str(self) -> &'static str {
match self {
Field::Added => "scope_added",
Field::Completed => "completions",
}
}
}
#[derive(Debug, Clone, Default)]
struct Tally {
count: usize,
first_event: Option<String>,
reason: Option<String>,
remedy: Option<String>,
}
impl Tally {
fn record(&mut self, event: &AnalyticsEvent) {
self.count += 1;
if self.first_event.is_none() {
self.first_event = Some(event.event_id.clone());
}
}
fn record_refusal(&mut self, event: &AnalyticsEvent, refusal: &Refused) {
let first = self.count == 0;
self.record(event);
if first {
self.reason = Some(refusal.reason.clone());
self.remedy = refusal.remedy.clone();
}
}
}