Skip to main content

ralph/productivity/
calculations.rs

1//! Productivity calculations and core business logic.
2//!
3//! Responsibilities:
4//! - Record task completions and update stats.
5//! - Calculate streaks, velocity, and estimation accuracy.
6//! - Check and record milestones.
7//!
8//! Not handled here:
9//! - Persistence (see `super::persistence`).
10//! - Data structure definitions (see `super::types`).
11//! - Report formatting (see `super::reports`).
12
13use 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
22/// Maximum number of days to retain in daily stats (90 days = ~3 months).
23/// This prevents unbounded memory growth from historical data accumulation.
24const DAILY_STATS_RETENTION_DAYS: i64 = 90;
25use super::types::{
26    CompletionResult, DayStats, EstimationMetrics, ProductivityStats, TaskEstimationPoint,
27    VelocityMetrics,
28};
29
30/// Record a task completion and update stats
31pub 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
41/// Record a task completion by ID and title (for cases where Task isn't available)
42pub 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
58/// Update stats with a task completion (internal)
59fn 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
71/// Update stats with a task completion reference (internal)
72fn 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    // Update first completion timestamp
82    if stats.first_task_completed_at.is_none() {
83        stats.first_task_completed_at = Some(completed_at.to_string());
84    }
85
86    // Update daily stats
87    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    // Check if task already recorded today (avoid duplicates)
97    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    // Update total completed
107    stats.total_completed += 1;
108
109    // Update streak
110    let streak_updated = update_streak(stats, &today);
111
112    // Check for milestone
113    let milestone_achieved = check_milestone(stats);
114
115    stats.last_updated_at = now;
116
117    // Prune old daily stats to prevent unbounded growth
118    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
128/// Update streak based on completion date
129pub fn update_streak(stats: &mut ProductivityStats, today: &str) -> bool {
130    // Defensive: avoid poisoning persisted stats with an invalid key.
131    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            // Already completed today, streak unchanged
140            false
141        }
142        Some(last_date) if yesterday.as_deref() == Some(last_date.as_str()) => {
143            // Completed yesterday, increment streak
144            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            // Streak broken or first completion, start new streak
153            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
163/// Check if a milestone was achieved and record it
164fn check_milestone(stats: &mut ProductivityStats) -> Option<u64> {
165    for &threshold in MILESTONE_THRESHOLDS {
166        if stats.total_completed == threshold {
167            // Check if already recorded
168            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
182/// Prune daily stats older than DAILY_STATS_RETENTION_DAYS to prevent unbounded growth.
183/// Uses the current date to calculate the cutoff, removing any entries older than the threshold.
184pub(crate) fn prune_old_daily_stats(stats: &mut ProductivityStats, today: &str) {
185    let Some(today_dt) = parse_date_key(today) else {
186        return; // Defensive: if we can't parse today, don't prune anything
187    };
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) // Keep entries we can't parse (defensive)
195    });
196}
197
198/// Mark a milestone as celebrated
199pub 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
214/// Calculate velocity metrics for the given number of days.
215pub 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
220/// Calculate estimation accuracy metrics from completed tasks.
221/// Only includes tasks that have both estimated_minutes and actual_minutes set.
222pub 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    // Defensive: if callers pass an invalid key, treat it as "no data".
294    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
328/// Get the next milestone threshold
329pub fn next_milestone(current_total: u64) -> Option<u64> {
330    MILESTONE_THRESHOLDS
331        .iter()
332        .copied()
333        .find(|&t| t > current_total)
334}
335
336/// Get recent completed tasks
337pub 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}