Repository: fniessink/next-action

Issue:
Take due date into account when determining next action
Due dates are not part of the todo.txt spec, but are usually added using the key:value pattern, e.g.:
`Pay taxes due:2018-05-01`

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5353797..b08b416 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
 and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
 
+## [0.1.0] - 2018-05-13
+
+### Added
+
+- Take due date into account when determining the next action. Tasks due earlier take precedence. Closes #33.
+
 ## [0.0.9] - 2018-05-13
 
 ### Added
diff --git a/README.md b/README.md
index 3a98d93..beca8e8 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@
 [![Codacy Badge](https://api.codacy.com/project/badge/Grade/746457c462cd4d9fa23f05424fa932b4)](https://www.codacy.com/app/frank_10/next-action?utm_source=github.com&amp;utm_medium=referral&amp;utm_content=fniessink/next-action&amp;utm_campaign=Badge_Grade)
 [![Codacy Badge](https://api.codacy.com/project/badge/Coverage/746457c462cd4d9fa23f05424fa932b4)](https://www.codacy.com/app/frank_10/next-action?utm_source=github.com&utm_medium=referral&utm_content=fniessink/next-action&utm_campaign=Badge_Coverage)
 
-Determine the next action to work on from a list of actions in a todo.txt file. *Next-action* is pre-alpha-stage at the moment, so its functionality is still rather limited.
+Determine the next action to work on from a list of actions in a todo.txt file. *Next-action* is alpha-stage at the moment, so its functionality is still rather limited.
 
 Don't know what *Todo.txt* is? See <https://github.com/todotxt/todo.txt> for the *Todo.txt* specification.
 
@@ -50,7 +50,7 @@ $ next-action
 (A) Call mom @phone
 ```
 
-The next action is determined using priority. Creation date is considered after priority, with older tasks getting precedence over newer tasks.
+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.
 
 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.
 
@@ -97,7 +97,7 @@ $ next-action --all @store
 
 Note again that completed tasks and task with a future creation date are never shown since they can't be a next action.
 
-*Next-action* being still pre-alpha-stage, this is it for the moment. Stay tuned for more options.
+*Next-action* being still alpha-stage, this is it for the moment. Stay tuned for more options.
 
 ## Develop
 
diff --git a/next_action/__init__.py b/next_action/__init__.py
index f916b9e..9aa288e 100644
--- a/next_action/__init__.py
+++ b/next_action/__init__.py
@@ -1,4 +1,4 @@
 """ Main Next-action package. """
 
 __title__ = "next-action"
-__version__ = "0.0.9"
+__version__ = "0.1.0"
diff --git a/next_action/pick_action.py b/next_action/pick_action.py
index a950112..444f65a 100644
--- a/next_action/pick_action.py
+++ b/next_action/pick_action.py
@@ -8,7 +8,7 @@ from .todotxt import Task
 
 def sort_key(task: Task) -> Tuple[str, datetime.date]:
     """ Return the sort key for a task. """
-    return (task.priority() or "ZZZ", task.creation_date() or datetime.date.max)
+    return (task.priority() or "ZZZ", task.due_date() or datetime.date.max, task.creation_date() or datetime.date.max)
 
 
 def next_actions(tasks: Sequence[Task], contexts: Set[str] = None, projects: Set[str] = None) -> Sequence[Task]:
@@ -19,5 +19,5 @@ def next_actions(tasks: Sequence[Task], contexts: Set[str] = None, projects: Set
     tasks_in_context = filter(lambda task: contexts <= task.contexts() if contexts else True, actionable_tasks)
     # Next, select the tasks that belong to at least one of the given projects, if any
     tasks_in_project = filter(lambda task: projects & task.projects() if projects else True, tasks_in_context)
-    # Finally, sort by priority and creation date
+    # Finally, sort by priority, due date and creation date
     return sorted(tasks_in_project, key=sort_key)
diff --git a/next_action/todotxt/task.py b/next_action/todotxt/task.py
index 6ea1319..0ee3042 100644
--- a/next_action/todotxt/task.py
+++ b/next_action/todotxt/task.py
@@ -3,10 +3,14 @@
 import datetime
 import re
 from typing import Optional, Set
+from typing.re import Match  # pylint: disable=import-error
 
 
 class Task(object):
     """ A task from a line in a todo.txt file. """
+
+    iso_date_reg_exp = r"(\d{4})-(\d{1,2})-(\d{1,2})"
+
     def __init__(self, todo_txt: str) -> None:
         self.text = todo_txt
 
@@ -28,13 +32,13 @@ class Task(object):
 
     def creation_date(self) -> Optional[datetime.date]:
         """ Return the creation date of the task. """
-        match = re.match(r"(?:\([A-Z]\) )?(\d{4})-(\d{1,2})-(\d{1,2})", self.text)
-        if match:
-            try:
-                return datetime.date(*(int(group) for group in match.groups()))
-            except ValueError:
-                pass
-        return None
+        match = re.match(r"(?:\([A-Z]\) )?{0}\b".format(self.iso_date_reg_exp), self.text)
+        return self.__create_date(match)
+
+    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)
 
     def is_completed(self) -> bool:
         """ Return whether the task is completed or not. """
@@ -48,3 +52,13 @@ class Task(object):
     def __prefixed_items(self, prefix: str) -> Set[str]:
         """ Return the prefixed items in the task. """
         return {match.group(1) for match in re.finditer(" {0}([^ ]+)".format(prefix), self.text)}
+
+    @staticmethod
+    def __create_date(match: Match) -> Optional[datetime.date]:
+        """ Create a date from the match, if possible. """
+        if match:
+            try:
+                return datetime.date(*(int(group) for group in match.groups()))
+            except ValueError:
+                pass
+        return None
diff --git a/setup.py b/setup.py
index bdbb6cd..67b4fd0 100644
--- a/setup.py
+++ b/setup.py
@@ -24,7 +24,7 @@ and more.""",
     },
     test_suite="tests",
     classifiers=[
-        "Development Status :: 2 - Pre-Alpha",
+        "Development Status :: 3 - Alpha",
         "Environment :: Console",
         "Intended Audience :: End Users/Desktop",
         "License :: OSI Approved :: Apache Software License",


Test patch:
diff --git a/tests/unittests/test_pick_action.py b/tests/unittests/test_pick_action.py
index 163b4aa..afd7844 100644
--- a/tests/unittests/test_pick_action.py
+++ b/tests/unittests/test_pick_action.py
@@ -94,3 +94,17 @@ class PickActionTest(unittest.TestCase):
         older_task = todotxt.Task("2017-01-01 Task 3")
         self.assertEqual([priority, older_task, newer_task],
                          pick_action.next_actions([priority, newer_task, older_task]))
+
+    def test_due_dates(self):
+        """ Test that a task with an earlier due date takes precedence. """
+        no_due_date = todotxt.Task("Task 1")
+        earlier_task = todotxt.Task("Task 2 due:2018-02-02")
+        later_task = todotxt.Task("Task 3 due:2019-01-01")
+        self.assertEqual([earlier_task, later_task, no_due_date],
+                         pick_action.next_actions([no_due_date, later_task, earlier_task]))
+
+    def test_due_and_creation_dates(self):
+        """ Test that a task with a due date takes precedence over creation date. """
+        task1 = todotxt.Task("2018-1-1 Task 1")
+        task2 = todotxt.Task("Task 2 due:2018-1-1")
+        self.assertEqual([task2, task1], pick_action.next_actions([task1, task2]))
diff --git a/tests/unittests/todotxt/test_task.py b/tests/unittests/todotxt/test_task.py
index a0b1bf9..a6fa2b8 100644
--- a/tests/unittests/todotxt/test_task.py
+++ b/tests/unittests/todotxt/test_task.py
@@ -95,6 +95,10 @@ class CreationDateTest(unittest.TestCase):
         """ Test an invalid creation date. """
         self.assertEqual(None, todotxt.Task("2018-14-02 Todo").creation_date())
 
+    def test_no_space_after(self):
+        """ Test a creation date without a word boundary. """
+        self.assertEqual(None, todotxt.Task("2018-10-10Todo").creation_date())
+
     def test_single_digits(self):
         """ Test a creation date with single digits for day and/or month. """
         self.assertEqual(datetime.date(2018, 12, 3), todotxt.Task("(B) 2018-12-3 Todo").creation_date())
@@ -106,6 +110,33 @@ class CreationDateTest(unittest.TestCase):
         self.assertTrue(todotxt.Task("9999-01-01 Prepare for five-digit years").is_future())
 
 
+class DueDateTest(unittest.TestCase):
+    """ Unit tests for the due date of tasks. """
+
+    def test_no_due_date(self):
+        """ Test that tasks have no due date by default. """
+        self.assertEqual(None, todotxt.Task("Todo").due_date())
+
+    def test_due_date(self):
+        """ Test a valid due date. """
+        self.assertEqual(datetime.date(2018, 1, 2), todotxt.Task("Todo due:2018-01-02").due_date())
+
+    def test_invalid_date(self):
+        """ Test an invalid due date. """
+        self.assertEqual(None, todotxt.Task("Todo due:2018-01-32").due_date())
+
+    def test_no_space_after(self):
+        """ Test a due date without a word boundary following it. """
+        self.assertEqual(None, todotxt.Task("Todo due:2018-01-023").due_date())
+
+    def test_single_digits(self):
+        """ Test a due date with single digits for day and/or month. """
+        self.assertEqual(datetime.date(2018, 12, 3), todotxt.Task("(B) due:2018-12-3 Todo").due_date())
+        self.assertEqual(datetime.date(2018, 1, 13), todotxt.Task("(B) due:2018-1-13 Todo").due_date())
+        self.assertEqual(datetime.date(2018, 1, 1), todotxt.Task("(B) due:2018-1-1 Todo").due_date())
+
+
+
 class TaskCompletionTest(unittest.TestCase):
     """ Unit tests for the completion status of tasks. """