Repository: fniessink/next-action

Issue:
Support t: for threshold (start date) in todo.txt files

Discussion:

Patch:
diff --git a/README.md b/README.md
index 2797c49..dc14156 100644
--- a/README.md
+++ b/README.md
@@ -103,9 +103,9 @@ $ next-action
 (A) Call mom @phone
 ```
 
-The next action is determined using priority. Due date is considered after priority, with tasks due earlier getting precedence over tasks due later. Creation date is considered after due date, with older tasks getting precedence over newer tasks. FInally, tasks that belong to more projects get precedence over tasks that belong to fewer projects.
+The next action is determined using priority. Due date is considered after priority, with tasks due earlier getting precedence over tasks due later. Creation date is considered after due date, with older tasks getting precedence over newer tasks. Finally, tasks that belong to more projects get precedence over tasks that belong to fewer projects.
 
-Completed tasks (~~`x This is a completed task`~~) and tasks with a creation date in the future (`9999-01-01 Start preparing for five-digit years`) are not considered when determining the next action.
+Completed tasks (~~`x This is a completed task`~~), tasks with a creation date in the future (`9999-01-01 Start preparing for five-digit years`), and tasks with a future threshold date (`Start preparing for emigration to Mars t:3000-01-01`) are not considered when determining the next action.
 
 ### Limiting the tasks from which next actions are selected
 
@@ -194,7 +194,7 @@ $ next-action --all @store
 (G) Buy wood for new +DogHouse @store
 ```
 
-Note again that completed tasks and task with a future creation date are never shown since they can't be a next action.
+Note again that completed tasks and task with a future creation or threshold date are never shown since they can't be a next action.
 
 ### Styling the output
 
diff --git a/docs/todo.txt b/docs/todo.txt
index 06842c9..7df531b 100644
--- a/docs/todo.txt
+++ b/docs/todo.txt
@@ -9,3 +9,4 @@ Buy flowers due:2018-02-14
 (K) Pay July invoice @home due:2018-07-28
 x This is a completed task
 9999-01-01 Start preparing for five-digit years
+Start preparing for emigration to Mars t:3000-01-01
diff --git a/next_action/pick_action.py b/next_action/pick_action.py
index b65d42a..24dbff0 100644
--- a/next_action/pick_action.py
+++ b/next_action/pick_action.py
@@ -24,7 +24,8 @@ def next_actions(tasks: Sequence[Task], arguments: argparse.Namespace) -> Sequen
     projects = subset(arguments.filters, "+")
     excluded_contexts = subset(arguments.filters, "-@")
     excluded_projects = subset(arguments.filters, "-+")
