use super::{DateTime, Utc, Complexity, Task, Sprint, TaskStatus, Roadmap};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::time::Duration;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VelocityTracker {
pub sprint_id: String,
pub started_at: DateTime<Utc>,
pub tasks_completed: Vec<CompletedTask>,
pub quality_scores: Vec<QualityScore>,
pub average_cycle_time: Duration,
pub burndown_data: Vec<BurndownPoint>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompletedTask {
pub task_id: String,
pub started_at: DateTime<Utc>,
pub completed_at: DateTime<Utc>,
pub complexity: Complexity,
pub quality_score: f64,
pub rework_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QualityScore {
pub task_id: String,
pub score: f64,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BurndownPoint {
pub day: u32,
pub remaining_tasks: u32,
pub timestamp: DateTime<Utc>,
}
impl VelocityTracker {
#[must_use]
pub fn new(sprint_id: &str) -> Self {
Self {
sprint_id: sprint_id.to_string(),
started_at: Utc::now(),
tasks_completed: Vec::new(),
quality_scores: Vec::new(),
average_cycle_time: Duration::from_secs(0),
burndown_data: Vec::new(),
}
}
pub fn add_completed_task(&mut self, task: CompletedTask) {
self.quality_scores.push(QualityScore {
task_id: task.task_id.clone(),
score: task.quality_score,
timestamp: Utc::now(),
});
self.tasks_completed.push(task);
self.update_average_cycle_time();
}
pub fn load(sprint_id: &str) -> Result<Self> {
let path = format!("docs/execution/velocity_{sprint_id}.json");
let content = std::fs::read_to_string(&path)?;
Ok(serde_json::from_str(&content)?)
}
pub fn save(&self) -> Result<()> {
let path = format!("docs/execution/velocity_{}.json", self.sprint_id);
let content = serde_json::to_string_pretty(self)?;
std::fs::write(&path, content)?;
Ok(())
}
pub fn add_completed_task_from_task(&mut self, task: &Task, quality_score: f64) {
if let (Some(started), Some(completed)) = (task.started_at, task.completed_at) {
self.tasks_completed.push(CompletedTask {
task_id: task.id.clone(),
started_at: started,
completed_at: completed,
complexity: task.complexity,
quality_score,
rework_count: 0,
});
self.quality_scores.push(QualityScore {
task_id: task.id.clone(),
score: quality_score,
timestamp: Utc::now(),
});
self.update_average_cycle_time();
}
}
fn update_average_cycle_time(&mut self) {
if self.tasks_completed.is_empty() {
return;
}
let total_duration: Duration = self
.tasks_completed
.iter()
.map(|t| (t.completed_at - t.started_at).to_std().unwrap_or_default())
.sum();
self.average_cycle_time = total_duration / self.tasks_completed.len() as u32;
}
pub fn add_burndown_point(&mut self, remaining_tasks: u32) {
let day = (Utc::now() - self.started_at).num_days() as u32;
self.burndown_data.push(BurndownPoint {
day,
remaining_tasks,
timestamp: Utc::now(),
});
}
#[must_use]
pub fn average_quality_score(&self) -> f64 {
if self.quality_scores.is_empty() {
return 0.0;
}
let sum: f64 = self.quality_scores.iter().map(|s| s.score).sum();
sum / self.quality_scores.len() as f64
}
#[must_use]
pub fn velocity(&self) -> f64 {
let days_elapsed = (Utc::now() - self.started_at).num_days() as f64;
if days_elapsed <= 0.0 {
return 0.0;
}
self.tasks_completed.len() as f64 / days_elapsed
}
#[must_use]
pub fn calculate_velocity(&self) -> f64 {
self.velocity()
}
pub fn add_quality_score(&mut self, task_id: &str, score: f64) {
self.quality_scores.push(QualityScore {
task_id: task_id.to_string(),
score,
timestamp: Utc::now(),
});
}
pub fn update_burndown(&mut self, day: u32, remaining_tasks: u32) {
self.burndown_data.push(BurndownPoint {
day,
remaining_tasks,
timestamp: Utc::now(),
});
}
#[must_use]
pub fn get_average_quality(&self) -> f64 {
self.average_quality_score()
}
#[must_use]
pub fn get_cycle_time_stats(&self) -> CycleTimeStats {
let mut min_cycle_time = Duration::from_secs(u64::MAX);
let mut max_cycle_time = Duration::from_secs(0);
let mut total_cycle_time = Duration::from_secs(0);
for task in &self.tasks_completed {
let cycle_time = (task.completed_at - task.started_at)
.to_std()
.unwrap_or_default();
if cycle_time < min_cycle_time {
min_cycle_time = cycle_time;
}
if cycle_time > max_cycle_time {
max_cycle_time = cycle_time;
}
total_cycle_time += cycle_time;
}
if self.tasks_completed.is_empty() {
min_cycle_time = Duration::from_secs(0);
}
CycleTimeStats {
min_cycle_time,
max_cycle_time,
avg_cycle_time: if self.tasks_completed.is_empty() {
Duration::from_secs(0)
} else {
total_cycle_time / self.tasks_completed.len() as u32
},
task_count: self.tasks_completed.len(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CycleTimeStats {
pub min_cycle_time: Duration,
pub max_cycle_time: Duration,
pub avg_cycle_time: Duration,
pub task_count: usize,
}
pub struct ProgressReporter;
impl ProgressReporter {
pub fn generate_report(sprint: &Sprint) -> Result<String> {
let mut report = String::new();
report.push_str(&format!("# Sprint {} Progress Report\n\n", sprint.version));
report.push_str(&format!("## {}\n\n", sprint.title));
report.push_str("### Tasks\n");
let completed = sprint
.tasks
.iter()
.filter(|t| t.status == TaskStatus::Completed)
.count();
let in_progress = sprint
.tasks
.iter()
.filter(|t| t.status == TaskStatus::InProgress)
.count();
let planned = sprint
.tasks
.iter()
.filter(|t| t.status == TaskStatus::Planned)
.count();
report.push_str(&format!("- Completed: {completed}\n"));
report.push_str(&format!("- In Progress: {in_progress}\n"));
report.push_str(&format!("- Planned: {planned}\n\n"));
report.push_str("### Definition of Done\n");
for item in &sprint.definition_of_done {
report.push_str(&format!("- {item}\n"));
}
report.push('\n');
report.push_str("### Quality Gates\n");
for gate in &sprint.quality_gates {
report.push_str(&format!("- {gate}\n"));
}
Ok(report)
}
}
pub struct RoadmapDashboard;
impl RoadmapDashboard {
pub async fn generate(sprint_id: &str, roadmap: &Roadmap) -> Result<String> {
let mut output = String::new();
let sprint = roadmap
.get_sprint(sprint_id)
.ok_or_else(|| anyhow::anyhow!("Sprint {sprint_id} not found"))?;
output.push_str(&format!("# Sprint {sprint_id} Dashboard\n\n"));
output.push_str(&format!("**{}**\n\n", sprint.title));
output.push_str(&format!(
"Duration: {} to {}\n\n",
sprint.start_date.format("%Y-%m-%d"),
sprint.end_date.format("%Y-%m-%d")
));
let completed = sprint
.tasks
.iter()
.filter(|t| t.status == TaskStatus::Completed)
.count();
let in_progress = sprint
.tasks
.iter()
.filter(|t| t.status == TaskStatus::InProgress)
.count();
let total = sprint.tasks.len();
output.push_str("## Progress\n\n");
output.push_str(&format!(
"- **Completed**: {}/{} ({:.0}%)\n",
completed,
total,
(completed as f64 / total as f64) * 100.0
));
output.push_str(&format!("- **In Progress**: {in_progress}\n"));
output.push_str(&format!(
"- **Remaining**: {}\n\n",
total - completed - in_progress
));
output.push_str("```\n");
let progress_width = 50;
let completed_width = (completed as f64 / total as f64 * progress_width as f64) as usize;
output.push('[');
for i in 0..progress_width {
if i < completed_width {
output.push('â–ˆ');
} else {
output.push('â–‘');
}
}
output.push_str(&format!(
"] {:.0}%\n",
(completed as f64 / total as f64) * 100.0
));
output.push_str("```\n\n");
output.push_str("## Tasks\n\n");
output.push_str("### ✅ Completed\n");
for task in sprint
.tasks
.iter()
.filter(|t| t.status == TaskStatus::Completed)
{
output.push_str(&format!("- {} - {}\n", task.id, task.description));
}
output.push('\n');
output.push_str("### 🚧 In Progress\n");
for task in sprint
.tasks
.iter()
.filter(|t| t.status == TaskStatus::InProgress)
{
output.push_str(&format!("- {} - {}\n", task.id, task.description));
}
output.push('\n');
output.push_str("### 📋 Planned\n");
for task in sprint
.tasks
.iter()
.filter(|t| t.status == TaskStatus::Planned)
{
output.push_str(&format!("- {} - {}\n", task.id, task.description));
}
output.push('\n');
if let Ok(tracker) = VelocityTracker::load(sprint_id) {
output.push_str("## Metrics\n\n");
output.push_str(&format!(
"- **Average Cycle Time**: {} hours\n",
tracker.average_cycle_time.as_secs() / 3600
));
output.push_str(&format!(
"- **Velocity**: {:.1} tasks/day\n",
tracker.velocity()
));
output.push_str(&format!(
"- **Quality Score**: {:.1}%\n\n",
tracker.average_quality_score() * 100.0
));
if !tracker.burndown_data.is_empty() {
output.push_str("## Burndown Chart\n\n");
output.push_str("```mermaid\n");
output.push_str("graph LR\n");
for point in &tracker.burndown_data {
output.push_str(&format!(
" Day{} --> Tasks{}\n",
point.day, point.remaining_tasks
));
}
output.push_str("```\n\n");
}
}
output.push_str("## Definition of Done\n\n");
for item in &sprint.definition_of_done {
let checked = if completed == total { "x" } else { " " };
output.push_str(&format!("- [{checked}] {item}\n"));
}
output.push('\n');
output.push_str("## Quality Gates\n\n");
for gate in &sprint.quality_gates {
output.push_str(&format!("- [ ] {gate}\n"));
}
Ok(output)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::roadmap::Priority;
use chrono::{TimeZone, Utc};
#[test]
fn test_velocity_tracker_creation() {
let tracker = VelocityTracker::new("sprint-45");
assert_eq!(tracker.sprint_id, "sprint-45");
assert!(tracker.tasks_completed.is_empty());
assert!(tracker.quality_scores.is_empty());
assert_eq!(tracker.average_cycle_time, Duration::new(0, 0));
assert!(tracker.burndown_data.is_empty());
}
#[test]
fn test_add_completed_task() {
let mut tracker = VelocityTracker::new("test-sprint");
let started = Utc.with_ymd_and_hms(2025, 9, 1, 10, 0, 0).unwrap();
let completed = Utc.with_ymd_and_hms(2025, 9, 2, 15, 0, 0).unwrap();
let task = CompletedTask {
task_id: "TASK-001".to_string(),
started_at: started,
completed_at: completed,
complexity: Complexity::Medium,
quality_score: 85.0,
rework_count: 1,
};
tracker.add_completed_task(task.clone());
assert_eq!(tracker.tasks_completed.len(), 1);
assert_eq!(tracker.tasks_completed[0].task_id, "TASK-001");
assert_eq!(tracker.tasks_completed[0].quality_score, 85.0);
}
#[test]
fn test_calculate_velocity() {
let mut tracker = VelocityTracker::new("test-sprint");
let task1 = CompletedTask {
task_id: "TASK-001".to_string(),
started_at: Utc::now(),
completed_at: Utc::now(),
complexity: Complexity::High,
quality_score: 90.0,
rework_count: 0,
};
let task2 = CompletedTask {
task_id: "TASK-002".to_string(),
started_at: Utc::now(),
completed_at: Utc::now(),
complexity: Complexity::Medium,
quality_score: 85.0,
rework_count: 1,
};
tracker.add_completed_task(task1);
tracker.add_completed_task(task2);
let velocity = tracker.calculate_velocity();
assert!(velocity > 0.0);
}
#[test]
fn test_add_quality_score() {
let mut tracker = VelocityTracker::new("test-sprint");
tracker.add_quality_score("TASK-001", 92.5);
assert_eq!(tracker.quality_scores.len(), 1);
assert_eq!(tracker.quality_scores[0].task_id, "TASK-001");
assert_eq!(tracker.quality_scores[0].score, 92.5);
}
#[test]
fn test_update_burndown() {
let mut tracker = VelocityTracker::new("test-sprint");
tracker.update_burndown(5, 10);
tracker.update_burndown(6, 8);
assert_eq!(tracker.burndown_data.len(), 2);
assert_eq!(tracker.burndown_data[0].day, 5);
assert_eq!(tracker.burndown_data[0].remaining_tasks, 10);
assert_eq!(tracker.burndown_data[1].day, 6);
assert_eq!(tracker.burndown_data[1].remaining_tasks, 8);
}
#[test]
fn test_get_average_quality() {
let mut tracker = VelocityTracker::new("test-sprint");
tracker.add_quality_score("TASK-001", 90.0);
tracker.add_quality_score("TASK-002", 85.0);
tracker.add_quality_score("TASK-003", 95.0);
let avg_quality = tracker.get_average_quality();
assert_eq!(avg_quality, 90.0); }
#[test]
fn test_get_cycle_time_stats() {
let mut tracker = VelocityTracker::new("test-sprint");
let base_time = Utc::now();
let task = CompletedTask {
task_id: "TASK-001".to_string(),
started_at: base_time,
completed_at: base_time + chrono::Duration::hours(24), complexity: Complexity::Medium,
quality_score: 85.0,
rework_count: 0,
};
tracker.add_completed_task(task);
let stats = tracker.get_cycle_time_stats();
assert!(stats.min_cycle_time > Duration::new(0, 0));
assert_eq!(stats.task_count, 1);
}
#[test]
fn test_generate_report() {
let sprint = Sprint {
version: "v2.43.0".to_string(),
title: "Test Sprint".to_string(),
start_date: Utc::now(),
end_date: Utc::now() + chrono::Duration::days(7),
priority: Priority::P1,
tasks: vec![],
definition_of_done: vec!["All tests pass".to_string()],
quality_gates: vec!["Code coverage > 80%".to_string()],
};
let report = ProgressReporter::generate_report(&sprint).unwrap();
assert!(report.contains("v2.43.0"));
assert!(report.contains("Test Sprint"));
assert!(report.contains("All tests pass"));
assert!(report.contains("Code coverage > 80%"));
}
#[test]
fn test_empty_quality_scores_average() {
let tracker = VelocityTracker::new("empty-sprint");
assert_eq!(tracker.get_average_quality(), 0.0);
}
}
#[cfg(test)]
mod property_tests {
use proptest::prelude::*;
proptest! {
#[test]
fn basic_property_stability(_input in ".*") {
prop_assert!(true);
}
#[test]
fn module_consistency_check(_x in 0u32..1000) {
prop_assert!(_x < 1001);
}
}
}