//! Milestone management commands. //! //! Provides CRUD operations for milestones within projects. //! Milestones are scope boundaries: "when these tasks are done, ship it." use chrono::NaiveDate; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tauri::State; use tracing::instrument; use goingson_core::{DbValue, MilestoneId, MilestoneStatus, NewMilestone, ParseableEnum, ProjectId}; use crate::state::{AppState, DESKTOP_USER_ID}; use super::{ApiError, OptionNotFound, ResultApiError}; // ============ Types ============ #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MilestoneInput { pub project_id: ProjectId, pub name: String, pub description: Option, pub target_date: Option, pub status: Option, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct MilestoneResponse { pub id: MilestoneId, pub project_id: ProjectId, pub name: String, pub description: String, pub position: i32, pub target_date: Option, pub status: String, pub created_at: String, pub task_count: usize, pub completed_count: usize, pub progress: u8, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ReorderMilestonesInput { pub milestone_ids: Vec, } // ============ Commands ============ /// Lists all milestones for a project with task progress. #[tauri::command] #[instrument(skip_all)] pub async fn list_milestones( state: State<'_, Arc>, project_id: ProjectId, ) -> Result, ApiError> { let milestones = state.milestones .list_by_project(project_id, DESKTOP_USER_ID) .await?; let all_tasks = state.tasks.list_by_project(DESKTOP_USER_ID, project_id).await?; let mut responses = Vec::with_capacity(milestones.len()); for m in milestones { let milestone_tasks: Vec<_> = all_tasks.iter() .filter(|t| t.milestone_id == Some(m.id)) .collect(); let task_count = milestone_tasks.len(); let completed_count = milestone_tasks.iter() .filter(|t| t.status == goingson_core::TaskStatus::Completed) .count(); let progress = if task_count > 0 { ((completed_count as f64 / task_count as f64) * 100.0).round() as u8 } else { 0 }; responses.push(MilestoneResponse { id: m.id, project_id: m.project_id, name: m.name, description: m.description, position: m.position, target_date: m.target_date.map(|d| d.format("%Y-%m-%d").to_string()), status: m.status.db_value().to_string(), created_at: m.created_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(), task_count, completed_count, progress, }); } Ok(responses) } /// Creates a new milestone in a project. #[tauri::command] #[instrument(skip_all)] pub async fn create_milestone( state: State<'_, Arc>, input: MilestoneInput, ) -> Result { if input.name.trim().is_empty() { return Err(ApiError::validation("name", "Milestone name is required")); } let target_date = input.target_date .as_deref() .filter(|s| !s.is_empty()) .map(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d")) .transpose() .map_api_err("Invalid target_date", ApiError::parse)?; // Get next position let existing = state.milestones .list_by_project(input.project_id, DESKTOP_USER_ID) .await?; let position = existing.len() as i32; let new_milestone = NewMilestone { project_id: input.project_id, name: input.name, description: input.description.unwrap_or_default(), position, target_date, }; let m = state.milestones .create(DESKTOP_USER_ID, new_milestone) .await?; Ok(MilestoneResponse { id: m.id, project_id: m.project_id, name: m.name, description: m.description, position: m.position, target_date: m.target_date.map(|d| d.format("%Y-%m-%d").to_string()), status: m.status.db_value().to_string(), created_at: m.created_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(), task_count: 0, completed_count: 0, progress: 0, }) } /// Updates an existing milestone. #[tauri::command] #[instrument(skip_all)] pub async fn update_milestone( state: State<'_, Arc>, id: MilestoneId, input: MilestoneInput, ) -> Result { if input.name.trim().is_empty() { return Err(ApiError::validation("name", "Milestone name is required")); } let target_date = input.target_date .as_deref() .filter(|s| !s.is_empty()) .map(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d")) .transpose() .map_api_err("Invalid target_date", ApiError::parse)?; let status = input.status .as_deref() .map(MilestoneStatus::from_str_or_default) .unwrap_or(MilestoneStatus::Open); let m = state.milestones .update( id, DESKTOP_USER_ID, &input.name, &input.description.unwrap_or_default(), target_date, &status, ) .await? .or_not_found("milestone", id)?; // Compute task progress let tasks = state.tasks.list_by_project(DESKTOP_USER_ID, m.project_id).await?; let milestone_tasks: Vec<_> = tasks.iter() .filter(|t| t.milestone_id == Some(m.id)) .collect(); let task_count = milestone_tasks.len(); let completed_count = milestone_tasks.iter() .filter(|t| t.status == goingson_core::TaskStatus::Completed) .count(); let progress = if task_count > 0 { ((completed_count as f64 / task_count as f64) * 100.0).round() as u8 } else { 0 }; Ok(MilestoneResponse { id: m.id, project_id: m.project_id, name: m.name, description: m.description, position: m.position, target_date: m.target_date.map(|d| d.format("%Y-%m-%d").to_string()), status: m.status.db_value().to_string(), created_at: m.created_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(), task_count, completed_count, progress, }) } /// Deletes a milestone. #[tauri::command] #[instrument(skip_all)] pub async fn delete_milestone( state: State<'_, Arc>, id: MilestoneId, ) -> Result { Ok(state.milestones.delete(id, DESKTOP_USER_ID).await?) } /// Reorders milestones within a project. #[tauri::command] #[instrument(skip_all)] pub async fn reorder_milestones( state: State<'_, Arc>, project_id: ProjectId, input: ReorderMilestonesInput, ) -> Result<(), ApiError> { state.milestones .reorder(project_id, DESKTOP_USER_ID, &input.milestone_ids) .await?; Ok(()) }