| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
use std::num::NonZeroU32; |
| 8 |
|
| 9 |
use chrono::{DateTime, Utc}; |
| 10 |
use serde::{Deserialize, Serialize}; |
| 11 |
use crate::error::CoreError; |
| 12 |
use crate::id_types::{TaskId, TimeSessionId, UserId, ProjectId}; |
| 13 |
|
| 14 |
|
| 15 |
|
| 16 |
|
| 17 |
|
| 18 |
|
| 19 |
|
| 20 |
|
| 21 |
#[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 22 |
pub struct PositiveMinutes(NonZeroU32); |
| 23 |
|
| 24 |
impl PositiveMinutes { |
| 25 |
|
| 26 |
|
| 27 |
|
| 28 |
|
| 29 |
|
| 30 |
pub fn try_new(minutes: i32) -> crate::Result<Self> { |
| 31 |
if minutes > crate::constants::MAX_SCHEDULED_DURATION_MINUTES { |
| 32 |
return Err(CoreError::validation( |
| 33 |
"minutes", |
| 34 |
format!( |
| 35 |
"Minutes must be at most {}", |
| 36 |
crate::constants::MAX_SCHEDULED_DURATION_MINUTES |
| 37 |
), |
| 38 |
)); |
| 39 |
} |
| 40 |
u32::try_from(minutes) |
| 41 |
.ok() |
| 42 |
.and_then(NonZeroU32::new) |
| 43 |
.map(Self) |
| 44 |
.ok_or_else(|| CoreError::validation("minutes", "Minutes must be at least 1")) |
| 45 |
} |
| 46 |
|
| 47 |
|
| 48 |
pub fn as_i32(self) -> i32 { |
| 49 |
self.0.get() as i32 |
| 50 |
} |
| 51 |
} |
| 52 |
|
| 53 |
|
| 54 |
#[derive(Debug, Clone, Serialize, Deserialize)] |
| 55 |
#[serde(rename_all = "camelCase")] |
| 56 |
pub struct TimeSession { |
| 57 |
pub id: TimeSessionId, |
| 58 |
pub task_id: TaskId, |
| 59 |
pub user_id: UserId, |
| 60 |
pub started_at: DateTime<Utc>, |
| 61 |
pub ended_at: Option<DateTime<Utc>>, |
| 62 |
pub duration_minutes: Option<i32>, |
| 63 |
pub created_at: DateTime<Utc>, |
| 64 |
} |
| 65 |
|
| 66 |
impl TimeSession { |
| 67 |
|
| 68 |
pub fn is_active(&self) -> bool { |
| 69 |
self.ended_at.is_none() |
| 70 |
} |
| 71 |
|
| 72 |
|
| 73 |
pub fn elapsed_minutes(&self) -> i32 { |
| 74 |
let end = self.ended_at.unwrap_or_else(Utc::now); |
| 75 |
let diff = end.signed_duration_since(self.started_at); |
| 76 |
diff.num_minutes().max(0) as i32 |
| 77 |
} |
| 78 |
} |
| 79 |
|
| 80 |
|
| 81 |
#[derive(Debug, Clone, Serialize, Deserialize)] |
| 82 |
#[serde(rename_all = "camelCase")] |
| 83 |
pub struct TimeTrackingSummary { |
| 84 |
pub project_id: Option<ProjectId>, |
| 85 |
pub project_name: Option<String>, |
| 86 |
pub date: String, |
| 87 |
pub total_minutes: i32, |
| 88 |
pub session_count: i32, |
| 89 |
} |
| 90 |
|
| 91 |
|
| 92 |
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 93 |
#[serde(rename_all = "camelCase")] |
| 94 |
pub struct TimeSummaryProject { |
| 95 |
|
| 96 |
pub name: String, |
| 97 |
pub total_minutes: i32, |
| 98 |
|
| 99 |
|
| 100 |
pub percent: i32, |
| 101 |
|
| 102 |
|
| 103 |
pub bar_percent: i32, |
| 104 |
} |
| 105 |
|
| 106 |
|
| 107 |
|
| 108 |
|
| 109 |
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 110 |
#[serde(rename_all = "camelCase")] |
| 111 |
pub struct TimeSummaryPanel { |
| 112 |
pub today_minutes: i32, |
| 113 |
pub projects: Vec<TimeSummaryProject>, |
| 114 |
} |
| 115 |
|
| 116 |
|
| 117 |
|
| 118 |
|
| 119 |
|
| 120 |
|
| 121 |
|
| 122 |
pub fn roll_up_time_summary(rows: &[TimeTrackingSummary], today: &str) -> TimeSummaryPanel { |
| 123 |
use std::collections::HashMap; |
| 124 |
|
| 125 |
let today_minutes = rows |
| 126 |
.iter() |
| 127 |
.filter(|r| r.date == today) |
| 128 |
.map(|r| r.total_minutes) |
| 129 |
.sum(); |
| 130 |
|
| 131 |
|
| 132 |
|
| 133 |
|
| 134 |
let mut order: Vec<Option<ProjectId>> = Vec::new(); |
| 135 |
let mut acc: HashMap<Option<ProjectId>, (String, i32)> = HashMap::new(); |
| 136 |
for r in rows { |
| 137 |
let entry = acc.entry(r.project_id).or_insert_with(|| { |
| 138 |
order.push(r.project_id); |
| 139 |
let name = r |
| 140 |
.project_name |
| 141 |
.clone() |
| 142 |
.unwrap_or_else(|| "No Project".to_string()); |
| 143 |
(name, 0) |
| 144 |
}); |
| 145 |
entry.1 += r.total_minutes; |
| 146 |
} |
| 147 |
|
| 148 |
let total: i32 = acc.values().map(|(_, m)| *m).sum(); |
| 149 |
let max: i32 = acc.values().map(|(_, m)| *m).max().unwrap_or(0); |
| 150 |
|
| 151 |
let mut projects: Vec<TimeSummaryProject> = order |
| 152 |
.into_iter() |
| 153 |
.map(|key| { |
| 154 |
let (name, minutes) = acc[&key].clone(); |
| 155 |
let percent = if total > 0 { |
| 156 |
((f64::from(minutes) / f64::from(total)) * 100.0).round() as i32 |
| 157 |
} else { |
| 158 |
0 |
| 159 |
}; |
| 160 |
let bar_percent = if max > 0 { |
| 161 |
((f64::from(minutes) / f64::from(max)) * 100.0).round() as i32 |
| 162 |
} else { |
| 163 |
0 |
| 164 |
}; |
| 165 |
TimeSummaryProject { |
| 166 |
name, |
| 167 |
total_minutes: minutes, |
| 168 |
percent, |
| 169 |
bar_percent, |
| 170 |
} |
| 171 |
}) |
| 172 |
.collect(); |
| 173 |
|
| 174 |
|
| 175 |
projects.sort_by_key(|p| std::cmp::Reverse(p.total_minutes)); |
| 176 |
|
| 177 |
TimeSummaryPanel { |
| 178 |
today_minutes, |
| 179 |
projects, |
| 180 |
} |
| 181 |
} |
| 182 |
|
| 183 |
#[cfg(test)] |
| 184 |
mod tests { |
| 185 |
use super::*; |
| 186 |
|
| 187 |
fn make_session(started: DateTime<Utc>, ended: Option<DateTime<Utc>>) -> TimeSession { |
| 188 |
TimeSession { |
| 189 |
id: TimeSessionId::new(), |
| 190 |
task_id: TaskId::new(), |
| 191 |
user_id: UserId::new(), |
| 192 |
started_at: started, |
| 193 |
ended_at: ended, |
| 194 |
duration_minutes: ended.map(|e| (e - started).num_minutes() as i32), |
| 195 |
created_at: started, |
| 196 |
} |
| 197 |
} |
| 198 |
|
| 199 |
#[test] |
| 200 |
fn is_active_when_no_end() { |
| 201 |
let session = make_session(Utc::now(), None); |
| 202 |
assert!(session.is_active()); |
| 203 |
} |
| 204 |
|
| 205 |
#[test] |
| 206 |
fn is_not_active_when_ended() { |
| 207 |
let start = Utc::now() - chrono::Duration::minutes(30); |
| 208 |
let end = Utc::now(); |
| 209 |
let session = make_session(start, Some(end)); |
| 210 |
assert!(!session.is_active()); |
| 211 |
} |
| 212 |
|
| 213 |
#[test] |
| 214 |
fn elapsed_minutes_for_ended_session() { |
| 215 |
let start = Utc::now() - chrono::Duration::minutes(45); |
| 216 |
let end = Utc::now(); |
| 217 |
let session = make_session(start, Some(end)); |
| 218 |
assert_eq!(session.elapsed_minutes(), 45); |
| 219 |
} |
| 220 |
|
| 221 |
#[test] |
| 222 |
fn elapsed_minutes_active_session_positive() { |
| 223 |
let start = Utc::now() - chrono::Duration::minutes(10); |
| 224 |
let session = make_session(start, None); |
| 225 |
assert!(session.elapsed_minutes() >= 9); |
| 226 |
} |
| 227 |
|
| 228 |
#[test] |
| 229 |
fn positive_minutes_accepts_positive_values() { |
| 230 |
assert_eq!(PositiveMinutes::try_new(1).unwrap().as_i32(), 1); |
| 231 |
assert_eq!(PositiveMinutes::try_new(30).unwrap().as_i32(), 30); |
| 232 |
} |
| 233 |
|
| 234 |
#[test] |
| 235 |
fn positive_minutes_rejects_zero_and_negatives() { |
| 236 |
assert!(PositiveMinutes::try_new(0).is_err()); |
| 237 |
assert!(PositiveMinutes::try_new(-1).is_err()); |
| 238 |
assert!(PositiveMinutes::try_new(-30).is_err()); |
| 239 |
} |
| 240 |
|
| 241 |
fn summary_row( |
| 242 |
project_id: Option<ProjectId>, |
| 243 |
name: Option<&str>, |
| 244 |
date: &str, |
| 245 |
minutes: i32, |
| 246 |
sessions: i32, |
| 247 |
) -> TimeTrackingSummary { |
| 248 |
TimeTrackingSummary { |
| 249 |
project_id, |
| 250 |
project_name: name.map(String::from), |
| 251 |
date: date.to_string(), |
| 252 |
total_minutes: minutes, |
| 253 |
session_count: sessions, |
| 254 |
} |
| 255 |
} |
| 256 |
|
| 257 |
#[test] |
| 258 |
fn roll_up_empty_yields_zeroed_empty_panel() { |
| 259 |
let panel = roll_up_time_summary(&[], "2026-07-04"); |
| 260 |
assert_eq!(panel.today_minutes, 0); |
| 261 |
assert!(panel.projects.is_empty()); |
| 262 |
} |
| 263 |
|
| 264 |
#[test] |
| 265 |
fn roll_up_aggregates_totals_percentages_and_sort_order() { |
| 266 |
let alpha = ProjectId::new(); |
| 267 |
let beta = ProjectId::new(); |
| 268 |
|
| 269 |
let rows = vec![ |
| 270 |
summary_row(Some(beta), Some("Beta"), "2026-07-01", 30, 1), |
| 271 |
summary_row(Some(alpha), Some("Alpha"), "2026-07-01", 60, 2), |
| 272 |
summary_row(Some(alpha), Some("Alpha"), "2026-07-04", 30, 1), |
| 273 |
]; |
| 274 |
|
| 275 |
let panel = roll_up_time_summary(&rows, "2026-07-04"); |
| 276 |
|
| 277 |
|
| 278 |
assert_eq!(panel.today_minutes, 30); |
| 279 |
|
| 280 |
|
| 281 |
assert_eq!(panel.projects.len(), 2); |
| 282 |
assert_eq!(panel.projects[0].name, "Alpha"); |
| 283 |
assert_eq!(panel.projects[0].total_minutes, 90); |
| 284 |
assert_eq!(panel.projects[1].name, "Beta"); |
| 285 |
assert_eq!(panel.projects[1].total_minutes, 30); |
| 286 |
|
| 287 |
|
| 288 |
assert_eq!(panel.projects[0].percent, 75); |
| 289 |
assert_eq!(panel.projects[1].percent, 25); |
| 290 |
let percent_sum: i32 = panel.projects.iter().map(|p| p.percent).sum(); |
| 291 |
assert!((99..=101).contains(&percent_sum), "percents sum to ~100: {percent_sum}"); |
| 292 |
|
| 293 |
|
| 294 |
assert_eq!(panel.projects[0].bar_percent, 100); |
| 295 |
assert_eq!(panel.projects[1].bar_percent, 33); |
| 296 |
} |
| 297 |
|
| 298 |
#[test] |
| 299 |
fn roll_up_collapses_unassigned_into_no_project_bucket() { |
| 300 |
let rows = vec![ |
| 301 |
summary_row(None, None, "2026-07-02", 15, 1), |
| 302 |
summary_row(None, None, "2026-07-03", 45, 1), |
| 303 |
]; |
| 304 |
|
| 305 |
let panel = roll_up_time_summary(&rows, "2026-07-04"); |
| 306 |
|
| 307 |
assert_eq!(panel.projects.len(), 1); |
| 308 |
assert_eq!(panel.projects[0].name, "No Project"); |
| 309 |
assert_eq!(panel.projects[0].total_minutes, 60); |
| 310 |
assert_eq!(panel.projects[0].percent, 100); |
| 311 |
assert_eq!(panel.projects[0].bar_percent, 100); |
| 312 |
assert_eq!(panel.today_minutes, 0); |
| 313 |
} |
| 314 |
} |
| 315 |
|