//! Monthly review commands. //! //! Provides the Month view with heat-map data, stats, goals, reflections, //! and pattern insights. Delegates aggregation to `goingson_core::monthly_review`. use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tauri::State; use tracing::instrument; use goingson_core::monthly_review::{ self, MonthDayData, MonthlyReviewData, MonthlyReviewInput, ProjectPulse, }; use goingson_core::weekly_review::ProjectHealth; use goingson_core::{MonthlyGoal, MonthlyGoalId, MonthlyGoalStatus, MonthlyReflection}; use crate::state::{AppState, DESKTOP_USER_ID}; use super::ApiError; use super::task::TaskResponse; // ============ Response Type ============ /// Pre-computed monthly review data for the frontend. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct MonthlyReviewResponse { pub month: String, pub month_display: String, pub month_start_date: String, pub month_end_date: String, pub days: Vec, pub week_count: u32, pub first_day_offset: u32, pub tasks_completed_count: usize, pub tasks_completed_top: Vec, pub tasks_created_count: usize, pub events_count: usize, pub busiest_day: Option, pub quietest_day: Option, pub completion_streak: u32, pub project_pulse: Vec, pub project_health: Vec, pub goals: Vec, pub reflection: Option, pub patterns: Vec, } impl From for MonthlyReviewResponse { fn from(d: MonthlyReviewData) -> Self { Self { month: d.month, month_display: d.month_display, month_start_date: d.month_start_date, month_end_date: d.month_end_date, days: d.days, week_count: d.week_count, first_day_offset: d.first_day_offset, tasks_completed_count: d.tasks_completed_count, tasks_completed_top: d.tasks_completed_top.into_iter().map(TaskResponse::from).collect(), tasks_created_count: d.tasks_created_count, events_count: d.events_count, busiest_day: d.busiest_day, quietest_day: d.quietest_day, completion_streak: d.completion_streak, project_pulse: d.project_pulse, project_health: d.project_health, goals: d.goals, reflection: d.reflection, patterns: d.patterns, } } } // ============ Input Types ============ #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MonthInput { /// Optional month in YYYY-MM format. Defaults to current month. pub month: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpsertGoalInput { pub month: String, pub text: String, pub position: i32, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpdateGoalStatusInput { pub status: MonthlyGoalStatus, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SaveReflectionInput { pub month: String, pub highlight: String, pub change: String, } // ============ Commands ============ /// Gets the monthly review data for a given month (or current month). #[tauri::command] #[instrument(skip_all)] pub async fn get_monthly_review( state: State<'_, Arc>, input: MonthInput, ) -> Result { // Resolve month boundaries let month_start = match &input.month { Some(m) => monthly_review::parse_month(m) .ok_or_else(|| ApiError::bad_request("Invalid month format, expected YYYY-MM"))?, None => monthly_review::current_month_start(), }; let month_end_date = monthly_review::month_end(month_start); // Time boundaries let month_start_dt = month_start.and_hms_opt(0, 0, 0) .map(|dt| DateTime::::from_naive_utc_and_offset(dt, Utc)) .unwrap_or_else(Utc::now); let month_end_dt = month_end_date.and_hms_opt(23, 59, 59) .map(|dt| DateTime::::from_naive_utc_and_offset(dt, Utc)) .unwrap_or_else(Utc::now); let month_str = month_start.format("%Y-%m").to_string(); // Fetch all data from repositories let tasks_completed = state.tasks.list_completed_between(DESKTOP_USER_ID, month_start_dt, month_end_dt).await?; let tasks_created = state.tasks.list_created_between(DESKTOP_USER_ID, month_start_dt, month_end_dt).await?; let events = state.events.list_between(DESKTOP_USER_ID, month_start_dt, month_end_dt).await?; let all_tasks = state.tasks.list_all(DESKTOP_USER_ID).await?; let projects = state.projects.list_all(DESKTOP_USER_ID).await?; let goals = state.monthly_reviews.list_goals(DESKTOP_USER_ID, &month_str).await?; let reflection = state.monthly_reviews.get_reflection(DESKTOP_USER_ID, &month_str).await?; // Collect vacation days from weekly reviews that fall within this month let vacation_days = collect_vacation_days(&state, month_start, month_end_date).await; let data = monthly_review::compute_monthly_review(MonthlyReviewInput { month_start, month_end: month_end_date, tasks_completed, tasks_created, events, all_tasks, projects, goals, reflection, vacation_days, }); Ok(MonthlyReviewResponse::from(data)) } /// Collects vacation days from weekly reviews that overlap with the given month. async fn collect_vacation_days( state: &AppState, month_start: NaiveDate, month_end: NaiveDate, ) -> Vec { let mut vacation_dates = Vec::new(); // Check each week that overlaps with this month let mut week_monday = { let dow = month_start.weekday().num_days_from_monday(); month_start - Duration::days(dow as i64) }; while week_monday <= month_end { if let Ok(Some(review)) = state.weekly_reviews.get_for_week(DESKTOP_USER_ID, week_monday).await { for &day_idx in &review.vacation_days { let date = week_monday + Duration::days(day_idx as i64); if date >= month_start && date <= month_end { vacation_dates.push(date); } } } week_monday += Duration::days(7); } vacation_dates } /// Creates or updates a monthly goal. #[tauri::command] #[instrument(skip_all)] pub async fn upsert_monthly_goal( state: State<'_, Arc>, input: UpsertGoalInput, ) -> Result { if input.position < 1 || input.position > 3 { return Err(ApiError::bad_request("Position must be 1-3")); } if input.text.trim().is_empty() { return Err(ApiError::bad_request("Goal text cannot be empty")); } Ok(state.monthly_reviews.upsert_goal(DESKTOP_USER_ID, &input.month, &input.text, input.position).await?) } /// Updates the status of a monthly goal. #[tauri::command] #[instrument(skip_all)] pub async fn update_monthly_goal_status( state: State<'_, Arc>, id: MonthlyGoalId, input: UpdateGoalStatusInput, ) -> Result, ApiError> { Ok(state.monthly_reviews.update_goal_status(id, DESKTOP_USER_ID, &input.status).await?) } /// Deletes a monthly goal. #[tauri::command] #[instrument(skip_all)] pub async fn delete_monthly_goal( state: State<'_, Arc>, id: MonthlyGoalId, ) -> Result { Ok(state.monthly_reviews.delete_goal(id, DESKTOP_USER_ID).await?) } /// Saves the monthly reflection. #[tauri::command] #[instrument(skip_all)] pub async fn save_monthly_reflection( state: State<'_, Arc>, input: SaveReflectionInput, ) -> Result { Ok(state.monthly_reviews.upsert_reflection(DESKTOP_USER_ID, &input.month, &input.highlight, &input.change).await?) }