Skip to main content

max / goingson

6.9 KB · 207 lines History Blame Raw
1 //! Task urgency scoring based on priority, due date, and age.
2
3 use chrono::{DateTime, Utc};
4 use crate::constants::{DAYS_PER_WEEK, HOURS_PER_DAY};
5 use crate::models::{Priority, TaskStatus};
6
7 /// Configuration for urgency coefficient weights
8 /// Based on TaskWarrior's urgency calculation
9 pub struct UrgencyConfig {
10 pub priority_high: f64,
11 pub priority_medium: f64,
12 pub priority_low: f64,
13 pub overdue: f64,
14 pub due_soon_base: f64, // Applied when due within 7 days
15 pub age_max: f64, // Maximum age bonus
16 pub age_days_for_max: f64, // Days to reach max age bonus
17 pub started: f64,
18 pub tag_urgent: f64,
19 }
20
21 impl Default for UrgencyConfig {
22 fn default() -> Self {
23 UrgencyConfig {
24 priority_high: 6.0,
25 priority_medium: 3.9,
26 priority_low: 1.8,
27 overdue: 12.0,
28 due_soon_base: 1.0,
29 age_max: 2.0,
30 age_days_for_max: 30.0,
31 started: 4.0,
32 tag_urgent: 2.0,
33 }
34 }
35 }
36
37 /// Calculate urgency score for a task
38 ///
39 /// Factors considered:
40 /// - Priority: H=6.0, M=3.9, L=1.8
41 /// - Overdue: +12.0
42 /// - Due soon (within 7 days): (7-days)*1.0
43 /// - Age: min(days_old/30, 2.0)
44 /// - Started status: +4.0
45 /// - Tag "urgent": +2.0
46 pub fn calculate_urgency(
47 priority: &Priority,
48 status: &TaskStatus,
49 due: Option<&DateTime<Utc>>,
50 created_at: &DateTime<Utc>,
51 tags: &[String],
52 ) -> f64 {
53 let config = UrgencyConfig::default();
54 calculate_urgency_with_config(priority, status, due, created_at, tags, &config)
55 }
56
57 /// Calculate urgency with custom configuration.
58 ///
59 /// Additive score from these factors (all configurable via `UrgencyConfig`):
60 ///
61 /// | Factor | Default | Condition |
62 /// |-------------|---------|-------------------------------------------|
63 /// | Priority | 3-7 | High=7, Medium=5, Low=3 |
64 /// | Overdue | +12.0 | Due date in the past |
65 /// | Due soon | 0-7.0 | Linear scale: 7 days out (0) → due (7.0) |
66 /// | Age | 0-2.0 | Linear over 30 days, capped at 2.0 |
67 /// | Started | +4.0 | Task status is Started |
68 /// | "urgent" tag| +2.0 | Case-insensitive tag match |
69 ///
70 /// Result is rounded to 1 decimal place for stable sorting.
71 /// Sub-day precision: hours are used internally so tasks due at 3pm sort
72 /// differently from tasks due at 9am even on the same day.
73 pub fn calculate_urgency_with_config(
74 priority: &Priority,
75 status: &TaskStatus,
76 due: Option<&DateTime<Utc>>,
77 created_at: &DateTime<Utc>,
78 tags: &[String],
79 config: &UrgencyConfig,
80 ) -> f64 {
81 let now = Utc::now();
82 let mut urgency = 0.0;
83
84 // Priority coefficient
85 urgency += match priority {
86 Priority::High => config.priority_high,
87 Priority::Medium => config.priority_medium,
88 Priority::Low => config.priority_low,
89 };
90
91 // Due date coefficient
92 if let Some(due_date) = due {
93 let hours_until = due_date.signed_duration_since(now).num_hours() as f64;
94 let days_until = hours_until / HOURS_PER_DAY;
95 let days_threshold = DAYS_PER_WEEK as f64;
96
97 if days_until < 0.0 {
98 // Overdue - highest urgency boost
99 urgency += config.overdue;
100 } else if days_until <= days_threshold {
101 // Due soon - linear scale from 7 days (0) to due (7.0)
102 urgency += (days_threshold - days_until) * config.due_soon_base;
103 }
104 }
105
106 // Age coefficient - older tasks get higher urgency
107 let age_hours = now.signed_duration_since(*created_at).num_hours() as f64;
108 let age_days = (age_hours / HOURS_PER_DAY).max(0.0);
109 let age_bonus = (age_days / config.age_days_for_max).min(1.0) * config.age_max;
110 urgency += age_bonus;
111
112 // Started status coefficient
113 if *status == TaskStatus::Started {
114 urgency += config.started;
115 }
116
117 // Tag "urgent" coefficient
118 if tags.iter().any(|t| t.to_lowercase() == "urgent") {
119 urgency += config.tag_urgent;
120 }
121
122 // Round to 1 decimal place
123 (urgency * 10.0).round() / 10.0
124 }
125
126 #[cfg(test)]
127 mod tests {
128 use super::*;
129 use chrono::Duration;
130
131 #[test]
132 fn test_priority_only() {
133 let now = Utc::now();
134 let tags: Vec<String> = vec![];
135
136 let high = calculate_urgency(&Priority::High, &TaskStatus::Pending, None, &now, &tags);
137 let medium = calculate_urgency(&Priority::Medium, &TaskStatus::Pending, None, &now, &tags);
138 let low = calculate_urgency(&Priority::Low, &TaskStatus::Pending, None, &now, &tags);
139
140 assert!(high > medium);
141 assert!(medium > low);
142 }
143
144 #[test]
145 fn test_overdue_boost() {
146 let now = Utc::now();
147 let yesterday = now - Duration::days(1);
148 let tags: Vec<String> = vec![];
149
150 let overdue = calculate_urgency(&Priority::Medium, &TaskStatus::Pending, Some(&yesterday), &now, &tags);
151 let no_due = calculate_urgency(&Priority::Medium, &TaskStatus::Pending, None, &now, &tags);
152
153 assert!(overdue > no_due + 10.0); // Overdue should add at least 10 points
154 }
155
156 #[test]
157 fn test_due_soon_boost() {
158 let now = Utc::now();
159 let in_3_days = now + Duration::days(3);
160 let in_10_days = now + Duration::days(10);
161 let tags: Vec<String> = vec![];
162
163 let soon = calculate_urgency(&Priority::Medium, &TaskStatus::Pending, Some(&in_3_days), &now, &tags);
164 let later = calculate_urgency(&Priority::Medium, &TaskStatus::Pending, Some(&in_10_days), &now, &tags);
165
166 assert!(soon > later);
167 }
168
169 #[test]
170 fn test_started_boost() {
171 let now = Utc::now();
172 let tags: Vec<String> = vec![];
173
174 let started = calculate_urgency(&Priority::Medium, &TaskStatus::Started, None, &now, &tags);
175 let pending = calculate_urgency(&Priority::Medium, &TaskStatus::Pending, None, &now, &tags);
176
177 assert!(started > pending + 3.0); // Started should add at least 3 points
178 }
179
180 #[test]
181 fn test_urgent_tag_boost() {
182 let now = Utc::now();
183 let urgent_tags = vec!["urgent".to_string()];
184 let other_tags = vec!["work".to_string()];
185
186 let with_urgent = calculate_urgency(&Priority::Medium, &TaskStatus::Pending, None, &now, &urgent_tags);
187 let without_urgent = calculate_urgency(&Priority::Medium, &TaskStatus::Pending, None, &now, &other_tags);
188
189 assert!(with_urgent > without_urgent + 1.0);
190 }
191
192 #[test]
193 fn test_age_boost() {
194 let now = Utc::now();
195 let old_created = now - Duration::days(45);
196 let new_created = now;
197 let tags: Vec<String> = vec![];
198
199 let old_task = calculate_urgency(&Priority::Medium, &TaskStatus::Pending, None, &old_created, &tags);
200 let new_task = calculate_urgency(&Priority::Medium, &TaskStatus::Pending, None, &new_created, &tags);
201
202 assert!(old_task > new_task);
203 // Age bonus should be capped at 2.0
204 assert!(old_task - new_task <= 2.1);
205 }
206 }
207