Skip to main content

max / goingson

6.9 KB · 238 lines History Blame Raw
1 //! Milestone management commands.
2 //!
3 //! Provides CRUD operations for milestones within projects.
4 //! Milestones are scope boundaries: "when these tasks are done, ship it."
5
6 use chrono::NaiveDate;
7 use serde::{Deserialize, Serialize};
8 use std::sync::Arc;
9 use tauri::State;
10 use tracing::instrument;
11
12 use goingson_core::{DbValue, MilestoneId, MilestoneStatus, NewMilestone, ParseableEnum, ProjectId};
13
14 use crate::state::{AppState, DESKTOP_USER_ID};
15 use super::{ApiError, OptionNotFound, ResultApiError};
16
17 // ============ Types ============
18
19 #[derive(Debug, Deserialize)]
20 #[serde(rename_all = "camelCase")]
21 pub struct MilestoneInput {
22 pub project_id: ProjectId,
23 pub name: String,
24 pub description: Option<String>,
25 pub target_date: Option<String>,
26 pub status: Option<String>,
27 }
28
29 #[derive(Debug, Serialize)]
30 #[serde(rename_all = "camelCase")]
31 pub struct MilestoneResponse {
32 pub id: MilestoneId,
33 pub project_id: ProjectId,
34 pub name: String,
35 pub description: String,
36 pub position: i32,
37 pub target_date: Option<String>,
38 pub status: String,
39 pub created_at: String,
40 pub task_count: usize,
41 pub completed_count: usize,
42 pub progress: u8,
43 }
44
45 #[derive(Debug, Deserialize)]
46 #[serde(rename_all = "camelCase")]
47 pub struct ReorderMilestonesInput {
48 pub milestone_ids: Vec<MilestoneId>,
49 }
50
51 // ============ Commands ============
52
53 /// Lists all milestones for a project with task progress.
54 #[tauri::command]
55 #[instrument(skip_all)]
56 pub async fn list_milestones(
57 state: State<'_, Arc<AppState>>,
58 project_id: ProjectId,
59 ) -> Result<Vec<MilestoneResponse>, ApiError> {
60 let milestones = state.milestones
61 .list_by_project(project_id, DESKTOP_USER_ID)
62 .await?;
63
64 let all_tasks = state.tasks.list_by_project(DESKTOP_USER_ID, project_id).await?;
65 let mut responses = Vec::with_capacity(milestones.len());
66 for m in milestones {
67 let milestone_tasks: Vec<_> = all_tasks.iter()
68 .filter(|t| t.milestone_id == Some(m.id))
69 .collect();
70 let task_count = milestone_tasks.len();
71 let completed_count = milestone_tasks.iter()
72 .filter(|t| t.status == goingson_core::TaskStatus::Completed)
73 .count();
74 let progress = if task_count > 0 {
75 ((completed_count as f64 / task_count as f64) * 100.0).round() as u8
76 } else {
77 0
78 };
79
80 responses.push(MilestoneResponse {
81 id: m.id,
82 project_id: m.project_id,
83 name: m.name,
84 description: m.description,
85 position: m.position,
86 target_date: m.target_date.map(|d| d.format("%Y-%m-%d").to_string()),
87 status: m.status.db_value().to_string(),
88 created_at: m.created_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
89 task_count,
90 completed_count,
91 progress,
92 });
93 }
94
95 Ok(responses)
96 }
97
98 /// Creates a new milestone in a project.
99 #[tauri::command]
100 #[instrument(skip_all)]
101 pub async fn create_milestone(
102 state: State<'_, Arc<AppState>>,
103 input: MilestoneInput,
104 ) -> Result<MilestoneResponse, ApiError> {
105 if input.name.trim().is_empty() {
106 return Err(ApiError::validation("name", "Milestone name is required"));
107 }
108
109 let target_date = input.target_date
110 .as_deref()
111 .filter(|s| !s.is_empty())
112 .map(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d"))
113 .transpose()
114 .map_api_err("Invalid target_date", ApiError::parse)?;
115
116 // Get next position
117 let existing = state.milestones
118 .list_by_project(input.project_id, DESKTOP_USER_ID)
119 .await?;
120 let position = existing.len() as i32;
121
122 let new_milestone = NewMilestone {
123 project_id: input.project_id,
124 name: input.name,
125 description: input.description.unwrap_or_default(),
126 position,
127 target_date,
128 };
129
130 let m = state.milestones
131 .create(DESKTOP_USER_ID, new_milestone)
132 .await?;
133
134 Ok(MilestoneResponse {
135 id: m.id,
136 project_id: m.project_id,
137 name: m.name,
138 description: m.description,
139 position: m.position,
140 target_date: m.target_date.map(|d| d.format("%Y-%m-%d").to_string()),
141 status: m.status.db_value().to_string(),
142 created_at: m.created_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
143 task_count: 0,
144 completed_count: 0,
145 progress: 0,
146 })
147 }
148
149 /// Updates an existing milestone.
150 #[tauri::command]
151 #[instrument(skip_all)]
152 pub async fn update_milestone(
153 state: State<'_, Arc<AppState>>,
154 id: MilestoneId,
155 input: MilestoneInput,
156 ) -> Result<MilestoneResponse, ApiError> {
157 if input.name.trim().is_empty() {
158 return Err(ApiError::validation("name", "Milestone name is required"));
159 }
160
161 let target_date = input.target_date
162 .as_deref()
163 .filter(|s| !s.is_empty())
164 .map(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d"))
165 .transpose()
166 .map_api_err("Invalid target_date", ApiError::parse)?;
167
168 let status = input.status
169 .as_deref()
170 .map(MilestoneStatus::from_str_or_default)
171 .unwrap_or(MilestoneStatus::Open);
172
173 let m = state.milestones
174 .update(
175 id,
176 DESKTOP_USER_ID,
177 &input.name,
178 &input.description.unwrap_or_default(),
179 target_date,
180 &status,
181 )
182 .await?
183 .or_not_found("milestone", id)?;
184
185 // Compute task progress
186 let tasks = state.tasks.list_by_project(DESKTOP_USER_ID, m.project_id).await?;
187 let milestone_tasks: Vec<_> = tasks.iter()
188 .filter(|t| t.milestone_id == Some(m.id))
189 .collect();
190 let task_count = milestone_tasks.len();
191 let completed_count = milestone_tasks.iter()
192 .filter(|t| t.status == goingson_core::TaskStatus::Completed)
193 .count();
194 let progress = if task_count > 0 {
195 ((completed_count as f64 / task_count as f64) * 100.0).round() as u8
196 } else {
197 0
198 };
199
200 Ok(MilestoneResponse {
201 id: m.id,
202 project_id: m.project_id,
203 name: m.name,
204 description: m.description,
205 position: m.position,
206 target_date: m.target_date.map(|d| d.format("%Y-%m-%d").to_string()),
207 status: m.status.db_value().to_string(),
208 created_at: m.created_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
209 task_count,
210 completed_count,
211 progress,
212 })
213 }
214
215 /// Deletes a milestone.
216 #[tauri::command]
217 #[instrument(skip_all)]
218 pub async fn delete_milestone(
219 state: State<'_, Arc<AppState>>,
220 id: MilestoneId,
221 ) -> Result<bool, ApiError> {
222 Ok(state.milestones.delete(id, DESKTOP_USER_ID).await?)
223 }
224
225 /// Reorders milestones within a project.
226 #[tauri::command]
227 #[instrument(skip_all)]
228 pub async fn reorder_milestones(
229 state: State<'_, Arc<AppState>>,
230 project_id: ProjectId,
231 input: ReorderMilestonesInput,
232 ) -> Result<(), ApiError> {
233 state.milestones
234 .reorder(project_id, DESKTOP_USER_ID, &input.milestone_ids)
235 .await?;
236 Ok(())
237 }
238