| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc, Weekday}; |
| 8 |
use serde::Serialize; |
| 9 |
use std::collections::HashMap; |
| 10 |
|
| 11 |
use crate::id_types::{EventId, ProjectId}; |
| 12 |
use crate::models::{Event, Task, TaskStatus, WeeklyReview}; |
| 13 |
|
| 14 |
|
| 15 |
|
| 16 |
|
| 17 |
|
| 18 |
#[derive(Debug, Serialize)] |
| 19 |
#[serde(rename_all = "camelCase")] |
| 20 |
pub struct WeeklyReviewData { |
| 21 |
|
| 22 |
pub week_start_date: String, |
| 23 |
|
| 24 |
pub week_end_date: String, |
| 25 |
|
| 26 |
pub week_display: String, |
| 27 |
|
| 28 |
pub is_completed: bool, |
| 29 |
|
| 30 |
pub completed_at: Option<DateTime<Utc>>, |
| 31 |
|
| 32 |
pub notes: String, |
| 33 |
|
| 34 |
|
| 35 |
|
| 36 |
pub timeline_days: Vec<TimelineDayData>, |
| 37 |
|
| 38 |
|
| 39 |
|
| 40 |
pub tasks_completed_count: usize, |
| 41 |
|
| 42 |
pub tasks_completed: Vec<Task>, |
| 43 |
|
| 44 |
pub tasks_overdue_count: usize, |
| 45 |
|
| 46 |
pub tasks_overdue: Vec<Task>, |
| 47 |
|
| 48 |
pub events_occurred_count: usize, |
| 49 |
|
| 50 |
pub events_occurred: Vec<EventSummary>, |
| 51 |
|
| 52 |
pub tasks_pending_count: usize, |
| 53 |
|
| 54 |
pub carried_over_tasks: Vec<Task>, |
| 55 |
|
| 56 |
pub carried_over_count: usize, |
| 57 |
|
| 58 |
|
| 59 |
|
| 60 |
pub tasks_due_next_week_count: usize, |
| 61 |
|
| 62 |
pub tasks_due_next_week: Vec<Task>, |
| 63 |
|
| 64 |
pub tasks_already_overdue_count: usize, |
| 65 |
|
| 66 |
|
| 67 |
|
| 68 |
pub focused_tasks: Vec<Task>, |
| 69 |
|
| 70 |
pub available_for_focus: Vec<Task>, |
| 71 |
|
| 72 |
|
| 73 |
|
| 74 |
pub focused_projects: Vec<ProjectSummary>, |
| 75 |
|
| 76 |
|
| 77 |
|
| 78 |
pub project_health: Vec<ProjectHealth>, |
| 79 |
|
| 80 |
|
| 81 |
|
| 82 |
pub vacation_days: Vec<u8>, |
| 83 |
|
| 84 |
|
| 85 |
pub show_nudge: bool, |
| 86 |
} |
| 87 |
|
| 88 |
|
| 89 |
#[derive(Debug, Clone, Serialize)] |
| 90 |
#[serde(rename_all = "camelCase")] |
| 91 |
pub struct EventSummary { |
| 92 |
pub id: EventId, |
| 93 |
pub title: String, |
| 94 |
pub start_time: DateTime<Utc>, |
| 95 |
|
| 96 |
pub formatted_time: String, |
| 97 |
pub project_name: Option<String>, |
| 98 |
} |
| 99 |
|
| 100 |
|
| 101 |
#[derive(Debug, Clone, Serialize)] |
| 102 |
#[serde(rename_all = "camelCase")] |
| 103 |
pub struct ProjectSummary { |
| 104 |
pub id: ProjectId, |
| 105 |
pub name: String, |
| 106 |
pub focused_task_count: usize, |
| 107 |
} |
| 108 |
|
| 109 |
|
| 110 |
#[derive(Debug, Clone, Serialize)] |
| 111 |
#[serde(rename_all = "camelCase")] |
| 112 |
pub struct TimelineDayData { |
| 113 |
|
| 114 |
pub date: String, |
| 115 |
|
| 116 |
pub day_name: String, |
| 117 |
|
| 118 |
pub day_number: u32, |
| 119 |
|
| 120 |
pub is_today: bool, |
| 121 |
|
| 122 |
pub is_past: bool, |
| 123 |
|
| 124 |
pub completed_count: i32, |
| 125 |
|
| 126 |
pub event_count: i32, |
| 127 |
|
| 128 |
pub overdue_count: i32, |
| 129 |
|
| 130 |
pub due_count: i32, |
| 131 |
|
| 132 |
pub is_vacation: bool, |
| 133 |
|
| 134 |
pub events: Vec<EventSummary>, |
| 135 |
} |
| 136 |
|
| 137 |
|
| 138 |
#[derive(Debug, Clone, Serialize)] |
| 139 |
#[serde(rename_all = "camelCase")] |
| 140 |
pub struct ProjectHealth { |
| 141 |
pub id: ProjectId, |
| 142 |
pub name: String, |
| 143 |
|
| 144 |
pub active_count: i32, |
| 145 |
|
| 146 |
pub overdue_count: i32, |
| 147 |
|
| 148 |
pub total_count: i32, |
| 149 |
|
| 150 |
pub status: String, |
| 151 |
} |
| 152 |
|
| 153 |
|
| 154 |
pub struct WeeklyReviewInput { |
| 155 |
pub week_start: NaiveDate, |
| 156 |
pub review: Option<WeeklyReview>, |
| 157 |
pub tasks_completed: Vec<Task>, |
| 158 |
pub tasks_overdue: Vec<Task>, |
| 159 |
pub events_occurred: Vec<Event>, |
| 160 |
pub upcoming_events: Vec<Event>, |
| 161 |
pub tasks_due_next_week: Vec<Task>, |
| 162 |
pub tasks_already_overdue: Vec<Task>, |
| 163 |
pub all_tasks: Vec<Task>, |
| 164 |
pub focused_tasks: Vec<Task>, |
| 165 |
pub available_for_focus: Vec<Task>, |
| 166 |
pub projects: Vec<crate::models::Project>, |
| 167 |
} |
| 168 |
|
| 169 |
|
| 170 |
|
| 171 |
|
| 172 |
pub fn current_week_start() -> NaiveDate { |
| 173 |
let today = Utc::now().date_naive(); |
| 174 |
let days_from_monday = today.weekday().num_days_from_monday(); |
| 175 |
today - Duration::days(days_from_monday as i64) |
| 176 |
} |
| 177 |
|
| 178 |
|
| 179 |
|
| 180 |
|
| 181 |
pub fn parse_week_start(s: &str) -> Option<NaiveDate> { |
| 182 |
let date = NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()?; |
| 183 |
let days_from_monday = date.weekday().num_days_from_monday(); |
| 184 |
Some(date - Duration::days(days_from_monday as i64)) |
| 185 |
} |
| 186 |
|
| 187 |
|
| 188 |
pub fn week_end(week_start: NaiveDate) -> NaiveDate { |
| 189 |
week_start + Duration::days(6) |
| 190 |
} |
| 191 |
|
| 192 |
|
| 193 |
pub fn format_week_display(start: NaiveDate, end: NaiveDate) -> String { |
| 194 |
format!("{} - {}", start.format("%b %d"), end.format("%b %d")) |
| 195 |
} |
| 196 |
|
| 197 |
|
| 198 |
|
| 199 |
|
| 200 |
|
| 201 |
|
| 202 |
pub fn compute_weekly_review(input: WeeklyReviewInput) -> WeeklyReviewData { |
| 203 |
let week_start = input.week_start; |
| 204 |
let week_end_date = week_end(week_start); |
| 205 |
let now = Utc::now(); |
| 206 |
let today = now.date_naive(); |
| 207 |
|
| 208 |
let week_start_dt = week_start.and_hms_opt(0, 0, 0) |
| 209 |
.map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc)) |
| 210 |
.unwrap_or_else(Utc::now); |
| 211 |
|
| 212 |
let vacation_days: Vec<u8> = input.review.as_ref() |
| 213 |
.map(|r| r.vacation_days.clone()) |
| 214 |
.unwrap_or_default(); |
| 215 |
|
| 216 |
|
| 217 |
let pending_count = input.all_tasks.iter() |
| 218 |
.filter(|t| t.status == TaskStatus::Pending || t.status == TaskStatus::Started) |
| 219 |
.count(); |
| 220 |
|
| 221 |
let carried_over_tasks: Vec<_> = input.all_tasks.iter() |
| 222 |
.filter(|t| { |
| 223 |
(t.status == TaskStatus::Pending || t.status == TaskStatus::Started) |
| 224 |
&& t.created_at < week_start_dt |
| 225 |
}) |
| 226 |
.cloned() |
| 227 |
.collect(); |
| 228 |
let carried_over_count = carried_over_tasks.len(); |
| 229 |
|
| 230 |
|
| 231 |
let focused_projects = compute_focused_projects(&input.focused_tasks); |
| 232 |
|
| 233 |
|
| 234 |
let timeline_days = build_timeline_days( |
| 235 |
week_start, |
| 236 |
today, |
| 237 |
&vacation_days, |
| 238 |
&input.tasks_completed, |
| 239 |
&input.events_occurred, |
| 240 |
&input.tasks_overdue, |
| 241 |
&input.tasks_due_next_week, |
| 242 |
&input.upcoming_events, |
| 243 |
); |
| 244 |
|
| 245 |
|
| 246 |
let project_health = compute_project_health(&input.projects, &input.all_tasks); |
| 247 |
|
| 248 |
|
| 249 |
let show_nudge = should_show_nudge(&input.review); |
| 250 |
|
| 251 |
WeeklyReviewData { |
| 252 |
week_start_date: week_start.format("%Y-%m-%d").to_string(), |
| 253 |
week_end_date: week_end_date.format("%Y-%m-%d").to_string(), |
| 254 |
week_display: format_week_display(week_start, week_end_date), |
| 255 |
is_completed: input.review.is_some(), |
| 256 |
completed_at: input.review.as_ref().map(|r| r.completed_at), |
| 257 |
notes: input.review.as_ref().map(|r| r.notes.clone()).unwrap_or_default(), |
| 258 |
|
| 259 |
timeline_days, |
| 260 |
|
| 261 |
tasks_completed_count: input.tasks_completed.len(), |
| 262 |
tasks_completed: input.tasks_completed, |
| 263 |
tasks_overdue_count: input.tasks_overdue.len(), |
| 264 |
tasks_overdue: input.tasks_overdue, |
| 265 |
events_occurred_count: input.events_occurred.len(), |
| 266 |
events_occurred: input.events_occurred.iter().map(event_to_summary).collect(), |
| 267 |
tasks_pending_count: pending_count, |
| 268 |
carried_over_tasks, |
| 269 |
carried_over_count, |
| 270 |
|
| 271 |
tasks_due_next_week_count: input.tasks_due_next_week.len(), |
| 272 |
tasks_due_next_week: input.tasks_due_next_week, |
| 273 |
tasks_already_overdue_count: input.tasks_already_overdue.len(), |
| 274 |
|
| 275 |
focused_tasks: input.focused_tasks, |
| 276 |
available_for_focus: input.available_for_focus, |
| 277 |
focused_projects, |
| 278 |
project_health, |
| 279 |
|
| 280 |
vacation_days, |
| 281 |
show_nudge, |
| 282 |
} |
| 283 |
} |
| 284 |
|
| 285 |
|
| 286 |
#[allow(clippy::too_many_arguments)] |
| 287 |
pub fn build_timeline_days( |
| 288 |
week_start: NaiveDate, |
| 289 |
today: NaiveDate, |
| 290 |
vacation_days: &[u8], |
| 291 |
tasks_completed: &[Task], |
| 292 |
events_occurred: &[Event], |
| 293 |
tasks_overdue: &[Task], |
| 294 |
tasks_due_next_week: &[Task], |
| 295 |
upcoming_events: &[Event], |
| 296 |
) -> Vec<TimelineDayData> { |
| 297 |
let day_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; |
| 298 |
|
| 299 |
(0..7) |
| 300 |
.map(|day_offset| { |
| 301 |
let date = week_start + Duration::days(day_offset); |
| 302 |
let day_start = date.and_hms_opt(0, 0, 0) |
| 303 |
.map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc)) |
| 304 |
.unwrap_or_else(Utc::now); |
| 305 |
let next_day_start = (date + Duration::days(1)).and_hms_opt(0, 0, 0) |
| 306 |
.map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc)) |
| 307 |
.unwrap_or_else(Utc::now); |
| 308 |
|
| 309 |
let completed_count = tasks_completed.iter() |
| 310 |
.filter(|t| { |
| 311 |
t.completed_at |
| 312 |
.map(|ca| ca >= day_start && ca < next_day_start) |
| 313 |
.unwrap_or(false) |
| 314 |
}) |
| 315 |
.count() as i32; |
| 316 |
|
| 317 |
let event_count = events_occurred.iter() |
| 318 |
.chain(upcoming_events.iter()) |
| 319 |
.filter(|e| e.start_time >= day_start && e.start_time < next_day_start) |
| 320 |
.count() as i32; |
| 321 |
|
| 322 |
let overdue_count = tasks_overdue.iter() |
| 323 |
.filter(|t| t.due.map(|d| d.date_naive() == date).unwrap_or(false)) |
| 324 |
.count() as i32; |
| 325 |
|
| 326 |
let due_count = if date > today { |
| 327 |
tasks_due_next_week.iter() |
| 328 |
.filter(|t| t.due.map(|d| d.date_naive() == date).unwrap_or(false)) |
| 329 |
.count() as i32 |
| 330 |
} else { |
| 331 |
0 |
| 332 |
}; |
| 333 |
|
| 334 |
|
| 335 |
let day_events: Vec<EventSummary> = events_occurred.iter() |
| 336 |
.chain(upcoming_events.iter()) |
| 337 |
.filter(|e| e.start_time >= day_start && e.start_time < next_day_start) |
| 338 |
.take(5) |
| 339 |
.map(event_to_summary) |
| 340 |
.collect(); |
| 341 |
|
| 342 |
TimelineDayData { |
| 343 |
date: date.format("%Y-%m-%d").to_string(), |
| 344 |
day_name: day_names[day_offset as usize].to_string(), |
| 345 |
day_number: date.day(), |
| 346 |
is_today: date == today, |
| 347 |
is_past: date < today, |
| 348 |
completed_count, |
| 349 |
event_count, |
| 350 |
overdue_count, |
| 351 |
due_count, |
| 352 |
is_vacation: vacation_days.contains(&(day_offset as u8)), |
| 353 |
events: day_events, |
| 354 |
} |
| 355 |
}) |
| 356 |
.collect() |
| 357 |
} |
| 358 |
|
| 359 |
|
| 360 |
pub fn compute_project_health( |
| 361 |
projects: &[crate::models::Project], |
| 362 |
all_tasks: &[Task], |
| 363 |
) -> Vec<ProjectHealth> { |
| 364 |
projects.iter() |
| 365 |
.filter_map(|project| { |
| 366 |
let project_tasks: Vec<_> = all_tasks.iter() |
| 367 |
.filter(|t| t.project_id == Some(project.id) && t.status != TaskStatus::Deleted) |
| 368 |
.collect(); |
| 369 |
|
| 370 |
if project_tasks.is_empty() { |
| 371 |
return None; |
| 372 |
} |
| 373 |
|
| 374 |
let active_count = project_tasks.iter() |
| 375 |
.filter(|t| t.status == TaskStatus::Pending || t.status == TaskStatus::Started) |
| 376 |
.count() as i32; |
| 377 |
|
| 378 |
let overdue_count = project_tasks.iter() |
| 379 |
.filter(|t| t.is_overdue()) |
| 380 |
.count() as i32; |
| 381 |
|
| 382 |
let total_count = project_tasks.len() as i32; |
| 383 |
|
| 384 |
let status = if overdue_count >= 3 { |
| 385 |
"danger" |
| 386 |
} else if overdue_count > 0 { |
| 387 |
"warning" |
| 388 |
} else { |
| 389 |
"healthy" |
| 390 |
}.to_string(); |
| 391 |
|
| 392 |
Some(ProjectHealth { |
| 393 |
id: project.id, |
| 394 |
name: project.name.clone(), |
| 395 |
active_count, |
| 396 |
overdue_count, |
| 397 |
total_count, |
| 398 |
status, |
| 399 |
}) |
| 400 |
}) |
| 401 |
.collect() |
| 402 |
} |
| 403 |
|
| 404 |
|
| 405 |
fn compute_focused_projects(focused_tasks: &[Task]) -> Vec<ProjectSummary> { |
| 406 |
let mut project_focus_counts: HashMap<ProjectId, (String, usize)> = HashMap::new(); |
| 407 |
for task in focused_tasks { |
| 408 |
if let Some(project_id) = task.project_id { |
| 409 |
let project_name = task.project_name.clone().unwrap_or_else(|| "Unknown".to_string()); |
| 410 |
project_focus_counts.entry(project_id) |
| 411 |
.and_modify(|(_, count)| *count += 1) |
| 412 |
.or_insert((project_name, 1)); |
| 413 |
} |
| 414 |
} |
| 415 |
project_focus_counts.into_iter() |
| 416 |
.map(|(id, (name, count))| ProjectSummary { |
| 417 |
id, |
| 418 |
name, |
| 419 |
focused_task_count: count, |
| 420 |
}) |
| 421 |
.collect() |
| 422 |
} |
| 423 |
|
| 424 |
|
| 425 |
pub fn should_show_nudge(review: &Option<WeeklyReview>) -> bool { |
| 426 |
let is_monday = Utc::now().weekday() == Weekday::Mon; |
| 427 |
is_monday && review.is_none() |
| 428 |
} |
| 429 |
|
| 430 |
|
| 431 |
fn format_event_time(dt: &DateTime<Utc>) -> String { |
| 432 |
dt.format("%a %H:%M").to_string() |
| 433 |
} |
| 434 |
|
| 435 |
|
| 436 |
fn event_to_summary(event: &Event) -> EventSummary { |
| 437 |
EventSummary { |
| 438 |
id: event.id, |
| 439 |
title: event.title.clone(), |
| 440 |
start_time: event.start_time, |
| 441 |
formatted_time: format_event_time(&event.start_time), |
| 442 |
project_name: event.project_name.clone(), |
| 443 |
} |
| 444 |
} |
| 445 |
|
| 446 |
#[cfg(test)] |
| 447 |
mod tests { |
| 448 |
use super::*; |
| 449 |
use crate::id_types::{EventId, TaskId, UserId, WeeklyReviewId}; |
| 450 |
use crate::models::{Priority, Recurrence}; |
| 451 |
use chrono::NaiveDate; |
| 452 |
|
| 453 |
|
| 454 |
fn make_completed_task( |
| 455 |
created_at: DateTime<Utc>, |
| 456 |
completed_at: DateTime<Utc>, |
| 457 |
) -> Task { |
| 458 |
Task { |
| 459 |
id: TaskId::new(), |
| 460 |
project_id: None, |
| 461 |
project_name: None, |
| 462 |
milestone_id: None, |
| 463 |
contact_id: None, |
| 464 |
contact_name: None, |
| 465 |
description: "test".to_string(), |
| 466 |
status: TaskStatus::Completed, |
| 467 |
priority: Priority::Medium, |
| 468 |
due: None, |
| 469 |
tags: vec![], |
| 470 |
urgency: 0.0, |
| 471 |
recurrence: Recurrence::None, |
| 472 |
recurrence_rule: None, |
| 473 |
recurrence_parent_id: None, |
| 474 |
source_email_id: None, |
| 475 |
snoozed_until: None, |
| 476 |
waiting_for_response: false, |
| 477 |
waiting_since: None, |
| 478 |
expected_response_date: None, |
| 479 |
scheduled_start: None, |
| 480 |
scheduled_duration: None, |
| 481 |
annotations: vec![], |
| 482 |
subtasks: vec![], |
| 483 |
created_at, |
| 484 |
completed_at: Some(completed_at), |
| 485 |
is_focus: false, |
| 486 |
focus_set_at: None, |
| 487 |
estimated_minutes: None, |
| 488 |
actual_minutes: 0, |
| 489 |
active_session: None, |
| 490 |
} |
| 491 |
} |
| 492 |
|
| 493 |
#[test] |
| 494 |
fn test_week_end() { |
| 495 |
let monday = NaiveDate::from_ymd_opt(2026, 2, 9).unwrap(); |
| 496 |
let sunday = week_end(monday); |
| 497 |
assert_eq!(sunday, NaiveDate::from_ymd_opt(2026, 2, 15).unwrap()); |
| 498 |
} |
| 499 |
|
| 500 |
#[test] |
| 501 |
fn test_format_week_display() { |
| 502 |
let start = NaiveDate::from_ymd_opt(2026, 2, 9).unwrap(); |
| 503 |
let end = NaiveDate::from_ymd_opt(2026, 2, 15).unwrap(); |
| 504 |
assert_eq!(format_week_display(start, end), "Feb 09 - Feb 15"); |
| 505 |
} |
| 506 |
|
| 507 |
#[test] |
| 508 |
fn test_should_show_nudge_with_review() { |
| 509 |
let review = Some(WeeklyReview { |
| 510 |
id: WeeklyReviewId::new(), |
| 511 |
user_id: UserId::new(), |
| 512 |
week_start_date: NaiveDate::from_ymd_opt(2026, 2, 9).unwrap(), |
| 513 |
notes: String::new(), |
| 514 |
completed_at: Utc::now(), |
| 515 |
vacation_days: vec![], |
| 516 |
}); |
| 517 |
|
| 518 |
assert!(!should_show_nudge(&review)); |
| 519 |
} |
| 520 |
|
| 521 |
#[test] |
| 522 |
fn test_compute_project_health_empty_projects() { |
| 523 |
let projects = vec![]; |
| 524 |
let tasks = vec![]; |
| 525 |
let health = compute_project_health(&projects, &tasks); |
| 526 |
assert!(health.is_empty()); |
| 527 |
} |
| 528 |
|
| 529 |
#[test] |
| 530 |
fn test_build_timeline_days_count() { |
| 531 |
let week_start = NaiveDate::from_ymd_opt(2026, 2, 9).unwrap(); |
| 532 |
let today = NaiveDate::from_ymd_opt(2026, 2, 11).unwrap(); |
| 533 |
let days = build_timeline_days(week_start, today, &[], &[], &[], &[], &[], &[]); |
| 534 |
assert_eq!(days.len(), 7); |
| 535 |
assert_eq!(days[0].day_name, "Mon"); |
| 536 |
assert_eq!(days[6].day_name, "Sun"); |
| 537 |
assert!(!days[0].is_today); |
| 538 |
assert!(days[2].is_today); |
| 539 |
assert!(days[0].is_past); |
| 540 |
assert!(days[1].is_past); |
| 541 |
assert!(!days[2].is_past); |
| 542 |
} |
| 543 |
|
| 544 |
#[test] |
| 545 |
fn test_timeline_uses_completed_at_not_created_at() { |
| 546 |
|
| 547 |
|
| 548 |
let week_start = NaiveDate::from_ymd_opt(2026, 2, 9).unwrap(); |
| 549 |
let today = NaiveDate::from_ymd_opt(2026, 2, 14).unwrap(); |
| 550 |
|
| 551 |
let monday = week_start |
| 552 |
.and_hms_opt(10, 0, 0) |
| 553 |
.map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc)) |
| 554 |
.unwrap(); |
| 555 |
let friday = NaiveDate::from_ymd_opt(2026, 2, 13) |
| 556 |
.unwrap() |
| 557 |
.and_hms_opt(15, 0, 0) |
| 558 |
.map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc)) |
| 559 |
.unwrap(); |
| 560 |
|
| 561 |
let task = make_completed_task(monday, friday); |
| 562 |
let tasks_completed = vec![task]; |
| 563 |
|
| 564 |
let days = build_timeline_days( |
| 565 |
week_start, |
| 566 |
today, |
| 567 |
&[], |
| 568 |
&tasks_completed, |
| 569 |
&[], |
| 570 |
&[], |
| 571 |
&[], |
| 572 |
&[], |
| 573 |
); |
| 574 |
|
| 575 |
|
| 576 |
assert_eq!( |
| 577 |
days[0].completed_count, 0, |
| 578 |
"Monday should have 0 completions (task was created Monday but completed Friday)" |
| 579 |
); |
| 580 |
|
| 581 |
assert_eq!( |
| 582 |
days[4].completed_count, 1, |
| 583 |
"Friday should have 1 completion (task was completed on Friday)" |
| 584 |
); |
| 585 |
} |
| 586 |
|
| 587 |
#[test] |
| 588 |
fn test_timeline_task_without_completed_at_not_counted() { |
| 589 |
|
| 590 |
let week_start = NaiveDate::from_ymd_opt(2026, 2, 9).unwrap(); |
| 591 |
let today = NaiveDate::from_ymd_opt(2026, 2, 11).unwrap(); |
| 592 |
|
| 593 |
let monday = week_start |
| 594 |
.and_hms_opt(10, 0, 0) |
| 595 |
.map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc)) |
| 596 |
.unwrap(); |
| 597 |
|
| 598 |
let mut task = make_completed_task(monday, monday); |
| 599 |
task.completed_at = None; |
| 600 |
|
| 601 |
let tasks_completed = vec![task]; |
| 602 |
let days = build_timeline_days( |
| 603 |
week_start, |
| 604 |
today, |
| 605 |
&[], |
| 606 |
&tasks_completed, |
| 607 |
&[], |
| 608 |
&[], |
| 609 |
&[], |
| 610 |
&[], |
| 611 |
); |
| 612 |
|
| 613 |
|
| 614 |
for day in &days { |
| 615 |
assert_eq!( |
| 616 |
day.completed_count, 0, |
| 617 |
"Day {} should have 0 completions for task without completed_at", |
| 618 |
day.day_name |
| 619 |
); |
| 620 |
} |
| 621 |
} |
| 622 |
|
| 623 |
|
| 624 |
fn make_event(title: &str, start_time: DateTime<Utc>) -> Event { |
| 625 |
Event { |
| 626 |
id: EventId::new(), |
| 627 |
user_id: None, |
| 628 |
project_id: None, |
| 629 |
project_name: None, |
| 630 |
contact_id: None, |
| 631 |
contact_name: None, |
| 632 |
title: title.to_string(), |
| 633 |
description: String::new(), |
| 634 |
start_time, |
| 635 |
end_time: None, |
| 636 |
location: None, |
| 637 |
linked_task_id: None, |
| 638 |
recurrence: Recurrence::None, |
| 639 |
recurrence_rule: None, |
| 640 |
is_recurring_instance: false, |
| 641 |
recurrence_parent_id: None, |
| 642 |
block_type: None, |
| 643 |
external_id: None, |
| 644 |
external_source: None, |
| 645 |
is_read_only: false, |
| 646 |
snoozed_until: None, |
| 647 |
reminder_offsets_seconds: Vec::new(), |
| 648 |
} |
| 649 |
} |
| 650 |
|
| 651 |
#[test] |
| 652 |
fn test_build_timeline_days_with_events() { |
| 653 |
let week_start = NaiveDate::from_ymd_opt(2026, 2, 9).unwrap(); |
| 654 |
let today = NaiveDate::from_ymd_opt(2026, 2, 11).unwrap(); |
| 655 |
|
| 656 |
|
| 657 |
let mon_10am = NaiveDate::from_ymd_opt(2026, 2, 9).unwrap() |
| 658 |
.and_hms_opt(10, 0, 0) |
| 659 |
.map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc)) |
| 660 |
.unwrap(); |
| 661 |
let mon_event = make_event("Monday standup", mon_10am); |
| 662 |
|
| 663 |
|
| 664 |
let thu_14 = NaiveDate::from_ymd_opt(2026, 2, 12).unwrap() |
| 665 |
.and_hms_opt(14, 0, 0) |
| 666 |
.map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc)) |
| 667 |
.unwrap(); |
| 668 |
let thu_event = make_event("Thursday meeting", thu_14); |
| 669 |
|
| 670 |
|
| 671 |
let fri_9 = NaiveDate::from_ymd_opt(2026, 2, 13).unwrap() |
| 672 |
.and_hms_opt(9, 0, 0) |
| 673 |
.map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc)) |
| 674 |
.unwrap(); |
| 675 |
let fri_15 = NaiveDate::from_ymd_opt(2026, 2, 13).unwrap() |
| 676 |
.and_hms_opt(15, 0, 0) |
| 677 |
.map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc)) |
| 678 |
.unwrap(); |
| 679 |
let fri_event1 = make_event("Friday morning", fri_9); |
| 680 |
let fri_event2 = make_event("Friday afternoon", fri_15); |
| 681 |
|
| 682 |
let events_occurred = vec![mon_event]; |
| 683 |
let upcoming_events = vec![thu_event, fri_event1, fri_event2]; |
| 684 |
|
| 685 |
let days = build_timeline_days( |
| 686 |
week_start, |
| 687 |
today, |
| 688 |
&[], |
| 689 |
&[], |
| 690 |
&events_occurred, |
| 691 |
&[], |
| 692 |
&[], |
| 693 |
&upcoming_events, |
| 694 |
); |
| 695 |
|
| 696 |
|
| 697 |
assert_eq!(days[0].events.len(), 1, "Monday should have 1 event"); |
| 698 |
assert_eq!(days[0].events[0].title, "Monday standup"); |
| 699 |
|
| 700 |
|
| 701 |
assert_eq!(days[1].events.len(), 0, "Tuesday should have 0 events"); |
| 702 |
|
| 703 |
|
| 704 |
assert_eq!(days[2].events.len(), 0, "Wednesday should have 0 events"); |
| 705 |
|
| 706 |
|
| 707 |
assert_eq!(days[3].events.len(), 1, "Thursday should have 1 event"); |
| 708 |
assert_eq!(days[3].events[0].title, "Thursday meeting"); |
| 709 |
|
| 710 |
|
| 711 |
assert_eq!(days[4].events.len(), 2, "Friday should have 2 events"); |
| 712 |
|
| 713 |
|
| 714 |
assert_eq!(days[5].events.len(), 0, "Saturday should have 0 events"); |
| 715 |
assert_eq!(days[6].events.len(), 0, "Sunday should have 0 events"); |
| 716 |
} |
| 717 |
} |
| 718 |
|