Skip to main content

max / goingson

23.6 KB · 724 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};
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 /// A task representing work to be done.
178 ///
179 /// Tasks can be associated with a project, have due dates, recurrence patterns,
180 /// annotations, and subtasks. They support snoozing and waiting-for-response
181 /// tracking, as well as time-block scheduling.
182 #[derive(Debug, Clone, Serialize, Deserialize)]
183 #[serde(rename_all = "camelCase")]
184 pub struct Task {
185 /// Unique identifier.
186 pub id: TaskId,
187 /// Associated project, if any.
188 pub project_id: Option<ProjectId>,
189 /// Denormalized project name for display.
190 pub project_name: Option<String>,
191 /// Associated milestone, if any.
192 pub milestone_id: Option<MilestoneId>,
193 /// Associated contact, if any.
194 pub contact_id: Option<ContactId>,
195 /// Denormalized contact name for display.
196 pub contact_name: Option<String>,
197 /// Task description/title.
198 pub description: String,
199 /// Current lifecycle status.
200 pub status: TaskStatus,
201 /// Priority level.
202 pub priority: Priority,
203 /// Due date, if set.
204 pub due: Option<DateTime<Utc>>,
205 /// User-defined tags for categorization.
206 pub tags: Vec<String>,
207 /// Calculated urgency score for sorting.
208 pub urgency: f64,
209 /// Recurrence pattern for repeating tasks (legacy).
210 pub recurrence: Recurrence,
211 /// Rich recurrence configuration (JSON). Takes precedence over `recurrence`.
212 pub recurrence_rule: Option<RecurrenceRule>,
213 /// Original task ID if this is a recurrence instance.
214 pub recurrence_parent_id: Option<TaskId>,
215 /// Email this task was created from, if any.
216 pub source_email_id: Option<EmailId>,
217 /// If snoozed, when to resurface.
218 pub snoozed_until: Option<DateTime<Utc>>,
219 /// Whether waiting for external response.
220 pub waiting_for_response: bool,
221 /// When waiting status was set.
222 pub waiting_since: Option<DateTime<Utc>>,
223 /// Expected response date when waiting.
224 pub expected_response_date: Option<DateTime<Utc>>,
225 /// Scheduled start time for time-blocking.
226 pub scheduled_start: Option<DateTime<Utc>>,
227 /// Scheduled duration in minutes.
228 pub scheduled_duration: Option<i32>,
229 /// Attached notes.
230 pub annotations: Vec<Annotation>,
231 /// Checklist items.
232 pub subtasks: Vec<Subtask>,
233 /// Estimated duration in minutes (user-provided).
234 pub estimated_minutes: Option<i32>,
235 /// Cached total actual tracked minutes across all sessions.
236 pub actual_minutes: i32,
237 /// Currently active time session, if any (populated on fetch).
238 #[serde(skip_serializing_if = "Option::is_none")]
239 pub active_session: Option<TimeSession>,
240 /// When the task was created.
241 pub created_at: DateTime<Utc>,
242 /// When the task was completed (set on status transition to Completed).
243 pub completed_at: Option<DateTime<Utc>>,
244 /// Whether this task is marked as a focus for the week.
245 pub is_focus: bool,
246 /// When the focus was set.
247 pub focus_set_at: Option<DateTime<Utc>>,
248 }
249
250 impl Task {
251 /// Returns a human-readable due date string relative to now.
252 ///
253 /// Examples: "today", "tomorrow", "+3d", "2d ago", "2026-03-15", or "-" if no due date.
254 pub fn due_formatted(&self) -> String {
255 match &self.due {
256 Some(dt) => {
257 let now = Utc::now();
258 let days = (dt.date_naive() - now.date_naive()).num_days();
259
260 if days < 0 {
261 format!("{}d ago", -days)
262 } else if days == 0 {
263 "today".to_string()
264 } else if days == 1 {
265 "tomorrow".to_string()
266 } else if days < DAYS_THRESHOLD_SHORT_FORMAT {
267 format!("+{}d", days)
268 } else {
269 dt.format("%Y-%m-%d").to_string()
270 }
271 }
272 None => "-".to_string(),
273 }
274 }
275
276 /// Returns the number of annotations on this task.
277 pub fn annotation_count(&self) -> usize {
278 self.annotations.len()
279 }
280
281 /// Returns true if the task has any annotations attached.
282 pub fn has_annotations(&self) -> bool {
283 !self.annotations.is_empty()
284 }
285
286 /// Returns true if the task has a recurrence pattern set (not `None`).
287 pub fn has_recurrence(&self) -> bool {
288 self.recurrence_rule.is_some() || self.recurrence != Recurrence::None
289 }
290
291 /// Returns the effective recurrence rule, synthesizing from the legacy
292 /// column if no explicit rule is set.
293 pub fn effective_recurrence_rule(&self) -> Option<RecurrenceRule> {
294 RecurrenceRule::effective(self.recurrence_rule.as_ref(), &self.recurrence)
295 }
296
297 /// Returns the project name, or `"-"` if unset. The dash fallback is used
298 /// for display in table views where an empty cell would look broken.
299 pub fn project_name_or_dash(&self) -> &str {
300 self.project_name.as_deref().unwrap_or("-")
301 }
302
303 /// Returns the project name, or an empty string if unset.
304 pub fn project_name_or_empty(&self) -> &str {
305 self.project_name.as_deref().unwrap_or("")
306 }
307
308 /// Returns the due date as a Unix timestamp (seconds since epoch).
309 /// Returns 0 (Unix epoch) when no due date is set, which sorts
310 /// undated tasks to the beginning in timestamp-based ordering.
311 pub fn due_timestamp(&self) -> i64 {
312 self.due.map(|d| d.timestamp()).unwrap_or(0)
313 }
314
315 /// Returns urgency as a formatted string with one decimal place (e.g., "8.3").
316 pub fn urgency_formatted(&self) -> String {
317 format!("{:.1}", self.urgency)
318 }
319
320 /// Returns true if the task is past its due date.
321 pub fn is_overdue(&self) -> bool {
322 match self.due {
323 Some(due) => due < Utc::now(),
324 None => false,
325 }
326 }
327
328 /// Returns the CSS class for urgency styling.
329 /// Red (overdue) is reserved for actually overdue tasks.
330 pub fn urgency_class(&self) -> &'static str {
331 // Overdue takes priority - only overdue tasks get red
332 if self.is_overdue() {
333 "urgency-overdue"
334 } else if self.urgency >= URGENCY_HIGH_THRESHOLD {
335 "urgency-high"
336 } else if self.urgency >= URGENCY_MEDIUM_THRESHOLD {
337 "urgency-medium"
338 } else {
339 "urgency-low"
340 }
341 }
342
343 /// Returns the total number of subtasks.
344 pub fn subtask_count(&self) -> usize {
345 self.subtasks.len()
346 }
347
348 /// Returns the number of completed subtasks.
349 pub fn subtasks_completed(&self) -> usize {
350 self.subtasks.iter().filter(|s| s.is_completed).count()
351 }
352
353 /// Returns true if the task has any subtasks.
354 pub fn has_subtasks(&self) -> bool {
355 !self.subtasks.is_empty()
356 }
357
358 /// Returns subtask progress as "completed/total" (e.g., "3/5").
359 pub fn subtasks_progress(&self) -> String {
360 format!("{}/{}", self.subtasks_completed(), self.subtask_count())
361 }
362
363 /// Returns true if the task was created from an email.
364 pub fn has_source_email(&self) -> bool {
365 self.source_email_id.is_some()
366 }
367
368 /// Returns true if the task is currently snoozed (snoozed_until is in the future).
369 pub fn is_snoozed(&self) -> bool {
370 self.snoozed_until
371 .map(|until| until > Utc::now())
372 .unwrap_or(false)
373 }
374
375 /// Returns true if the task is waiting for an external response.
376 pub fn is_waiting(&self) -> bool {
377 self.waiting_for_response
378 }
379
380 /// Returns true if the task is waiting and the expected response date has passed.
381 pub fn is_response_overdue(&self) -> bool {
382 self.waiting_for_response
383 && self.expected_response_date
384 .map(|date| date < Utc::now())
385 .unwrap_or(false)
386 }
387
388 /// Returns true if this task is marked as a weekly focus.
389 pub fn is_focused(&self) -> bool {
390 self.is_focus
391 }
392
393 /// Returns time progress as a percentage (0-100), or None if no estimate.
394 pub fn time_progress(&self) -> Option<u8> {
395 self.estimated_minutes.map(|est| {
396 if est <= 0 {
397 return 0;
398 }
399 ((self.actual_minutes as f64 / est as f64) * 100.0).round().min(100.0) as u8
400 })
401 }
402
403 /// Returns true if actual tracked time exceeds the estimate.
404 pub fn is_over_estimate(&self) -> bool {
405 match self.estimated_minutes {
406 Some(est) if est > 0 => self.actual_minutes > est,
407 _ => false,
408 }
409 }
410
411 /// Returns true if a timer is currently running on this task.
412 pub fn has_active_timer(&self) -> bool {
413 self.active_session.is_some()
414 }
415 }
416
417 /// Column to sort tasks by.
418 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
419 pub enum TaskSortColumn {
420 /// Sort by task description (alphabetical)
421 Description,
422 /// Sort by project name (alphabetical, nulls last)
423 Project,
424 /// Sort by priority (High > Medium > Low)
425 Priority,
426 /// Sort by due date (nulls last)
427 Due,
428 /// Sort by calculated urgency score
429 #[default]
430 Urgency,
431 }
432
433 impl TaskSortColumn {
434 /// Parses a string to TaskSortColumn, defaulting to Urgency if unrecognized.
435 pub fn from_str_or_default(s: &str) -> Self {
436 match s.to_lowercase().as_str() {
437 "description" => Self::Description,
438 "project" => Self::Project,
439 "priority" => Self::Priority,
440 "due" => Self::Due,
441 "urgency" => Self::Urgency,
442 _ => Self::default(),
443 }
444 }
445 }
446
447 /// Query parameters for filtered task listing.
448 /// All fields are optional - omitted fields don't restrict results.
449 #[derive(Debug, Clone, Default)]
450 pub struct TaskFilterQuery {
451 /// Filter by status (exact match)
452 pub status: Option<TaskStatus>,
453 /// Filter by project ID
454 pub project_id: Option<ProjectId>,
455 /// Filter by milestone ID
456 pub milestone_id: Option<MilestoneId>,
457 /// Filter by priority (exact match)
458 pub priority: Option<Priority>,
459 /// If false (default), hide tasks where snoozed_until > now
460 pub show_snoozed: bool,
461 /// If true, only show tasks with waiting_for_response = true
462 pub waiting_only: bool,
463 /// Pagination: number of items to skip
464 pub offset: Option<i64>,
465 /// Pagination: maximum items to return
466 pub limit: Option<i64>,
467 /// Column to sort by (default: Urgency)
468 pub sort_column: Option<TaskSortColumn>,
469 /// Sort direction (default: Desc for Urgency, Asc for others)
470 pub sort_direction: Option<SortDirection>,
471 }
472
473 // ============ Task DTOs ============
474
475 /// Data for creating a new task.
476 #[derive(Debug, Clone, Serialize, Deserialize)]
477 pub struct NewTask {
478 /// Associated project, if any.
479 pub project_id: Option<ProjectId>,
480 /// Target milestone within the project, if any.
481 pub milestone_id: Option<MilestoneId>,
482 /// Associated contact, if any.
483 pub contact_id: Option<ContactId>,
484 /// Task description/title (required, validated non-empty).
485 pub description: String,
486 /// Priority level, affects urgency calculation.
487 pub priority: Priority,
488 /// Due date, if set. Used in urgency scoring.
489 pub due: Option<DateTime<Utc>>,
490 /// User-defined tags for categorization and urgency modifiers.
491 pub tags: Vec<String>,
492 /// Recurrence pattern (None, Daily, Weekly, Monthly).
493 pub recurrence: Recurrence,
494 /// Rich recurrence configuration (JSON).
495 pub recurrence_rule: Option<RecurrenceRule>,
496 /// Pre-calculated urgency score based on priority, due date, age, and tags.
497 pub urgency: f64,
498 /// Email this task was created from, if any (set by email-to-task flow).
499 pub source_email_id: Option<EmailId>,
500 /// Scheduled start time for time-blocking.
501 pub scheduled_start: Option<DateTime<Utc>>,
502 /// Scheduled duration in minutes for time-blocking.
503 pub scheduled_duration: Option<i32>,
504 /// Estimated duration in minutes.
505 pub estimated_minutes: Option<i32>,
506 /// Root task ID for recurrence chain (set when spawning next recurring instance).
507 pub recurrence_parent_id: Option<TaskId>,
508 }
509
510 impl NewTask {
511 /// Creates a builder for constructing a new task.
512 ///
513 /// # Example
514 ///
515 /// ```rust
516 /// use goingson_core::{NewTask, Priority};
517 /// use chrono::Utc;
518 ///
519 /// let task = NewTask::builder("Fix the bug")
520 /// .priority(Priority::High)
521 /// .tag("urgent")
522 /// .urgency(8.0)
523 /// .build();
524 /// ```
525 pub fn builder(description: impl Into<String>) -> NewTaskBuilder {
526 NewTaskBuilder::new(description)
527 }
528 }
529
530 /// Builder for constructing [`NewTask`] with sensible defaults.
531 #[derive(Debug, Clone)]
532 pub struct NewTaskBuilder {
533 description: String,
534 project_id: Option<ProjectId>,
535 milestone_id: Option<MilestoneId>,
536 contact_id: Option<ContactId>,
537 priority: Priority,
538 due: Option<DateTime<Utc>>,
539 tags: Vec<String>,
540 recurrence: Recurrence,
541 recurrence_rule: Option<RecurrenceRule>,
542 urgency: f64,
543 source_email_id: Option<EmailId>,
544 scheduled_start: Option<DateTime<Utc>>,
545 scheduled_duration: Option<i32>,
546 estimated_minutes: Option<i32>,
547 recurrence_parent_id: Option<TaskId>,
548 }
549
550 impl NewTaskBuilder {
551 /// Creates a new builder with the given description.
552 pub fn new(description: impl Into<String>) -> Self {
553 Self {
554 description: description.into(),
555 project_id: None,
556 milestone_id: None,
557 contact_id: None,
558 priority: Priority::default(),
559 due: None,
560 tags: Vec::new(),
561 recurrence: Recurrence::default(),
562 recurrence_rule: None,
563 urgency: 0.0,
564 source_email_id: None,
565 scheduled_start: None,
566 scheduled_duration: None,
567 estimated_minutes: None,
568 recurrence_parent_id: None,
569 }
570 }
571
572 /// Sets the project ID.
573 pub fn project_id(mut self, project_id: ProjectId) -> Self {
574 self.project_id = Some(project_id);
575 self
576 }
577
578 /// Sets the milestone ID.
579 pub fn milestone_id(mut self, milestone_id: MilestoneId) -> Self {
580 self.milestone_id = Some(milestone_id);
581 self
582 }
583
584 /// Sets the contact ID.
585 pub fn contact_id(mut self, contact_id: ContactId) -> Self {
586 self.contact_id = Some(contact_id);
587 self
588 }
589
590 /// Sets the priority level.
591 pub fn priority(mut self, priority: Priority) -> Self {
592 self.priority = priority;
593 self
594 }
595
596 /// Sets the due date.
597 pub fn due(mut self, due: DateTime<Utc>) -> Self {
598 self.due = Some(due);
599 self
600 }
601
602 /// Adds a tag.
603 pub fn tag(mut self, tag: impl Into<String>) -> Self {
604 self.tags.push(tag.into());
605 self
606 }
607
608 /// Sets all tags at once.
609 pub fn tags(mut self, tags: Vec<String>) -> Self {
610 self.tags = tags;
611 self
612 }
613
614 /// Sets the recurrence pattern.
615 pub fn recurrence(mut self, recurrence: Recurrence) -> Self {
616 self.recurrence = recurrence;
617 self
618 }
619
620 /// Sets the rich recurrence rule.
621 pub fn recurrence_rule(mut self, rule: RecurrenceRule) -> Self {
622 self.recurrence_rule = Some(rule);
623 self
624 }
625
626 /// Sets the urgency score.
627 pub fn urgency(mut self, urgency: f64) -> Self {
628 self.urgency = urgency;
629 self
630 }
631
632 /// Sets the source email ID.
633 pub fn source_email_id(mut self, email_id: EmailId) -> Self {
634 self.source_email_id = Some(email_id);
635 self
636 }
637
638 /// Sets the scheduled start time.
639 pub fn scheduled_start(mut self, start: DateTime<Utc>) -> Self {
640 self.scheduled_start = Some(start);
641 self
642 }
643
644 /// Sets the scheduled duration in minutes.
645 pub fn scheduled_duration(mut self, duration: i32) -> Self {
646 self.scheduled_duration = Some(duration);
647 self
648 }
649
650 /// Sets the estimated duration in minutes.
651 pub fn estimated_minutes(mut self, minutes: i32) -> Self {
652 self.estimated_minutes = Some(minutes);
653 self
654 }
655
656 /// Sets the recurrence parent ID (root of the recurrence chain).
657 pub fn recurrence_parent_id(mut self, id: TaskId) -> Self {
658 self.recurrence_parent_id = Some(id);
659 self
660 }
661
662 /// Builds the [`NewTask`].
663 pub fn build(self) -> NewTask {
664 NewTask {
665 project_id: self.project_id,
666 milestone_id: self.milestone_id,
667 contact_id: self.contact_id,
668 description: self.description,
669 priority: self.priority,
670 due: self.due,
671 tags: self.tags,
672 recurrence: self.recurrence,
673 recurrence_rule: self.recurrence_rule,
674 urgency: self.urgency,
675 source_email_id: self.source_email_id,
676 scheduled_start: self.scheduled_start,
677 scheduled_duration: self.scheduled_duration,
678 estimated_minutes: self.estimated_minutes,
679 recurrence_parent_id: self.recurrence_parent_id,
680 }
681 }
682 }
683
684 /// Lightweight context for task update logic — avoids fetching annotations, subtasks, sessions.
685 #[derive(Debug, Clone)]
686 pub struct TaskUpdateContext {
687 pub created_at: DateTime<Utc>,
688 pub status: TaskStatus,
689 pub completed_at: Option<DateTime<Utc>>,
690 pub scheduled_start: Option<DateTime<Utc>>,
691 pub scheduled_duration: Option<i32>,
692 }
693
694 /// Data for updating an existing task.
695 #[derive(Debug, Clone, Serialize, Deserialize)]
696 pub struct UpdateTask {
697 /// Associated project, if any.
698 pub project_id: Option<ProjectId>,
699 /// Target milestone within the project, if any.
700 pub milestone_id: Option<MilestoneId>,
701 /// Associated contact, if any.
702 pub contact_id: Option<ContactId>,
703 /// Task description/title (required, validated non-empty).
704 pub description: String,
705 /// Updated lifecycle status.
706 pub status: TaskStatus,
707 /// Priority level, affects urgency calculation.
708 pub priority: Priority,
709 /// Due date, if set. Used in urgency scoring.
710 pub due: Option<DateTime<Utc>>,
711 /// User-defined tags for categorization and urgency modifiers.
712 pub tags: Vec<String>,
713 /// Recurrence pattern (None, Daily, Weekly, Monthly).
714 pub recurrence: Recurrence,
715 /// Re-calculated urgency score based on priority, due date, age, and tags.
716 pub urgency: f64,
717 /// Scheduled start time for time-blocking.
718 pub scheduled_start: Option<DateTime<Utc>>,
719 /// Scheduled duration in minutes for time-blocking.
720 pub scheduled_duration: Option<i32>,
721 /// Estimated duration in minutes.
722 pub estimated_minutes: Option<i32>,
723 }
724