//! 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, StatusTokenId}; 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, } /// The kind identifier for a `commit` status token: a repo-qualified /// `@` reference that is `Pending` until pushed, `Complete` once /// pushed. The first — and today only — [`StatusToken`] kind. pub const TOKEN_KIND_COMMIT: &str = "commit"; /// Fixed namespace UUID for GoingsOn status-token ids (generated once, never /// changes). See [`StatusToken::deterministic_id`]. const GOINGSON_STATUS_TOKEN_NS: uuid::Uuid = uuid::Uuid::from_bytes([ 0x2f, 0x9d, 0x4a, 0x61, 0xb3, 0x0c, 0x5e, 0x72, 0x8a, 0x1f, 0x6c, 0x4d, 0x0b, 0xe5, 0x93, 0x27, ]); /// The colour-mapped state of a [`StatusToken`]. A task with no tokens reads as /// neutral; a token is `Pending` (amber) or `Complete` (green). Each token kind /// interprets these in its own domain — for `commit`, `Pending` = recorded but not /// pushed, `Complete` = pushed. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, EnumString)] pub enum TokenState { /// Work-in-progress (amber). E.g. a commit that has not been pushed. #[strum(serialize = "Pending")] #[default] Pending, /// Done (green). E.g. a commit that has been pushed. #[strum(serialize = "Complete")] Complete, } impl TokenState { /// Returns the stored/display string (`"Pending"` / `"Complete"`). pub fn as_str(&self) -> &'static str { match self { TokenState::Pending => "Pending", TokenState::Complete => "Complete", } } } impl ParseableEnum for TokenState {} impl DbValue for TokenState { fn db_value(&self) -> &'static str { self.as_str() } } /// A typed at-a-glance marker attached to a task, driving its colour indicator. /// /// A task carries an ordered list of these; at most one may be `is_primary`, the /// token that resolved the task. `kind` routes the domain (e.g. [`TOKEN_KIND_COMMIT`]) /// and `reference` is that domain's value — for a commit, the repo-qualified /// `@`, since a bare short SHA is not unique across the ecosystem's /// repos. `state` is the generic, colour-mapped [`TokenState`]. /// /// The row id is deterministic in `(task_id, kind, reference)` (UUID v5), so /// recording the same token twice — on one device or across devices — converges to a /// single row through the sync changelog's id-keyed upsert. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct StatusToken { /// Deterministic identifier (UUID v5 of `::`). pub id: StatusTokenId, /// Parent task ID. #[serde(skip_serializing)] pub task_id: TaskId, /// Token kind, e.g. [`TOKEN_KIND_COMMIT`]. pub kind: String, /// The kind's value; for `commit`, a `@` reference. pub reference: String, /// Colour-mapped state. pub state: TokenState, /// Whether this is the token that resolved the task (at most one per task). pub is_primary: bool, /// Display/append order (lower = earlier). #[serde(rename = "sortOrder")] pub position: i32, } impl StatusToken { /// The deterministic row id for a `(task_id, kind, reference)` triple. /// /// Keeping this pure and content-derived is what lets two devices record the /// same token independently and still collapse to one row on sync. pub fn deterministic_id(task_id: TaskId, kind: &str, reference: &str) -> StatusTokenId { let key = format!("{task_id}:{kind}:{reference}"); StatusTokenId::from(uuid::Uuid::new_v5(&GOINGSON_STATUS_TOKEN_NS, key.as_bytes())) } } /// 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, /// Status tokens (ordered); the `is_primary` one resolved the task. pub status_tokens: 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 has any status tokens. pub fn has_status_tokens(&self) -> bool { !self.status_tokens.is_empty() } /// Returns the primary token (the one that resolved the task), if flagged. pub fn primary_token(&self) -> Option<&StatusToken> { self.status_tokens.iter().find(|t| t.is_primary) } /// Rolls the task's tokens up to a single at-a-glance state for the indicator: /// `"neutral"` (no tokens), `"complete"` (every token `Complete`), or /// `"pending"` (at least one token still `Pending`). pub fn status_token_summary(&self) -> &'static str { if self.status_tokens.is_empty() { "neutral" } else if self.status_tokens.iter().all(|t| t.state == TokenState::Complete) { "complete" } else { "pending" } } /// 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, /// Rich recurrence configuration (JSON). Threaded on edit so a changed custom /// rule is persisted rather than leaving the stored rule stale. pub recurrence_rule: Option, /// 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, } #[cfg(test)] mod tests { use super::*; use crate::id_types::{SubtaskId, TaskId}; use crate::models::shared::{CssClass, DbValue, Recurrence}; use chrono::{Duration, Utc}; use std::str::FromStr; /// A baseline task with everything empty/defaulted; mutate fields per test. fn task() -> Task { Task { id: TaskId::new(), project_id: None, project_name: None, milestone_id: None, contact_id: None, contact_name: None, description: "Test task".to_string(), status: TaskStatus::Pending, priority: Priority::Medium, due: None, tags: Vec::new(), urgency: 0.0, recurrence: Recurrence::None, recurrence_rule: None, recurrence_parent_id: None, source_email_id: None, snoozed_until: None, waiting_for_response: false, waiting_since: None, expected_response_date: None, scheduled_start: None, scheduled_duration: None, annotations: Vec::new(), subtasks: Vec::new(), status_tokens: Vec::new(), estimated_minutes: None, actual_minutes: 0, active_session: None, created_at: Utc::now(), completed_at: None, is_focus: false, focus_set_at: None, } } fn subtask(is_completed: bool) -> Subtask { Subtask { id: SubtaskId::new(), task_id: TaskId::new(), text: "sub".to_string(), linked_task_id: None, is_completed, position: 0, } } // ---- TaskStatus ---- #[test] fn task_status_as_str_and_css_and_db() { assert_eq!(TaskStatus::Started.as_str(), "Started"); assert_eq!(TaskStatus::Completed.css_class(), "task-completed"); assert_eq!(TaskStatus::Deleted.db_value(), "Deleted"); assert_eq!(TaskStatus::default(), TaskStatus::Pending); } #[test] fn task_status_from_str() { assert_eq!(TaskStatus::from_str("Completed").unwrap(), TaskStatus::Completed); assert!(TaskStatus::from_str("nonsense").is_err()); } // ---- Priority ---- #[test] fn priority_as_str_is_short_form() { assert_eq!(Priority::High.as_str(), "H"); assert_eq!(Priority::Medium.as_str(), "M"); assert_eq!(Priority::Low.as_str(), "L"); } #[test] fn priority_from_str_or_default_accepts_variants() { for s in ["High", "H", "high", "h"] { assert_eq!(Priority::from_str_or_default(s), Priority::High, "{s}"); } for s in ["Low", "L", "low", "l"] { assert_eq!(Priority::from_str_or_default(s), Priority::Low, "{s}"); } for s in ["Medium", "M", "Med", "med", "m"] { assert_eq!(Priority::from_str_or_default(s), Priority::Medium, "{s}"); } } #[test] fn priority_from_str_or_default_falls_back_to_medium() { assert_eq!(Priority::from_str_or_default(""), Priority::Medium); assert_eq!(Priority::from_str_or_default("URGENT"), Priority::Medium); assert_eq!(Priority::default(), Priority::Medium); } #[test] fn priority_db_value_is_long_form() { assert_eq!(Priority::High.db_value(), "High"); assert_eq!(Priority::Low.css_class(), "priority-low"); } // ---- TaskSortColumn ---- #[test] fn sort_column_parses_case_insensitively() { assert_eq!(TaskSortColumn::from_str_or_default("DUE"), TaskSortColumn::Due); assert_eq!(TaskSortColumn::from_str_or_default("Project"), TaskSortColumn::Project); assert_eq!(TaskSortColumn::from_str_or_default("priority"), TaskSortColumn::Priority); // unknown falls back to the default (Urgency) assert_eq!(TaskSortColumn::from_str_or_default("xyz"), TaskSortColumn::Urgency); assert_eq!(TaskSortColumn::default(), TaskSortColumn::Urgency); } // ---- due_formatted ---- #[test] fn due_formatted_none_is_dash() { assert_eq!(task().due_formatted(), "-"); } #[test] fn due_formatted_relative_buckets() { let mut t = task(); t.due = Some(Utc::now()); assert_eq!(t.due_formatted(), "today"); t.due = Some(Utc::now() + Duration::days(1)); assert_eq!(t.due_formatted(), "tomorrow"); t.due = Some(Utc::now() + Duration::days(3)); assert_eq!(t.due_formatted(), "+3d"); t.due = Some(Utc::now() - Duration::days(2)); assert_eq!(t.due_formatted(), "2d ago"); } #[test] fn due_formatted_far_future_is_iso_date() { let mut t = task(); let far = Utc::now() + Duration::days(30); t.due = Some(far); assert_eq!(t.due_formatted(), far.format("%Y-%m-%d").to_string()); } // ---- overdue / urgency_class ---- #[test] fn is_overdue_reads_due_vs_now() { let mut t = task(); assert!(!t.is_overdue(), "no due date is never overdue"); t.due = Some(Utc::now() - Duration::hours(1)); assert!(t.is_overdue()); t.due = Some(Utc::now() + Duration::hours(1)); assert!(!t.is_overdue()); } #[test] fn urgency_class_thresholds() { let mut t = task(); t.urgency = 9.0; assert_eq!(t.urgency_class(), "urgency-high"); t.urgency = 5.0; assert_eq!(t.urgency_class(), "urgency-medium"); t.urgency = 4.9; assert_eq!(t.urgency_class(), "urgency-low"); } #[test] fn urgency_class_overdue_wins_over_score() { let mut t = task(); t.urgency = 9.9; // would be "high" t.due = Some(Utc::now() - Duration::days(1)); assert_eq!(t.urgency_class(), "urgency-overdue"); } #[test] fn urgency_formatted_one_decimal() { let mut t = task(); t.urgency = 8.34; assert_eq!(t.urgency_formatted(), "8.3"); t.urgency = 0.0; assert_eq!(t.urgency_formatted(), "0.0"); } #[test] fn due_timestamp_defaults_to_zero() { let mut t = task(); assert_eq!(t.due_timestamp(), 0); let d = Utc::now(); t.due = Some(d); assert_eq!(t.due_timestamp(), d.timestamp()); } // ---- subtasks / annotations ---- #[test] fn subtask_counts_and_progress() { let mut t = task(); assert!(!t.has_subtasks()); assert_eq!(t.subtasks_progress(), "0/0"); t.subtasks = vec![subtask(true), subtask(false), subtask(true)]; assert!(t.has_subtasks()); assert_eq!(t.subtask_count(), 3); assert_eq!(t.subtasks_completed(), 2); assert_eq!(t.subtasks_progress(), "2/3"); } #[test] fn project_name_fallbacks() { let mut t = task(); assert_eq!(t.project_name_or_dash(), "-"); assert_eq!(t.project_name_or_empty(), ""); t.project_name = Some("Website".to_string()); assert_eq!(t.project_name_or_dash(), "Website"); assert_eq!(t.project_name_or_empty(), "Website"); } fn token(t: &Task, reference: &str, state: TokenState, primary: bool, pos: i32) -> StatusToken { StatusToken { id: StatusToken::deterministic_id(t.id, TOKEN_KIND_COMMIT, reference), task_id: t.id, kind: TOKEN_KIND_COMMIT.to_string(), reference: reference.to_string(), state, is_primary: primary, position: pos, } } #[test] fn status_token_deterministic_id_is_stable_and_content_derived() { let tid = TaskId::new(); let a = StatusToken::deterministic_id(tid, "commit", "deox@7c236fca8"); let b = StatusToken::deterministic_id(tid, "commit", "deox@7c236fca8"); assert_eq!(a, b, "same task+kind+ref must yield the same id"); assert_ne!(a, StatusToken::deterministic_id(tid, "commit", "deox@a19f0011"), "ref differs"); assert_ne!(a, StatusToken::deterministic_id(tid, "attachment", "deox@7c236fca8"), "kind differs"); assert_ne!(a, StatusToken::deterministic_id(TaskId::new(), "commit", "deox@7c236fca8"), "task differs"); assert_eq!(a.as_uuid().get_version_num(), 5); } #[test] fn primary_token_finds_the_flagged_one() { let mut t = task(); assert!(!t.has_status_tokens()); assert!(t.primary_token().is_none()); t.status_tokens = vec![ token(&t, "deox@aaa", TokenState::Pending, false, 0), token(&t, "deox@bbb", TokenState::Complete, true, 1), ]; assert!(t.has_status_tokens()); assert_eq!(t.primary_token().map(|c| c.reference.as_str()), Some("deox@bbb")); } #[test] fn status_token_summary_rolls_up_states() { let mut t = task(); assert_eq!(t.status_token_summary(), "neutral"); t.status_tokens = vec![token(&t, "deox@aaa", TokenState::Pending, false, 0)]; assert_eq!(t.status_token_summary(), "pending"); t.status_tokens = vec![ token(&t, "deox@aaa", TokenState::Complete, false, 0), token(&t, "deox@bbb", TokenState::Pending, true, 1), ]; assert_eq!(t.status_token_summary(), "pending", "any pending keeps the rollup pending"); t.status_tokens = vec![ token(&t, "deox@aaa", TokenState::Complete, false, 0), token(&t, "deox@bbb", TokenState::Complete, true, 1), ]; assert_eq!(t.status_token_summary(), "complete", "all complete rolls up complete"); } #[test] fn recurrence_and_source_flags() { let mut t = task(); assert!(!t.has_recurrence()); assert!(t.effective_recurrence_rule().is_none()); t.recurrence = Recurrence::Weekly; assert!(t.has_recurrence()); assert!(!t.has_source_email()); } // ---- snooze / waiting / focus ---- #[test] fn is_snoozed_only_when_future() { let mut t = task(); assert!(!t.is_snoozed()); t.snoozed_until = Some(Utc::now() + Duration::hours(1)); assert!(t.is_snoozed()); t.snoozed_until = Some(Utc::now() - Duration::hours(1)); assert!(!t.is_snoozed()); } #[test] fn response_overdue_requires_waiting_and_past_date() { let mut t = task(); assert!(!t.is_response_overdue()); // past expected date but not waiting -> false t.expected_response_date = Some(Utc::now() - Duration::days(1)); assert!(!t.is_response_overdue()); // waiting + past -> true t.waiting_for_response = true; assert!(t.is_waiting()); assert!(t.is_response_overdue()); // waiting but future -> false t.expected_response_date = Some(Utc::now() + Duration::days(1)); assert!(!t.is_response_overdue()); } #[test] fn is_focused_reads_flag() { let mut t = task(); assert!(!t.is_focused()); t.is_focus = true; assert!(t.is_focused()); } // ---- time progress ---- #[test] fn time_progress_none_without_estimate() { assert_eq!(task().time_progress(), None); } #[test] fn time_progress_percentage_and_clamp() { let mut t = task(); t.estimated_minutes = Some(100); t.actual_minutes = 50; assert_eq!(t.time_progress(), Some(50)); // clamps at 100 even when over t.actual_minutes = 250; assert_eq!(t.time_progress(), Some(100)); // zero estimate is treated as 0%, never divides by zero t.estimated_minutes = Some(0); assert_eq!(t.time_progress(), Some(0)); } #[test] fn is_over_estimate_rules() { let mut t = task(); assert!(!t.is_over_estimate(), "no estimate -> not over"); t.estimated_minutes = Some(60); t.actual_minutes = 61; assert!(t.is_over_estimate()); t.actual_minutes = 60; assert!(!t.is_over_estimate(), "equal is not over"); t.estimated_minutes = Some(0); t.actual_minutes = 5; assert!(!t.is_over_estimate(), "zero estimate is never over"); } #[test] fn has_active_timer_reads_session() { assert!(!task().has_active_timer()); } // ---- NewTaskBuilder ---- #[test] fn builder_defaults() { let nt = NewTask::builder("Write tests").build(); assert_eq!(nt.description, "Write tests"); assert_eq!(nt.priority, Priority::Medium); assert_eq!(nt.urgency, 0.0); assert_eq!(nt.recurrence, Recurrence::None); assert!(nt.tags.is_empty()); assert!(nt.due.is_none()); assert!(nt.estimated_minutes.is_none()); } #[test] fn builder_sets_fields() { let due = Utc::now(); let nt = NewTask::builder("Fix bug") .priority(Priority::High) .due(due) .tag("urgent") .tag("backend") .urgency(8.0) .estimated_minutes(45) .recurrence(Recurrence::Daily) .build(); assert_eq!(nt.priority, Priority::High); assert_eq!(nt.due, Some(due)); assert_eq!(nt.tags, vec!["urgent".to_string(), "backend".to_string()]); assert_eq!(nt.urgency, 8.0); assert_eq!(nt.estimated_minutes, Some(45)); assert_eq!(nt.recurrence, Recurrence::Daily); } #[test] fn builder_tags_replaces_accumulated() { let nt = NewTask::builder("t") .tag("a") .tags(vec!["x".to_string(), "y".to_string()]) .build(); assert_eq!(nt.tags, vec!["x".to_string(), "y".to_string()]); } }