//! Monthly review aggregation logic. //! //! Contains pure functions for computing monthly 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}; use serde::Serialize; use std::collections::HashMap; use crate::id_types::ProjectId; use crate::models::{Event, MonthlyGoal, MonthlyReflection, Task, TaskStatus}; use crate::weekly_review::{compute_project_health, ProjectHealth}; // ============ Types ============ /// Pre-computed monthly review data for the frontend. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct MonthlyReviewData { /// Month in YYYY-MM format. pub month: String, /// Human-readable display (e.g., "April 2026"). pub month_display: String, /// First day of the month. pub month_start_date: String, /// Last day of the month. pub month_end_date: String, // ===== Heat Map ===== /// Per-day data for the calendar heat map. pub days: Vec, /// Number of weeks (rows) the calendar grid needs. pub week_count: u32, /// Day-of-week offset for the 1st (0=Mon, 6=Sun). pub first_day_offset: u32, // ===== Stats ===== pub tasks_completed_count: usize, /// Up to 6 completed tasks for the Accomplished card. pub tasks_completed_top: Vec, pub tasks_created_count: usize, pub events_count: usize, /// Busiest day (most completed tasks). pub busiest_day: Option, /// Quietest day (fewest completed tasks, at least 1 day in past). pub quietest_day: Option, /// Longest streak of consecutive days with completed tasks. pub completion_streak: u32, // ===== Project Pulse ===== pub project_pulse: Vec, // ===== Project Health ===== pub project_health: Vec, // ===== Goals & Reflection ===== pub goals: Vec, pub reflection: Option, // ===== Patterns ===== pub patterns: Vec, } /// Per-day data for the calendar heat map grid. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct MonthDayData { /// Date in YYYY-MM-DD format. pub date: 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, /// Whether this day is a vacation day. pub is_vacation: bool, /// Number of tasks completed on this day. pub completed_count: i32, /// Number of events on this day. pub event_count: i32, /// Activity intensity level: 0 (none), 1 (low), 2 (medium), 3 (high). pub intensity: u8, } /// Per-project pulse showing net progress direction. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ProjectPulse { pub id: ProjectId, pub name: String, /// Tasks completed this month in this project. pub completed: i32, /// Tasks created this month in this project. pub created: i32, /// "growing" if created > completed, "shrinking" if completed > created, "stable" otherwise. pub direction: String, } /// All data needed to compute the monthly review, pre-fetched by the command layer. pub struct MonthlyReviewInput { pub month_start: NaiveDate, pub month_end: NaiveDate, pub tasks_completed: Vec, pub tasks_created: Vec, pub events: Vec, pub all_tasks: Vec, pub projects: Vec, pub goals: Vec, pub reflection: Option, pub vacation_days: Vec, } // ============ Date Helpers ============ /// Gets the first day of the current month. pub fn current_month_start() -> NaiveDate { let today = Utc::now().date_naive(); NaiveDate::from_ymd_opt(today.year(), today.month(), 1).expect("day 1 is always valid") } /// Gets the last day of the month. pub fn month_end(month_start: NaiveDate) -> NaiveDate { // Move to next month day 1, then subtract 1 day let (next_year, next_month) = if month_start.month() == 12 { (month_start.year() + 1, 1) } else { (month_start.year(), month_start.month() + 1) }; NaiveDate::from_ymd_opt(next_year, next_month, 1) .expect("next month day 1 is valid") - Duration::days(1) } /// Formats a month for display (e.g., "April 2026"). pub fn format_month_display(month_start: NaiveDate) -> String { month_start.format("%B %Y").to_string() } /// Parses a YYYY-MM string into the first day of that month. pub fn parse_month(month_str: &str) -> Option { let parts: Vec<&str> = month_str.split('-').collect(); if parts.len() != 2 { return None; } let year: i32 = parts[0].parse().ok()?; let month: u32 = parts[1].parse().ok()?; NaiveDate::from_ymd_opt(year, month, 1) } // ============ Pure Aggregation ============ /// Computes the full monthly review from pre-fetched data. pub fn compute_monthly_review(input: MonthlyReviewInput) -> MonthlyReviewData { let today = Utc::now().date_naive(); let month_start = input.month_start; let month_end_date = input.month_end; let days_in_month = (month_end_date - month_start).num_days() as u32 + 1; // Calendar grid layout let first_day_offset = month_start.weekday().num_days_from_monday(); let week_count = (first_day_offset + days_in_month).div_ceil(7); // Build per-day data let days: Vec = (0..days_in_month) .map(|day_offset| { let date = month_start + Duration::days(day_offset as i64); 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 = input.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 = input.events.iter() .filter(|e| e.start_time >= day_start && e.start_time < next_day_start) .count() as i32; let activity = completed_count + event_count; let intensity = match activity { 0 => 0, 1..=2 => 1, 3..=5 => 2, _ => 3, }; MonthDayData { date: date.format("%Y-%m-%d").to_string(), day_number: date.day(), is_today: date == today, is_past: date < today, is_vacation: input.vacation_days.contains(&date), completed_count, event_count, intensity, } }) .collect(); // Stats let tasks_completed_count = input.tasks_completed.len(); let tasks_completed_top: Vec = input.tasks_completed.iter().take(6).cloned().collect(); let tasks_created_count = input.tasks_created.len(); let events_count = input.events.len(); // Busiest/quietest day (only past days) let past_days: Vec<_> = days.iter().filter(|d| d.is_past || d.is_today).collect(); let busiest_day = past_days.iter() .max_by_key(|d| d.completed_count) .filter(|d| d.completed_count > 0) .map(|d| d.date.clone()); let quietest_day = past_days.iter() .find(|d| d.completed_count == 0 && !d.is_vacation) .or_else(|| past_days.iter().min_by_key(|d| d.completed_count)) .map(|d| d.date.clone()); // Completion streak let completion_streak = compute_streak(&days); // Project pulse let project_pulse = compute_project_pulse(&input.tasks_completed, &input.tasks_created, &input.projects); // Project health (reuse weekly review logic) let project_health = compute_project_health(&input.projects, &input.all_tasks); // Patterns let patterns = compute_patterns(&days, &input.all_tasks, &input.projects, &input.tasks_completed); MonthlyReviewData { month: month_start.format("%Y-%m").to_string(), month_display: format_month_display(month_start), month_start_date: month_start.format("%Y-%m-%d").to_string(), month_end_date: month_end_date.format("%Y-%m-%d").to_string(), days, week_count, first_day_offset, tasks_completed_count, tasks_completed_top, tasks_created_count, events_count, busiest_day, quietest_day, completion_streak, project_pulse, project_health, goals: input.goals, reflection: input.reflection, patterns, } } /// Computes the longest streak of consecutive days with completed tasks. fn compute_streak(days: &[MonthDayData]) -> u32 { let mut max_streak = 0u32; let mut current_streak = 0u32; for day in days { if !day.is_past && !day.is_today { break; } if day.completed_count > 0 { current_streak += 1; max_streak = max_streak.max(current_streak); } else if !day.is_vacation { current_streak = 0; } // Vacation days don't break the streak } max_streak } /// Computes per-project pulse data. fn compute_project_pulse( tasks_completed: &[Task], tasks_created: &[Task], projects: &[crate::models::Project], ) -> Vec { let mut completed_by_project: HashMap = HashMap::new(); let mut created_by_project: HashMap = HashMap::new(); for task in tasks_completed { if let Some(pid) = task.project_id { *completed_by_project.entry(pid).or_default() += 1; } } for task in tasks_created { if let Some(pid) = task.project_id { *created_by_project.entry(pid).or_default() += 1; } } projects.iter() .filter_map(|p| { let completed = completed_by_project.get(&p.id).copied().unwrap_or(0); let created = created_by_project.get(&p.id).copied().unwrap_or(0); if completed == 0 && created == 0 { return None; } let direction = if created > completed { "growing" } else if completed > created { "shrinking" } else { "stable" }.to_string(); Some(ProjectPulse { id: p.id, name: p.name.clone(), completed, created, direction, }) }) .collect() } /// Computes simple pattern observations. fn compute_patterns( days: &[MonthDayData], all_tasks: &[Task], projects: &[crate::models::Project], tasks_completed: &[Task], ) -> Vec { let mut patterns = Vec::new(); // Day-of-week productivity pattern let day_names = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; let mut completions_by_dow = [0i32; 7]; let mut days_counted_by_dow = [0i32; 7]; for day in days.iter().filter(|d| d.is_past || d.is_today) { if let Ok(date) = chrono::NaiveDate::parse_from_str(&day.date, "%Y-%m-%d") { let dow = date.weekday().num_days_from_monday() as usize; completions_by_dow[dow] += day.completed_count; days_counted_by_dow[dow] += 1; } } // Find most productive day(s) of week let max_completions = completions_by_dow.iter().max().copied().unwrap_or(0); if max_completions > 0 { let best_days: Vec<&str> = completions_by_dow.iter() .enumerate() .filter(|(_, c)| **c == max_completions && **c > 0) .map(|(i, _)| day_names[i]) .collect(); if best_days.len() <= 2 && max_completions >= 3 { patterns.push(format!( "You completed the most tasks on {}", best_days.join(" and ") )); } } // Chronic overdue tasks (3+ weeks overdue) let now = Utc::now(); let chronic_overdue: Vec<_> = all_tasks.iter() .filter(|t| { t.is_overdue() && t.due.map(|d| (now - d).num_weeks() >= 3).unwrap_or(false) }) .collect(); if !chronic_overdue.is_empty() { patterns.push(format!( "{} task{} been overdue for 3+ weeks", chronic_overdue.len(), if chronic_overdue.len() == 1 { " has" } else { "s have" } )); } // Inactive projects let active_project_ids: std::collections::HashSet<_> = tasks_completed.iter() .filter_map(|t| t.project_id) .collect(); let inactive_projects: Vec<_> = projects.iter() .filter(|p| { !active_project_ids.contains(&p.id) && all_tasks.iter().any(|t| t.project_id == Some(p.id) && (t.status == TaskStatus::Pending || t.status == TaskStatus::Started)) }) .collect(); for p in inactive_projects.iter().take(2) { patterns.push(format!("{} had no activity this month", p.name)); } patterns } #[cfg(test)] mod tests { use super::*; #[test] fn test_month_end() { let jan = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(); assert_eq!(month_end(jan), NaiveDate::from_ymd_opt(2026, 1, 31).unwrap()); let feb = NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(); assert_eq!(month_end(feb), NaiveDate::from_ymd_opt(2026, 2, 28).unwrap()); let dec = NaiveDate::from_ymd_opt(2025, 12, 1).unwrap(); assert_eq!(month_end(dec), NaiveDate::from_ymd_opt(2025, 12, 31).unwrap()); } #[test] fn test_format_month_display() { let april = NaiveDate::from_ymd_opt(2026, 4, 1).unwrap(); assert_eq!(format_month_display(april), "April 2026"); } #[test] fn test_parse_month() { assert_eq!( parse_month("2026-04"), Some(NaiveDate::from_ymd_opt(2026, 4, 1).unwrap()) ); assert_eq!(parse_month("invalid"), None); assert_eq!(parse_month("2026-13"), None); } #[test] fn test_compute_streak() { let days = vec![ MonthDayData { date: "2026-04-01".into(), day_number: 1, is_today: false, is_past: true, is_vacation: false, completed_count: 1, event_count: 0, intensity: 1 }, MonthDayData { date: "2026-04-02".into(), day_number: 2, is_today: false, is_past: true, is_vacation: false, completed_count: 2, event_count: 0, intensity: 2 }, MonthDayData { date: "2026-04-03".into(), day_number: 3, is_today: false, is_past: true, is_vacation: false, completed_count: 0, event_count: 0, intensity: 0 }, MonthDayData { date: "2026-04-04".into(), day_number: 4, is_today: false, is_past: true, is_vacation: false, completed_count: 1, event_count: 0, intensity: 1 }, MonthDayData { date: "2026-04-05".into(), day_number: 5, is_today: true, is_past: false, is_vacation: false, completed_count: 1, event_count: 0, intensity: 1 }, ]; assert_eq!(compute_streak(&days), 2); // days 1-2, then broken by day 3 } #[test] fn test_streak_vacation_doesnt_break() { let days = vec![ MonthDayData { date: "2026-04-01".into(), day_number: 1, is_today: false, is_past: true, is_vacation: false, completed_count: 1, event_count: 0, intensity: 1 }, MonthDayData { date: "2026-04-02".into(), day_number: 2, is_today: false, is_past: true, is_vacation: true, completed_count: 0, event_count: 0, intensity: 0 }, MonthDayData { date: "2026-04-03".into(), day_number: 3, is_today: true, is_past: false, is_vacation: false, completed_count: 1, event_count: 0, intensity: 1 }, ]; assert_eq!(compute_streak(&days), 2); // vacation doesn't break streak } }