Skip to main content

max / goingson

8.1 KB · 241 lines History Blame Raw
1 //! Monthly review commands.
2 //!
3 //! Provides the Month view with heat-map data, stats, goals, reflections,
4 //! and pattern insights. Delegates aggregation to `goingson_core::monthly_review`.
5
6 use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc};
7 use serde::{Deserialize, Serialize};
8 use std::sync::Arc;
9 use tauri::State;
10 use tracing::instrument;
11
12 use goingson_core::monthly_review::{
13 self, MonthDayData, MonthlyReviewData, MonthlyReviewInput, ProjectPulse,
14 };
15 use goingson_core::weekly_review::ProjectHealth;
16 use goingson_core::{MonthlyGoal, MonthlyGoalId, MonthlyGoalStatus, MonthlyReflection};
17
18 use crate::state::{AppState, DESKTOP_USER_ID};
19
20 use super::ApiError;
21 use super::task::TaskResponse;
22
23 // ============ Response Type ============
24
25 /// Pre-computed monthly review data for the frontend.
26 #[derive(Debug, Serialize)]
27 #[serde(rename_all = "camelCase")]
28 pub struct MonthlyReviewResponse {
29 pub month: String,
30 pub month_display: String,
31 pub month_start_date: String,
32 pub month_end_date: String,
33
34 pub days: Vec<MonthDayData>,
35 pub week_count: u32,
36 pub first_day_offset: u32,
37
38 pub tasks_completed_count: usize,
39 pub tasks_completed_top: Vec<TaskResponse>,
40 pub tasks_created_count: usize,
41 pub events_count: usize,
42 pub busiest_day: Option<String>,
43 pub quietest_day: Option<String>,
44 pub completion_streak: u32,
45
46 pub project_pulse: Vec<ProjectPulse>,
47 pub project_health: Vec<ProjectHealth>,
48
49 pub goals: Vec<MonthlyGoal>,
50 pub reflection: Option<MonthlyReflection>,
51
52 pub patterns: Vec<String>,
53 }
54
55 impl From<MonthlyReviewData> for MonthlyReviewResponse {
56 fn from(d: MonthlyReviewData) -> Self {
57 Self {
58 month: d.month,
59 month_display: d.month_display,
60 month_start_date: d.month_start_date,
61 month_end_date: d.month_end_date,
62 days: d.days,
63 week_count: d.week_count,
64 first_day_offset: d.first_day_offset,
65 tasks_completed_count: d.tasks_completed_count,
66 tasks_completed_top: d.tasks_completed_top.into_iter().map(TaskResponse::from).collect(),
67 tasks_created_count: d.tasks_created_count,
68 events_count: d.events_count,
69 busiest_day: d.busiest_day,
70 quietest_day: d.quietest_day,
71 completion_streak: d.completion_streak,
72 project_pulse: d.project_pulse,
73 project_health: d.project_health,
74 goals: d.goals,
75 reflection: d.reflection,
76 patterns: d.patterns,
77 }
78 }
79 }
80
81 // ============ Input Types ============
82
83 #[derive(Debug, Deserialize)]
84 #[serde(rename_all = "camelCase")]
85 pub struct MonthInput {
86 /// Optional month in YYYY-MM format. Defaults to current month.
87 pub month: Option<String>,
88 }
89
90 #[derive(Debug, Deserialize)]
91 #[serde(rename_all = "camelCase")]
92 pub struct UpsertGoalInput {
93 pub month: String,
94 pub text: String,
95 pub position: i32,
96 }
97
98 #[derive(Debug, Deserialize)]
99 #[serde(rename_all = "camelCase")]
100 pub struct UpdateGoalStatusInput {
101 pub status: MonthlyGoalStatus,
102 }
103
104 #[derive(Debug, Deserialize)]
105 #[serde(rename_all = "camelCase")]
106 pub struct SaveReflectionInput {
107 pub month: String,
108 pub highlight: String,
109 pub change: String,
110 }
111
112 // ============ Commands ============
113
114 /// Gets the monthly review data for a given month (or current month).
115 #[tauri::command]
116 #[instrument(skip_all)]
117 pub async fn get_monthly_review(
118 state: State<'_, Arc<AppState>>,
119 input: MonthInput,
120 ) -> Result<MonthlyReviewResponse, ApiError> {
121 // Resolve month boundaries
122 let month_start = match &input.month {
123 Some(m) => monthly_review::parse_month(m)
124 .ok_or_else(|| ApiError::bad_request("Invalid month format, expected YYYY-MM"))?,
125 None => monthly_review::current_month_start(),
126 };
127 let month_end_date = monthly_review::month_end(month_start);
128
129 // Time boundaries
130 let month_start_dt = month_start.and_hms_opt(0, 0, 0)
131 .map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc))
132 .unwrap_or_else(Utc::now);
133 let month_end_dt = month_end_date.and_hms_opt(23, 59, 59)
134 .map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc))
135 .unwrap_or_else(Utc::now);
136 let month_str = month_start.format("%Y-%m").to_string();
137
138 // Fetch all data from repositories
139 let tasks_completed = state.tasks.list_completed_between(DESKTOP_USER_ID, month_start_dt, month_end_dt).await?;
140 let tasks_created = state.tasks.list_created_between(DESKTOP_USER_ID, month_start_dt, month_end_dt).await?;
141 let events = state.events.list_between(DESKTOP_USER_ID, month_start_dt, month_end_dt).await?;
142 let all_tasks = state.tasks.list_all(DESKTOP_USER_ID).await?;
143 let projects = state.projects.list_all(DESKTOP_USER_ID).await?;
144 let goals = state.monthly_reviews.list_goals(DESKTOP_USER_ID, &month_str).await?;
145 let reflection = state.monthly_reviews.get_reflection(DESKTOP_USER_ID, &month_str).await?;
146
147 // Collect vacation days from weekly reviews that fall within this month
148 let vacation_days = collect_vacation_days(&state, month_start, month_end_date).await;
149
150 let data = monthly_review::compute_monthly_review(MonthlyReviewInput {
151 month_start,
152 month_end: month_end_date,
153 tasks_completed,
154 tasks_created,
155 events,
156 all_tasks,
157 projects,
158 goals,
159 reflection,
160 vacation_days,
161 });
162
163 Ok(MonthlyReviewResponse::from(data))
164 }
165
166 /// Collects vacation days from weekly reviews that overlap with the given month.
167 async fn collect_vacation_days(
168 state: &AppState,
169 month_start: NaiveDate,
170 month_end: NaiveDate,
171 ) -> Vec<NaiveDate> {
172 let mut vacation_dates = Vec::new();
173
174 // Check each week that overlaps with this month
175 let mut week_monday = {
176 let dow = month_start.weekday().num_days_from_monday();
177 month_start - Duration::days(dow as i64)
178 };
179
180 while week_monday <= month_end {
181 if let Ok(Some(review)) = state.weekly_reviews.get_for_week(DESKTOP_USER_ID, week_monday).await {
182 for &day_idx in &review.vacation_days {
183 let date = week_monday + Duration::days(day_idx as i64);
184 if date >= month_start && date <= month_end {
185 vacation_dates.push(date);
186 }
187 }
188 }
189 week_monday += Duration::days(7);
190 }
191
192 vacation_dates
193 }
194
195 /// Creates or updates a monthly goal.
196 #[tauri::command]
197 #[instrument(skip_all)]
198 pub async fn upsert_monthly_goal(
199 state: State<'_, Arc<AppState>>,
200 input: UpsertGoalInput,
201 ) -> Result<MonthlyGoal, ApiError> {
202 if input.position < 1 || input.position > 3 {
203 return Err(ApiError::bad_request("Position must be 1-3"));
204 }
205 if input.text.trim().is_empty() {
206 return Err(ApiError::bad_request("Goal text cannot be empty"));
207 }
208 Ok(state.monthly_reviews.upsert_goal(DESKTOP_USER_ID, &input.month, &input.text, input.position).await?)
209 }
210
211 /// Updates the status of a monthly goal.
212 #[tauri::command]
213 #[instrument(skip_all)]
214 pub async fn update_monthly_goal_status(
215 state: State<'_, Arc<AppState>>,
216 id: MonthlyGoalId,
217 input: UpdateGoalStatusInput,
218 ) -> Result<Option<MonthlyGoal>, ApiError> {
219 Ok(state.monthly_reviews.update_goal_status(id, DESKTOP_USER_ID, &input.status).await?)
220 }
221
222 /// Deletes a monthly goal.
223 #[tauri::command]
224 #[instrument(skip_all)]
225 pub async fn delete_monthly_goal(
226 state: State<'_, Arc<AppState>>,
227 id: MonthlyGoalId,
228 ) -> Result<bool, ApiError> {
229 Ok(state.monthly_reviews.delete_goal(id, DESKTOP_USER_ID).await?)
230 }
231
232 /// Saves the monthly reflection.
233 #[tauri::command]
234 #[instrument(skip_all)]
235 pub async fn save_monthly_reflection(
236 state: State<'_, Arc<AppState>>,
237 input: SaveReflectionInput,
238 ) -> Result<MonthlyReflection, ApiError> {
239 Ok(state.monthly_reviews.upsert_reflection(DESKTOP_USER_ID, &input.month, &input.highlight, &input.change).await?)
240 }
241