Skip to main content

max / goingson

8.7 KB · 210 lines History Blame Raw
1 //! Task row mapping: the shared SELECT column list, the JOIN row struct, and
2 //! the conversion from a database row to a [`Task`].
3
4 use goingson_core::{
5 Annotation, ParseableEnum, Priority, Recurrence, Result, StatusToken, Subtask, Task,
6 TaskSortColumn, TaskStatus,
7 };
8
9 use crate::utils::{parse_datetime, parse_tags, parse_uuid, parse_uuid_opt};
10
11 /// Returns the SQL column expression for a [`TaskSortColumn`].
12 pub(super) fn sort_column_sql(col: TaskSortColumn) -> &'static str {
13 match col {
14 TaskSortColumn::Description => "t.title",
15 TaskSortColumn::Project => "p.name",
16 TaskSortColumn::Priority => {
17 "CASE t.priority WHEN 'High' THEN 3 WHEN 'Medium' THEN 2 WHEN 'Low' THEN 1 ELSE 0 END"
18 }
19 TaskSortColumn::Due => "t.due",
20 // The effective score, not the stored base. `graph_urgency` is what
21 // sinks blocked work and floats blockers, so sorting by `t.urgency`
22 // alone would order the list differently from the number each row
23 // displays. See `Task::effective_urgency`.
24 TaskSortColumn::Urgency => "(t.urgency + t.graph_urgency)",
25 }
26 }
27
28 /// Returns whether NULLs should sort last for the given column.
29 pub(super) fn sort_column_nulls_last(col: TaskSortColumn) -> bool {
30 matches!(col, TaskSortColumn::Project | TaskSortColumn::Due)
31 }
32
33 /// Common SELECT columns for task queries with project JOIN.
34 ///
35 /// This constant ensures consistent column ordering across all task queries.
36 /// Usage: `format!("SELECT {} FROM tasks t LEFT JOIN projects p ON ...", TASK_SELECT_COLUMNS)`
37 pub(crate) const TASK_SELECT_COLUMNS: &str = r"t.id, t.project_id, p.name as project_name,
38 t.contact_id, ct.display_name as contact_name,
39 t.milestone_id,
40 t.title, t.description, t.status,
41 t.priority, t.due, t.tags, t.urgency, t.recurrence, t.recurrence_rule, t.recurrence_parent_id, t.source_email_id,
42 t.snoozed_until, t.waiting_for_response, t.waiting_since, t.expected_response_date,
43 t.scheduled_start, t.scheduled_duration,
44 t.estimated_minutes, t.actual_minutes,
45 t.created_at, t.completed_at, t.is_focus, t.focus_set_at,
46 t.block_depth, t.unblocks_count, t.in_cycle, t.graph_urgency";
47
48 /// Row struct for task with project name from JOIN
49 #[derive(Debug, Clone)]
50 pub(crate) struct TaskRowWithProject {
51 pub id: String,
52 pub project_id: Option<String>,
53 pub project_name: Option<String>,
54 pub contact_id: Option<String>,
55 pub contact_name: Option<String>,
56 pub milestone_id: Option<String>,
57 pub title: String,
58 pub description: String,
59 pub status: String,
60 pub priority: String,
61 pub due: Option<String>,
62 pub tags: String,
63 pub urgency: f64,
64 pub recurrence: String,
65 pub recurrence_rule: Option<String>,
66 pub recurrence_parent_id: Option<String>,
67 pub source_email_id: Option<String>,
68 pub snoozed_until: Option<String>,
69 pub waiting_for_response: i32,
70 pub waiting_since: Option<String>,
71 pub expected_response_date: Option<String>,
72 pub scheduled_start: Option<String>,
73 pub scheduled_duration: Option<i32>,
74 pub estimated_minutes: Option<i32>,
75 pub actual_minutes: i32,
76 pub created_at: String,
77 pub completed_at: Option<String>,
78 pub is_focus: i32,
79 pub focus_set_at: Option<String>,
80 /// Cached graph columns. Derived from `task_dependencies`, never set by a
81 /// task write; see `dependency_repo::recompute_graph`.
82 pub block_depth: i64,
83 pub unblocks_count: i64,
84 pub in_cycle: i32,
85 pub graph_urgency: f64,
86 }
87
88 impl TaskRowWithProject {
89 pub(crate) fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
90 Ok(Self {
91 id: row.get("id")?,
92 project_id: row.get("project_id")?,
93 project_name: row.get("project_name")?,
94 contact_id: row.get("contact_id")?,
95 contact_name: row.get("contact_name")?,
96 milestone_id: row.get("milestone_id")?,
97 title: row.get("title")?,
98 description: row.get("description")?,
99 status: row.get("status")?,
100 priority: row.get("priority")?,
101 due: row.get("due")?,
102 tags: row.get("tags")?,
103 urgency: row.get("urgency")?,
104 recurrence: row.get("recurrence")?,
105 recurrence_rule: row.get("recurrence_rule")?,
106 recurrence_parent_id: row.get("recurrence_parent_id")?,
107 source_email_id: row.get("source_email_id")?,
108 snoozed_until: row.get("snoozed_until")?,
109 waiting_for_response: row.get("waiting_for_response")?,
110 waiting_since: row.get("waiting_since")?,
111 expected_response_date: row.get("expected_response_date")?,
112 scheduled_start: row.get("scheduled_start")?,
113 scheduled_duration: row.get("scheduled_duration")?,
114 estimated_minutes: row.get("estimated_minutes")?,
115 actual_minutes: row.get("actual_minutes")?,
116 created_at: row.get("created_at")?,
117 completed_at: row.get("completed_at")?,
118 is_focus: row.get("is_focus")?,
119 focus_set_at: row.get("focus_set_at")?,
120 block_depth: row.get("block_depth")?,
121 unblocks_count: row.get("unblocks_count")?,
122 in_cycle: row.get("in_cycle")?,
123 graph_urgency: row.get("graph_urgency")?,
124 })
125 }
126 }
127
128 impl TaskRowWithProject {
129 pub(super) fn into_task(
130 self,
131 annotations: Vec<Annotation>,
132 subtasks: Vec<Subtask>,
133 status_tokens: Vec<StatusToken>,
134 ) -> Result<Task> {
135 Ok(Task {
136 id: parse_uuid(&self.id)?.into(),
137 project_id: parse_uuid_opt(self.project_id.as_deref())?.map(Into::into),
138 project_name: self.project_name,
139 contact_id: parse_uuid_opt(self.contact_id.as_deref())?.map(Into::into),
140 contact_name: self.contact_name,
141 milestone_id: parse_uuid_opt(self.milestone_id.as_deref())?.map(Into::into),
142 title: self.title,
143 description: self.description,
144 status: TaskStatus::from_str_or_default(&self.status),
145 priority: Priority::from_str_or_default(&self.priority),
146 due: self.due.as_ref().map(|s| parse_datetime(s)).transpose()?,
147 tags: parse_tags(&self.tags),
148 urgency: self.urgency,
149 recurrence: Recurrence::from_str_or_default(&self.recurrence),
150 recurrence_rule: self
151 .recurrence_rule
152 .as_deref()
153 .and_then(|s| serde_json::from_str(s).ok()),
154 recurrence_parent_id: parse_uuid_opt(self.recurrence_parent_id.as_deref())?
155 .map(Into::into),
156 source_email_id: parse_uuid_opt(self.source_email_id.as_deref())?.map(Into::into),
157 snoozed_until: self
158 .snoozed_until
159 .as_ref()
160 .map(|s| parse_datetime(s))
161 .transpose()?,
162 waiting_for_response: self.waiting_for_response != 0,
163 waiting_since: self
164 .waiting_since
165 .as_ref()
166 .map(|s| parse_datetime(s))
167 .transpose()?,
168 expected_response_date: self
169 .expected_response_date
170 .as_ref()
171 .map(|s| parse_datetime(s))
172 .transpose()?,
173 scheduled_start: self
174 .scheduled_start
175 .as_ref()
176 .map(|s| parse_datetime(s))
177 .transpose()?,
178 scheduled_duration: self.scheduled_duration,
179 estimated_minutes: self.estimated_minutes,
180 actual_minutes: self.actual_minutes,
181 active_session: None,
182 annotations,
183 subtasks,
184 status_tokens,
185 created_at: parse_datetime(&self.created_at)?,
186 completed_at: self
187 .completed_at
188 .as_ref()
189 .map(|s| parse_datetime(s))
190 .transpose()?,
191 is_focus: self.is_focus != 0,
192 focus_set_at: self
193 .focus_set_at
194 .as_ref()
195 .map(|s| parse_datetime(s))
196 .transpose()?,
197 // Clamped rather than trusted. These are cached derivations, and a
198 // negative or absurd value means the cache is corrupt, not that the
199 // task really is a billion steps deep; saturating keeps the score
200 // finite and the repair path is `recompute_graph`.
201 graph: goingson_core::GraphPosition {
202 block_depth: u32::try_from(self.block_depth).unwrap_or(0),
203 unblocks_count: u32::try_from(self.unblocks_count).unwrap_or(0),
204 in_cycle: self.in_cycle != 0,
205 },
206 graph_urgency: self.graph_urgency,
207 })
208 }
209 }
210