Skip to main content

max / goingson

10.3 KB · 296 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_in_tz};
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. The week's civil dates are in the user's
112 // local zone; convert via the local zone so edge-of-week rows aren't
113 // misattributed by the UTC offset (matches day_planning's handling).
114 let week_start_dt = crate::tz::local_civil_to_utc(
115 week_start.and_hms_opt(0, 0, 0).expect("00:00:00 is valid"),
116 );
117 let week_end_dt = crate::tz::local_civil_to_utc(
118 week_end_date.and_hms_opt(23, 59, 59).expect("23:59:59 is valid"),
119 );
120 let next_week_end_dt = week_end_dt + Duration::days(7);
121 let now = Utc::now();
122
123 // Fetch all data from repositories in parallel
124 let epoch_start = DateTime::<Utc>::from_naive_utc_and_offset(
125 NaiveDate::from_ymd_opt(2000, 1, 1).expect("2000-01-01 is valid").and_hms_opt(0, 0, 0).expect("midnight is valid"),
126 Utc,
127 );
128
129 let (
130 review,
131 tasks_completed,
132 tasks_overdue,
133 events_occurred,
134 upcoming_events,
135 recurring_events,
136 tasks_due_next_week,
137 tasks_already_overdue,
138 all_tasks,
139 focused_tasks,
140 available_for_focus,
141 projects,
142 ) = tokio::join!(
143 state.weekly_reviews.get_for_week(DESKTOP_USER_ID, week_start),
144 state.tasks.list_completed_between(DESKTOP_USER_ID, week_start_dt, week_end_dt),
145 state.tasks.list_became_overdue_between(DESKTOP_USER_ID, week_start_dt, week_end_dt),
146 state.events.list_between(DESKTOP_USER_ID, week_start_dt, now.min(week_end_dt)),
147 state.events.list_between(DESKTOP_USER_ID, now, next_week_end_dt),
148 state.events.list_recurring(DESKTOP_USER_ID),
149 state.tasks.list_due_between(DESKTOP_USER_ID, now, next_week_end_dt),
150 state.tasks.list_became_overdue_between(DESKTOP_USER_ID, epoch_start, week_start_dt),
151 state.tasks.list_all(DESKTOP_USER_ID),
152 state.tasks.list_focused(DESKTOP_USER_ID),
153 state.tasks.list_available_for_focus(DESKTOP_USER_ID, 10),
154 state.projects.list_all(DESKTOP_USER_ID),
155 );
156
157 let review = review?;
158 let tasks_completed = tasks_completed?;
159 let tasks_overdue = tasks_overdue?;
160 let recurring_events = recurring_events?;
161
162 // Expand recurring events into both occurred and upcoming ranges
163 let mut events_occurred = events_occurred?;
164 let past_range_end = now.min(week_end_dt);
165 let tz = crate::tz::system_tz();
166 for r in &recurring_events {
167 let expanded = expand_recurrence_in_tz(r, week_start_dt, past_range_end, tz);
168 events_occurred.extend(expanded);
169 }
170 events_occurred.sort_by_key(|e| e.start_time);
171
172 let mut upcoming_events = upcoming_events?;
173 for r in &recurring_events {
174 let expanded = expand_recurrence_in_tz(r, now, next_week_end_dt, tz);
175 upcoming_events.extend(expanded);
176 }
177 upcoming_events.sort_by_key(|e| e.start_time);
178 let tasks_due_next_week = tasks_due_next_week?;
179 let tasks_already_overdue = tasks_already_overdue?;
180 let all_tasks = all_tasks?;
181 let focused_tasks = focused_tasks?;
182 let available_for_focus = available_for_focus?;
183 let projects = projects?;
184
185 // Delegate aggregation to core
186 let data = weekly_review::compute_weekly_review(WeeklyReviewInput {
187 week_start,
188 review,
189 tasks_completed,
190 tasks_overdue,
191 events_occurred,
192 upcoming_events,
193 tasks_due_next_week,
194 tasks_already_overdue,
195 all_tasks,
196 focused_tasks,
197 available_for_focus,
198 projects,
199 tz,
200 });
201
202 // Convert Task → TaskResponse for frontend
203 Ok(WeeklyReviewResponse {
204 week_start_date: data.week_start_date,
205 week_end_date: data.week_end_date,
206 week_display: data.week_display,
207 is_completed: data.is_completed,
208 completed_at: data.completed_at,
209 notes: data.notes,
210
211 timeline_days: data.timeline_days,
212
213 tasks_completed_count: data.tasks_completed_count,
214 tasks_completed: data.tasks_completed.into_iter().map(TaskResponse::from).collect(),
215 tasks_overdue_count: data.tasks_overdue_count,
216 tasks_overdue: data.tasks_overdue.into_iter().map(TaskResponse::from).collect(),
217 events_occurred_count: data.events_occurred_count,
218 events_occurred: data.events_occurred,
219 tasks_pending_count: data.tasks_pending_count,
220 carried_over_tasks: data.carried_over_tasks.into_iter().map(TaskResponse::from).collect(),
221 carried_over_count: data.carried_over_count,
222
223 tasks_due_next_week_count: data.tasks_due_next_week_count,
224 tasks_due_next_week: data.tasks_due_next_week.into_iter().map(TaskResponse::from).collect(),
225 tasks_already_overdue_count: data.tasks_already_overdue_count,
226
227 focused_tasks: data.focused_tasks.into_iter().map(TaskResponse::from).collect(),
228 available_for_focus: data.available_for_focus.into_iter().map(TaskResponse::from).collect(),
229 focused_projects: data.focused_projects,
230 project_health: data.project_health,
231
232 vacation_days: data.vacation_days,
233 show_nudge: data.show_nudge,
234 })
235 }
236
237 /// Completes the weekly review for the given week (or current week if omitted).
238 #[tauri::command]
239 #[instrument(skip_all)]
240 pub async fn complete_weekly_review(
241 state: State<'_, Arc<AppState>>,
242 input: CompleteReviewInput,
243 ) -> Result<WeeklyReview, ApiError> {
244 let week_start = resolve_week_start(input.week_start.as_deref())?;
245 Ok(state.weekly_reviews.upsert(DESKTOP_USER_ID, week_start, &input.notes).await?)
246 }
247
248 /// Sets or clears the focus status on a task.
249 #[tauri::command]
250 #[instrument(skip_all)]
251 pub async fn set_task_focus(
252 state: State<'_, Arc<AppState>>,
253 id: TaskId,
254 input: SetFocusInput,
255 ) -> Result<Option<TaskResponse>, ApiError> {
256 let task = state.tasks.set_focus(id, DESKTOP_USER_ID, input.is_focus).await?;
257 Ok(task.map(TaskResponse::from))
258 }
259
260 /// Clears focus from all tasks.
261 #[tauri::command]
262 #[instrument(skip_all)]
263 pub async fn clear_all_focus(
264 state: State<'_, Arc<AppState>>,
265 ) -> Result<u64, ApiError> {
266 Ok(state.tasks.clear_all_focus(DESKTOP_USER_ID).await?)
267 }
268
269 /// Sets vacation days for the given week (or current week if omitted).
270 #[tauri::command]
271 #[instrument(skip_all)]
272 pub async fn set_vacation_days(
273 state: State<'_, Arc<AppState>>,
274 input: VacationDaysInput,
275 ) -> Result<(), ApiError> {
276 let week_start = resolve_week_start(input.week_start.as_deref())?;
277 state.weekly_reviews.set_vacation_days(DESKTOP_USER_ID, week_start, &input.days).await?;
278 Ok(())
279 }
280
281 /// Checks if the weekly review nudge should be shown.
282 /// Call this on app startup to show Monday nudge.
283 #[tauri::command]
284 #[instrument(skip_all)]
285 pub async fn check_weekly_review_nudge(
286 state: State<'_, Arc<AppState>>,
287 ) -> Result<bool, ApiError> {
288 let is_monday = Utc::now().weekday() == Weekday::Mon;
289 if !is_monday {
290 return Ok(false);
291 }
292
293 let is_completed = state.weekly_reviews.is_current_week_completed(DESKTOP_USER_ID).await?;
294 Ok(!is_completed)
295 }
296