//! Weekly review commands. //! //! Thin wrapper around `goingson_core::weekly_review` — fetches data from //! repositories and delegates aggregation to the core crate. use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc, Weekday}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tauri::State; use tracing::instrument; use goingson_core::weekly_review::{ self, EventSummary, ProjectHealth, ProjectSummary, TimelineDayData, WeeklyReviewInput, }; use goingson_core::{TaskId, WeeklyReview, expand_recurrence}; use crate::state::{AppState, DESKTOP_USER_ID}; use super::ApiError; use super::task::TaskResponse; // ============ Response Type ============ /// Pre-computed weekly review data for the frontend. /// Maps `WeeklyReviewData` task lists from `Task` → `TaskResponse`. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct WeeklyReviewResponse { pub week_start_date: String, pub week_end_date: String, pub week_display: String, pub is_completed: bool, pub completed_at: Option>, pub notes: String, pub timeline_days: Vec, pub tasks_completed_count: usize, pub tasks_completed: Vec, pub tasks_overdue_count: usize, pub tasks_overdue: Vec, pub events_occurred_count: usize, pub events_occurred: Vec, pub tasks_pending_count: usize, pub carried_over_tasks: Vec, pub carried_over_count: usize, pub tasks_due_next_week_count: usize, pub tasks_due_next_week: Vec, pub tasks_already_overdue_count: usize, pub focused_tasks: Vec, pub available_for_focus: Vec, pub focused_projects: Vec, pub project_health: Vec, pub vacation_days: Vec, pub show_nudge: bool, } // ============ Input Types ============ #[derive(Debug, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct WeekStartInput { /// Optional week start in YYYY-MM-DD format (any date in the week works). /// Defaults to current week. pub week_start: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CompleteReviewInput { pub notes: String, pub week_start: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SetFocusInput { pub is_focus: bool, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct VacationDaysInput { pub days: Vec, pub week_start: Option, } fn resolve_week_start(s: Option<&str>) -> Result { match s { Some(raw) => weekly_review::parse_week_start(raw) .ok_or_else(|| ApiError::bad_request("Invalid weekStart, expected YYYY-MM-DD")), None => Ok(weekly_review::current_week_start()), } } // ============ Commands ============ /// Gets the weekly review data for the requested week (or current week if omitted). /// Fetches data from repositories, delegates aggregation to core. #[tauri::command] #[instrument(skip_all)] pub async fn get_weekly_review( state: State<'_, Arc>, input: Option, ) -> Result { let week_start = resolve_week_start(input.as_ref().and_then(|i| i.week_start.as_deref()))?; let week_end_date = weekly_review::week_end(week_start); // Time boundaries for queries let week_start_dt = week_start.and_hms_opt(0, 0, 0) .map(|dt| DateTime::::from_naive_utc_and_offset(dt, Utc)) .unwrap_or_else(Utc::now); let week_end_dt = week_end_date.and_hms_opt(23, 59, 59) .map(|dt| DateTime::::from_naive_utc_and_offset(dt, Utc)) .unwrap_or_else(Utc::now); let next_week_end_dt = week_end_dt + Duration::days(7); let now = Utc::now(); // Fetch all data from repositories in parallel let epoch_start = DateTime::::from_naive_utc_and_offset( NaiveDate::from_ymd_opt(2000, 1, 1).expect("2000-01-01 is valid").and_hms_opt(0, 0, 0).expect("midnight is valid"), Utc, ); let ( review, tasks_completed, tasks_overdue, events_occurred, upcoming_events, recurring_events, tasks_due_next_week, tasks_already_overdue, all_tasks, focused_tasks, available_for_focus, projects, ) = tokio::join!( state.weekly_reviews.get_for_week(DESKTOP_USER_ID, week_start), state.tasks.list_completed_between(DESKTOP_USER_ID, week_start_dt, week_end_dt), state.tasks.list_became_overdue_between(DESKTOP_USER_ID, week_start_dt, week_end_dt), state.events.list_between(DESKTOP_USER_ID, week_start_dt, now.min(week_end_dt)), state.events.list_between(DESKTOP_USER_ID, now, next_week_end_dt), state.events.list_recurring(DESKTOP_USER_ID), state.tasks.list_due_between(DESKTOP_USER_ID, now, next_week_end_dt), state.tasks.list_became_overdue_between(DESKTOP_USER_ID, epoch_start, week_start_dt), state.tasks.list_all(DESKTOP_USER_ID), state.tasks.list_focused(DESKTOP_USER_ID), state.tasks.list_available_for_focus(DESKTOP_USER_ID, 10), state.projects.list_all(DESKTOP_USER_ID), ); let review = review?; let tasks_completed = tasks_completed?; let tasks_overdue = tasks_overdue?; let recurring_events = recurring_events?; // Expand recurring events into both occurred and upcoming ranges let mut events_occurred = events_occurred?; let past_range_end = now.min(week_end_dt); for r in &recurring_events { let expanded = expand_recurrence(r, week_start_dt, past_range_end); events_occurred.extend(expanded); } events_occurred.sort_by_key(|e| e.start_time); let mut upcoming_events = upcoming_events?; for r in &recurring_events { let expanded = expand_recurrence(r, now, next_week_end_dt); upcoming_events.extend(expanded); } upcoming_events.sort_by_key(|e| e.start_time); let tasks_due_next_week = tasks_due_next_week?; let tasks_already_overdue = tasks_already_overdue?; let all_tasks = all_tasks?; let focused_tasks = focused_tasks?; let available_for_focus = available_for_focus?; let projects = projects?; // Delegate aggregation to core let data = weekly_review::compute_weekly_review(WeeklyReviewInput { week_start, review, tasks_completed, tasks_overdue, events_occurred, upcoming_events, tasks_due_next_week, tasks_already_overdue, all_tasks, focused_tasks, available_for_focus, projects, }); // Convert Task → TaskResponse for frontend Ok(WeeklyReviewResponse { week_start_date: data.week_start_date, week_end_date: data.week_end_date, week_display: data.week_display, is_completed: data.is_completed, completed_at: data.completed_at, notes: data.notes, timeline_days: data.timeline_days, tasks_completed_count: data.tasks_completed_count, tasks_completed: data.tasks_completed.into_iter().map(TaskResponse::from).collect(), tasks_overdue_count: data.tasks_overdue_count, tasks_overdue: data.tasks_overdue.into_iter().map(TaskResponse::from).collect(), events_occurred_count: data.events_occurred_count, events_occurred: data.events_occurred, tasks_pending_count: data.tasks_pending_count, carried_over_tasks: data.carried_over_tasks.into_iter().map(TaskResponse::from).collect(), carried_over_count: data.carried_over_count, tasks_due_next_week_count: data.tasks_due_next_week_count, tasks_due_next_week: data.tasks_due_next_week.into_iter().map(TaskResponse::from).collect(), tasks_already_overdue_count: data.tasks_already_overdue_count, focused_tasks: data.focused_tasks.into_iter().map(TaskResponse::from).collect(), available_for_focus: data.available_for_focus.into_iter().map(TaskResponse::from).collect(), focused_projects: data.focused_projects, project_health: data.project_health, vacation_days: data.vacation_days, show_nudge: data.show_nudge, }) } /// Completes the weekly review for the given week (or current week if omitted). #[tauri::command] #[instrument(skip_all)] pub async fn complete_weekly_review( state: State<'_, Arc>, input: CompleteReviewInput, ) -> Result { let week_start = resolve_week_start(input.week_start.as_deref())?; Ok(state.weekly_reviews.upsert(DESKTOP_USER_ID, week_start, &input.notes).await?) } /// Sets or clears the focus status on a task. #[tauri::command] #[instrument(skip_all)] pub async fn set_task_focus( state: State<'_, Arc>, id: TaskId, input: SetFocusInput, ) -> Result, ApiError> { let task = state.tasks.set_focus(id, DESKTOP_USER_ID, input.is_focus).await?; Ok(task.map(TaskResponse::from)) } /// Clears focus from all tasks. #[tauri::command] #[instrument(skip_all)] pub async fn clear_all_focus( state: State<'_, Arc>, ) -> Result { Ok(state.tasks.clear_all_focus(DESKTOP_USER_ID).await?) } /// Sets vacation days for the given week (or current week if omitted). #[tauri::command] #[instrument(skip_all)] pub async fn set_vacation_days( state: State<'_, Arc>, input: VacationDaysInput, ) -> Result<(), ApiError> { let week_start = resolve_week_start(input.week_start.as_deref())?; state.weekly_reviews.set_vacation_days(DESKTOP_USER_ID, week_start, &input.days).await?; Ok(()) } /// Checks if the weekly review nudge should be shown. /// Call this on app startup to show Monday nudge. #[tauri::command] #[instrument(skip_all)] pub async fn check_weekly_review_nudge( state: State<'_, Arc>, ) -> Result { let is_monday = Utc::now().weekday() == Weekday::Mon; if !is_monday { return Ok(false); } let is_completed = state.weekly_reviews.is_current_week_completed(DESKTOP_USER_ID).await?; Ok(!is_completed) }