Skip to main content

max / goingson

41.7 KB · 1250 lines History Blame Raw
1 //! Task domain types and DTOs.
2 //!
3 //! Tasks are the primary work unit in GoingsOn. Each task carries a priority,
4 //! an urgency score computed from priority/due date/age/tags, and optional
5 //! recurrence (Daily, Weekly, Monthly). Tasks can be snoozed to temporarily
6 //! hide them, marked as waiting-for-response, and scheduled into time blocks.
7 //! Subtasks provide checklist items and can link to other tasks for multi-phase
8 //! workflows.
9
10 use chrono::{DateTime, Utc};
11 use serde::{Deserialize, Serialize};
12 use strum_macros::EnumString;
13 use crate::constants::{
14 DAYS_THRESHOLD_SHORT_FORMAT, URGENCY_HIGH_THRESHOLD, URGENCY_MEDIUM_THRESHOLD,
15 };
16 use crate::id_types::{TaskId, ProjectId, MilestoneId, ContactId, EmailId, AnnotationId, SubtaskId, StatusTokenId};
17 use super::time_session::TimeSession;
18 use super::shared::{CssClass, DbValue, ParseableEnum, Recurrence, RecurrenceRule, SortDirection};
19
20 // ============ Task Types ============
21
22 /// Lifecycle status of a task.
23 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, EnumString)]
24 pub enum TaskStatus {
25 /// Not yet started.
26 #[strum(serialize = "Pending")]
27 #[default]
28 Pending,
29 /// Work in progress.
30 #[strum(serialize = "Started")]
31 Started,
32 /// Successfully finished.
33 #[strum(serialize = "Completed")]
34 Completed,
35 /// Soft-deleted.
36 #[strum(serialize = "Deleted")]
37 Deleted,
38 }
39
40 impl TaskStatus {
41 /// Returns a human-readable display string.
42 pub fn as_str(&self) -> &'static str {
43 match self {
44 TaskStatus::Pending => "Pending",
45 TaskStatus::Started => "Started",
46 TaskStatus::Completed => "Completed",
47 TaskStatus::Deleted => "Deleted",
48 }
49 }
50
51 }
52
53 impl ParseableEnum for TaskStatus {}
54
55 impl DbValue for TaskStatus {
56 fn db_value(&self) -> &'static str {
57 self.as_str()
58 }
59 }
60
61 impl CssClass for TaskStatus {
62 fn css_class(&self) -> &'static str {
63 match self {
64 TaskStatus::Pending => "task-pending",
65 TaskStatus::Started => "task-started",
66 TaskStatus::Completed => "task-completed",
67 TaskStatus::Deleted => "task-deleted",
68 }
69 }
70 }
71
72 /// Task priority level, affects urgency calculation.
73 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, EnumString)]
74 pub enum Priority {
75 /// Urgent, high-impact task.
76 #[strum(serialize = "High")]
77 High,
78 /// Normal priority.
79 #[strum(serialize = "Medium")]
80 #[default]
81 Medium,
82 /// Can be deferred.
83 #[strum(serialize = "Low")]
84 Low,
85 }
86
87 impl Priority {
88 /// Returns the short form (H/M/L) for display.
89 pub fn as_str(&self) -> &'static str {
90 match self {
91 Priority::High => "H",
92 Priority::Medium => "M",
93 Priority::Low => "L",
94 }
95 }
96
97 /// Parses a string into a Priority, falling back to `Medium` on invalid input.
98 ///
99 /// Accepts various formats: "High"/"H"/"high"/"h", "Medium"/"M"/"Med"/etc., "Low"/"L"/"low"/"l".
100 /// This intentional fallback ensures database reads and frontend input never fail.
101 /// Use `str.parse::<Priority>()` if you need error handling.
102 #[allow(clippy::should_implement_trait)]
103 pub fn from_str_or_default(s: &str) -> Self {
104 match s {
105 "High" | "H" | "high" | "h" => Priority::High,
106 "Medium" | "M" | "medium" | "m" | "Med" | "med" => Priority::Medium,
107 "Low" | "L" | "low" | "l" => Priority::Low,
108 _ => Priority::default(),
109 }
110 }
111 }
112
113 impl DbValue for Priority {
114 fn db_value(&self) -> &'static str {
115 match self {
116 Priority::High => "High",
117 Priority::Medium => "Medium",
118 Priority::Low => "Low",
119 }
120 }
121 }
122
123 impl CssClass for Priority {
124 fn css_class(&self) -> &'static str {
125 match self {
126 Priority::High => "priority-high",
127 Priority::Medium => "priority-medium",
128 Priority::Low => "priority-low",
129 }
130 }
131 }
132
133 /// A timestamped note attached to a task.
134 #[derive(Debug, Clone, Serialize, Deserialize)]
135 #[serde(rename_all = "camelCase")]
136 pub struct Annotation {
137 /// Unique identifier.
138 pub id: AnnotationId,
139 /// Parent task ID.
140 #[serde(skip_serializing)]
141 pub task_id: TaskId,
142 /// When the annotation was created.
143 pub timestamp: DateTime<Utc>,
144 /// The annotation text.
145 pub note: String,
146 }
147
148 /// A checklist item within a task.
149 ///
150 /// Subtasks can be either:
151 /// - Text-only: A simple checklist item with text description
152 /// - Task link: A link to another task, enabling multi-phase features
153 ///
154 /// When `linked_task_id` is set, the subtask represents a link to another task.
155 /// In this case, `text` may be empty (synced from linked task) or override text.
156 /// Completion status syncs with the linked task's status.
157 #[derive(Debug, Clone, Serialize, Deserialize)]
158 #[serde(rename_all = "camelCase")]
159 pub struct Subtask {
160 /// Unique identifier.
161 pub id: SubtaskId,
162 /// Parent task ID.
163 #[serde(skip_serializing)]
164 pub task_id: TaskId,
165 /// Subtask description (for text-only subtasks).
166 pub text: String,
167 /// Linked task ID (for task-link subtasks).
168 /// When set, this subtask represents a link to another task.
169 pub linked_task_id: Option<TaskId>,
170 /// Whether this subtask is done.
171 pub is_completed: bool,
172 /// Display order (lower = first).
173 #[serde(rename = "sortOrder")]
174 pub position: i32,
175 }
176
177 /// The kind identifier for a `commit` status token: a repo-qualified
178 /// `<repo>@<shortsha>` reference that is `Pending` until pushed, `Complete` once
179 /// pushed. The first — and today only — [`StatusToken`] kind.
180 pub const TOKEN_KIND_COMMIT: &str = "commit";
181
182 /// Fixed namespace UUID for GoingsOn status-token ids (generated once, never
183 /// changes). See [`StatusToken::deterministic_id`].
184 const GOINGSON_STATUS_TOKEN_NS: uuid::Uuid = uuid::Uuid::from_bytes([
185 0x2f, 0x9d, 0x4a, 0x61, 0xb3, 0x0c, 0x5e, 0x72,
186 0x8a, 0x1f, 0x6c, 0x4d, 0x0b, 0xe5, 0x93, 0x27,
187 ]);
188
189 /// The colour-mapped state of a [`StatusToken`]. A task with no tokens reads as
190 /// neutral; a token is `Pending` (amber) or `Complete` (green). Each token kind
191 /// interprets these in its own domain — for `commit`, `Pending` = recorded but not
192 /// pushed, `Complete` = pushed.
193 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, EnumString)]
194 pub enum TokenState {
195 /// Work-in-progress (amber). E.g. a commit that has not been pushed.
196 #[strum(serialize = "Pending")]
197 #[default]
198 Pending,
199 /// Done (green). E.g. a commit that has been pushed.
200 #[strum(serialize = "Complete")]
201 Complete,
202 }
203
204 impl TokenState {
205 /// Returns the stored/display string (`"Pending"` / `"Complete"`).
206 pub fn as_str(&self) -> &'static str {
207 match self {
208 TokenState::Pending => "Pending",
209 TokenState::Complete => "Complete",
210 }
211 }
212 }
213
214 impl ParseableEnum for TokenState {}
215
216 impl DbValue for TokenState {
217 fn db_value(&self) -> &'static str {
218 self.as_str()
219 }
220 }
221
222 /// A typed at-a-glance marker attached to a task, driving its colour indicator.
223 ///
224 /// A task carries an ordered list of these; at most one may be `is_primary`, the
225 /// token that resolved the task. `kind` routes the domain (e.g. [`TOKEN_KIND_COMMIT`])
226 /// and `reference` is that domain's value — for a commit, the repo-qualified
227 /// `<repo>@<shortsha>`, since a bare short SHA is not unique across the ecosystem's
228 /// repos. `state` is the generic, colour-mapped [`TokenState`].
229 ///
230 /// The row id is deterministic in `(task_id, kind, reference)` (UUID v5), so
231 /// recording the same token twice — on one device or across devices — converges to a
232 /// single row through the sync changelog's id-keyed upsert.
233 #[derive(Debug, Clone, Serialize, Deserialize)]
234 #[serde(rename_all = "camelCase")]
235 pub struct StatusToken {
236 /// Deterministic identifier (UUID v5 of `<task_id>:<kind>:<reference>`).
237 pub id: StatusTokenId,
238 /// Parent task ID.
239 #[serde(skip_serializing)]
240 pub task_id: TaskId,
241 /// Token kind, e.g. [`TOKEN_KIND_COMMIT`].
242 pub kind: String,
243 /// The kind's value; for `commit`, a `<repo>@<shortsha>` reference.
244 pub reference: String,
245 /// Colour-mapped state.
246 pub state: TokenState,
247 /// Whether this is the token that resolved the task (at most one per task).
248 pub is_primary: bool,
249 /// Display/append order (lower = earlier).
250 #[serde(rename = "sortOrder")]
251 pub position: i32,
252 }
253
254 impl StatusToken {
255 /// The deterministic row id for a `(task_id, kind, reference)` triple.
256 ///
257 /// Keeping this pure and content-derived is what lets two devices record the
258 /// same token independently and still collapse to one row on sync.
259 pub fn deterministic_id(task_id: TaskId, kind: &str, reference: &str) -> StatusTokenId {
260 let key = format!("{task_id}:{kind}:{reference}");
261 StatusTokenId::from(uuid::Uuid::new_v5(&GOINGSON_STATUS_TOKEN_NS, key.as_bytes()))
262 }
263 }
264
265 /// A task representing work to be done.
266 ///
267 /// Tasks can be associated with a project, have due dates, recurrence patterns,
268 /// annotations, and subtasks. They support snoozing and waiting-for-response
269 /// tracking, as well as time-block scheduling.
270 #[derive(Debug, Clone, Serialize, Deserialize)]
271 #[serde(rename_all = "camelCase")]
272 pub struct Task {
273 /// Unique identifier.
274 pub id: TaskId,
275 /// Associated project, if any.
276 pub project_id: Option<ProjectId>,
277 /// Denormalized project name for display.
278 pub project_name: Option<String>,
279 /// Associated milestone, if any.
280 pub milestone_id: Option<MilestoneId>,
281 /// Associated contact, if any.
282 pub contact_id: Option<ContactId>,
283 /// Denormalized contact name for display.
284 pub contact_name: Option<String>,
285 /// Task description/title.
286 pub description: String,
287 /// Current lifecycle status.
288 pub status: TaskStatus,
289 /// Priority level.
290 pub priority: Priority,
291 /// Due date, if set.
292 pub due: Option<DateTime<Utc>>,
293 /// User-defined tags for categorization.
294 pub tags: Vec<String>,
295 /// Calculated urgency score for sorting.
296 pub urgency: f64,
297 /// Recurrence pattern for repeating tasks (legacy).
298 pub recurrence: Recurrence,
299 /// Rich recurrence configuration (JSON). Takes precedence over `recurrence`.
300 pub recurrence_rule: Option<RecurrenceRule>,
301 /// Original task ID if this is a recurrence instance.
302 pub recurrence_parent_id: Option<TaskId>,
303 /// Email this task was created from, if any.
304 pub source_email_id: Option<EmailId>,
305 /// If snoozed, when to resurface.
306 pub snoozed_until: Option<DateTime<Utc>>,
307 /// Whether waiting for external response.
308 pub waiting_for_response: bool,
309 /// When waiting status was set.
310 pub waiting_since: Option<DateTime<Utc>>,
311 /// Expected response date when waiting.
312 pub expected_response_date: Option<DateTime<Utc>>,
313 /// Scheduled start time for time-blocking.
314 pub scheduled_start: Option<DateTime<Utc>>,
315 /// Scheduled duration in minutes.
316 pub scheduled_duration: Option<i32>,
317 /// Attached notes.
318 pub annotations: Vec<Annotation>,
319 /// Checklist items.
320 pub subtasks: Vec<Subtask>,
321 /// Status tokens (ordered); the `is_primary` one resolved the task.
322 pub status_tokens: Vec<StatusToken>,
323 /// Estimated duration in minutes (user-provided).
324 pub estimated_minutes: Option<i32>,
325 /// Cached total actual tracked minutes across all sessions.
326 pub actual_minutes: i32,
327 /// Currently active time session, if any (populated on fetch).
328 #[serde(skip_serializing_if = "Option::is_none")]
329 pub active_session: Option<TimeSession>,
330 /// When the task was created.
331 pub created_at: DateTime<Utc>,
332 /// When the task was completed (set on status transition to Completed).
333 pub completed_at: Option<DateTime<Utc>>,
334 /// Whether this task is marked as a focus for the week.
335 pub is_focus: bool,
336 /// When the focus was set.
337 pub focus_set_at: Option<DateTime<Utc>>,
338 }
339
340 impl Task {
341 /// Returns a human-readable due date string relative to now.
342 ///
343 /// Examples: "today", "tomorrow", "+3d", "2d ago", "2026-03-15", or "-" if no due date.
344 pub fn due_formatted(&self) -> String {
345 match &self.due {
346 Some(dt) => {
347 let now = Utc::now();
348 let days = (dt.date_naive() - now.date_naive()).num_days();
349
350 if days < 0 {
351 format!("{}d ago", -days)
352 } else if days == 0 {
353 "today".to_string()
354 } else if days == 1 {
355 "tomorrow".to_string()
356 } else if days < DAYS_THRESHOLD_SHORT_FORMAT {
357 format!("+{}d", days)
358 } else {
359 dt.format("%Y-%m-%d").to_string()
360 }
361 }
362 None => "-".to_string(),
363 }
364 }
365
366 /// Returns the number of annotations on this task.
367 pub fn annotation_count(&self) -> usize {
368 self.annotations.len()
369 }
370
371 /// Returns true if the task has any annotations attached.
372 pub fn has_annotations(&self) -> bool {
373 !self.annotations.is_empty()
374 }
375
376 /// Returns true if the task has a recurrence pattern set (not `None`).
377 pub fn has_recurrence(&self) -> bool {
378 self.recurrence_rule.is_some() || self.recurrence != Recurrence::None
379 }
380
381 /// Returns the effective recurrence rule, synthesizing from the legacy
382 /// column if no explicit rule is set.
383 pub fn effective_recurrence_rule(&self) -> Option<RecurrenceRule> {
384 RecurrenceRule::effective(self.recurrence_rule.as_ref(), &self.recurrence)
385 }
386
387 /// Returns the project name, or `"-"` if unset. The dash fallback is used
388 /// for display in table views where an empty cell would look broken.
389 pub fn project_name_or_dash(&self) -> &str {
390 self.project_name.as_deref().unwrap_or("-")
391 }
392
393 /// Returns the project name, or an empty string if unset.
394 pub fn project_name_or_empty(&self) -> &str {
395 self.project_name.as_deref().unwrap_or("")
396 }
397
398 /// Returns the due date as a Unix timestamp (seconds since epoch).
399 /// Returns 0 (Unix epoch) when no due date is set, which sorts
400 /// undated tasks to the beginning in timestamp-based ordering.
401 pub fn due_timestamp(&self) -> i64 {
402 self.due.map(|d| d.timestamp()).unwrap_or(0)
403 }
404
405 /// Returns urgency as a formatted string with one decimal place (e.g., "8.3").
406 pub fn urgency_formatted(&self) -> String {
407 format!("{:.1}", self.urgency)
408 }
409
410 /// Returns true if the task is past its due date.
411 pub fn is_overdue(&self) -> bool {
412 match self.due {
413 Some(due) => due < Utc::now(),
414 None => false,
415 }
416 }
417
418 /// Returns the CSS class for urgency styling.
419 /// Red (overdue) is reserved for actually overdue tasks.
420 pub fn urgency_class(&self) -> &'static str {
421 // Overdue takes priority - only overdue tasks get red
422 if self.is_overdue() {
423 "urgency-overdue"
424 } else if self.urgency >= URGENCY_HIGH_THRESHOLD {
425 "urgency-high"
426 } else if self.urgency >= URGENCY_MEDIUM_THRESHOLD {
427 "urgency-medium"
428 } else {
429 "urgency-low"
430 }
431 }
432
433 /// Returns the total number of subtasks.
434 pub fn subtask_count(&self) -> usize {
435 self.subtasks.len()
436 }
437
438 /// Returns the number of completed subtasks.
439 pub fn subtasks_completed(&self) -> usize {
440 self.subtasks.iter().filter(|s| s.is_completed).count()
441 }
442
443 /// Returns true if the task has any subtasks.
444 pub fn has_subtasks(&self) -> bool {
445 !self.subtasks.is_empty()
446 }
447
448 /// Returns subtask progress as "completed/total" (e.g., "3/5").
449 pub fn subtasks_progress(&self) -> String {
450 format!("{}/{}", self.subtasks_completed(), self.subtask_count())
451 }
452
453 /// Returns true if the task was created from an email.
454 pub fn has_source_email(&self) -> bool {
455 self.source_email_id.is_some()
456 }
457
458 /// Returns true if the task has any status tokens.
459 pub fn has_status_tokens(&self) -> bool {
460 !self.status_tokens.is_empty()
461 }
462
463 /// Returns the primary token (the one that resolved the task), if flagged.
464 pub fn primary_token(&self) -> Option<&StatusToken> {
465 self.status_tokens.iter().find(|t| t.is_primary)
466 }
467
468 /// Rolls the task's tokens up to a single at-a-glance state for the indicator:
469 /// `"neutral"` (no tokens), `"complete"` (every token `Complete`), or
470 /// `"pending"` (at least one token still `Pending`).
471 pub fn status_token_summary(&self) -> &'static str {
472 if self.status_tokens.is_empty() {
473 "neutral"
474 } else if self.status_tokens.iter().all(|t| t.state == TokenState::Complete) {
475 "complete"
476 } else {
477 "pending"
478 }
479 }
480
481 /// Returns true if the task is currently snoozed (snoozed_until is in the future).
482 pub fn is_snoozed(&self) -> bool {
483 self.snoozed_until
484 .map(|until| until > Utc::now())
485 .unwrap_or(false)
486 }
487
488 /// Returns true if the task is waiting for an external response.
489 pub fn is_waiting(&self) -> bool {
490 self.waiting_for_response
491 }
492
493 /// Returns true if the task is waiting and the expected response date has passed.
494 pub fn is_response_overdue(&self) -> bool {
495 self.waiting_for_response
496 && self.expected_response_date
497 .map(|date| date < Utc::now())
498 .unwrap_or(false)
499 }
500
501 /// Returns true if this task is marked as a weekly focus.
502 pub fn is_focused(&self) -> bool {
503 self.is_focus
504 }
505
506 /// Returns time progress as a percentage (0-100), or None if no estimate.
507 pub fn time_progress(&self) -> Option<u8> {
508 self.estimated_minutes.map(|est| {
509 if est <= 0 {
510 return 0;
511 }
512 ((self.actual_minutes as f64 / est as f64) * 100.0).round().min(100.0) as u8
513 })
514 }
515
516 /// Returns true if actual tracked time exceeds the estimate.
517 pub fn is_over_estimate(&self) -> bool {
518 match self.estimated_minutes {
519 Some(est) if est > 0 => self.actual_minutes > est,
520 _ => false,
521 }
522 }
523
524 /// Returns true if a timer is currently running on this task.
525 pub fn has_active_timer(&self) -> bool {
526 self.active_session.is_some()
527 }
528 }
529
530 /// Column to sort tasks by.
531 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
532 pub enum TaskSortColumn {
533 /// Sort by task description (alphabetical)
534 Description,
535 /// Sort by project name (alphabetical, nulls last)
536 Project,
537 /// Sort by priority (High > Medium > Low)
538 Priority,
539 /// Sort by due date (nulls last)
540 Due,
541 /// Sort by calculated urgency score
542 #[default]
543 Urgency,
544 }
545
546 impl TaskSortColumn {
547 /// Parses a string to TaskSortColumn, defaulting to Urgency if unrecognized.
548 pub fn from_str_or_default(s: &str) -> Self {
549 match s.to_lowercase().as_str() {
550 "description" => Self::Description,
551 "project" => Self::Project,
552 "priority" => Self::Priority,
553 "due" => Self::Due,
554 "urgency" => Self::Urgency,
555 _ => Self::default(),
556 }
557 }
558 }
559
560 /// Query parameters for filtered task listing.
561 /// All fields are optional - omitted fields don't restrict results.
562 #[derive(Debug, Clone, Default)]
563 pub struct TaskFilterQuery {
564 /// Filter by status (exact match)
565 pub status: Option<TaskStatus>,
566 /// Filter by project ID
567 pub project_id: Option<ProjectId>,
568 /// Filter by milestone ID
569 pub milestone_id: Option<MilestoneId>,
570 /// Filter by priority (exact match)
571 pub priority: Option<Priority>,
572 /// If false (default), hide tasks where snoozed_until > now
573 pub show_snoozed: bool,
574 /// If true, only show tasks with waiting_for_response = true
575 pub waiting_only: bool,
576 /// Pagination: number of items to skip
577 pub offset: Option<i64>,
578 /// Pagination: maximum items to return
579 pub limit: Option<i64>,
580 /// Column to sort by (default: Urgency)
581 pub sort_column: Option<TaskSortColumn>,
582 /// Sort direction (default: Desc for Urgency, Asc for others)
583 pub sort_direction: Option<SortDirection>,
584 }
585
586 // ============ Task DTOs ============
587
588 /// Data for creating a new task.
589 #[derive(Debug, Clone, Serialize, Deserialize)]
590 pub struct NewTask {
591 /// Associated project, if any.
592 pub project_id: Option<ProjectId>,
593 /// Target milestone within the project, if any.
594 pub milestone_id: Option<MilestoneId>,
595 /// Associated contact, if any.
596 pub contact_id: Option<ContactId>,
597 /// Task description/title (required, validated non-empty).
598 pub description: String,
599 /// Priority level, affects urgency calculation.
600 pub priority: Priority,
601 /// Due date, if set. Used in urgency scoring.
602 pub due: Option<DateTime<Utc>>,
603 /// User-defined tags for categorization and urgency modifiers.
604 pub tags: Vec<String>,
605 /// Recurrence pattern (None, Daily, Weekly, Monthly).
606 pub recurrence: Recurrence,
607 /// Rich recurrence configuration (JSON).
608 pub recurrence_rule: Option<RecurrenceRule>,
609 /// Pre-calculated urgency score based on priority, due date, age, and tags.
610 pub urgency: f64,
611 /// Email this task was created from, if any (set by email-to-task flow).
612 pub source_email_id: Option<EmailId>,
613 /// Scheduled start time for time-blocking.
614 pub scheduled_start: Option<DateTime<Utc>>,
615 /// Scheduled duration in minutes for time-blocking.
616 pub scheduled_duration: Option<i32>,
617 /// Estimated duration in minutes.
618 pub estimated_minutes: Option<i32>,
619 /// Root task ID for recurrence chain (set when spawning next recurring instance).
620 pub recurrence_parent_id: Option<TaskId>,
621 }
622
623 impl NewTask {
624 /// Creates a builder for constructing a new task.
625 ///
626 /// # Example
627 ///
628 /// ```rust
629 /// use goingson_core::{NewTask, Priority};
630 /// use chrono::Utc;
631 ///
632 /// let task = NewTask::builder("Fix the bug")
633 /// .priority(Priority::High)
634 /// .tag("urgent")
635 /// .urgency(8.0)
636 /// .build();
637 /// ```
638 pub fn builder(description: impl Into<String>) -> NewTaskBuilder {
639 NewTaskBuilder::new(description)
640 }
641 }
642
643 /// Builder for constructing [`NewTask`] with sensible defaults.
644 #[derive(Debug, Clone)]
645 pub struct NewTaskBuilder {
646 description: String,
647 project_id: Option<ProjectId>,
648 milestone_id: Option<MilestoneId>,
649 contact_id: Option<ContactId>,
650 priority: Priority,
651 due: Option<DateTime<Utc>>,
652 tags: Vec<String>,
653 recurrence: Recurrence,
654 recurrence_rule: Option<RecurrenceRule>,
655 urgency: f64,
656 source_email_id: Option<EmailId>,
657 scheduled_start: Option<DateTime<Utc>>,
658 scheduled_duration: Option<i32>,
659 estimated_minutes: Option<i32>,
660 recurrence_parent_id: Option<TaskId>,
661 }
662
663 impl NewTaskBuilder {
664 /// Creates a new builder with the given description.
665 pub fn new(description: impl Into<String>) -> Self {
666 Self {
667 description: description.into(),
668 project_id: None,
669 milestone_id: None,
670 contact_id: None,
671 priority: Priority::default(),
672 due: None,
673 tags: Vec::new(),
674 recurrence: Recurrence::default(),
675 recurrence_rule: None,
676 urgency: 0.0,
677 source_email_id: None,
678 scheduled_start: None,
679 scheduled_duration: None,
680 estimated_minutes: None,
681 recurrence_parent_id: None,
682 }
683 }
684
685 /// Sets the project ID.
686 pub fn project_id(mut self, project_id: ProjectId) -> Self {
687 self.project_id = Some(project_id);
688 self
689 }
690
691 /// Sets the milestone ID.
692 pub fn milestone_id(mut self, milestone_id: MilestoneId) -> Self {
693 self.milestone_id = Some(milestone_id);
694 self
695 }
696
697 /// Sets the contact ID.
698 pub fn contact_id(mut self, contact_id: ContactId) -> Self {
699 self.contact_id = Some(contact_id);
700 self
701 }
702
703 /// Sets the priority level.
704 pub fn priority(mut self, priority: Priority) -> Self {
705 self.priority = priority;
706 self
707 }
708
709 /// Sets the due date.
710 pub fn due(mut self, due: DateTime<Utc>) -> Self {
711 self.due = Some(due);
712 self
713 }
714
715 /// Adds a tag.
716 pub fn tag(mut self, tag: impl Into<String>) -> Self {
717 self.tags.push(tag.into());
718 self
719 }
720
721 /// Sets all tags at once.
722 pub fn tags(mut self, tags: Vec<String>) -> Self {
723 self.tags = tags;
724 self
725 }
726
727 /// Sets the recurrence pattern.
728 pub fn recurrence(mut self, recurrence: Recurrence) -> Self {
729 self.recurrence = recurrence;
730 self
731 }
732
733 /// Sets the rich recurrence rule.
734 pub fn recurrence_rule(mut self, rule: RecurrenceRule) -> Self {
735 self.recurrence_rule = Some(rule);
736 self
737 }
738
739 /// Sets the urgency score.
740 pub fn urgency(mut self, urgency: f64) -> Self {
741 self.urgency = urgency;
742 self
743 }
744
745 /// Sets the source email ID.
746 pub fn source_email_id(mut self, email_id: EmailId) -> Self {
747 self.source_email_id = Some(email_id);
748 self
749 }
750
751 /// Sets the scheduled start time.
752 pub fn scheduled_start(mut self, start: DateTime<Utc>) -> Self {
753 self.scheduled_start = Some(start);
754 self
755 }
756
757 /// Sets the scheduled duration in minutes.
758 pub fn scheduled_duration(mut self, duration: i32) -> Self {
759 self.scheduled_duration = Some(duration);
760 self
761 }
762
763 /// Sets the estimated duration in minutes.
764 pub fn estimated_minutes(mut self, minutes: i32) -> Self {
765 self.estimated_minutes = Some(minutes);
766 self
767 }
768
769 /// Sets the recurrence parent ID (root of the recurrence chain).
770 pub fn recurrence_parent_id(mut self, id: TaskId) -> Self {
771 self.recurrence_parent_id = Some(id);
772 self
773 }
774
775 /// Builds the [`NewTask`].
776 pub fn build(self) -> NewTask {
777 NewTask {
778 project_id: self.project_id,
779 milestone_id: self.milestone_id,
780 contact_id: self.contact_id,
781 description: self.description,
782 priority: self.priority,
783 due: self.due,
784 tags: self.tags,
785 recurrence: self.recurrence,
786 recurrence_rule: self.recurrence_rule,
787 urgency: self.urgency,
788 source_email_id: self.source_email_id,
789 scheduled_start: self.scheduled_start,
790 scheduled_duration: self.scheduled_duration,
791 estimated_minutes: self.estimated_minutes,
792 recurrence_parent_id: self.recurrence_parent_id,
793 }
794 }
795 }
796
797 /// Lightweight context for task update logic — avoids fetching annotations, subtasks, sessions.
798 #[derive(Debug, Clone)]
799 pub struct TaskUpdateContext {
800 pub created_at: DateTime<Utc>,
801 pub status: TaskStatus,
802 pub completed_at: Option<DateTime<Utc>>,
803 pub scheduled_start: Option<DateTime<Utc>>,
804 pub scheduled_duration: Option<i32>,
805 }
806
807 /// Data for updating an existing task.
808 #[derive(Debug, Clone, Serialize, Deserialize)]
809 pub struct UpdateTask {
810 /// Associated project, if any.
811 pub project_id: Option<ProjectId>,
812 /// Target milestone within the project, if any.
813 pub milestone_id: Option<MilestoneId>,
814 /// Associated contact, if any.
815 pub contact_id: Option<ContactId>,
816 /// Task description/title (required, validated non-empty).
817 pub description: String,
818 /// Updated lifecycle status.
819 pub status: TaskStatus,
820 /// Priority level, affects urgency calculation.
821 pub priority: Priority,
822 /// Due date, if set. Used in urgency scoring.
823 pub due: Option<DateTime<Utc>>,
824 /// User-defined tags for categorization and urgency modifiers.
825 pub tags: Vec<String>,
826 /// Recurrence pattern (None, Daily, Weekly, Monthly).
827 pub recurrence: Recurrence,
828 /// Rich recurrence configuration (JSON). Threaded on edit so a changed custom
829 /// rule is persisted rather than leaving the stored rule stale.
830 pub recurrence_rule: Option<RecurrenceRule>,
831 /// Re-calculated urgency score based on priority, due date, age, and tags.
832 pub urgency: f64,
833 /// Scheduled start time for time-blocking.
834 pub scheduled_start: Option<DateTime<Utc>>,
835 /// Scheduled duration in minutes for time-blocking.
836 pub scheduled_duration: Option<i32>,
837 /// Estimated duration in minutes.
838 pub estimated_minutes: Option<i32>,
839 }
840
841 #[cfg(test)]
842 mod tests {
843 use super::*;
844 use crate::id_types::{SubtaskId, TaskId};
845 use crate::models::shared::{CssClass, DbValue, Recurrence};
846 use chrono::{Duration, Utc};
847 use std::str::FromStr;
848
849 /// A baseline task with everything empty/defaulted; mutate fields per test.
850 fn task() -> Task {
851 Task {
852 id: TaskId::new(),
853 project_id: None,
854 project_name: None,
855 milestone_id: None,
856 contact_id: None,
857 contact_name: None,
858 description: "Test task".to_string(),
859 status: TaskStatus::Pending,
860 priority: Priority::Medium,
861 due: None,
862 tags: Vec::new(),
863 urgency: 0.0,
864 recurrence: Recurrence::None,
865 recurrence_rule: None,
866 recurrence_parent_id: None,
867 source_email_id: None,
868 snoozed_until: None,
869 waiting_for_response: false,
870 waiting_since: None,
871 expected_response_date: None,
872 scheduled_start: None,
873 scheduled_duration: None,
874 annotations: Vec::new(),
875 subtasks: Vec::new(),
876 status_tokens: Vec::new(),
877 estimated_minutes: None,
878 actual_minutes: 0,
879 active_session: None,
880 created_at: Utc::now(),
881 completed_at: None,
882 is_focus: false,
883 focus_set_at: None,
884 }
885 }
886
887 fn subtask(is_completed: bool) -> Subtask {
888 Subtask {
889 id: SubtaskId::new(),
890 task_id: TaskId::new(),
891 text: "sub".to_string(),
892 linked_task_id: None,
893 is_completed,
894 position: 0,
895 }
896 }
897
898 // ---- TaskStatus ----
899
900 #[test]
901 fn task_status_as_str_and_css_and_db() {
902 assert_eq!(TaskStatus::Started.as_str(), "Started");
903 assert_eq!(TaskStatus::Completed.css_class(), "task-completed");
904 assert_eq!(TaskStatus::Deleted.db_value(), "Deleted");
905 assert_eq!(TaskStatus::default(), TaskStatus::Pending);
906 }
907
908 #[test]
909 fn task_status_from_str() {
910 assert_eq!(TaskStatus::from_str("Completed").unwrap(), TaskStatus::Completed);
911 assert!(TaskStatus::from_str("nonsense").is_err());
912 }
913
914 // ---- Priority ----
915
916 #[test]
917 fn priority_as_str_is_short_form() {
918 assert_eq!(Priority::High.as_str(), "H");
919 assert_eq!(Priority::Medium.as_str(), "M");
920 assert_eq!(Priority::Low.as_str(), "L");
921 }
922
923 #[test]
924 fn priority_from_str_or_default_accepts_variants() {
925 for s in ["High", "H", "high", "h"] {
926 assert_eq!(Priority::from_str_or_default(s), Priority::High, "{s}");
927 }
928 for s in ["Low", "L", "low", "l"] {
929 assert_eq!(Priority::from_str_or_default(s), Priority::Low, "{s}");
930 }
931 for s in ["Medium", "M", "Med", "med", "m"] {
932 assert_eq!(Priority::from_str_or_default(s), Priority::Medium, "{s}");
933 }
934 }
935
936 #[test]
937 fn priority_from_str_or_default_falls_back_to_medium() {
938 assert_eq!(Priority::from_str_or_default(""), Priority::Medium);
939 assert_eq!(Priority::from_str_or_default("URGENT"), Priority::Medium);
940 assert_eq!(Priority::default(), Priority::Medium);
941 }
942
943 #[test]
944 fn priority_db_value_is_long_form() {
945 assert_eq!(Priority::High.db_value(), "High");
946 assert_eq!(Priority::Low.css_class(), "priority-low");
947 }
948
949 // ---- TaskSortColumn ----
950
951 #[test]
952 fn sort_column_parses_case_insensitively() {
953 assert_eq!(TaskSortColumn::from_str_or_default("DUE"), TaskSortColumn::Due);
954 assert_eq!(TaskSortColumn::from_str_or_default("Project"), TaskSortColumn::Project);
955 assert_eq!(TaskSortColumn::from_str_or_default("priority"), TaskSortColumn::Priority);
956 // unknown falls back to the default (Urgency)
957 assert_eq!(TaskSortColumn::from_str_or_default("xyz"), TaskSortColumn::Urgency);
958 assert_eq!(TaskSortColumn::default(), TaskSortColumn::Urgency);
959 }
960
961 // ---- due_formatted ----
962
963 #[test]
964 fn due_formatted_none_is_dash() {
965 assert_eq!(task().due_formatted(), "-");
966 }
967
968 #[test]
969 fn due_formatted_relative_buckets() {
970 let mut t = task();
971
972 t.due = Some(Utc::now());
973 assert_eq!(t.due_formatted(), "today");
974
975 t.due = Some(Utc::now() + Duration::days(1));
976 assert_eq!(t.due_formatted(), "tomorrow");
977
978 t.due = Some(Utc::now() + Duration::days(3));
979 assert_eq!(t.due_formatted(), "+3d");
980
981 t.due = Some(Utc::now() - Duration::days(2));
982 assert_eq!(t.due_formatted(), "2d ago");
983 }
984
985 #[test]
986 fn due_formatted_far_future_is_iso_date() {
987 let mut t = task();
988 let far = Utc::now() + Duration::days(30);
989 t.due = Some(far);
990 assert_eq!(t.due_formatted(), far.format("%Y-%m-%d").to_string());
991 }
992
993 // ---- overdue / urgency_class ----
994
995 #[test]
996 fn is_overdue_reads_due_vs_now() {
997 let mut t = task();
998 assert!(!t.is_overdue(), "no due date is never overdue");
999 t.due = Some(Utc::now() - Duration::hours(1));
1000 assert!(t.is_overdue());
1001 t.due = Some(Utc::now() + Duration::hours(1));
1002 assert!(!t.is_overdue());
1003 }
1004
1005 #[test]
1006 fn urgency_class_thresholds() {
1007 let mut t = task();
1008 t.urgency = 9.0;
1009 assert_eq!(t.urgency_class(), "urgency-high");
1010 t.urgency = 5.0;
1011 assert_eq!(t.urgency_class(), "urgency-medium");
1012 t.urgency = 4.9;
1013 assert_eq!(t.urgency_class(), "urgency-low");
1014 }
1015
1016 #[test]
1017 fn urgency_class_overdue_wins_over_score() {
1018 let mut t = task();
1019 t.urgency = 9.9; // would be "high"
1020 t.due = Some(Utc::now() - Duration::days(1));
1021 assert_eq!(t.urgency_class(), "urgency-overdue");
1022 }
1023
1024 #[test]
1025 fn urgency_formatted_one_decimal() {
1026 let mut t = task();
1027 t.urgency = 8.34;
1028 assert_eq!(t.urgency_formatted(), "8.3");
1029 t.urgency = 0.0;
1030 assert_eq!(t.urgency_formatted(), "0.0");
1031 }
1032
1033 #[test]
1034 fn due_timestamp_defaults_to_zero() {
1035 let mut t = task();
1036 assert_eq!(t.due_timestamp(), 0);
1037 let d = Utc::now();
1038 t.due = Some(d);
1039 assert_eq!(t.due_timestamp(), d.timestamp());
1040 }
1041
1042 // ---- subtasks / annotations ----
1043
1044 #[test]
1045 fn subtask_counts_and_progress() {
1046 let mut t = task();
1047 assert!(!t.has_subtasks());
1048 assert_eq!(t.subtasks_progress(), "0/0");
1049 t.subtasks = vec![subtask(true), subtask(false), subtask(true)];
1050 assert!(t.has_subtasks());
1051 assert_eq!(t.subtask_count(), 3);
1052 assert_eq!(t.subtasks_completed(), 2);
1053 assert_eq!(t.subtasks_progress(), "2/3");
1054 }
1055
1056 #[test]
1057 fn project_name_fallbacks() {
1058 let mut t = task();
1059 assert_eq!(t.project_name_or_dash(), "-");
1060 assert_eq!(t.project_name_or_empty(), "");
1061 t.project_name = Some("Website".to_string());
1062 assert_eq!(t.project_name_or_dash(), "Website");
1063 assert_eq!(t.project_name_or_empty(), "Website");
1064 }
1065
1066 fn token(t: &Task, reference: &str, state: TokenState, primary: bool, pos: i32) -> StatusToken {
1067 StatusToken {
1068 id: StatusToken::deterministic_id(t.id, TOKEN_KIND_COMMIT, reference),
1069 task_id: t.id,
1070 kind: TOKEN_KIND_COMMIT.to_string(),
1071 reference: reference.to_string(),
1072 state,
1073 is_primary: primary,
1074 position: pos,
1075 }
1076 }
1077
1078 #[test]
1079 fn status_token_deterministic_id_is_stable_and_content_derived() {
1080 let tid = TaskId::new();
1081 let a = StatusToken::deterministic_id(tid, "commit", "deox@7c236fca8");
1082 let b = StatusToken::deterministic_id(tid, "commit", "deox@7c236fca8");
1083 assert_eq!(a, b, "same task+kind+ref must yield the same id");
1084 assert_ne!(a, StatusToken::deterministic_id(tid, "commit", "deox@a19f0011"), "ref differs");
1085 assert_ne!(a, StatusToken::deterministic_id(tid, "attachment", "deox@7c236fca8"), "kind differs");
1086 assert_ne!(a, StatusToken::deterministic_id(TaskId::new(), "commit", "deox@7c236fca8"), "task differs");
1087 assert_eq!(a.as_uuid().get_version_num(), 5);
1088 }
1089
1090 #[test]
1091 fn primary_token_finds_the_flagged_one() {
1092 let mut t = task();
1093 assert!(!t.has_status_tokens());
1094 assert!(t.primary_token().is_none());
1095 t.status_tokens = vec![
1096 token(&t, "deox@aaa", TokenState::Pending, false, 0),
1097 token(&t, "deox@bbb", TokenState::Complete, true, 1),
1098 ];
1099 assert!(t.has_status_tokens());
1100 assert_eq!(t.primary_token().map(|c| c.reference.as_str()), Some("deox@bbb"));
1101 }
1102
1103 #[test]
1104 fn status_token_summary_rolls_up_states() {
1105 let mut t = task();
1106 assert_eq!(t.status_token_summary(), "neutral");
1107 t.status_tokens = vec![token(&t, "deox@aaa", TokenState::Pending, false, 0)];
1108 assert_eq!(t.status_token_summary(), "pending");
1109 t.status_tokens = vec![
1110 token(&t, "deox@aaa", TokenState::Complete, false, 0),
1111 token(&t, "deox@bbb", TokenState::Pending, true, 1),
1112 ];
1113 assert_eq!(t.status_token_summary(), "pending", "any pending keeps the rollup pending");
1114 t.status_tokens = vec![
1115 token(&t, "deox@aaa", TokenState::Complete, false, 0),
1116 token(&t, "deox@bbb", TokenState::Complete, true, 1),
1117 ];
1118 assert_eq!(t.status_token_summary(), "complete", "all complete rolls up complete");
1119 }
1120
1121 #[test]
1122 fn recurrence_and_source_flags() {
1123 let mut t = task();
1124 assert!(!t.has_recurrence());
1125 assert!(t.effective_recurrence_rule().is_none());
1126 t.recurrence = Recurrence::Weekly;
1127 assert!(t.has_recurrence());
1128 assert!(!t.has_source_email());
1129 }
1130
1131 // ---- snooze / waiting / focus ----
1132
1133 #[test]
1134 fn is_snoozed_only_when_future() {
1135 let mut t = task();
1136 assert!(!t.is_snoozed());
1137 t.snoozed_until = Some(Utc::now() + Duration::hours(1));
1138 assert!(t.is_snoozed());
1139 t.snoozed_until = Some(Utc::now() - Duration::hours(1));
1140 assert!(!t.is_snoozed());
1141 }
1142
1143 #[test]
1144 fn response_overdue_requires_waiting_and_past_date() {
1145 let mut t = task();
1146 assert!(!t.is_response_overdue());
1147 // past expected date but not waiting -> false
1148 t.expected_response_date = Some(Utc::now() - Duration::days(1));
1149 assert!(!t.is_response_overdue());
1150 // waiting + past -> true
1151 t.waiting_for_response = true;
1152 assert!(t.is_waiting());
1153 assert!(t.is_response_overdue());
1154 // waiting but future -> false
1155 t.expected_response_date = Some(Utc::now() + Duration::days(1));
1156 assert!(!t.is_response_overdue());
1157 }
1158
1159 #[test]
1160 fn is_focused_reads_flag() {
1161 let mut t = task();
1162 assert!(!t.is_focused());
1163 t.is_focus = true;
1164 assert!(t.is_focused());
1165 }
1166
1167 // ---- time progress ----
1168
1169 #[test]
1170 fn time_progress_none_without_estimate() {
1171 assert_eq!(task().time_progress(), None);
1172 }
1173
1174 #[test]
1175 fn time_progress_percentage_and_clamp() {
1176 let mut t = task();
1177 t.estimated_minutes = Some(100);
1178 t.actual_minutes = 50;
1179 assert_eq!(t.time_progress(), Some(50));
1180 // clamps at 100 even when over
1181 t.actual_minutes = 250;
1182 assert_eq!(t.time_progress(), Some(100));
1183 // zero estimate is treated as 0%, never divides by zero
1184 t.estimated_minutes = Some(0);
1185 assert_eq!(t.time_progress(), Some(0));
1186 }
1187
1188 #[test]
1189 fn is_over_estimate_rules() {
1190 let mut t = task();
1191 assert!(!t.is_over_estimate(), "no estimate -> not over");
1192 t.estimated_minutes = Some(60);
1193 t.actual_minutes = 61;
1194 assert!(t.is_over_estimate());
1195 t.actual_minutes = 60;
1196 assert!(!t.is_over_estimate(), "equal is not over");
1197 t.estimated_minutes = Some(0);
1198 t.actual_minutes = 5;
1199 assert!(!t.is_over_estimate(), "zero estimate is never over");
1200 }
1201
1202 #[test]
1203 fn has_active_timer_reads_session() {
1204 assert!(!task().has_active_timer());
1205 }
1206
1207 // ---- NewTaskBuilder ----
1208
1209 #[test]
1210 fn builder_defaults() {
1211 let nt = NewTask::builder("Write tests").build();
1212 assert_eq!(nt.description, "Write tests");
1213 assert_eq!(nt.priority, Priority::Medium);
1214 assert_eq!(nt.urgency, 0.0);
1215 assert_eq!(nt.recurrence, Recurrence::None);
1216 assert!(nt.tags.is_empty());
1217 assert!(nt.due.is_none());
1218 assert!(nt.estimated_minutes.is_none());
1219 }
1220
1221 #[test]
1222 fn builder_sets_fields() {
1223 let due = Utc::now();
1224 let nt = NewTask::builder("Fix bug")
1225 .priority(Priority::High)
1226 .due(due)
1227 .tag("urgent")
1228 .tag("backend")
1229 .urgency(8.0)
1230 .estimated_minutes(45)
1231 .recurrence(Recurrence::Daily)
1232 .build();
1233 assert_eq!(nt.priority, Priority::High);
1234 assert_eq!(nt.due, Some(due));
1235 assert_eq!(nt.tags, vec!["urgent".to_string(), "backend".to_string()]);
1236 assert_eq!(nt.urgency, 8.0);
1237 assert_eq!(nt.estimated_minutes, Some(45));
1238 assert_eq!(nt.recurrence, Recurrence::Daily);
1239 }
1240
1241 #[test]
1242 fn builder_tags_replaces_accumulated() {
1243 let nt = NewTask::builder("t")
1244 .tag("a")
1245 .tags(vec!["x".to_string(), "y".to_string()])
1246 .build();
1247 assert_eq!(nt.tags, vec!["x".to_string(), "y".to_string()]);
1248 }
1249 }
1250