Skip to main content

jot_core/
model.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4use chrono::{DateTime, NaiveDate, Utc};
5use serde::{Deserialize, Serialize};
6
7/// A Jot task extends joy-core::Item with recurrence and task-specific fields.
8/// Uses serde flatten to inherit all base Item fields.
9#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
10pub struct Task {
11    #[serde(flatten)]
12    pub item: joy_core::model::item::Item,
13
14    /// Due date (date only, no time)
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub due_date: Option<NaiveDate>,
17
18    /// Reminder datetime
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub reminder: Option<DateTime<Utc>>,
21
22    /// Recurrence rule (RFC 5545 RRULE format)
23    /// e.g. "FREQ=WEEKLY;BYDAY=MO,WE,FR" or "FREQ=DAILY;INTERVAL=2"
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub recurrence: Option<String>,
26
27    /// Project this task belongs to
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub project: Option<String>,
30
31    /// Source reference for dispatched tasks (e.g. "joy:acme/product:JOY-002A")
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub source: Option<String>,
34}
35
36impl Task {
37    pub fn new(id: String, title: String) -> Self {
38        Self {
39            item: joy_core::model::item::Item::new(
40                id,
41                title,
42                joy_core::model::item::ItemType::Task,
43                joy_core::model::item::Priority::Medium,
44                vec![joy_core::model::item::Capability::Implement],
45            ),
46            due_date: None,
47            reminder: None,
48            recurrence: None,
49            project: None,
50            source: None,
51        }
52    }
53
54    /// Check if this task is recurring
55    pub fn is_recurring(&self) -> bool {
56        self.recurrence.is_some()
57    }
58
59    /// Check if this task was created by dispatch
60    pub fn is_dispatched(&self) -> bool {
61        self.source.is_some()
62    }
63}
64
65/// A Jot project groups tasks by theme or dispatch source.
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
67pub struct Project {
68    pub id: String,
69    pub title: String,
70
71    /// Source workspace for dispatch projects (e.g. "joy:acme/product")
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub source: Option<String>,
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    #[test]
81    fn task_new_defaults() {
82        let task = Task::new("JOT-0001".into(), "Buy milk".into());
83        assert_eq!(task.item.id, "JOT-0001");
84        assert_eq!(task.item.title, "Buy milk");
85        assert_eq!(task.item.item_type, joy_core::model::item::ItemType::Task);
86        assert!(!task.is_recurring());
87        assert!(!task.is_dispatched());
88    }
89
90    #[test]
91    fn task_serialization_roundtrip() {
92        let mut task = Task::new("JOT-0001".into(), "Weekly standup".into());
93        task.recurrence = Some("FREQ=WEEKLY;BYDAY=MO".into());
94        task.due_date = Some(NaiveDate::from_ymd_opt(2026, 3, 24).unwrap());
95        task.project = Some("JOT-P-01".into());
96
97        let yaml = serde_yaml_ng::to_string(&task).unwrap();
98        let parsed: Task = serde_yaml_ng::from_str(&yaml).unwrap();
99
100        assert_eq!(parsed.item.id, "JOT-0001");
101        assert_eq!(parsed.recurrence, Some("FREQ=WEEKLY;BYDAY=MO".into()));
102        assert!(parsed.is_recurring());
103        assert_eq!(parsed.project, Some("JOT-P-01".into()));
104    }
105
106    #[test]
107    fn dispatched_task() {
108        let mut task = Task::new("JOT-0003".into(), "Review JOY-002A".into());
109        task.source = Some("joy:acme/product:JOY-002A".into());
110        task.project = Some("JOT-P-03".into());
111
112        assert!(task.is_dispatched());
113    }
114}