Skip to main content

max / goingson

8.2 KB · 244 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::{Datelike, Duration, NaiveDate};
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. The month's civil dates are in the user's local zone;
130 // convert via the local zone so edge-of-month rows aren't misattributed by
131 // the UTC offset (matches day_planning's handling).
132 let month_start_dt = crate::tz::local_civil_to_utc(
133 month_start.and_hms_opt(0, 0, 0).expect("00:00:00 is valid"),
134 );
135 let month_end_dt = crate::tz::local_civil_to_utc(
136 month_end_date.and_hms_opt(23, 59, 59).expect("23:59:59 is valid"),
137 );
138 let month_str = month_start.format("%Y-%m").to_string();
139
140 // Fetch all data from repositories
141 let tasks_completed = state.tasks.list_completed_between(DESKTOP_USER_ID, month_start_dt, month_end_dt).await?;
142 let tasks_created = state.tasks.list_created_between(DESKTOP_USER_ID, month_start_dt, month_end_dt).await?;
143 let events = state.events.list_between(DESKTOP_USER_ID, month_start_dt, month_end_dt).await?;
144 let all_tasks = state.tasks.list_all(DESKTOP_USER_ID).await?;
145 let projects = state.projects.list_all(DESKTOP_USER_ID).await?;
146 let goals = state.monthly_reviews.list_goals(DESKTOP_USER_ID, &month_str).await?;
147 let reflection = state.monthly_reviews.get_reflection(DESKTOP_USER_ID, &month_str).await?;
148
149 // Collect vacation days from weekly reviews that fall within this month
150 let vacation_days = collect_vacation_days(&state, month_start, month_end_date).await;
151
152 let data = monthly_review::compute_monthly_review(MonthlyReviewInput {
153 month_start,
154 month_end: month_end_date,
155 tasks_completed,
156 tasks_created,
157 events,
158 all_tasks,
159 projects,
160 goals,
161 reflection,
162 vacation_days,
163 tz: crate::tz::system_tz(),
164 });
165
166 Ok(MonthlyReviewResponse::from(data))
167 }
168
169 /// Collects vacation days from weekly reviews that overlap with the given month.
170 async fn collect_vacation_days(
171 state: &AppState,
172 month_start: NaiveDate,
173 month_end: NaiveDate,
174 ) -> Vec<NaiveDate> {
175 let mut vacation_dates = Vec::new();
176
177 // Check each week that overlaps with this month
178 let mut week_monday = {
179 let dow = month_start.weekday().num_days_from_monday();
180 month_start - Duration::days(dow as i64)
181 };
182
183 while week_monday <= month_end {
184 if let Ok(Some(review)) = state.weekly_reviews.get_for_week(DESKTOP_USER_ID, week_monday).await {
185 for &day_idx in &review.vacation_days {
186 let date = week_monday + Duration::days(day_idx as i64);
187 if date >= month_start && date <= month_end {
188 vacation_dates.push(date);
189 }
190 }
191 }
192 week_monday += Duration::days(7);
193 }
194
195 vacation_dates
196 }
197
198 /// Creates or updates a monthly goal.
199 #[tauri::command]
200 #[instrument(skip_all)]
201 pub async fn upsert_monthly_goal(
202 state: State<'_, Arc<AppState>>,
203 input: UpsertGoalInput,
204 ) -> Result<MonthlyGoal, ApiError> {
205 if input.position < 1 || input.position > 3 {
206 return Err(ApiError::bad_request("Position must be 1-3"));
207 }
208 if input.text.trim().is_empty() {
209 return Err(ApiError::bad_request("Goal text cannot be empty"));
210 }
211 Ok(state.monthly_reviews.upsert_goal(DESKTOP_USER_ID, &input.month, &input.text, input.position).await?)
212 }
213
214 /// Updates the status of a monthly goal.
215 #[tauri::command]
216 #[instrument(skip_all)]
217 pub async fn update_monthly_goal_status(
218 state: State<'_, Arc<AppState>>,
219 id: MonthlyGoalId,
220 input: UpdateGoalStatusInput,
221 ) -> Result<Option<MonthlyGoal>, ApiError> {
222 Ok(state.monthly_reviews.update_goal_status(id, DESKTOP_USER_ID, &input.status).await?)
223 }
224
225 /// Deletes a monthly goal.
226 #[tauri::command]
227 #[instrument(skip_all)]
228 pub async fn delete_monthly_goal(
229 state: State<'_, Arc<AppState>>,
230 id: MonthlyGoalId,
231 ) -> Result<bool, ApiError> {
232 Ok(state.monthly_reviews.delete_goal(id, DESKTOP_USER_ID).await?)
233 }
234
235 /// Saves the monthly reflection.
236 #[tauri::command]
237 #[instrument(skip_all)]
238 pub async fn save_monthly_reflection(
239 state: State<'_, Arc<AppState>>,
240 input: SaveReflectionInput,
241 ) -> Result<MonthlyReflection, ApiError> {
242 Ok(state.monthly_reviews.upsert_reflection(DESKTOP_USER_ID, &input.month, &input.highlight, &input.change).await?)
243 }
244