Skip to main content

max / goingson

10.1 KB · 292 lines History Blame Raw
1 //! Weekly review commands.
2 //!
3 //! Thin wrapper around `goingson_core::weekly_review` — fetches data from
4 //! repositories and delegates aggregation to the core crate.
5
6 use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc, Weekday};
7 use serde::{Deserialize, Serialize};
8 use std::sync::Arc;
9 use tauri::State;
10 use tracing::instrument;
11
12 use goingson_core::weekly_review::{
13 self, EventSummary, ProjectHealth, ProjectSummary, TimelineDayData, WeeklyReviewInput,
14 };
15 use goingson_core::{TaskId, WeeklyReview, expand_recurrence};
16
17 use crate::state::{AppState, DESKTOP_USER_ID};
18 use super::ApiError;
19 use super::task::TaskResponse;
20
21 // ============ Response Type ============
22
23 /// Pre-computed weekly review data for the frontend.
24 /// Maps `WeeklyReviewData` task lists from `Task` → `TaskResponse`.
25 #[derive(Debug, Serialize)]
26 #[serde(rename_all = "camelCase")]
27 pub struct WeeklyReviewResponse {
28 pub week_start_date: String,
29 pub week_end_date: String,
30 pub week_display: String,
31 pub is_completed: bool,
32 pub completed_at: Option<DateTime<Utc>>,
33 pub notes: String,
34
35 pub timeline_days: Vec<TimelineDayData>,
36
37 pub tasks_completed_count: usize,
38 pub tasks_completed: Vec<TaskResponse>,
39 pub tasks_overdue_count: usize,
40 pub tasks_overdue: Vec<TaskResponse>,
41 pub events_occurred_count: usize,
42 pub events_occurred: Vec<EventSummary>,
43 pub tasks_pending_count: usize,
44 pub carried_over_tasks: Vec<TaskResponse>,
45 pub carried_over_count: usize,
46
47 pub tasks_due_next_week_count: usize,
48 pub tasks_due_next_week: Vec<TaskResponse>,
49 pub tasks_already_overdue_count: usize,
50
51 pub focused_tasks: Vec<TaskResponse>,
52 pub available_for_focus: Vec<TaskResponse>,
53 pub focused_projects: Vec<ProjectSummary>,
54 pub project_health: Vec<ProjectHealth>,
55
56 pub vacation_days: Vec<u8>,
57 pub show_nudge: bool,
58 }
59
60 // ============ Input Types ============
61
62 #[derive(Debug, Deserialize, Default)]
63 #[serde(rename_all = "camelCase")]
64 pub struct WeekStartInput {
65 /// Optional week start in YYYY-MM-DD format (any date in the week works).
66 /// Defaults to current week.
67 pub week_start: Option<String>,
68 }
69
70 #[derive(Debug, Deserialize)]
71 #[serde(rename_all = "camelCase")]
72 pub struct CompleteReviewInput {
73 pub notes: String,
74 pub week_start: Option<String>,
75 }
76
77 #[derive(Debug, Deserialize)]
78 #[serde(rename_all = "camelCase")]
79 pub struct SetFocusInput {
80 pub is_focus: bool,
81 }
82
83 #[derive(Debug, Deserialize)]
84 #[serde(rename_all = "camelCase")]
85 pub struct VacationDaysInput {
86 pub days: Vec<u8>,
87 pub week_start: Option<String>,
88 }
89
90 fn resolve_week_start(s: Option<&str>) -> Result<NaiveDate, ApiError> {
91 match s {
92 Some(raw) => weekly_review::parse_week_start(raw)
93 .ok_or_else(|| ApiError::bad_request("Invalid weekStart, expected YYYY-MM-DD")),
94 None => Ok(weekly_review::current_week_start()),
95 }
96 }
97
98 // ============ Commands ============
99
100 /// Gets the weekly review data for the requested week (or current week if omitted).
101 /// Fetches data from repositories, delegates aggregation to core.
102 #[tauri::command]
103 #[instrument(skip_all)]
104 pub async fn get_weekly_review(
105 state: State<'_, Arc<AppState>>,
106 input: Option<WeekStartInput>,
107 ) -> Result<WeeklyReviewResponse, ApiError> {
108 let week_start = resolve_week_start(input.as_ref().and_then(|i| i.week_start.as_deref()))?;
109 let week_end_date = weekly_review::week_end(week_start);
110
111 // Time boundaries for queries
112 let week_start_dt = week_start.and_hms_opt(0, 0, 0)
113 .map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc))
114 .unwrap_or_else(Utc::now);
115 let week_end_dt = week_end_date.and_hms_opt(23, 59, 59)
116 .map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc))
117 .unwrap_or_else(Utc::now);
118 let next_week_end_dt = week_end_dt + Duration::days(7);
119 let now = Utc::now();
120
121 // Fetch all data from repositories in parallel
122 let epoch_start = DateTime::<Utc>::from_naive_utc_and_offset(
123 NaiveDate::from_ymd_opt(2000, 1, 1).expect("2000-01-01 is valid").and_hms_opt(0, 0, 0).expect("midnight is valid"),
124 Utc,
125 );
126
127 let (
128 review,
129 tasks_completed,
130 tasks_overdue,
131 events_occurred,
132 upcoming_events,
133 recurring_events,
134 tasks_due_next_week,
135 tasks_already_overdue,
136 all_tasks,
137 focused_tasks,
138 available_for_focus,
139 projects,
140 ) = tokio::join!(
141 state.weekly_reviews.get_for_week(DESKTOP_USER_ID, week_start),
142 state.tasks.list_completed_between(DESKTOP_USER_ID, week_start_dt, week_end_dt),
143 state.tasks.list_became_overdue_between(DESKTOP_USER_ID, week_start_dt, week_end_dt),
144 state.events.list_between(DESKTOP_USER_ID, week_start_dt, now.min(week_end_dt)),
145 state.events.list_between(DESKTOP_USER_ID, now, next_week_end_dt),
146 state.events.list_recurring(DESKTOP_USER_ID),
147 state.tasks.list_due_between(DESKTOP_USER_ID, now, next_week_end_dt),
148 state.tasks.list_became_overdue_between(DESKTOP_USER_ID, epoch_start, week_start_dt),
149 state.tasks.list_all(DESKTOP_USER_ID),
150 state.tasks.list_focused(DESKTOP_USER_ID),
151 state.tasks.list_available_for_focus(DESKTOP_USER_ID, 10),
152 state.projects.list_all(DESKTOP_USER_ID),
153 );
154
155 let review = review?;
156 let tasks_completed = tasks_completed?;
157 let tasks_overdue = tasks_overdue?;
158 let recurring_events = recurring_events?;
159
160 // Expand recurring events into both occurred and upcoming ranges
161 let mut events_occurred = events_occurred?;
162 let past_range_end = now.min(week_end_dt);
163 for r in &recurring_events {
164 let expanded = expand_recurrence(r, week_start_dt, past_range_end);
165 events_occurred.extend(expanded);
166 }
167 events_occurred.sort_by_key(|e| e.start_time);
168
169 let mut upcoming_events = upcoming_events?;
170 for r in &recurring_events {
171 let expanded = expand_recurrence(r, now, next_week_end_dt);
172 upcoming_events.extend(expanded);
173 }
174 upcoming_events.sort_by_key(|e| e.start_time);
175 let tasks_due_next_week = tasks_due_next_week?;
176 let tasks_already_overdue = tasks_already_overdue?;
177 let all_tasks = all_tasks?;
178 let focused_tasks = focused_tasks?;
179 let available_for_focus = available_for_focus?;
180 let projects = projects?;
181
182 // Delegate aggregation to core
183 let data = weekly_review::compute_weekly_review(WeeklyReviewInput {
184 week_start,
185 review,
186 tasks_completed,
187 tasks_overdue,
188 events_occurred,
189 upcoming_events,
190 tasks_due_next_week,
191 tasks_already_overdue,
192 all_tasks,
193 focused_tasks,
194 available_for_focus,
195 projects,
196 });
197
198 // Convert Task → TaskResponse for frontend
199 Ok(WeeklyReviewResponse {
200 week_start_date: data.week_start_date,
201 week_end_date: data.week_end_date,
202 week_display: data.week_display,
203 is_completed: data.is_completed,
204 completed_at: data.completed_at,
205 notes: data.notes,
206
207 timeline_days: data.timeline_days,
208
209 tasks_completed_count: data.tasks_completed_count,
210 tasks_completed: data.tasks_completed.into_iter().map(TaskResponse::from).collect(),
211 tasks_overdue_count: data.tasks_overdue_count,
212 tasks_overdue: data.tasks_overdue.into_iter().map(TaskResponse::from).collect(),
213 events_occurred_count: data.events_occurred_count,
214 events_occurred: data.events_occurred,
215 tasks_pending_count: data.tasks_pending_count,
216 carried_over_tasks: data.carried_over_tasks.into_iter().map(TaskResponse::from).collect(),
217 carried_over_count: data.carried_over_count,
218
219 tasks_due_next_week_count: data.tasks_due_next_week_count,
220 tasks_due_next_week: data.tasks_due_next_week.into_iter().map(TaskResponse::from).collect(),
221 tasks_already_overdue_count: data.tasks_already_overdue_count,
222
223 focused_tasks: data.focused_tasks.into_iter().map(TaskResponse::from).collect(),
224 available_for_focus: data.available_for_focus.into_iter().map(TaskResponse::from).collect(),
225 focused_projects: data.focused_projects,
226 project_health: data.project_health,
227
228 vacation_days: data.vacation_days,
229 show_nudge: data.show_nudge,
230 })
231 }
232
233 /// Completes the weekly review for the given week (or current week if omitted).
234 #[tauri::command]
235 #[instrument(skip_all)]
236 pub async fn complete_weekly_review(
237 state: State<'_, Arc<AppState>>,
238 input: CompleteReviewInput,
239 ) -> Result<WeeklyReview, ApiError> {
240 let week_start = resolve_week_start(input.week_start.as_deref())?;
241 Ok(state.weekly_reviews.upsert(DESKTOP_USER_ID, week_start, &input.notes).await?)
242 }
243
244 /// Sets or clears the focus status on a task.
245 #[tauri::command]
246 #[instrument(skip_all)]
247 pub async fn set_task_focus(
248 state: State<'_, Arc<AppState>>,
249 id: TaskId,
250 input: SetFocusInput,
251 ) -> Result<Option<TaskResponse>, ApiError> {
252 let task = state.tasks.set_focus(id, DESKTOP_USER_ID, input.is_focus).await?;
253 Ok(task.map(TaskResponse::from))
254 }
255
256 /// Clears focus from all tasks.
257 #[tauri::command]
258 #[instrument(skip_all)]
259 pub async fn clear_all_focus(
260 state: State<'_, Arc<AppState>>,
261 ) -> Result<u64, ApiError> {
262 Ok(state.tasks.clear_all_focus(DESKTOP_USER_ID).await?)
263 }
264
265 /// Sets vacation days for the given week (or current week if omitted).
266 #[tauri::command]
267 #[instrument(skip_all)]
268 pub async fn set_vacation_days(
269 state: State<'_, Arc<AppState>>,
270 input: VacationDaysInput,
271 ) -> Result<(), ApiError> {
272 let week_start = resolve_week_start(input.week_start.as_deref())?;
273 state.weekly_reviews.set_vacation_days(DESKTOP_USER_ID, week_start, &input.days).await?;
274 Ok(())
275 }
276
277 /// Checks if the weekly review nudge should be shown.
278 /// Call this on app startup to show Monday nudge.
279 #[tauri::command]
280 #[instrument(skip_all)]
281 pub async fn check_weekly_review_nudge(
282 state: State<'_, Arc<AppState>>,
283 ) -> Result<bool, ApiError> {
284 let is_monday = Utc::now().weekday() == Weekday::Mon;
285 if !is_monday {
286 return Ok(false);
287 }
288
289 let is_completed = state.weekly_reviews.is_current_week_completed(DESKTOP_USER_ID).await?;
290 Ok(!is_completed)
291 }
292