//! Task CRUD and lifecycle commands. //! //! Provides core CRUD operations for tasks, the primary work unit in GoingsOn. //! Snooze/waiting commands live in [`super::task_state`], and annotation/subtask //! commands live in [`super::task_subtasks`]. //! //! # Task Features //! //! - Priority levels (High, Medium, Low) //! - Recurrence (Daily, Weekly, Monthly) //! - Urgency calculation based on priority, due date, age, and tags //! - Time blocking (scheduled_start + scheduled_duration) use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tauri::State; use tracing::instrument; use goingson_core::{ Annotation, MilestoneStatus, NewTask, ParseableEnum, Priority, Recurrence, RecurrenceRule, Subtask, Task, TaskStatus, UpdateTask, Validate, calculate_next_due, calculate_next_due_rich, calculate_urgency, parse_quick_add, TaskId, ProjectId, MilestoneId, ContactId, EmailId, date_utils::{format_relative_date, format_relative_future}, }; use crate::state::{AppState, DESKTOP_USER_ID}; use super::{ApiError, OptionNotFound}; // ============ Types ============ /// Frontend input for creating or updating a task. /// /// String-typed fields like `status`, `priority`, and `recurrence` are parsed /// into their enum equivalents in the command handler using `from_str_or_default`. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TaskInput { /// Associated project, if any. pub project_id: Option, /// Task description/title (required, validated non-empty by the command handler). pub description: String, /// Lifecycle status as a string ("Pending", "Started", "Completed"), parsed to `TaskStatus`. pub status: Option, /// Priority level as a string ("H", "M", "L" or full names), parsed to `Priority`. pub priority: String, /// Due date, if set. pub due: Option>, /// User-defined tags for categorization (defaults to empty vec if omitted). pub tags: Option>, /// Recurrence pattern as a string ("Daily", "Weekly", "Monthly"), parsed to `Recurrence`. pub recurrence: Option, /// Associated contact, if any. pub contact_id: Option, /// Target milestone within the project, if any. pub milestone_id: Option, /// Estimated duration in minutes. pub estimated_minutes: Option, /// Rich recurrence configuration (JSON). pub recurrence_rule: Option, } /// Task response with pre-computed fields for UI. /// Uses domain Task fields + computed fields for efficiency. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct TaskResponse { pub id: TaskId, pub project_id: Option, pub project_name: Option, pub description: String, pub description_html: String, pub status: String, pub priority: String, pub due: Option>, pub tags: Vec, pub urgency: f64, pub recurrence: String, pub recurrence_parent_id: Option, pub source_email_id: Option, pub snoozed_until: Option>, pub waiting_for_response: bool, pub waiting_since: Option>, pub expected_response_date: Option>, pub scheduled_start: Option>, pub scheduled_duration: Option, pub contact_id: Option, pub contact_name: Option, pub milestone_id: Option, pub annotations: Vec, pub subtasks: Vec, pub created_at: DateTime, /// Whether this task is marked as focus for the week pub is_focus: bool, /// When the focus was set pub focus_set_at: Option>, /// Estimated duration in minutes pub estimated_minutes: Option, /// Total tracked time in minutes pub actual_minutes: i32, /// Time progress as percentage (0-100+), None if no estimate pub time_progress: Option, /// Whether actual exceeds estimate pub is_over_estimate: bool, /// Whether a timer is currently running pub timer_active: bool, /// When the active timer started (for frontend elapsed display) pub timer_started_at: Option>, // Pre-computed fields /// True if snoozed_until > now pub is_snoozed: bool, /// True if due date is in the past pub is_overdue: bool, /// Total number of subtasks pub subtask_count: usize, /// Number of completed subtasks pub subtask_completed: usize, /// Urgency classification: "overdue", "high", "medium", or "low" pub urgency_class: String, /// Subtask progress as percentage (0-100), None if no subtasks pub subtask_progress: Option, /// Human-readable due date: "today", "tomorrow", "+3d", "2d ago", etc. pub due_formatted: Option, /// Human-readable snooze time: "today", "tomorrow", "+3d", "Mar 15" pub snoozed_until_formatted: Option, } impl From for TaskResponse { fn from(t: Task) -> Self { // Pre-compute fields let is_snoozed = t.is_snoozed(); let is_overdue = t.is_overdue(); let subtask_count = t.subtask_count(); let subtask_completed = t.subtasks_completed(); // Strip the "urgency-" CSS prefix so the frontend gets bare class names // ("overdue", "high", "medium", "low") for flexible styling. let urgency_class = t.urgency_class().trim_start_matches("urgency-").to_string(); let subtask_progress = if subtask_count > 0 { Some(((subtask_completed as f64 / subtask_count as f64) * 100.0).round() as u8) } else { None }; let now = Utc::now(); let due_formatted = t.due.map(|due| format_relative_date(due, now)); let snoozed_until_formatted = t.snoozed_until.map(|s| format_relative_future(s, now)); let time_progress = t.time_progress(); let is_over_estimate = t.is_over_estimate(); let timer_active = t.has_active_timer(); let timer_started_at = t.active_session.as_ref().map(|s| s.started_at); TaskResponse { id: t.id, project_id: t.project_id, project_name: t.project_name, description_html: docengine::render_standard(&t.description), description: t.description, status: t.status.as_str().to_string(), priority: t.priority.as_str().to_string(), due: t.due, tags: t.tags, urgency: t.urgency, recurrence: t.recurrence.as_str().to_string(), recurrence_parent_id: t.recurrence_parent_id, source_email_id: t.source_email_id, snoozed_until: t.snoozed_until, waiting_for_response: t.waiting_for_response, waiting_since: t.waiting_since, expected_response_date: t.expected_response_date, scheduled_start: t.scheduled_start, scheduled_duration: t.scheduled_duration, contact_id: t.contact_id, contact_name: t.contact_name, milestone_id: t.milestone_id, annotations: t.annotations, subtasks: t.subtasks, created_at: t.created_at, is_focus: t.is_focus, focus_set_at: t.focus_set_at, estimated_minutes: t.estimated_minutes, actual_minutes: t.actual_minutes, time_progress, is_over_estimate, timer_active, timer_started_at, is_snoozed, is_overdue, subtask_count, subtask_completed, urgency_class, subtask_progress, due_formatted, snoozed_until_formatted, } } } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct QuickAddInput { pub text: String, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct CompleteTaskResponse { pub completed: bool, pub next_recurring_task: Option, } /// Filter criteria for listing tasks. /// All fields are optional - omitted fields don't filter. #[derive(Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TaskFilterInput { /// Filter by status (Pending, Started, Completed) pub status: Option, /// Filter by project ID pub project_id: Option, /// Filter by milestone ID pub milestone_id: Option, /// Filter by priority (High, Medium, Low) pub priority: Option, /// Include snoozed tasks (default: false = hide snoozed) #[serde(default)] pub show_snoozed: bool, /// Show only tasks marked as waiting for response #[serde(default)] pub waiting_only: bool, /// Pagination: number of items to skip pub offset: Option, /// Pagination: maximum items to return pub limit: Option, /// Column to sort by: description, project, priority, due, urgency (default: urgency) pub sort_column: Option, /// Sort direction: asc or desc (default: desc for urgency, asc for others) pub sort_direction: Option, } /// Paginated response with total count for UI pagination. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct PaginatedTasksResponse { pub tasks: Vec, pub total: i64, } // ============ Task Commands ============ /// Lists all non-deleted tasks for the current user. /// /// Returns tasks sorted by urgency (descending) then creation date. /// /// # Errors /// /// Returns `DATABASE_ERROR` if the query fails. #[tauri::command] #[instrument(skip_all)] pub async fn list_tasks(state: State<'_, Arc>) -> Result, ApiError> { let tasks = state.tasks.list_all(DESKTOP_USER_ID).await?; Ok(tasks.into_iter().map(TaskResponse::from).collect()) } /// Lists tasks with server-side filtering and pagination. /// /// Returns paginated results with total count for UI pagination controls. /// /// # Arguments /// /// * `filters` - Filter criteria (all optional): /// - `status`: Filter by task status /// - `project_id`: Filter by project /// - `priority`: Filter by priority level /// - `show_snoozed`: Include snoozed tasks (default: false) /// - `waiting_only`: Show only tasks awaiting response /// - `offset`/`limit`: Pagination parameters /// /// # Errors /// /// Returns `DATABASE_ERROR` if the query fails. #[tauri::command] #[instrument(skip_all)] pub async fn list_tasks_filtered( state: State<'_, Arc>, filters: TaskFilterInput, ) -> Result { use goingson_core::{TaskFilterQuery, TaskStatus, Priority, TaskSortColumn, SortDirection}; let query = TaskFilterQuery { status: filters.status.map(|s| TaskStatus::from_str_or_default(&s)), project_id: filters.project_id, milestone_id: filters.milestone_id, priority: filters.priority.map(|p| Priority::from_str_or_default(&p)), show_snoozed: filters.show_snoozed, waiting_only: filters.waiting_only, offset: filters.offset, limit: filters.limit, sort_column: filters.sort_column.map(|s| TaskSortColumn::from_str_or_default(&s)), sort_direction: filters.sort_direction.map(|s| SortDirection::from_str_or_default(&s)), }; let (tasks, total) = state.tasks.list_filtered(DESKTOP_USER_ID, query).await?; Ok(PaginatedTasksResponse { tasks: tasks.into_iter().map(TaskResponse::from).collect(), total, }) } /// Retrieves a single task by ID. /// /// # Errors /// /// Returns `DATABASE_ERROR` if the query fails. /// Returns `None` (not an error) if the task doesn't exist. #[tauri::command] #[instrument(skip_all)] pub async fn get_task(state: State<'_, Arc>, id: TaskId) -> Result, ApiError> { let task = state.tasks.get_by_id(id, DESKTOP_USER_ID).await?; Ok(task.map(TaskResponse::from)) } /// Creates a new task. /// /// # Arguments /// /// * `input` - Task data: /// - `description` (required): Task description /// - `priority`: Priority level (defaults to Medium) /// - `due`: Optional due date /// - `project_id`: Optional project association /// - `tags`: Optional tags array /// - `recurrence`: Optional recurrence pattern /// /// # Errors /// /// Returns `VALIDATION_ERROR` if description is empty. /// Returns `DATABASE_ERROR` if the insert fails. #[tauri::command] #[instrument(skip_all)] pub async fn create_task(state: State<'_, Arc>, input: TaskInput) -> Result { if input.description.trim().is_empty() { return Err(ApiError::validation("description", "Description is required")); } let priority = Priority::from_str_or_default(&input.priority); let recurrence = input.recurrence.as_deref().map(Recurrence::from_str_or_default).unwrap_or(Recurrence::None); let tags = input.tags.unwrap_or_default(); let created_at = Utc::now(); let urgency = calculate_urgency( &priority, &TaskStatus::Pending, input.due.as_ref(), &created_at, &tags, ); let new_task = NewTask { project_id: input.project_id, description: input.description, priority, due: input.due, tags, recurrence, urgency, source_email_id: None, scheduled_start: None, scheduled_duration: None, estimated_minutes: input.estimated_minutes, contact_id: input.contact_id, milestone_id: input.milestone_id, recurrence_rule: input.recurrence_rule.clone(), recurrence_parent_id: None, }; new_task.validate()?; let task = state.tasks.create(DESKTOP_USER_ID, new_task).await?; Ok(TaskResponse::from(task)) } /// Creates a task from natural language input. /// /// Parses quick-add syntax like: /// - `Fix bug +work @today !high` /// - `Call mom @tomorrow #family` /// /// # Errors /// /// Returns `VALIDATION_ERROR` if the parsed description is empty. /// Returns `DATABASE_ERROR` if the insert fails. #[tauri::command] #[instrument(skip_all)] pub async fn quick_add_task(state: State<'_, Arc>, input: QuickAddInput) -> Result { let parsed = parse_quick_add(&input.text); if parsed.description.trim().is_empty() { return Err(ApiError::validation("text", "Task description is required")); } // Look up project by name if specified let project_id = if let Some(project_name) = &parsed.project_name { state.projects .find_by_name(DESKTOP_USER_ID, project_name) .await? .map(|p| p.id) } else { None }; let priority = parsed.priority.unwrap_or(Priority::Medium); let recurrence = parsed.recurrence.unwrap_or(Recurrence::None); let created_at = Utc::now(); let urgency = calculate_urgency( &priority, &TaskStatus::Pending, parsed.due.as_ref(), &created_at, &parsed.tags, ); let new_task = NewTask { project_id, description: parsed.description, priority, due: parsed.due, tags: parsed.tags, recurrence, urgency, source_email_id: None, scheduled_start: None, scheduled_duration: None, estimated_minutes: None, contact_id: None, milestone_id: None, recurrence_rule: None, recurrence_parent_id: None, }; new_task.validate()?; let task = state.tasks.create(DESKTOP_USER_ID, new_task).await?; Ok(TaskResponse::from(task)) } /// Updates an existing task. /// /// # Errors /// /// Returns `VALIDATION_ERROR` if description is empty. /// Returns `NOT_FOUND` if the task doesn't exist. /// Returns `DATABASE_ERROR` if the update fails. #[tauri::command] #[instrument(skip_all)] pub async fn update_task(state: State<'_, Arc>, id: TaskId, input: TaskInput) -> Result { if input.description.trim().is_empty() { return Err(ApiError::validation("description", "Description is required")); } let status = input.status.as_deref().map(TaskStatus::from_str_or_default).unwrap_or(TaskStatus::Pending); let priority = Priority::from_str_or_default(&input.priority); let recurrence = input.recurrence.as_deref().map(Recurrence::from_str_or_default).unwrap_or(Recurrence::None); let tags = input.tags.unwrap_or_default(); // Lightweight fetch for created_at + scheduling (avoids annotation/subtask/session sub-queries) let ctx = state.tasks .get_update_context(id, DESKTOP_USER_ID) .await? .or_not_found("task", id)?; let urgency = calculate_urgency( &priority, &status, input.due.as_ref(), &ctx.created_at, &tags, ); let update_task = UpdateTask { project_id: input.project_id, description: input.description, status, priority, due: input.due, tags, recurrence, urgency, scheduled_start: ctx.scheduled_start, scheduled_duration: ctx.scheduled_duration, estimated_minutes: input.estimated_minutes, contact_id: input.contact_id, milestone_id: input.milestone_id, }; update_task.validate()?; let task = state.tasks .update(id, DESKTOP_USER_ID, update_task) .await? .or_not_found("task", id)?; Ok(TaskResponse::from(task)) } /// Soft-deletes a task by setting its status to Deleted. /// /// # Errors /// /// Returns `DATABASE_ERROR` if the update fails. #[tauri::command] #[instrument(skip_all)] pub async fn delete_task(state: State<'_, Arc>, id: TaskId) -> Result { Ok(state.tasks.delete(id, DESKTOP_USER_ID).await?) } /// Marks a task as started (in progress). /// /// Only works for tasks in Pending status. /// /// # Errors /// /// Returns `DATABASE_ERROR` if the update fails. #[tauri::command] #[instrument(skip_all)] pub async fn start_task(state: State<'_, Arc>, id: TaskId) -> Result { Ok(state.tasks.start(id, DESKTOP_USER_ID).await?) } /// Marks a task as completed. /// /// For recurring tasks, this creates the next instance with an updated due date. /// /// # Returns /// /// - `completed`: Whether the task was marked complete /// - `next_recurring_task`: The newly created next instance (for recurring tasks) /// /// # Errors /// /// Returns `DATABASE_ERROR` if the update or insert fails. #[tauri::command] #[instrument(skip_all)] pub async fn complete_task(state: State<'_, Arc>, id: TaskId) -> Result { // Auto-stop any running timer before completing let _ = state.tasks.stop_timer(id, DESKTOP_USER_ID).await; // Read the task first to determine recurrence before completing let task = match state.tasks.get_by_id(id, DESKTOP_USER_ID).await? { Some(t) if t.status != TaskStatus::Completed => t, _ => return Ok(CompleteTaskResponse { completed: false, next_recurring_task: None }), }; // Build next recurring instance if needed let next_new_task = if task.has_recurrence() { let next_due = if let Some(ref rule) = task.recurrence_rule { calculate_next_due_rich(task.due.as_ref(), rule) } else { calculate_next_due(task.due.as_ref(), &task.recurrence) }; let created_at = Utc::now(); let fresh_urgency = calculate_urgency( &task.priority, &TaskStatus::Pending, next_due.as_ref(), &created_at, &task.tags, ); Some(NewTask { project_id: task.project_id, description: task.description.clone(), priority: task.priority.clone(), due: next_due, tags: task.tags.clone(), recurrence: task.recurrence.clone(), urgency: fresh_urgency, source_email_id: None, scheduled_start: None, scheduled_duration: None, estimated_minutes: task.estimated_minutes, contact_id: task.contact_id, milestone_id: task.milestone_id, recurrence_rule: task.recurrence_rule.clone(), recurrence_parent_id: Some(task.recurrence_parent_id.unwrap_or(task.id)), }) } else { None }; // Atomically complete + create next instance in a single transaction let (_completed, next_task) = state.tasks.complete_recurring(id, DESKTOP_USER_ID, next_new_task).await?; // Auto-complete milestone if all tasks in it are done. // Note: recurring tasks create a new Pending task above, so the count // check naturally prevents auto-complete when a task recurs. if let Some(milestone_id) = task.milestone_id { let remaining = state.tasks.count_incomplete_by_milestone(milestone_id, DESKTOP_USER_ID).await?; if remaining == 0 { if let Some(ms) = state.milestones.get_by_id(milestone_id, DESKTOP_USER_ID).await? { state.milestones.update( milestone_id, DESKTOP_USER_ID, &ms.name, &ms.description, ms.target_date, &MilestoneStatus::Completed, ).await?; } } } Ok(CompleteTaskResponse { completed: true, next_recurring_task: next_task.map(TaskResponse::from), }) } // ============ Task Overview ============ /// A completed instance in a recurrence chain (lightweight). #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct RecurrenceInstance { pub id: TaskId, pub status: String, pub completed_at: Option>, pub due: Option>, pub actual_minutes: i32, pub created_at: DateTime, } /// Streak and completion rate stats for a recurring task. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct StreakInfo { pub current_streak: u32, pub best_streak: u32, pub total_completed: u32, pub total_instances: u32, pub completion_rate_30d: f64, } /// Full task overview response. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct TaskOverviewResponse { pub task: TaskResponse, pub time_sessions: Vec, pub recurrence_chain: Vec, pub streak: Option, } /// Gets comprehensive task overview data. #[tauri::command] #[instrument(skip_all)] pub async fn get_task_overview( state: State<'_, Arc>, id: TaskId, ) -> Result { let (task, sessions) = tokio::join!( state.tasks.get_by_id(id, DESKTOP_USER_ID), state.tasks.list_time_sessions(id, DESKTOP_USER_ID), ); let task = task?.or_not_found("task", id)?; let sessions = sessions?; let (chain, streak) = if task.has_recurrence() || task.recurrence_parent_id.is_some() { let root_id = task.recurrence_parent_id.unwrap_or(task.id); let chain_tasks = state.tasks.list_recurrence_chain(root_id, DESKTOP_USER_ID).await?; let instances: Vec = chain_tasks.iter().map(|t| RecurrenceInstance { id: t.id, status: t.status.as_str().to_string(), completed_at: t.completed_at, due: t.due, actual_minutes: t.actual_minutes, created_at: t.created_at, }).collect(); let streak = compute_streak(&chain_tasks); (instances, Some(streak)) } else { (Vec::new(), None) }; Ok(TaskOverviewResponse { task: TaskResponse::from(task), time_sessions: sessions, recurrence_chain: chain, streak, }) } /// Compute streak stats from a recurrence chain (sorted by created_at DESC). fn compute_streak(chain: &[Task]) -> StreakInfo { let total_instances = chain.len() as u32; let total_completed = chain.iter().filter(|t| t.status == TaskStatus::Completed).count() as u32; // Sort by due date (or created_at) ascending for streak calculation let mut sorted: Vec<&Task> = chain.iter().collect(); sorted.sort_by_key(|t| t.due.unwrap_or(t.created_at)); let mut current_streak: u32 = 0; let mut best_streak: u32 = 0; let mut running: u32 = 0; for t in &sorted { if t.status == TaskStatus::Completed { running += 1; if running > best_streak { best_streak = running; } } else { running = 0; } } // Current streak: count from the end backwards for t in sorted.iter().rev() { if t.status == TaskStatus::Completed { current_streak += 1; } else { // Skip the current pending instance (the active one) if t.status == TaskStatus::Pending || t.status == TaskStatus::Started { continue; } break; } } // 30-day completion rate let thirty_days_ago = Utc::now() - chrono::Duration::days(30); let recent: Vec<&&Task> = sorted.iter() .filter(|t| t.created_at >= thirty_days_ago) .collect(); let recent_completed = recent.iter().filter(|t| t.status == TaskStatus::Completed).count(); let completion_rate_30d = if recent.is_empty() { 0.0 } else { (recent_completed as f64 / recent.len() as f64) * 100.0 }; StreakInfo { current_streak, best_streak, total_completed, total_instances, completion_rate_30d, } } // ============ Project Dashboard Commands ============ /// Lists all tasks for a specific project. /// /// # Errors /// /// Returns `DATABASE_ERROR` if the query fails. #[tauri::command] #[instrument(skip_all)] pub async fn list_tasks_for_project(state: State<'_, Arc>, project_id: ProjectId) -> Result, ApiError> { let mut tasks = state.tasks.list_by_project(DESKTOP_USER_ID, project_id).await?; // Pre-sort by urgency DESC so JS doesn't need to sort tasks.sort_by(|a, b| b.urgency.partial_cmp(&a.urgency).unwrap_or(std::cmp::Ordering::Equal)); Ok(tasks.into_iter().map(TaskResponse::from).collect()) }