1use anyhow::Result;
14use time::OffsetDateTime;
15
16use crate::constants::milestones::MILESTONE_THRESHOLDS;
17use crate::contracts::Task;
18use crate::timeutil;
19
20use super::date_utils::{date_key_add_days, format_date_key, parse_date_key, previous_date_key};
21
22const DAILY_STATS_RETENTION_DAYS: i64 = 90;
25use super::types::{
26 CompletionResult, DayStats, EstimationMetrics, ProductivityStats, TaskEstimationPoint,
27 VelocityMetrics,
28};
29
30pub fn record_task_completion(
32 task: &Task,
33 cache_dir: &std::path::Path,
34) -> Result<CompletionResult> {
35 let mut stats = super::persistence::load_productivity_stats(cache_dir)?;
36 let result = update_stats_with_completion(&mut stats, task)?;
37 super::persistence::save_productivity_stats(&stats, cache_dir)?;
38 Ok(result)
39}
40
41pub fn record_task_completion_by_id(
43 task_id: &str,
44 task_title: &str,
45 cache_dir: &std::path::Path,
46) -> Result<CompletionResult> {
47 let mut stats = super::persistence::load_productivity_stats(cache_dir)?;
48 let result = update_stats_with_completion_ref(
49 &mut stats,
50 task_id,
51 task_title,
52 &timeutil::now_utc_rfc3339()?,
53 )?;
54 super::persistence::save_productivity_stats(&stats, cache_dir)?;
55 Ok(result)
56}
57
58fn update_stats_with_completion(
60 stats: &mut ProductivityStats,
61 task: &Task,
62) -> Result<CompletionResult> {
63 let completed_at = task
64 .completed_at
65 .clone()
66 .unwrap_or_else(timeutil::now_utc_rfc3339_or_fallback);
67
68 update_stats_with_completion_ref(stats, &task.id, &task.title, &completed_at)
69}
70
71fn update_stats_with_completion_ref(
73 stats: &mut ProductivityStats,
74 task_id: &str,
75 task_title: &str,
76 completed_at: &str,
77) -> Result<CompletionResult> {
78 let now = timeutil::now_utc_rfc3339()?;
79 let today = now.split('T').next().unwrap_or(&now).to_string();
80
81 if stats.first_task_completed_at.is_none() {
83 stats.first_task_completed_at = Some(completed_at.to_string());
84 }
85
86 let day_stats = stats
88 .daily
89 .entry(today.clone())
90 .or_insert_with(|| DayStats {
91 date: today.clone(),
92 completed_count: 0,
93 tasks: Vec::new(),
94 });
95
96 if !day_stats.tasks.iter().any(|t| t.id == task_id) {
98 day_stats.completed_count += 1;
99 day_stats.tasks.push(super::types::CompletedTaskRef {
100 id: task_id.to_string(),
101 title: task_title.to_string(),
102 completed_at: completed_at.to_string(),
103 });
104 }
105
106 stats.total_completed += 1;
108
109 let streak_updated = update_streak(stats, &today);
111
112 let milestone_achieved = check_milestone(stats);
114
115 stats.last_updated_at = now;
116
117 prune_old_daily_stats(stats, &today);
119
120 Ok(CompletionResult {
121 milestone_achieved,
122 streak_updated,
123 new_streak: stats.streak.current_streak,
124 total_completed: stats.total_completed,
125 })
126}
127
128pub fn update_streak(stats: &mut ProductivityStats, today: &str) -> bool {
130 if parse_date_key(today).is_none() {
132 return false;
133 }
134
135 let yesterday = previous_date_key(today);
136
137 match &stats.streak.last_completed_date {
138 Some(last_date) if last_date.as_str() == today => {
139 false
141 }
142 Some(last_date) if yesterday.as_deref() == Some(last_date.as_str()) => {
143 stats.streak.current_streak += 1;
145 stats.streak.last_completed_date = Some(today.to_string());
146 if stats.streak.current_streak > stats.streak.longest_streak {
147 stats.streak.longest_streak = stats.streak.current_streak;
148 }
149 true
150 }
151 _ => {
152 stats.streak.current_streak = 1;
154 stats.streak.last_completed_date = Some(today.to_string());
155 if stats.streak.current_streak > stats.streak.longest_streak {
156 stats.streak.longest_streak = stats.streak.current_streak;
157 }
158 true
159 }
160 }
161}
162
163fn check_milestone(stats: &mut ProductivityStats) -> Option<u64> {
165 for &threshold in MILESTONE_THRESHOLDS {
166 if stats.total_completed == threshold {
167 if !stats.milestones.iter().any(|m| m.threshold == threshold) {
169 let now = timeutil::now_utc_rfc3339_or_fallback();
170 stats.milestones.push(super::types::Milestone {
171 threshold,
172 achieved_at: now,
173 celebrated: false,
174 });
175 return Some(threshold);
176 }
177 }
178 }
179 None
180}
181
182pub(crate) fn prune_old_daily_stats(stats: &mut ProductivityStats, today: &str) {
185 let Some(today_dt) = parse_date_key(today) else {
186 return; };
188
189 let cutoff_date = today_dt - time::Duration::days(DAILY_STATS_RETENTION_DAYS);
190
191 stats.daily.retain(|date_key, _| {
192 parse_date_key(date_key)
193 .map(|dt| dt >= cutoff_date)
194 .unwrap_or(true) });
196}
197
198pub fn mark_milestone_celebrated(cache_dir: &std::path::Path, threshold: u64) -> Result<()> {
200 let mut stats = super::persistence::load_productivity_stats(cache_dir)?;
201
202 if let Some(milestone) = stats
203 .milestones
204 .iter_mut()
205 .find(|m| m.threshold == threshold)
206 {
207 milestone.celebrated = true;
208 super::persistence::save_productivity_stats(&stats, cache_dir)?;
209 }
210
211 Ok(())
212}
213
214pub fn calculate_velocity(stats: &ProductivityStats, days: u32) -> VelocityMetrics {
216 let today = format_date_key(OffsetDateTime::now_utc().date());
217 calculate_velocity_for_today(stats, days, &today)
218}
219
220pub fn calculate_estimation_metrics(tasks: &[Task]) -> EstimationMetrics {
223 let estimation_points: Vec<TaskEstimationPoint> = tasks
224 .iter()
225 .filter_map(|task| {
226 let estimated = task.estimated_minutes?;
227 let actual = task.actual_minutes?;
228 if estimated == 0 {
229 return None;
230 }
231 let ratio = actual as f64 / estimated as f64;
232 Some(TaskEstimationPoint {
233 task_id: task.id.clone(),
234 task_title: task.title.clone(),
235 estimated_minutes: estimated,
236 actual_minutes: actual,
237 accuracy_ratio: ratio,
238 })
239 })
240 .collect();
241
242 let count = estimation_points.len();
243 if count == 0 {
244 return EstimationMetrics {
245 tasks_analyzed: 0,
246 average_accuracy_ratio: 0.0,
247 median_accuracy_ratio: 0.0,
248 within_25_percent: 0.0,
249 average_absolute_error_minutes: 0.0,
250 };
251 }
252
253 let ratios: Vec<f64> = estimation_points.iter().map(|p| p.accuracy_ratio).collect();
254 let average_ratio = ratios.iter().sum::<f64>() / count as f64;
255
256 let mut sorted_ratios = ratios.clone();
257 sorted_ratios.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
258 let median_ratio = if count % 2 == 1 {
259 sorted_ratios[count / 2]
260 } else {
261 (sorted_ratios[count / 2 - 1] + sorted_ratios[count / 2]) / 2.0
262 };
263
264 let within_25 = estimation_points
265 .iter()
266 .filter(|p| p.accuracy_ratio >= 0.75 && p.accuracy_ratio <= 1.25)
267 .count() as f64
268 / count as f64
269 * 100.0;
270
271 let avg_abs_error = estimation_points
272 .iter()
273 .map(|p| (p.actual_minutes as f64 - p.estimated_minutes as f64).abs())
274 .sum::<f64>()
275 / count as f64;
276
277 EstimationMetrics {
278 tasks_analyzed: count as u32,
279 average_accuracy_ratio: average_ratio,
280 median_accuracy_ratio: median_ratio,
281 within_25_percent: within_25,
282 average_absolute_error_minutes: avg_abs_error,
283 }
284}
285
286pub fn calculate_velocity_for_today(
287 stats: &ProductivityStats,
288 days: u32,
289 today: &str,
290) -> VelocityMetrics {
291 let days = days.max(1);
292
293 if parse_date_key(today).is_none() {
295 return VelocityMetrics {
296 days,
297 total_completed: 0,
298 average_per_day: 0.0,
299 best_day: None,
300 };
301 }
302
303 let mut total = 0u32;
304 let mut best_day: Option<(String, u32)> = None;
305
306 for i in 0..days {
307 let Some(date) = date_key_add_days(today, -(i as i64)) else {
308 continue;
309 };
310 if let Some(day_stats) = stats.daily.get(&date) {
311 total += day_stats.completed_count;
312 if best_day.is_none() || day_stats.completed_count > best_day.as_ref().unwrap().1 {
313 best_day = Some((date, day_stats.completed_count));
314 }
315 }
316 }
317
318 let average_per_day = total as f64 / days as f64;
319
320 VelocityMetrics {
321 days,
322 total_completed: total,
323 average_per_day,
324 best_day,
325 }
326}
327
328pub fn next_milestone(current_total: u64) -> Option<u64> {
330 MILESTONE_THRESHOLDS
331 .iter()
332 .copied()
333 .find(|&t| t > current_total)
334}
335
336pub fn recent_completed_tasks(
338 stats: &ProductivityStats,
339 limit: usize,
340) -> Vec<super::types::CompletedTaskRef> {
341 let mut out = Vec::new();
342 for (_day, day_stats) in stats.daily.iter().rev() {
343 for task in day_stats.tasks.iter().rev() {
344 out.push(task.clone());
345 if out.len() >= limit {
346 return out;
347 }
348 }
349 }
350 out
351}