use anyhow::Result;
use time::OffsetDateTime;
use crate::constants::milestones::MILESTONE_THRESHOLDS;
use crate::contracts::Task;
use crate::timeutil;
use super::date_utils::{date_key_add_days, format_date_key, parse_date_key, previous_date_key};
const DAILY_STATS_RETENTION_DAYS: i64 = 90;
use super::types::{
CompletionResult, DayStats, EstimationMetrics, ProductivityStats, TaskEstimationPoint,
VelocityMetrics,
};
pub fn record_task_completion(
task: &Task,
cache_dir: &std::path::Path,
) -> Result<CompletionResult> {
let mut stats = super::persistence::load_productivity_stats(cache_dir)?;
let result = update_stats_with_completion(&mut stats, task)?;
super::persistence::save_productivity_stats(&stats, cache_dir)?;
Ok(result)
}
pub fn record_task_completion_by_id(
task_id: &str,
task_title: &str,
cache_dir: &std::path::Path,
) -> Result<CompletionResult> {
let mut stats = super::persistence::load_productivity_stats(cache_dir)?;
let result = update_stats_with_completion_ref(
&mut stats,
task_id,
task_title,
&timeutil::now_utc_rfc3339()?,
)?;
super::persistence::save_productivity_stats(&stats, cache_dir)?;
Ok(result)
}
fn update_stats_with_completion(
stats: &mut ProductivityStats,
task: &Task,
) -> Result<CompletionResult> {
let completed_at = task
.completed_at
.clone()
.unwrap_or_else(timeutil::now_utc_rfc3339_or_fallback);
update_stats_with_completion_ref(stats, &task.id, &task.title, &completed_at)
}
fn update_stats_with_completion_ref(
stats: &mut ProductivityStats,
task_id: &str,
task_title: &str,
completed_at: &str,
) -> Result<CompletionResult> {
let now = timeutil::now_utc_rfc3339()?;
let today = now.split('T').next().unwrap_or(&now).to_string();
if stats.first_task_completed_at.is_none() {
stats.first_task_completed_at = Some(completed_at.to_string());
}
let day_stats = stats
.daily
.entry(today.clone())
.or_insert_with(|| DayStats {
date: today.clone(),
completed_count: 0,
tasks: Vec::new(),
});
if !day_stats.tasks.iter().any(|t| t.id == task_id) {
day_stats.completed_count += 1;
day_stats.tasks.push(super::types::CompletedTaskRef {
id: task_id.to_string(),
title: task_title.to_string(),
completed_at: completed_at.to_string(),
});
}
stats.total_completed += 1;
let streak_updated = update_streak(stats, &today);
let milestone_achieved = check_milestone(stats);
stats.last_updated_at = now;
prune_old_daily_stats(stats, &today);
Ok(CompletionResult {
milestone_achieved,
streak_updated,
new_streak: stats.streak.current_streak,
total_completed: stats.total_completed,
})
}
pub fn update_streak(stats: &mut ProductivityStats, today: &str) -> bool {
if parse_date_key(today).is_none() {
return false;
}
let yesterday = previous_date_key(today);
match &stats.streak.last_completed_date {
Some(last_date) if last_date.as_str() == today => {
false
}
Some(last_date) if yesterday.as_deref() == Some(last_date.as_str()) => {
stats.streak.current_streak += 1;
stats.streak.last_completed_date = Some(today.to_string());
if stats.streak.current_streak > stats.streak.longest_streak {
stats.streak.longest_streak = stats.streak.current_streak;
}
true
}
_ => {
stats.streak.current_streak = 1;
stats.streak.last_completed_date = Some(today.to_string());
if stats.streak.current_streak > stats.streak.longest_streak {
stats.streak.longest_streak = stats.streak.current_streak;
}
true
}
}
}
fn check_milestone(stats: &mut ProductivityStats) -> Option<u64> {
for &threshold in MILESTONE_THRESHOLDS {
if stats.total_completed == threshold {
if !stats.milestones.iter().any(|m| m.threshold == threshold) {
let now = timeutil::now_utc_rfc3339_or_fallback();
stats.milestones.push(super::types::Milestone {
threshold,
achieved_at: now,
celebrated: false,
});
return Some(threshold);
}
}
}
None
}
pub(crate) fn prune_old_daily_stats(stats: &mut ProductivityStats, today: &str) {
let Some(today_dt) = parse_date_key(today) else {
return; };
let cutoff_date = today_dt - time::Duration::days(DAILY_STATS_RETENTION_DAYS);
stats.daily.retain(|date_key, _| {
parse_date_key(date_key)
.map(|dt| dt >= cutoff_date)
.unwrap_or(true) });
}
pub fn mark_milestone_celebrated(cache_dir: &std::path::Path, threshold: u64) -> Result<()> {
let mut stats = super::persistence::load_productivity_stats(cache_dir)?;
if let Some(milestone) = stats
.milestones
.iter_mut()
.find(|m| m.threshold == threshold)
{
milestone.celebrated = true;
super::persistence::save_productivity_stats(&stats, cache_dir)?;
}
Ok(())
}
pub fn calculate_velocity(stats: &ProductivityStats, days: u32) -> VelocityMetrics {
let today = format_date_key(OffsetDateTime::now_utc().date());
calculate_velocity_for_today(stats, days, &today)
}
pub fn calculate_estimation_metrics(tasks: &[Task]) -> EstimationMetrics {
let estimation_points: Vec<TaskEstimationPoint> = tasks
.iter()
.filter_map(|task| {
let estimated = task.estimated_minutes?;
let actual = task.actual_minutes?;
if estimated == 0 {
return None;
}
let ratio = actual as f64 / estimated as f64;
Some(TaskEstimationPoint {
task_id: task.id.clone(),
task_title: task.title.clone(),
estimated_minutes: estimated,
actual_minutes: actual,
accuracy_ratio: ratio,
})
})
.collect();
let count = estimation_points.len();
if count == 0 {
return EstimationMetrics {
tasks_analyzed: 0,
average_accuracy_ratio: 0.0,
median_accuracy_ratio: 0.0,
within_25_percent: 0.0,
average_absolute_error_minutes: 0.0,
};
}
let ratios: Vec<f64> = estimation_points.iter().map(|p| p.accuracy_ratio).collect();
let average_ratio = ratios.iter().sum::<f64>() / count as f64;
let mut sorted_ratios = ratios.clone();
sorted_ratios.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let median_ratio = if count % 2 == 1 {
sorted_ratios[count / 2]
} else {
(sorted_ratios[count / 2 - 1] + sorted_ratios[count / 2]) / 2.0
};
let within_25 = estimation_points
.iter()
.filter(|p| p.accuracy_ratio >= 0.75 && p.accuracy_ratio <= 1.25)
.count() as f64
/ count as f64
* 100.0;
let avg_abs_error = estimation_points
.iter()
.map(|p| (p.actual_minutes as f64 - p.estimated_minutes as f64).abs())
.sum::<f64>()
/ count as f64;
EstimationMetrics {
tasks_analyzed: count as u32,
average_accuracy_ratio: average_ratio,
median_accuracy_ratio: median_ratio,
within_25_percent: within_25,
average_absolute_error_minutes: avg_abs_error,
}
}
pub fn calculate_velocity_for_today(
stats: &ProductivityStats,
days: u32,
today: &str,
) -> VelocityMetrics {
let days = days.max(1);
if parse_date_key(today).is_none() {
return VelocityMetrics {
days,
total_completed: 0,
average_per_day: 0.0,
best_day: None,
};
}
let mut total = 0u32;
let mut best_day: Option<(String, u32)> = None;
for i in 0..days {
let Some(date) = date_key_add_days(today, -(i as i64)) else {
continue;
};
if let Some(day_stats) = stats.daily.get(&date) {
total += day_stats.completed_count;
if best_day
.as_ref()
.is_none_or(|(_, best_count)| day_stats.completed_count > *best_count)
{
best_day = Some((date, day_stats.completed_count));
}
}
}
let average_per_day = total as f64 / days as f64;
VelocityMetrics {
days,
total_completed: total,
average_per_day,
best_day,
}
}
pub fn next_milestone(current_total: u64) -> Option<u64> {
MILESTONE_THRESHOLDS
.iter()
.copied()
.find(|&t| t > current_total)
}
pub fn recent_completed_tasks(
stats: &ProductivityStats,
limit: usize,
) -> Vec<super::types::CompletedTaskRef> {
let mut out = Vec::new();
for (_day, day_stats) in stats.daily.iter().rev() {
for task in day_stats.tasks.iter().rev() {
out.push(task.clone());
if out.len() >= limit {
return out;
}
}
}
out
}