//! Task domain types and DTOs. //! //! Tasks are the primary work unit in GoingsOn. Each task carries a priority, //! an urgency score computed from priority/due date/age/tags, and optional //! recurrence (Daily, Weekly, Monthly). Tasks can be snoozed to temporarily //! hide them, marked as waiting-for-response, and scheduled into time blocks. //! Subtasks provide checklist items and can link to other tasks for multi-phase //! workflows. use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use strum_macros::EnumString; use crate::constants::{ DAYS_THRESHOLD_SHORT_FORMAT, URGENCY_HIGH_THRESHOLD, URGENCY_MEDIUM_THRESHOLD, }; use crate::id_types::{TaskId, ProjectId, MilestoneId, ContactId, EmailId, AnnotationId, SubtaskId}; use super::time_session::TimeSession; use super::shared::{CssClass, DbValue, ParseableEnum, Recurrence, RecurrenceRule, SortDirection}; // ============ Task Types ============ /// Lifecycle status of a task. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, EnumString)] pub enum TaskStatus { /// Not yet started. #[strum(serialize = "Pending")] #[default] Pending, /// Work in progress. #[strum(serialize = "Started")] Started, /// Successfully finished. #[strum(serialize = "Completed")] Completed, /// Soft-deleted. #[strum(serialize = "Deleted")] Deleted, } impl TaskStatus { /// Returns a human-readable display string. pub fn as_str(&self) -> &'static str { match self { TaskStatus::Pending => "Pending", TaskStatus::Started => "Started", TaskStatus::Completed => "Completed", TaskStatus::Deleted => "Deleted", } } } impl ParseableEnum for TaskStatus {} impl DbValue for TaskStatus { fn db_value(&self) -> &'static str { self.as_str() } } impl CssClass for TaskStatus { fn css_class(&self) -> &'static str { match self { TaskStatus::Pending => "task-pending", TaskStatus::Started => "task-started", TaskStatus::Completed => "task-completed", TaskStatus::Deleted => "task-deleted", } } } /// Task priority level, affects urgency calculation. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, EnumString)] pub enum Priority { /// Urgent, high-impact task. #[strum(serialize = "High")] High, /// Normal priority. #[strum(serialize = "Medium")] #[default] Medium, /// Can be deferred. #[strum(serialize = "Low")] Low, } impl Priority { /// Returns the short form (H/M/L) for display. pub fn as_str(&self) -> &'static str { match self { Priority::High => "H", Priority::Medium => "M", Priority::Low => "L", } } /// Parses a string into a Priority, falling back to `Medium` on invalid input. /// /// Accepts various formats: "High"/"H"/"high"/"h", "Medium"/"M"/"Med"/etc., "Low"/"L"/"low"/"l". /// This intentional fallback ensures database reads and frontend input never fail. /// Use `str.parse::()` if you need error handling. #[allow(clippy::should_implement_trait)] pub fn from_str_or_default(s: &str) -> Self { match s { "High" | "H" | "high" | "h" => Priority::High, "Medium" | "M" | "medium" | "m" | "Med" | "med" => Priority::Medium, "Low" | "L" | "low" | "l" => Priority::Low, _ => Priority::default(), } } } impl DbValue for Priority { fn db_value(&self) -> &'static str { match self { Priority::High => "High", Priority::Medium => "Medium", Priority::Low => "Low", } } } impl CssClass for Priority { fn css_class(&self) -> &'static str { match self { Priority::High => "priority-high", Priority::Medium => "priority-medium", Priority::Low => "priority-low", } } } /// A timestamped note attached to a task. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Annotation { /// Unique identifier. pub id: AnnotationId, /// Parent task ID. #[serde(skip_serializing)] pub task_id: TaskId, /// When the annotation was created. pub timestamp: DateTime, /// The annotation text. pub note: String, } /// A checklist item within a task. /// /// Subtasks can be either: /// - Text-only: A simple checklist item with text description /// - Task link: A link to another task, enabling multi-phase features /// /// When `linked_task_id` is set, the subtask represents a link to another task. /// In this case, `text` may be empty (synced from linked task) or override text. /// Completion status syncs with the linked task's status. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Subtask { /// Unique identifier. pub id: SubtaskId, /// Parent task ID. #[serde(skip_serializing)] pub task_id: TaskId, /// Subtask description (for text-only subtasks). pub text: String, /// Linked task ID (for task-link subtasks). /// When set, this subtask represents a link to another task. pub linked_task_id: Option, /// Whether this subtask is done. pub is_completed: bool, /// Display order (lower = first). #[serde(rename = "sortOrder")] pub position: i32, } /// A task representing work to be done. /// /// Tasks can be associated with a project, have due dates, recurrence patterns, /// annotations, and subtasks. They support snoozing and waiting-for-response /// tracking, as well as time-block scheduling. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Task { /// Unique identifier. pub id: TaskId, /// Associated project, if any. pub project_id: Option, /// Denormalized project name for display. pub project_name: Option, /// Associated milestone, if any. pub milestone_id: Option, /// Associated contact, if any. pub contact_id: Option, /// Denormalized contact name for display. pub contact_name: Option, /// Task description/title. pub description: String, /// Current lifecycle status. pub status: TaskStatus, /// Priority level. pub priority: Priority, /// Due date, if set. pub due: Option>, /// User-defined tags for categorization. pub tags: Vec, /// Calculated urgency score for sorting. pub urgency: f64, /// Recurrence pattern for repeating tasks (legacy). pub recurrence: Recurrence, /// Rich recurrence configuration (JSON). Takes precedence over `recurrence`. pub recurrence_rule: Option, /// Original task ID if this is a recurrence instance. pub recurrence_parent_id: Option, /// Email this task was created from, if any. pub source_email_id: Option, /// If snoozed, when to resurface. pub snoozed_until: Option>, /// Whether waiting for external response. pub waiting_for_response: bool, /// When waiting status was set. pub waiting_since: Option>, /// Expected response date when waiting. pub expected_response_date: Option>, /// Scheduled start time for time-blocking. pub scheduled_start: Option>, /// Scheduled duration in minutes. pub scheduled_duration: Option, /// Attached notes. pub annotations: Vec, /// Checklist items. pub subtasks: Vec, /// Estimated duration in minutes (user-provided). pub estimated_minutes: Option, /// Cached total actual tracked minutes across all sessions. pub actual_minutes: i32, /// Currently active time session, if any (populated on fetch). #[serde(skip_serializing_if = "Option::is_none")] pub active_session: Option, /// When the task was created. pub created_at: DateTime, /// When the task was completed (set on status transition to Completed). pub completed_at: Option>, /// Whether this task is marked as a focus for the week. pub is_focus: bool, /// When the focus was set. pub focus_set_at: Option>, } impl Task { /// Returns a human-readable due date string relative to now. /// /// Examples: "today", "tomorrow", "+3d", "2d ago", "2026-03-15", or "-" if no due date. pub fn due_formatted(&self) -> String { match &self.due { Some(dt) => { let now = Utc::now(); let days = (dt.date_naive() - now.date_naive()).num_days(); if days < 0 { format!("{}d ago", -days) } else if days == 0 { "today".to_string() } else if days == 1 { "tomorrow".to_string() } else if days < DAYS_THRESHOLD_SHORT_FORMAT { format!("+{}d", days) } else { dt.format("%Y-%m-%d").to_string() } } None => "-".to_string(), } } /// Returns the number of annotations on this task. pub fn annotation_count(&self) -> usize { self.annotations.len() } /// Returns true if the task has any annotations attached. pub fn has_annotations(&self) -> bool { !self.annotations.is_empty() } /// Returns true if the task has a recurrence pattern set (not `None`). pub fn has_recurrence(&self) -> bool { self.recurrence_rule.is_some() || self.recurrence != Recurrence::None } /// Returns the effective recurrence rule, synthesizing from the legacy /// column if no explicit rule is set. pub fn effective_recurrence_rule(&self) -> Option { RecurrenceRule::effective(self.recurrence_rule.as_ref(), &self.recurrence) } /// Returns the project name, or `"-"` if unset. The dash fallback is used /// for display in table views where an empty cell would look broken. pub fn project_name_or_dash(&self) -> &str { self.project_name.as_deref().unwrap_or("-") } /// Returns the project name, or an empty string if unset. pub fn project_name_or_empty(&self) -> &str { self.project_name.as_deref().unwrap_or("") } /// Returns the due date as a Unix timestamp (seconds since epoch). /// Returns 0 (Unix epoch) when no due date is set, which sorts /// undated tasks to the beginning in timestamp-based ordering. pub fn due_timestamp(&self) -> i64 { self.due.map(|d| d.timestamp()).unwrap_or(0) } /// Returns urgency as a formatted string with one decimal place (e.g., "8.3"). pub fn urgency_formatted(&self) -> String { format!("{:.1}", self.urgency) } /// Returns true if the task is past its due date. pub fn is_overdue(&self) -> bool { match self.due { Some(due) => due < Utc::now(), None => false, } } /// Returns the CSS class for urgency styling. /// Red (overdue) is reserved for actually overdue tasks. pub fn urgency_class(&self) -> &'static str { // Overdue takes priority - only overdue tasks get red if self.is_overdue() { "urgency-overdue" } else if self.urgency >= URGENCY_HIGH_THRESHOLD { "urgency-high" } else if self.urgency >= URGENCY_MEDIUM_THRESHOLD { "urgency-medium" } else { "urgency-low" } } /// Returns the total number of subtasks. pub fn subtask_count(&self) -> usize { self.subtasks.len() } /// Returns the number of completed subtasks. pub fn subtasks_completed(&self) -> usize { self.subtasks.iter().filter(|s| s.is_completed).count() } /// Returns true if the task has any subtasks. pub fn has_subtasks(&self) -> bool { !self.subtasks.is_empty() } /// Returns subtask progress as "completed/total" (e.g., "3/5"). pub fn subtasks_progress(&self) -> String { format!("{}/{}", self.subtasks_completed(), self.subtask_count()) } /// Returns true if the task was created from an email. pub fn has_source_email(&self) -> bool { self.source_email_id.is_some() } /// Returns true if the task is currently snoozed (snoozed_until is in the future). pub fn is_snoozed(&self) -> bool { self.snoozed_until .map(|until| until > Utc::now()) .unwrap_or(false) } /// Returns true if the task is waiting for an external response. pub fn is_waiting(&self) -> bool { self.waiting_for_response } /// Returns true if the task is waiting and the expected response date has passed. pub fn is_response_overdue(&self) -> bool { self.waiting_for_response && self.expected_response_date .map(|date| date < Utc::now()) .unwrap_or(false) } /// Returns true if this task is marked as a weekly focus. pub fn is_focused(&self) -> bool { self.is_focus } /// Returns time progress as a percentage (0-100), or None if no estimate. pub fn time_progress(&self) -> Option { self.estimated_minutes.map(|est| { if est <= 0 { return 0; } ((self.actual_minutes as f64 / est as f64) * 100.0).round().min(100.0) as u8 }) } /// Returns true if actual tracked time exceeds the estimate. pub fn is_over_estimate(&self) -> bool { match self.estimated_minutes { Some(est) if est > 0 => self.actual_minutes > est, _ => false, } } /// Returns true if a timer is currently running on this task. pub fn has_active_timer(&self) -> bool { self.active_session.is_some() } } /// Column to sort tasks by. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum TaskSortColumn { /// Sort by task description (alphabetical) Description, /// Sort by project name (alphabetical, nulls last) Project, /// Sort by priority (High > Medium > Low) Priority, /// Sort by due date (nulls last) Due, /// Sort by calculated urgency score #[default] Urgency, } impl TaskSortColumn { /// Parses a string to TaskSortColumn, defaulting to Urgency if unrecognized. pub fn from_str_or_default(s: &str) -> Self { match s.to_lowercase().as_str() { "description" => Self::Description, "project" => Self::Project, "priority" => Self::Priority, "due" => Self::Due, "urgency" => Self::Urgency, _ => Self::default(), } } } /// Query parameters for filtered task listing. /// All fields are optional - omitted fields don't restrict results. #[derive(Debug, Clone, Default)] pub struct TaskFilterQuery { /// Filter by status (exact match) pub status: Option, /// Filter by project ID pub project_id: Option, /// Filter by milestone ID pub milestone_id: Option, /// Filter by priority (exact match) pub priority: Option, /// If false (default), hide tasks where snoozed_until > now pub show_snoozed: bool, /// If true, only show tasks with waiting_for_response = true 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 (default: Urgency) pub sort_column: Option, /// Sort direction (default: Desc for Urgency, Asc for others) pub sort_direction: Option, } // ============ Task DTOs ============ /// Data for creating a new task. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NewTask { /// Associated project, if any. pub project_id: Option, /// Target milestone within the project, if any. pub milestone_id: Option, /// Associated contact, if any. pub contact_id: Option, /// Task description/title (required, validated non-empty). pub description: String, /// Priority level, affects urgency calculation. pub priority: Priority, /// Due date, if set. Used in urgency scoring. pub due: Option>, /// User-defined tags for categorization and urgency modifiers. pub tags: Vec, /// Recurrence pattern (None, Daily, Weekly, Monthly). pub recurrence: Recurrence, /// Rich recurrence configuration (JSON). pub recurrence_rule: Option, /// Pre-calculated urgency score based on priority, due date, age, and tags. pub urgency: f64, /// Email this task was created from, if any (set by email-to-task flow). pub source_email_id: Option, /// Scheduled start time for time-blocking. pub scheduled_start: Option>, /// Scheduled duration in minutes for time-blocking. pub scheduled_duration: Option, /// Estimated duration in minutes. pub estimated_minutes: Option, /// Root task ID for recurrence chain (set when spawning next recurring instance). pub recurrence_parent_id: Option, } impl NewTask { /// Creates a builder for constructing a new task. /// /// # Example /// /// ```rust /// use goingson_core::{NewTask, Priority}; /// use chrono::Utc; /// /// let task = NewTask::builder("Fix the bug") /// .priority(Priority::High) /// .tag("urgent") /// .urgency(8.0) /// .build(); /// ``` pub fn builder(description: impl Into) -> NewTaskBuilder { NewTaskBuilder::new(description) } } /// Builder for constructing [`NewTask`] with sensible defaults. #[derive(Debug, Clone)] pub struct NewTaskBuilder { description: String, project_id: Option, milestone_id: Option, contact_id: Option, priority: Priority, due: Option>, tags: Vec, recurrence: Recurrence, recurrence_rule: Option, urgency: f64, source_email_id: Option, scheduled_start: Option>, scheduled_duration: Option, estimated_minutes: Option, recurrence_parent_id: Option, } impl NewTaskBuilder { /// Creates a new builder with the given description. pub fn new(description: impl Into) -> Self { Self { description: description.into(), project_id: None, milestone_id: None, contact_id: None, priority: Priority::default(), due: None, tags: Vec::new(), recurrence: Recurrence::default(), recurrence_rule: None, urgency: 0.0, source_email_id: None, scheduled_start: None, scheduled_duration: None, estimated_minutes: None, recurrence_parent_id: None, } } /// Sets the project ID. pub fn project_id(mut self, project_id: ProjectId) -> Self { self.project_id = Some(project_id); self } /// Sets the milestone ID. pub fn milestone_id(mut self, milestone_id: MilestoneId) -> Self { self.milestone_id = Some(milestone_id); self } /// Sets the contact ID. pub fn contact_id(mut self, contact_id: ContactId) -> Self { self.contact_id = Some(contact_id); self } /// Sets the priority level. pub fn priority(mut self, priority: Priority) -> Self { self.priority = priority; self } /// Sets the due date. pub fn due(mut self, due: DateTime) -> Self { self.due = Some(due); self } /// Adds a tag. pub fn tag(mut self, tag: impl Into) -> Self { self.tags.push(tag.into()); self } /// Sets all tags at once. pub fn tags(mut self, tags: Vec) -> Self { self.tags = tags; self } /// Sets the recurrence pattern. pub fn recurrence(mut self, recurrence: Recurrence) -> Self { self.recurrence = recurrence; self } /// Sets the rich recurrence rule. pub fn recurrence_rule(mut self, rule: RecurrenceRule) -> Self { self.recurrence_rule = Some(rule); self } /// Sets the urgency score. pub fn urgency(mut self, urgency: f64) -> Self { self.urgency = urgency; self } /// Sets the source email ID. pub fn source_email_id(mut self, email_id: EmailId) -> Self { self.source_email_id = Some(email_id); self } /// Sets the scheduled start time. pub fn scheduled_start(mut self, start: DateTime) -> Self { self.scheduled_start = Some(start); self } /// Sets the scheduled duration in minutes. pub fn scheduled_duration(mut self, duration: i32) -> Self { self.scheduled_duration = Some(duration); self } /// Sets the estimated duration in minutes. pub fn estimated_minutes(mut self, minutes: i32) -> Self { self.estimated_minutes = Some(minutes); self } /// Sets the recurrence parent ID (root of the recurrence chain). pub fn recurrence_parent_id(mut self, id: TaskId) -> Self { self.recurrence_parent_id = Some(id); self } /// Builds the [`NewTask`]. pub fn build(self) -> NewTask { NewTask { project_id: self.project_id, milestone_id: self.milestone_id, contact_id: self.contact_id, description: self.description, priority: self.priority, due: self.due, tags: self.tags, recurrence: self.recurrence, recurrence_rule: self.recurrence_rule, urgency: self.urgency, source_email_id: self.source_email_id, scheduled_start: self.scheduled_start, scheduled_duration: self.scheduled_duration, estimated_minutes: self.estimated_minutes, recurrence_parent_id: self.recurrence_parent_id, } } } /// Lightweight context for task update logic — avoids fetching annotations, subtasks, sessions. #[derive(Debug, Clone)] pub struct TaskUpdateContext { pub created_at: DateTime, pub status: TaskStatus, pub completed_at: Option>, pub scheduled_start: Option>, pub scheduled_duration: Option, } /// Data for updating an existing task. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpdateTask { /// Associated project, if any. pub project_id: Option, /// Target milestone within the project, if any. pub milestone_id: Option, /// Associated contact, if any. pub contact_id: Option, /// Task description/title (required, validated non-empty). pub description: String, /// Updated lifecycle status. pub status: TaskStatus, /// Priority level, affects urgency calculation. pub priority: Priority, /// Due date, if set. Used in urgency scoring. pub due: Option>, /// User-defined tags for categorization and urgency modifiers. pub tags: Vec, /// Recurrence pattern (None, Daily, Weekly, Monthly). pub recurrence: Recurrence, /// Re-calculated urgency score based on priority, due date, age, and tags. pub urgency: f64, /// Scheduled start time for time-blocking. pub scheduled_start: Option>, /// Scheduled duration in minutes for time-blocking. pub scheduled_duration: Option, /// Estimated duration in minutes. pub estimated_minutes: Option, }