-    # First, get the potential next actions by filtering out completed tasks and tasks with a future creation date
+    # First, get the potential next actions by filtering out completed tasks and tasks with a future creation date or
+    # future threshold date
     actionable_tasks = [task for task in tasks if task.is_actionable()]
     # Then, exclude tasks that have an excluded context
     eligible_tasks = filter(lambda task: not excluded_contexts & task.contexts() if excluded_contexts else True,
diff --git a/next_action/todotxt/task.py b/next_action/todotxt/task.py
index 6e420db..a8733b6 100644
--- a/next_action/todotxt/task.py
+++ b/next_action/todotxt/task.py
@@ -45,10 +45,13 @@ class Task(object):
         match = re.match(r"(?:\([A-Z]\) )?{0}\b".format(self.iso_date_reg_exp), self.text)
         return self.__create_date(match)
 
+    def threshold_date(self) -> Optional[datetime.date]:
+        """ Return the threshold date of the task. """
+        return self.__find_keyed_date("t")
+
     def due_date(self) -> Optional[datetime.date]:
         """ Return the due date of the task. """
-        match = re.search(r"\bdue:{0}\b".format(self.iso_date_reg_exp), self.text)
-        return self.__create_date(match)
+        return self.__find_keyed_date("due")
 
     def is_due(self, due_date: datetime.date) -> bool:
         """ Return whether the task is due on or before the given due date. """
@@ -60,9 +63,15 @@ class Task(object):
         return self.text.startswith("x ")
 
     def is_future(self) -> bool:
-        """ Return whether the task is a future task, i.e. has a creation date in the future. """
+        """ Return whether the task is a future task, i.e. has a creation or threshold date in the future. """
+        today = datetime.date.today()
         creation_date = self.creation_date()
-        return creation_date > datetime.date.today() if creation_date else False
+        if creation_date:
+            return creation_date > today
+        threshold_date = self.threshold_date()
+        if threshold_date:
+            return threshold_date > today
+        return False
 
     def is_actionable(self) -> bool:
         """ Return whether the task is actionable, i.e. whether it's not completed and doesn't have a future creation
@@ -70,7 +79,7 @@ class Task(object):
         return not self.is_completed() and not self.is_future()
 
     def is_overdue(self) -> bool:
-        """ Return whether the taks is overdue, i.e. whether it has a due date in the past. """
+        """ Return whether the task is overdue, i.e. whether it has a due date in the past. """
         due_date = self.due_date()
         return due_date < datetime.date.today() if due_date else False
 
@@ -78,6 +87,11 @@ class Task(object):
         """ Return the prefixed items in the task. """
         return {match.group(1) for match in re.finditer(" {0}([^ ]+)".format(prefix), self.text)}
 
+    def __find_keyed_date(self, key: str) -> Optional[datetime.date]:
+        """ Find a key:value pair with the supplied key where the value is a date. """
+        match = re.search(r"\b{0}:{1}\b".format(key, self.iso_date_reg_exp), self.text)
+        return self.__create_date(match)
+
     @staticmethod
     def __create_date(match: Optional[typing.Match[str]]) -> Optional[datetime.date]:
         """ Create a date from the match, if possible. """


Test patch:
diff --git a/tests/unittests/todotxt/test_task.py b/tests/unittests/todotxt/test_task.py
index 6172c77..e890535 100644
--- a/tests/unittests/todotxt/test_task.py
+++ b/tests/unittests/todotxt/test_task.py
@@ -87,7 +87,8 @@ class TaskPriorityTest(unittest.TestCase):
 
 
 class CreationDateTest(unittest.TestCase):
-    """ Unit tests for task creation dates. """
+    """ Unit tests for task creation dates. Next-action interprets creation dates in the future as threshold, or
+        start date. """
 
     def test_no_creation_date(self):
         """ Test that tasks have no creation date by default. """
@@ -121,6 +122,35 @@ class CreationDateTest(unittest.TestCase):
         self.assertFalse(todotxt.Task("{0} Todo".format(datetime.date.today().isoformat())).is_future())
 
 
+class ThresholdDateTest(unittest.TestCase):
+    """ Unit tests for the threshold date of a task. The core todo.txt standard only defines creation date, but
+        threshold (t:<date>) seems to be a widely used convention. """
+
+    def test_no_threshold(self):
+        """ Test that tasks have no threshold by default. """
+        task = todotxt.Task("Todo")
+        self.assertEqual(None, task.threshold_date())
+        self.assertFalse(task.is_future())
+
+    def test_past_threshold(self):
+        """ Test a past threshold date. """
+        task = todotxt.Task("Todo t:2018-01-02")
+        self.assertEqual(datetime.date(2018, 1, 2), task.threshold_date())
+        self.assertFalse(task.is_future())
+
+    def test_future_threshold(self):
+        """ Test a future threshold date. """
+        task = todotxt.Task("Todo t:9999-01-01")
+        self.assertEqual(datetime.date(9999, 1, 1), task.threshold_date())
+        self.assertTrue(task.is_future())
+
+    def test_threshold_today(self):
+        """ Test a task with threshold today. """
+        task = todotxt.Task("Todo t:{0}".format(datetime.date.today().isoformat()))
+        self.assertEqual(datetime.date.today(), task.threshold_date())
+        self.assertFalse(task.is_future())
+
+
 class DueDateTest(unittest.TestCase):
     """ Unit tests for the due date of tasks. """