diff --git a/docs/src/tasks.md b/docs/src/tasks.md index eb6756e22..b3285af88 100644 --- a/docs/src/tasks.md +++ b/docs/src/tasks.md @@ -17,6 +17,12 @@ The result of this reconciliation will be `oldtag,newtag2`, while the user almos The key names given below avoid this issue, allowing user updates such as adding a tag or deleting a dependency to be represented in a single `Update` operation. +## Validity + +_Any_ key/value map is a valid task. +Consumers of task data must make a best effort to interpret any map, even if it contains apparently contradictory information. +For example, a task with status "completed" but no "end" key present should be interpreted as completed at an unknown time. + ## Representations Integers are stored in decimal notation. @@ -31,6 +37,7 @@ The following keys, and key formats, are defined: * `description` - the one-line summary of the task * `modified` - the time of the last modification of this task * `start` - the most recent time at which this task was started (a task with no `start` key is not active) +* `end` - if present, the time at which this task was completed or deleted (note that this key may not agree with `status`: it may be present for a pending task, or absent for a deleted or completed task) * `tag_` - indicates this task has tag `` (value is an empty string) * `wait` - indicates the time before which this task should be hidden, as it is not actionable * `annotation_` - value is an annotation created at the given time diff --git a/taskchampion/src/replica.rs b/taskchampion/src/replica.rs index 2b833a323..af94f72ba 100644 --- a/taskchampion/src/replica.rs +++ b/taskchampion/src/replica.rs @@ -228,7 +228,7 @@ mod tests { .. } = op { - if property == "modified" { + if property == "modified" || property == "end" { if value.is_some() { value = Some("just-now".into()); } @@ -291,6 +291,13 @@ mod tests { value: Some("past tense".into()), timestamp: now, }, + ReplicaOp::Update { + uuid: t.get_uuid(), + property: "end".into(), + old_value: None, + value: Some("just-now".into()), + timestamp: now, + }, ReplicaOp::Update { uuid: t.get_uuid(), property: "status".into(), diff --git a/taskchampion/src/task/task.rs b/taskchampion/src/task/task.rs index 40a85ba87..e151f88d5 100644 --- a/taskchampion/src/task/task.rs +++ b/taskchampion/src/task/task.rs @@ -57,6 +57,7 @@ enum Prop { Start, Status, Wait, + End, } #[allow(clippy::ptr_arg)] @@ -263,9 +264,23 @@ impl<'r> TaskMut<'r> { /// Set the task's status. This also adds the task to the working set if the /// new status puts it in that set. pub fn set_status(&mut self, status: Status) -> anyhow::Result<()> { - if status == Status::Pending { - let uuid = self.uuid; - self.replica.add_to_working_set(uuid)?; + if status == Status::Pending {} + match status { + Status::Pending => { + // clear "end" when a task becomes "pending" + if self.taskmap.contains_key(Prop::End.as_ref()) { + self.set_timestamp(Prop::End.as_ref(), None)?; + } + let uuid = self.uuid; + self.replica.add_to_working_set(uuid)?; + } + Status::Completed | Status::Deleted => { + // set "end" when a task is deleted or completed + if !self.taskmap.contains_key(Prop::End.as_ref()) { + self.set_timestamp(Prop::End.as_ref(), Some(Utc::now()))?; + } + } + _ => {} } self.set_string( Prop::Status.as_ref(), @@ -304,6 +319,14 @@ impl<'r> TaskMut<'r> { self.set_status(Status::Completed) } + /// Mark this task as deleted. + /// + /// Note that this does not delete the task. It merely marks the task as + /// deleted. + pub fn delete(&mut self) -> anyhow::Result<()> { + self.set_status(Status::Deleted) + } + /// Add a tag to this task. Does nothing if the tag is already present. pub fn add_tag(&mut self, tag: &Tag) -> anyhow::Result<()> { if tag.is_synthetic() { @@ -676,6 +699,41 @@ mod test { }); } + #[test] + fn test_set_status_pending() { + with_mut_task(|mut task| { + task.done().unwrap(); + + task.set_status(Status::Pending).unwrap(); + assert_eq!(task.get_status(), Status::Pending); + assert!(!task.taskmap.contains_key("end")); + assert!(task.has_tag(&stag(SyntheticTag::Pending))); + assert!(!task.has_tag(&stag(SyntheticTag::Completed))); + }); + } + + #[test] + fn test_set_status_completed() { + with_mut_task(|mut task| { + task.set_status(Status::Completed).unwrap(); + assert_eq!(task.get_status(), Status::Completed); + assert!(task.taskmap.contains_key("end")); + assert!(!task.has_tag(&stag(SyntheticTag::Pending))); + assert!(task.has_tag(&stag(SyntheticTag::Completed))); + }); + } + + #[test] + fn test_set_status_deleted() { + with_mut_task(|mut task| { + task.set_status(Status::Deleted).unwrap(); + assert_eq!(task.get_status(), Status::Deleted); + assert!(task.taskmap.contains_key("end")); + assert!(!task.has_tag(&stag(SyntheticTag::Pending))); + assert!(!task.has_tag(&stag(SyntheticTag::Completed))); + }); + } + #[test] fn test_start() { with_mut_task(|mut task| { @@ -718,6 +776,7 @@ mod test { with_mut_task(|mut task| { task.done().unwrap(); assert_eq!(task.get_status(), Status::Completed); + assert!(task.taskmap.contains_key("end")); assert!(task.has_tag(&stag(SyntheticTag::Completed))); // redundant call does nothing.. @@ -727,6 +786,21 @@ mod test { }); } + #[test] + fn test_delete() { + with_mut_task(|mut task| { + task.delete().unwrap(); + assert_eq!(task.get_status(), Status::Deleted); + assert!(task.taskmap.contains_key("end")); + assert!(!task.has_tag(&stag(SyntheticTag::Completed))); + + // redundant call does nothing.. + task.delete().unwrap(); + assert_eq!(task.get_status(), Status::Deleted); + assert!(!task.has_tag(&stag(SyntheticTag::Completed))); + }); + } + #[test] fn test_add_tags() { with_mut_task(|mut task| {