use time::{Date, Month};
use crate::error::{Result, TixError};
use crate::model::validate_date;
use super::diagnostics::{AnalyticsDiagnostic, AnalyticsDiagnosticKind};
use super::events::AnalyticsScope;
use super::series::{BurndownSeries, Mode};
pub const PROJECTION_FORMULA_VERSION: &str = "burndown.projection.v1";
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
)]
#[serde(rename_all = "snake_case")]
pub enum ProjectionStatus {
Projected,
Unknown,
InsufficientData,
}
impl ProjectionStatus {
pub const ALL: [ProjectionStatus; 3] = [
ProjectionStatus::Projected,
ProjectionStatus::Unknown,
ProjectionStatus::InsufficientData,
];
pub const fn as_str(self) -> &'static str {
match self {
ProjectionStatus::Projected => "projected",
ProjectionStatus::Unknown => "unknown",
ProjectionStatus::InsufficientData => "insufficient_data",
}
}
pub fn parse(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"projected" => Some(ProjectionStatus::Projected),
"unknown" => Some(ProjectionStatus::Unknown),
"insufficient_data" => Some(ProjectionStatus::InsufficientData),
_ => None,
}
}
pub const fn is_projected(self) -> bool {
matches!(self, ProjectionStatus::Projected)
}
}
impl std::fmt::Display for ProjectionStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Projection {
pub scope: AnalyticsScope,
pub mode: Mode,
pub unit: Option<String>,
pub as_of: String,
pub end_date: String,
pub remaining: Option<i64>,
pub observed_rate: Option<f64>,
pub projected_completion: Option<String>,
pub meets_end_date: Option<bool>,
pub status: ProjectionStatus,
pub formula_version: String,
pub diagnostics: Vec<AnalyticsDiagnostic>,
}
impl Projection {
#[must_use]
pub fn is_projected(&self) -> bool {
self.status.is_projected()
}
#[must_use]
pub fn projected_completion(&self) -> Option<&str> {
self.projected_completion.as_deref()
}
fn unanswered(mut self, status: ProjectionStatus, message: String, remedy: &str) -> Self {
self.status = status;
self.diagnostics.push(
AnalyticsDiagnostic::new(
AnalyticsDiagnosticKind::ProjectionUnknown,
self.scope.id.clone(),
message,
)
.on(self.as_of.clone())
.with_remedy(remedy),
);
self
}
fn project_on(mut self, completion: String) -> Self {
self.meets_end_date = Some(completion <= self.end_date);
self.projected_completion = Some(completion);
self.status = ProjectionStatus::Projected;
self
}
}
pub fn project(series: &BurndownSeries, as_of: &str, end_date: &str) -> Result<Projection> {
let as_of = canonical("as_of", as_of)?;
let end_date = canonical("end_date", end_date)?;
let mut projection = Projection {
scope: series.scope.clone(),
mode: series.mode,
unit: series.unit.clone(),
as_of: as_of.clone(),
end_date,
remaining: None,
observed_rate: None,
projected_completion: None,
meets_end_date: None,
status: ProjectionStatus::InsufficientData,
formula_version: PROJECTION_FORMULA_VERSION.to_string(),
diagnostics: Vec::new(),
};
let covered = series.points.first().zip(series.points.last()).map_or_else(
|| "no day at all".to_string(),
|(first, last)| format!("{}..{}", first.date, last.date),
);
let Some(index) = series.points.iter().position(|point| point.date == as_of) else {
return Ok(projection.unanswered(
ProjectionStatus::InsufficientData,
format!(
"the series covers {covered} and holds no point for {as_of}, so the record cannot \
answer a forecast for that day"
),
"pass an `as-of` day the series covers, or widen the series' period",
));
};
let origin = &series.points[index];
projection.remaining = origin.remaining;
let Some(remaining) = origin.remaining else {
return Ok(projection.unanswered(
ProjectionStatus::InsufficientData,
format!(
"the remaining work on {as_of} is not known from the record (the period's levels \
are unanchored or a recorded completion outweighs its additions), so no rate can \
be read from it"
),
"widen the series' period with an earlier `--from` so its levels are anchored, then \
project again",
));
};
let (Some(first), Some(completed_first), Some(completed_origin)) = (
series.points.first(),
series.points.first().and_then(|point| point.completed),
origin.completed,
) else {
return Ok(projection.unanswered(
ProjectionStatus::InsufficientData,
format!(
"the recorded completions carried to {as_of} are not known, so no completion rate \
can be observed over the period"
),
"widen the series' period with an earlier `--from` so the level's origin is in it",
));
};
if index == 0 {
return Ok(projection.unanswered(
ProjectionStatus::InsufficientData,
format!(
"{as_of} is the series' first covered day, so the period holds no elapsed day to \
observe a completion rate over"
),
"ask for an origin later than the series' first covered day",
));
}
let delta = completed_origin - completed_first;
projection.observed_rate = Some(rate_per_day(delta, index));
if remaining == 0 {
if completed_origin > 0 {
return Ok(projection.project_on(as_of));
}
return Ok(projection.unanswered(
ProjectionStatus::InsufficientData,
"the period records no scope addition and no completion for this scope, so its levels \
are empty rather than finished and no completion date is drawn"
.to_string(),
"widen the series' period with an earlier `--from` so the scope's recorded work is in \
it",
));
}
if delta <= 0 {
return Ok(projection.unanswered(
ProjectionStatus::Unknown,
format!(
"the period records no completion between {} and {as_of}, so there is no observed \
rate to project {remaining} remaining `{}` from and no completion date is \
invented",
first.date,
series.unit.as_deref().unwrap_or("unit")
),
"record at least one completion inside the window before asking for a forecast",
));
}
let Some(days) = days_needed(remaining, index, delta) else {
return Ok(projection.unanswered(
ProjectionStatus::Unknown,
format!(
"the observed rate projects {remaining} remaining `{}` past the calendar this \
build can express, so no completion date is drawn",
series.unit.as_deref().unwrap_or("unit")
),
"project over a shorter remaining scope, or from an origin the rate can reach zero from",
));
};
let Some(completion) = shift(&as_of, days) else {
return Ok(projection.unanswered(
ProjectionStatus::Unknown,
format!(
"a completion {days} day(s) after {as_of} is outside the calendar this build can \
express, so no completion date is drawn"
),
"project from a different origin",
));
};
Ok(projection.project_on(completion))
}
#[allow(clippy::cast_precision_loss)]
fn rate_per_day(delta: i64, elapsed: usize) -> f64 {
delta as f64 / elapsed as f64
}
fn days_needed(remaining: i64, elapsed: usize, delta: i64) -> Option<i64> {
let denominator = i128::from(delta);
if denominator <= 0 {
return None;
}
let numerator = i128::from(remaining) * i128::try_from(elapsed).ok()?;
let days = (numerator + denominator - 1) / denominator;
i64::try_from(days).ok()
}
fn shift(date: &str, days: i64) -> Option<String> {
let start = calendar(date)?;
let julian = start
.to_julian_day()
.checked_add(i32::try_from(days).ok()?)?;
Date::from_julian_day(julian).ok().map(render)
}
fn calendar(date: &str) -> Option<Date> {
let year: i32 = date.get(0..4)?.parse().ok()?;
let month: u8 = date.get(5..7)?.parse().ok()?;
let day: u8 = date.get(8..10)?.parse().ok()?;
Date::from_calendar_date(year, Month::try_from(month).ok()?, day).ok()
}
fn render(date: Date) -> String {
format!(
"{:04}-{:02}-{:02}",
date.year(),
u8::from(date.month()),
date.day()
)
}
fn canonical(field: &str, raw: &str) -> Result<String> {
let date = raw.trim();
if !validate_date(date) {
return Err(TixError::Config(format!(
"invalid_period: `{field}` is `{raw}`, not a `YYYY-MM-DD` calendar date"
)));
}
Ok(date.to_string())
}