//! Weekly review aggregation logic. //! //! Contains pure functions for computing weekly review data. //! All I/O is done by the command layer; this module only transforms //! pre-fetched data into the final response shape. use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc, Weekday}; use serde::Serialize; use std::collections::HashMap; use crate::id_types::{EventId, ProjectId}; use crate::models::{Event, Task, TaskStatus, WeeklyReview}; // ============ Types ============ /// Pre-computed weekly review data. /// All stats, lists, and display formatting is done server-side. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct WeeklyReviewData { /// The Monday of the week being reviewed (YYYY-MM-DD) pub week_start_date: String, /// The Sunday of the week (YYYY-MM-DD) pub week_end_date: String, /// Human-readable date range (e.g., "Feb 10 - Feb 16") pub week_display: String, /// Whether this week's review is completed pub is_completed: bool, /// When the review was completed, if applicable pub completed_at: Option>, /// Notes from the review pub notes: String, // ===== Week Timeline ===== /// Timeline data for each day of the week (Mon-Sun) pub timeline_days: Vec, // ===== Past Week Stats ===== /// Count of tasks completed in the past week pub tasks_completed_count: usize, /// Tasks completed in the past week pub tasks_completed: Vec, /// Count of tasks that became overdue in the past week pub tasks_overdue_count: usize, /// Tasks that became overdue pub tasks_overdue: Vec, /// Count of events that occurred in the past week pub events_occurred_count: usize, /// Events that occurred in the past week pub events_occurred: Vec, /// Count of pending tasks at week end pub tasks_pending_count: usize, /// Tasks carried over from previous weeks (still pending, created before this week) pub carried_over_tasks: Vec, /// Count of carried over tasks pub carried_over_count: usize, // ===== Coming Week ===== /// Count of tasks due in the coming week pub tasks_due_next_week_count: usize, /// Tasks due in the coming week pub tasks_due_next_week: Vec, /// Count of overdue tasks (from before this week) pub tasks_already_overdue_count: usize, // ===== Focus ===== /// Tasks currently marked as focus for the week pub focused_tasks: Vec, /// High-priority pending tasks available for focus selection pub available_for_focus: Vec, // ===== Derived Projects ===== /// Projects that have focused tasks (computed from focused_tasks) pub focused_projects: Vec, // ===== Project Health ===== /// Health status for all projects pub project_health: Vec, // ===== Nudge ===== /// Days marked as vacation for the coming week (0=Mon ... 6=Sun) pub vacation_days: Vec, /// Whether to show the weekly review nudge (Monday, not completed) pub show_nudge: bool, } /// Minimal event info for display in the weekly review. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct EventSummary { pub id: EventId, pub title: String, pub start_time: DateTime, /// Human-readable date/time (e.g., "Mon 10:00 AM") pub formatted_time: String, pub project_name: Option, } /// Minimal project info for display. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ProjectSummary { pub id: ProjectId, pub name: String, pub focused_task_count: usize, } /// Timeline data for a single day in the week-at-a-glance view. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct TimelineDayData { /// Date in YYYY-MM-DD format pub date: String, /// Short day name (Mon, Tue, etc.) pub day_name: String, /// Day of month (1-31) pub day_number: u32, /// Whether this is today pub is_today: bool, /// Whether this day is in the past pub is_past: bool, /// Number of tasks completed on this day pub completed_count: i32, /// Number of events on this day pub event_count: i32, /// Number of tasks that became overdue on this day pub overdue_count: i32, /// Number of tasks due on this day (future days only) pub due_count: i32, /// Whether this day is marked as vacation pub is_vacation: bool, /// Events occurring on this day (capped at 5) pub events: Vec, } /// Project health status for the weekly review. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ProjectHealth { pub id: ProjectId, pub name: String, /// Number of active (pending/started) tasks pub active_count: i32, /// Number of overdue tasks pub overdue_count: i32, /// Total task count (non-deleted) pub total_count: i32, /// Health status: "healthy", "warning", or "danger" pub status: String, } /// All data needed to compute the weekly review, pre-fetched by the command layer. pub struct WeeklyReviewInput { pub week_start: NaiveDate, pub review: Option, pub tasks_completed: Vec, pub tasks_overdue: Vec, pub events_occurred: Vec, pub upcoming_events: Vec, pub tasks_due_next_week: Vec, pub tasks_already_overdue: Vec, pub all_tasks: Vec, pub focused_tasks: Vec, pub available_for_focus: Vec, pub projects: Vec, } // ============ Date Helpers ============ /// Gets the Monday of the current ISO week. pub fn current_week_start() -> NaiveDate { let today = Utc::now().date_naive(); let days_from_monday = today.weekday().num_days_from_monday(); today - Duration::days(days_from_monday as i64) } /// Parses a "YYYY-MM-DD" string into the Monday of that ISO week. /// Accepts any date in the week and snaps to its Monday, so callers don't /// have to pre-compute the boundary. pub fn parse_week_start(s: &str) -> Option { let date = NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()?; let days_from_monday = date.weekday().num_days_from_monday(); Some(date - Duration::days(days_from_monday as i64)) } /// Gets the Sunday of the week starting on the given Monday. pub fn week_end(week_start: NaiveDate) -> NaiveDate { week_start + Duration::days(6) } /// Formats a week range for display (e.g., "Feb 10 - Feb 16"). pub fn format_week_display(start: NaiveDate, end: NaiveDate) -> String { format!("{} - {}", start.format("%b %d"), end.format("%b %d")) } // ============ Pure Aggregation ============ /// Computes the full weekly review from pre-fetched data. /// /// This is a pure function — all I/O must be done before calling this. pub fn compute_weekly_review(input: WeeklyReviewInput) -> WeeklyReviewData { let week_start = input.week_start; let week_end_date = week_end(week_start); let now = Utc::now(); let today = now.date_naive(); let week_start_dt = week_start.and_hms_opt(0, 0, 0) .map(|dt| DateTime::::from_naive_utc_and_offset(dt, Utc)) .unwrap_or_else(Utc::now); let vacation_days: Vec = input.review.as_ref() .map(|r| r.vacation_days.clone()) .unwrap_or_default(); // Pending tasks count and carried-over tasks let pending_count = input.all_tasks.iter() .filter(|t| t.status == TaskStatus::Pending || t.status == TaskStatus::Started) .count(); let carried_over_tasks: Vec<_> = input.all_tasks.iter() .filter(|t| { (t.status == TaskStatus::Pending || t.status == TaskStatus::Started) && t.created_at < week_start_dt }) .cloned() .collect(); let carried_over_count = carried_over_tasks.len(); // Compute focused projects from focused tasks let focused_projects = compute_focused_projects(&input.focused_tasks); // Build timeline let timeline_days = build_timeline_days( week_start, today, &vacation_days, &input.tasks_completed, &input.events_occurred, &input.tasks_overdue, &input.tasks_due_next_week, &input.upcoming_events, ); // Compute project health let project_health = compute_project_health(&input.projects, &input.all_tasks); // Determine if nudge should show let show_nudge = should_show_nudge(&input.review); WeeklyReviewData { week_start_date: week_start.format("%Y-%m-%d").to_string(), week_end_date: week_end_date.format("%Y-%m-%d").to_string(), week_display: format_week_display(week_start, week_end_date), is_completed: input.review.is_some(), completed_at: input.review.as_ref().map(|r| r.completed_at), notes: input.review.as_ref().map(|r| r.notes.clone()).unwrap_or_default(), timeline_days, tasks_completed_count: input.tasks_completed.len(), tasks_completed: input.tasks_completed, tasks_overdue_count: input.tasks_overdue.len(), tasks_overdue: input.tasks_overdue, events_occurred_count: input.events_occurred.len(), events_occurred: input.events_occurred.iter().map(event_to_summary).collect(), tasks_pending_count: pending_count, carried_over_tasks, carried_over_count, tasks_due_next_week_count: input.tasks_due_next_week.len(), tasks_due_next_week: input.tasks_due_next_week, tasks_already_overdue_count: input.tasks_already_overdue.len(), focused_tasks: input.focused_tasks, available_for_focus: input.available_for_focus, focused_projects, project_health, vacation_days, show_nudge, } } /// Builds timeline data for each day of the review week (Mon–Sun). #[allow(clippy::too_many_arguments)] pub fn build_timeline_days( week_start: NaiveDate, today: NaiveDate, vacation_days: &[u8], tasks_completed: &[Task], events_occurred: &[Event], tasks_overdue: &[Task], tasks_due_next_week: &[Task], upcoming_events: &[Event], ) -> Vec { let day_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; (0..7) .map(|day_offset| { let date = week_start + Duration::days(day_offset); let day_start = date.and_hms_opt(0, 0, 0) .map(|dt| DateTime::::from_naive_utc_and_offset(dt, Utc)) .unwrap_or_else(Utc::now); let next_day_start = (date + Duration::days(1)).and_hms_opt(0, 0, 0) .map(|dt| DateTime::::from_naive_utc_and_offset(dt, Utc)) .unwrap_or_else(Utc::now); let completed_count = tasks_completed.iter() .filter(|t| { t.completed_at .map(|ca| ca >= day_start && ca < next_day_start) .unwrap_or(false) }) .count() as i32; let event_count = events_occurred.iter() .chain(upcoming_events.iter()) .filter(|e| e.start_time >= day_start && e.start_time < next_day_start) .count() as i32; let overdue_count = tasks_overdue.iter() .filter(|t| t.due.map(|d| d.date_naive() == date).unwrap_or(false)) .count() as i32; let due_count = if date > today { tasks_due_next_week.iter() .filter(|t| t.due.map(|d| d.date_naive() == date).unwrap_or(false)) .count() as i32 } else { 0 }; // Collect events for this day from both past and upcoming, capped at 5 let day_events: Vec = events_occurred.iter() .chain(upcoming_events.iter()) .filter(|e| e.start_time >= day_start && e.start_time < next_day_start) .take(5) .map(event_to_summary) .collect(); TimelineDayData { date: date.format("%Y-%m-%d").to_string(), day_name: day_names[day_offset as usize].to_string(), day_number: date.day(), is_today: date == today, is_past: date < today, completed_count, event_count, overdue_count, due_count, is_vacation: vacation_days.contains(&(day_offset as u8)), events: day_events, } }) .collect() } /// Computes project health from projects and all tasks. pub fn compute_project_health( projects: &[crate::models::Project], all_tasks: &[Task], ) -> Vec { projects.iter() .filter_map(|project| { let project_tasks: Vec<_> = all_tasks.iter() .filter(|t| t.project_id == Some(project.id) && t.status != TaskStatus::Deleted) .collect(); if project_tasks.is_empty() { return None; } let active_count = project_tasks.iter() .filter(|t| t.status == TaskStatus::Pending || t.status == TaskStatus::Started) .count() as i32; let overdue_count = project_tasks.iter() .filter(|t| t.is_overdue()) .count() as i32; let total_count = project_tasks.len() as i32; let status = if overdue_count >= 3 { "danger" } else if overdue_count > 0 { "warning" } else { "healthy" }.to_string(); Some(ProjectHealth { id: project.id, name: project.name.clone(), active_count, overdue_count, total_count, status, }) }) .collect() } /// Groups focused tasks by project. fn compute_focused_projects(focused_tasks: &[Task]) -> Vec { let mut project_focus_counts: HashMap = HashMap::new(); for task in focused_tasks { if let Some(project_id) = task.project_id { let project_name = task.project_name.clone().unwrap_or_else(|| "Unknown".to_string()); project_focus_counts.entry(project_id) .and_modify(|(_, count)| *count += 1) .or_insert((project_name, 1)); } } project_focus_counts.into_iter() .map(|(id, (name, count))| ProjectSummary { id, name, focused_task_count: count, }) .collect() } /// Determines if the weekly review nudge should show (Monday, not completed). pub fn should_show_nudge(review: &Option) -> bool { let is_monday = Utc::now().weekday() == Weekday::Mon; is_monday && review.is_none() } /// Formats event time for display (e.g., "Mon 10:00"). fn format_event_time(dt: &DateTime) -> String { dt.format("%a %H:%M").to_string() } /// Converts an Event to a minimal EventSummary. fn event_to_summary(event: &Event) -> EventSummary { EventSummary { id: event.id, title: event.title.clone(), start_time: event.start_time, formatted_time: format_event_time(&event.start_time), project_name: event.project_name.clone(), } } #[cfg(test)] mod tests { use super::*; use crate::id_types::{EventId, TaskId, UserId, WeeklyReviewId}; use crate::models::{Priority, Recurrence}; use chrono::NaiveDate; /// Creates a minimal completed task with specified created_at and completed_at. fn make_completed_task( created_at: DateTime, completed_at: DateTime, ) -> Task { Task { id: TaskId::new(), project_id: None, project_name: None, milestone_id: None, contact_id: None, contact_name: None, description: "test".to_string(), status: TaskStatus::Completed, priority: Priority::Medium, due: None, tags: vec![], urgency: 0.0, recurrence: Recurrence::None, recurrence_rule: None, recurrence_parent_id: None, source_email_id: None, snoozed_until: None, waiting_for_response: false, waiting_since: None, expected_response_date: None, scheduled_start: None, scheduled_duration: None, annotations: vec![], subtasks: vec![], created_at, completed_at: Some(completed_at), is_focus: false, focus_set_at: None, estimated_minutes: None, actual_minutes: 0, active_session: None, } } #[test] fn test_week_end() { let monday = NaiveDate::from_ymd_opt(2026, 2, 9).unwrap(); let sunday = week_end(monday); assert_eq!(sunday, NaiveDate::from_ymd_opt(2026, 2, 15).unwrap()); } #[test] fn test_format_week_display() { let start = NaiveDate::from_ymd_opt(2026, 2, 9).unwrap(); let end = NaiveDate::from_ymd_opt(2026, 2, 15).unwrap(); assert_eq!(format_week_display(start, end), "Feb 09 - Feb 15"); } #[test] fn test_should_show_nudge_with_review() { let review = Some(WeeklyReview { id: WeeklyReviewId::new(), user_id: UserId::new(), week_start_date: NaiveDate::from_ymd_opt(2026, 2, 9).unwrap(), notes: String::new(), completed_at: Utc::now(), vacation_days: vec![], }); // Should never show nudge if review exists assert!(!should_show_nudge(&review)); } #[test] fn test_compute_project_health_empty_projects() { let projects = vec![]; let tasks = vec![]; let health = compute_project_health(&projects, &tasks); assert!(health.is_empty()); } #[test] fn test_build_timeline_days_count() { let week_start = NaiveDate::from_ymd_opt(2026, 2, 9).unwrap(); let today = NaiveDate::from_ymd_opt(2026, 2, 11).unwrap(); let days = build_timeline_days(week_start, today, &[], &[], &[], &[], &[], &[]); assert_eq!(days.len(), 7); assert_eq!(days[0].day_name, "Mon"); assert_eq!(days[6].day_name, "Sun"); assert!(!days[0].is_today); assert!(days[2].is_today); // Wednesday = index 2 assert!(days[0].is_past); assert!(days[1].is_past); assert!(!days[2].is_past); // today is not past } #[test] fn test_timeline_uses_completed_at_not_created_at() { // Task created on Monday (Feb 9) but completed on Friday (Feb 13). // The timeline should show it as completed on Friday, not Monday. let week_start = NaiveDate::from_ymd_opt(2026, 2, 9).unwrap(); // Monday let today = NaiveDate::from_ymd_opt(2026, 2, 14).unwrap(); // Saturday let monday = week_start .and_hms_opt(10, 0, 0) .map(|dt| DateTime::::from_naive_utc_and_offset(dt, Utc)) .unwrap(); let friday = NaiveDate::from_ymd_opt(2026, 2, 13) .unwrap() .and_hms_opt(15, 0, 0) .map(|dt| DateTime::::from_naive_utc_and_offset(dt, Utc)) .unwrap(); let task = make_completed_task(monday, friday); let tasks_completed = vec![task]; let days = build_timeline_days( week_start, today, &[], &tasks_completed, &[], &[], &[], &[], ); // Monday (index 0) should have 0 completions (task was created Monday but not completed then) assert_eq!( days[0].completed_count, 0, "Monday should have 0 completions (task was created Monday but completed Friday)" ); // Friday (index 4) should have 1 completion assert_eq!( days[4].completed_count, 1, "Friday should have 1 completion (task was completed on Friday)" ); } #[test] fn test_timeline_task_without_completed_at_not_counted() { // Edge case: a completed task with no completed_at timestamp should not count. let week_start = NaiveDate::from_ymd_opt(2026, 2, 9).unwrap(); let today = NaiveDate::from_ymd_opt(2026, 2, 11).unwrap(); let monday = week_start .and_hms_opt(10, 0, 0) .map(|dt| DateTime::::from_naive_utc_and_offset(dt, Utc)) .unwrap(); let mut task = make_completed_task(monday, monday); task.completed_at = None; // No completed_at timestamp let tasks_completed = vec![task]; let days = build_timeline_days( week_start, today, &[], &tasks_completed, &[], &[], &[], &[], ); // No day should count this task for day in &days { assert_eq!( day.completed_count, 0, "Day {} should have 0 completions for task without completed_at", day.day_name ); } } /// Creates a minimal event at the given time. fn make_event(title: &str, start_time: DateTime) -> Event { Event { id: EventId::new(), user_id: None, project_id: None, project_name: None, contact_id: None, contact_name: None, title: title.to_string(), description: String::new(), start_time, end_time: None, location: None, linked_task_id: None, recurrence: Recurrence::None, recurrence_rule: None, is_recurring_instance: false, recurrence_parent_id: None, block_type: None, external_id: None, external_source: None, is_read_only: false, snoozed_until: None, reminder_offsets_seconds: Vec::new(), } } #[test] fn test_build_timeline_days_with_events() { let week_start = NaiveDate::from_ymd_opt(2026, 2, 9).unwrap(); // Monday let today = NaiveDate::from_ymd_opt(2026, 2, 11).unwrap(); // Wednesday // Past event on Monday let mon_10am = NaiveDate::from_ymd_opt(2026, 2, 9).unwrap() .and_hms_opt(10, 0, 0) .map(|dt| DateTime::::from_naive_utc_and_offset(dt, Utc)) .unwrap(); let mon_event = make_event("Monday standup", mon_10am); // Future event on Thursday let thu_14 = NaiveDate::from_ymd_opt(2026, 2, 12).unwrap() .and_hms_opt(14, 0, 0) .map(|dt| DateTime::::from_naive_utc_and_offset(dt, Utc)) .unwrap(); let thu_event = make_event("Thursday meeting", thu_14); // Two events on Friday let fri_9 = NaiveDate::from_ymd_opt(2026, 2, 13).unwrap() .and_hms_opt(9, 0, 0) .map(|dt| DateTime::::from_naive_utc_and_offset(dt, Utc)) .unwrap(); let fri_15 = NaiveDate::from_ymd_opt(2026, 2, 13).unwrap() .and_hms_opt(15, 0, 0) .map(|dt| DateTime::::from_naive_utc_and_offset(dt, Utc)) .unwrap(); let fri_event1 = make_event("Friday morning", fri_9); let fri_event2 = make_event("Friday afternoon", fri_15); let events_occurred = vec![mon_event]; let upcoming_events = vec![thu_event, fri_event1, fri_event2]; let days = build_timeline_days( week_start, today, &[], &[], &events_occurred, &[], &[], &upcoming_events, ); // Monday (index 0): 1 event from events_occurred assert_eq!(days[0].events.len(), 1, "Monday should have 1 event"); assert_eq!(days[0].events[0].title, "Monday standup"); // Tuesday (index 1): no events assert_eq!(days[1].events.len(), 0, "Tuesday should have 0 events"); // Wednesday (index 2): no events assert_eq!(days[2].events.len(), 0, "Wednesday should have 0 events"); // Thursday (index 3): 1 event from upcoming assert_eq!(days[3].events.len(), 1, "Thursday should have 1 event"); assert_eq!(days[3].events[0].title, "Thursday meeting"); // Friday (index 4): 2 events from upcoming assert_eq!(days[4].events.len(), 2, "Friday should have 2 events"); // Saturday & Sunday: no events assert_eq!(days[5].events.len(), 0, "Saturday should have 0 events"); assert_eq!(days[6].events.len(), 0, "Sunday should have 0 events"); } }