//! Task urgency scoring based on priority, due date, and age. use chrono::{DateTime, Utc}; use crate::constants::{DAYS_PER_WEEK, HOURS_PER_DAY}; use crate::models::{Priority, TaskStatus}; /// Configuration for urgency coefficient weights /// Based on TaskWarrior's urgency calculation pub struct UrgencyConfig { pub priority_high: f64, pub priority_medium: f64, pub priority_low: f64, pub overdue: f64, pub due_soon_base: f64, // Applied when due within 7 days pub age_max: f64, // Maximum age bonus pub age_days_for_max: f64, // Days to reach max age bonus pub started: f64, pub tag_urgent: f64, } impl Default for UrgencyConfig { fn default() -> Self { UrgencyConfig { priority_high: 6.0, priority_medium: 3.9, priority_low: 1.8, overdue: 12.0, due_soon_base: 1.0, age_max: 2.0, age_days_for_max: 30.0, started: 4.0, tag_urgent: 2.0, } } } /// Calculate urgency score for a task /// /// Factors considered: /// - Priority: H=6.0, M=3.9, L=1.8 /// - Overdue: +12.0 /// - Due soon (within 7 days): (7-days)*1.0 /// - Age: min(days_old/30, 2.0) /// - Started status: +4.0 /// - Tag "urgent": +2.0 pub fn calculate_urgency( priority: &Priority, status: &TaskStatus, due: Option<&DateTime>, created_at: &DateTime, tags: &[String], ) -> f64 { let config = UrgencyConfig::default(); calculate_urgency_with_config(priority, status, due, created_at, tags, &config) } /// Calculate urgency with custom configuration. /// /// Additive score from these factors (all configurable via `UrgencyConfig`): /// /// | Factor | Default | Condition | /// |-------------|---------|-------------------------------------------| /// | Priority | 3-7 | High=7, Medium=5, Low=3 | /// | Overdue | +12.0 | Due date in the past | /// | Due soon | 0-7.0 | Linear scale: 7 days out (0) → due (7.0) | /// | Age | 0-2.0 | Linear over 30 days, capped at 2.0 | /// | Started | +4.0 | Task status is Started | /// | "urgent" tag| +2.0 | Case-insensitive tag match | /// /// Result is rounded to 1 decimal place for stable sorting. /// Sub-day precision: hours are used internally so tasks due at 3pm sort /// differently from tasks due at 9am even on the same day. pub fn calculate_urgency_with_config( priority: &Priority, status: &TaskStatus, due: Option<&DateTime>, created_at: &DateTime, tags: &[String], config: &UrgencyConfig, ) -> f64 { let now = Utc::now(); let mut urgency = 0.0; // Priority coefficient urgency += match priority { Priority::High => config.priority_high, Priority::Medium => config.priority_medium, Priority::Low => config.priority_low, }; // Due date coefficient if let Some(due_date) = due { let hours_until = due_date.signed_duration_since(now).num_hours() as f64; let days_until = hours_until / HOURS_PER_DAY; let days_threshold = DAYS_PER_WEEK as f64; if days_until < 0.0 { // Overdue - highest urgency boost urgency += config.overdue; } else if days_until <= days_threshold { // Due soon - linear scale from 7 days (0) to due (7.0) urgency += (days_threshold - days_until) * config.due_soon_base; } } // Age coefficient - older tasks get higher urgency let age_hours = now.signed_duration_since(*created_at).num_hours() as f64; let age_days = (age_hours / HOURS_PER_DAY).max(0.0); let age_bonus = (age_days / config.age_days_for_max).min(1.0) * config.age_max; urgency += age_bonus; // Started status coefficient if *status == TaskStatus::Started { urgency += config.started; } // Tag "urgent" coefficient if tags.iter().any(|t| t.to_lowercase() == "urgent") { urgency += config.tag_urgent; } // Round to 1 decimal place (urgency * 10.0).round() / 10.0 } #[cfg(test)] mod tests { use super::*; use chrono::Duration; #[test] fn test_priority_only() { let now = Utc::now(); let tags: Vec = vec![]; let high = calculate_urgency(&Priority::High, &TaskStatus::Pending, None, &now, &tags); let medium = calculate_urgency(&Priority::Medium, &TaskStatus::Pending, None, &now, &tags); let low = calculate_urgency(&Priority::Low, &TaskStatus::Pending, None, &now, &tags); assert!(high > medium); assert!(medium > low); } #[test] fn test_overdue_boost() { let now = Utc::now(); let yesterday = now - Duration::days(1); let tags: Vec = vec![]; let overdue = calculate_urgency(&Priority::Medium, &TaskStatus::Pending, Some(&yesterday), &now, &tags); let no_due = calculate_urgency(&Priority::Medium, &TaskStatus::Pending, None, &now, &tags); assert!(overdue > no_due + 10.0); // Overdue should add at least 10 points } #[test] fn test_due_soon_boost() { let now = Utc::now(); let in_3_days = now + Duration::days(3); let in_10_days = now + Duration::days(10); let tags: Vec = vec![]; let soon = calculate_urgency(&Priority::Medium, &TaskStatus::Pending, Some(&in_3_days), &now, &tags); let later = calculate_urgency(&Priority::Medium, &TaskStatus::Pending, Some(&in_10_days), &now, &tags); assert!(soon > later); } #[test] fn test_started_boost() { let now = Utc::now(); let tags: Vec = vec![]; let started = calculate_urgency(&Priority::Medium, &TaskStatus::Started, None, &now, &tags); let pending = calculate_urgency(&Priority::Medium, &TaskStatus::Pending, None, &now, &tags); assert!(started > pending + 3.0); // Started should add at least 3 points } #[test] fn test_urgent_tag_boost() { let now = Utc::now(); let urgent_tags = vec!["urgent".to_string()]; let other_tags = vec!["work".to_string()]; let with_urgent = calculate_urgency(&Priority::Medium, &TaskStatus::Pending, None, &now, &urgent_tags); let without_urgent = calculate_urgency(&Priority::Medium, &TaskStatus::Pending, None, &now, &other_tags); assert!(with_urgent > without_urgent + 1.0); } #[test] fn test_age_boost() { let now = Utc::now(); let old_created = now - Duration::days(45); let new_created = now; let tags: Vec = vec![]; let old_task = calculate_urgency(&Priority::Medium, &TaskStatus::Pending, None, &old_created, &tags); let new_task = calculate_urgency(&Priority::Medium, &TaskStatus::Pending, None, &new_created, &tags); assert!(old_task > new_task); // Age bonus should be capped at 2.0 assert!(old_task - new_task <= 2.1); } }