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    /// Timestamp the task was closed. Mirrors VTODO's `COMPLETED` and
36    /// MS Graph's `completedDateTime`. Written by `jot close`, cleared
37    /// by `jot reopen`.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub closed_at: Option<DateTime<Utc>>,
40
41    /// Whether the task has been archived. Git-only concept, never
42    /// propagated to CalDAV or MS Graph.
43    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
44    pub archived: bool,
45
46    /// Timestamp the task was archived. Written by `jot archive`,
47    /// cleared by `jot unarchive`.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub archived_at: Option<DateTime<Utc>>,
50}
51
52impl Task {
53    pub fn new(id: String, title: String) -> Self {
54        Self {
55            item: joy_core::model::item::Item::new(
56                id,
57                title,
58                joy_core::model::item::ItemType::Task,
59                joy_core::model::item::Priority::Medium,
60                vec![joy_core::model::item::Capability::Implement],
61            ),
62            due_date: None,
63            reminder: None,
64            recurrence: None,
65            project: None,
66            source: None,
67            closed_at: None,
68            archived: false,
69            archived_at: None,
70        }
71    }
72
73    /// Check if this task is recurring
74    pub fn is_recurring(&self) -> bool {
75        self.recurrence.is_some()
76    }
77
78    /// Check if this task was created by dispatch
79    pub fn is_dispatched(&self) -> bool {
80        self.source.is_some()
81    }
82}
83
84/// A Jot project groups tasks by theme or dispatch source.
85#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86pub struct Project {
87    pub id: String,
88    pub title: String,
89
90    /// Source workspace for dispatch projects (e.g. "joy:acme/product")
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub source: Option<String>,
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn task_new_defaults() {
101        let task = Task::new("JOT-0001".into(), "Buy milk".into());
102        assert_eq!(task.item.id, "JOT-0001");
103        assert_eq!(task.item.title, "Buy milk");
104        assert_eq!(task.item.item_type, joy_core::model::item::ItemType::Task);
105        assert!(!task.is_recurring());
106        assert!(!task.is_dispatched());
107    }
108
109    #[test]
110    fn task_serialization_roundtrip() {
111        let mut task = Task::new("JOT-0001".into(), "Weekly standup".into());
112        task.recurrence = Some("FREQ=WEEKLY;BYDAY=MO".into());
113        task.due_date = Some(NaiveDate::from_ymd_opt(2026, 3, 24).unwrap());
114        task.project = Some("JOT-P-01".into());
115
116        let yaml = serde_yaml_ng::to_string(&task).unwrap();
117        let parsed: Task = serde_yaml_ng::from_str(&yaml).unwrap();
118
119        assert_eq!(parsed.item.id, "JOT-0001");
120        assert_eq!(parsed.recurrence, Some("FREQ=WEEKLY;BYDAY=MO".into()));
121        assert!(parsed.is_recurring());
122        assert_eq!(parsed.project, Some("JOT-P-01".into()));
123    }
124
125    #[test]
126    fn dispatched_task() {
127        let mut task = Task::new("JOT-0003".into(), "Review JOY-002A".into());
128        task.source = Some("joy:acme/product:JOY-002A".into());
129        task.project = Some("JOT-P-03".into());
130
131        assert!(task.is_dispatched());
132    }
133}