//! Task row mapping: the shared SELECT column list, the JOIN row struct, and //! the conversion from a database row to a [`Task`]. use goingson_core::{ Annotation, ParseableEnum, Priority, Recurrence, Result, StatusToken, Subtask, Task, TaskSortColumn, TaskStatus, }; use crate::utils::{parse_datetime, parse_tags, parse_uuid, parse_uuid_opt}; /// Returns the SQL column expression for a [`TaskSortColumn`]. pub(super) fn sort_column_sql(col: TaskSortColumn) -> &'static str { match col { TaskSortColumn::Description => "t.title", TaskSortColumn::Project => "p.name", TaskSortColumn::Priority => { "CASE t.priority WHEN 'High' THEN 3 WHEN 'Medium' THEN 2 WHEN 'Low' THEN 1 ELSE 0 END" } TaskSortColumn::Due => "t.due", // The effective score, not the stored base. `graph_urgency` is what // sinks blocked work and floats blockers, so sorting by `t.urgency` // alone would order the list differently from the number each row // displays. See `Task::effective_urgency`. TaskSortColumn::Urgency => "(t.urgency + t.graph_urgency)", } } /// Returns whether NULLs should sort last for the given column. pub(super) fn sort_column_nulls_last(col: TaskSortColumn) -> bool { matches!(col, TaskSortColumn::Project | TaskSortColumn::Due) } /// Common SELECT columns for task queries with project JOIN. /// /// This constant ensures consistent column ordering across all task queries. /// Usage: `format!("SELECT {} FROM tasks t LEFT JOIN projects p ON ...", TASK_SELECT_COLUMNS)` pub(crate) const TASK_SELECT_COLUMNS: &str = r"t.id, t.project_id, p.name as project_name, t.contact_id, ct.display_name as contact_name, t.milestone_id, t.title, t.description, t.status, t.priority, t.due, t.tags, t.urgency, t.recurrence, t.recurrence_rule, t.recurrence_parent_id, t.source_email_id, t.snoozed_until, t.waiting_for_response, t.waiting_since, t.expected_response_date, t.scheduled_start, t.scheduled_duration, t.estimated_minutes, t.actual_minutes, t.created_at, t.completed_at, t.is_focus, t.focus_set_at, t.block_depth, t.unblocks_count, t.in_cycle, t.graph_urgency"; /// Row struct for task with project name from JOIN #[derive(Debug, Clone)] pub(crate) struct TaskRowWithProject { pub id: String, pub project_id: Option, pub project_name: Option, pub contact_id: Option, pub contact_name: Option, pub milestone_id: Option, pub title: String, pub description: String, pub status: String, pub priority: String, pub due: Option, pub tags: String, pub urgency: f64, pub recurrence: String, pub recurrence_rule: Option, pub recurrence_parent_id: Option, pub source_email_id: Option, pub snoozed_until: Option, pub waiting_for_response: i32, pub waiting_since: Option, pub expected_response_date: Option, pub scheduled_start: Option, pub scheduled_duration: Option, pub estimated_minutes: Option, pub actual_minutes: i32, pub created_at: String, pub completed_at: Option, pub is_focus: i32, pub focus_set_at: Option, /// Cached graph columns. Derived from `task_dependencies`, never set by a /// task write; see `dependency_repo::recompute_graph`. pub block_depth: i64, pub unblocks_count: i64, pub in_cycle: i32, pub graph_urgency: f64, } impl TaskRowWithProject { pub(crate) fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(Self { id: row.get("id")?, project_id: row.get("project_id")?, project_name: row.get("project_name")?, contact_id: row.get("contact_id")?, contact_name: row.get("contact_name")?, milestone_id: row.get("milestone_id")?, title: row.get("title")?, description: row.get("description")?, status: row.get("status")?, priority: row.get("priority")?, due: row.get("due")?, tags: row.get("tags")?, urgency: row.get("urgency")?, recurrence: row.get("recurrence")?, recurrence_rule: row.get("recurrence_rule")?, recurrence_parent_id: row.get("recurrence_parent_id")?, source_email_id: row.get("source_email_id")?, snoozed_until: row.get("snoozed_until")?, waiting_for_response: row.get("waiting_for_response")?, waiting_since: row.get("waiting_since")?, expected_response_date: row.get("expected_response_date")?, scheduled_start: row.get("scheduled_start")?, scheduled_duration: row.get("scheduled_duration")?, estimated_minutes: row.get("estimated_minutes")?, actual_minutes: row.get("actual_minutes")?, created_at: row.get("created_at")?, completed_at: row.get("completed_at")?, is_focus: row.get("is_focus")?, focus_set_at: row.get("focus_set_at")?, block_depth: row.get("block_depth")?, unblocks_count: row.get("unblocks_count")?, in_cycle: row.get("in_cycle")?, graph_urgency: row.get("graph_urgency")?, }) } } impl TaskRowWithProject { pub(super) fn into_task( self, annotations: Vec, subtasks: Vec, status_tokens: Vec, ) -> Result { Ok(Task { id: parse_uuid(&self.id)?.into(), project_id: parse_uuid_opt(self.project_id.as_deref())?.map(Into::into), project_name: self.project_name, contact_id: parse_uuid_opt(self.contact_id.as_deref())?.map(Into::into), contact_name: self.contact_name, milestone_id: parse_uuid_opt(self.milestone_id.as_deref())?.map(Into::into), title: self.title, description: self.description, status: TaskStatus::from_str_or_default(&self.status), priority: Priority::from_str_or_default(&self.priority), due: self.due.as_ref().map(|s| parse_datetime(s)).transpose()?, tags: parse_tags(&self.tags), urgency: self.urgency, recurrence: Recurrence::from_str_or_default(&self.recurrence), recurrence_rule: self .recurrence_rule .as_deref() .and_then(|s| serde_json::from_str(s).ok()), recurrence_parent_id: parse_uuid_opt(self.recurrence_parent_id.as_deref())? .map(Into::into), source_email_id: parse_uuid_opt(self.source_email_id.as_deref())?.map(Into::into), snoozed_until: self .snoozed_until .as_ref() .map(|s| parse_datetime(s)) .transpose()?, waiting_for_response: self.waiting_for_response != 0, waiting_since: self .waiting_since .as_ref() .map(|s| parse_datetime(s)) .transpose()?, expected_response_date: self .expected_response_date .as_ref() .map(|s| parse_datetime(s)) .transpose()?, scheduled_start: self .scheduled_start .as_ref() .map(|s| parse_datetime(s)) .transpose()?, scheduled_duration: self.scheduled_duration, estimated_minutes: self.estimated_minutes, actual_minutes: self.actual_minutes, active_session: None, annotations, subtasks, status_tokens, created_at: parse_datetime(&self.created_at)?, completed_at: self .completed_at .as_ref() .map(|s| parse_datetime(s)) .transpose()?, is_focus: self.is_focus != 0, focus_set_at: self .focus_set_at .as_ref() .map(|s| parse_datetime(s)) .transpose()?, // Clamped rather than trusted. These are cached derivations, and a // negative or absurd value means the cache is corrupt, not that the // task really is a billion steps deep; saturating keeps the score // finite and the repair path is `recompute_graph`. graph: goingson_core::GraphPosition { block_depth: u32::try_from(self.block_depth).unwrap_or(0), unblocks_count: u32::try_from(self.unblocks_count).unwrap_or(0), in_cycle: self.in_cycle != 0, }, graph_urgency: self.graph_urgency, }) } }