Permalink
Browse files

Fix #12: Added estimates, due dates, completion

This commit adds the ability for users to set an estimated duration and
due date to a task, and also allows a task to be marked as completed.
The new commands "show pending/completed" allows these stats to be
viewed. The new command "task <task> estimate/due" allows the estimate
and due date to be set, and "stop complete" can be used instead of
regular "stop" to also mark a task as completed.
  • Loading branch information...
Cartroo committed Apr 6, 2015
1 parent 12cefa1 commit 127a4feb0f1b31b5f2eaf5f97df05c7ce28b7793
Showing with 525 additions and 65 deletions.
  1. +37 −0 cmdparser/cmdparser/datetimeparse.py
  2. +89 −0 cmdparser/test/test_datetimeparse.py
  3. +101 −29 ttrack/bin/ttrack
  4. +85 −12 ttrack/lib/tracklib.py
  5. +213 −24 ttrack/test/test_tracklib.py
@@ -379,6 +379,43 @@ def convert(self, args, fields, context):
+class DurationSubtree(cmdparser.Subtree):
+ """A subtree matching a period of time.
+
+ This subtree matches any duration, accepting any of the units accepted as
+ part of OffsetSubtree.
+ """
+
+ spec = """(<offset> [,|and]) [...]"""
+
+ @staticmethod
+ def ident_factory(token):
+
+ if token == "offset":
+ return OffsetSubtree(token)
+ return None
+
+
+ def __init__(self, name):
+
+ cmdparser.Subtree.__init__(self, name, self.spec,
+ ident_factory=self.ident_factory)
+
+
+ def convert(self, args, fields, context):
+
+ # What we get back from OffsetSubtree is a DateDelta, but actually
+ # we want a raw datetime.timedelta here, so we calculate the number
+ # of days as the integer part of 365/12. This is about the best we
+ # can do as we have no calendar date for reference for a raw duration.
+ delta = DateDelta()
+ for item in fields["<offset>"]:
+ delta += item
+ extra_days = int((delta.months * 365.0) / 12.0)
+ return [(delta.delta + datetime.timedelta(extra_days))]
+
+
+
class RelativeTimeSubtree(cmdparser.Subtree):
"""A subtree matching a relative time.
@@ -2,6 +2,7 @@
import contextlib
import datetime
+import itertools
import time
import unittest
@@ -318,6 +319,94 @@ def test_relative(self):
12, 59, 15)]})
+class TestDurationSubtree(unittest.TestCase):
+
+ longMessage = True
+
+ _units = (("second", 0, 0, 1), ("minute", 0, 0, 60), ("hour", 0, 0, 3600),
+ ("day", 0, 1, 0), ("week", 0, 7, 0), ("month", 1, 0, 0),
+ ("year", 12, 0, 0))
+
+
+ def test_simple_units(self):
+ tree = datetimeparse.DurationSubtree("x")
+ # For the purposes of DurationSubtree, a month is 365/12 days, but the
+ # result is an integer number of days.
+ for unit, months, days, secs in self._units:
+ for n in xrange(1, 100):
+ units = unit if n == 1 else unit + "s"
+ fields = {}
+ self.assertEqual(tree.check_match((str(n), units), fields=fields),
+ None)
+ n_days = n * days + int((n * months * 365.0) / 12.0)
+ self.assertEqual(fields,
+ {"<x>": [datetime.timedelta(n_days, n * secs)]},
+ "for %d %s" % (n, units))
+
+
+ def test_two_compound_units(self):
+ tree = datetimeparse.DurationSubtree("x")
+ # For the purposes of DurationSubtree, a month is 365/12 days, but the
+ # result is an integer number of days.
+ i = itertools.product(self._units, xrange(1, 15), repeat=2)
+ for (unit1, months1, days1, secs1), n1, (unit2, months2, days2, secs2), n2 in i:
+ units1 = unit1 if n1 == 1 else unit1 + "s"
+ units2 = unit2 if n2 == 1 else unit2 + "s"
+ n_days = (n1 * days1 + n2 * days2 +
+ int(((n1 * months1 + n2 * months2) * 365.0) / 12.0))
+ delta = datetime.timedelta(n_days, n1 * secs1 + n2 * secs2)
+
+ phrase = (str(n1), units1, "and", str(n2), units2)
+ fields = {}
+ self.assertEqual(tree.check_match(phrase, fields=fields), None)
+ self.assertEqual(fields, {"<x>": [delta]}, "for %r" % (phrase,))
+
+ phrase = (str(n1), units1 + ",", str(n2), units2)
+ fields = {}
+ self.assertEqual(tree.check_match(phrase, fields=fields), None)
+ self.assertEqual(fields, {"<x>": [delta]}, "for %r" % (phrase,))
+
+ phrase = (str(n1), units1, str(n2), units2)
+ fields = {}
+ self.assertEqual(tree.check_match(phrase, fields=fields), None)
+ self.assertEqual(fields, {"<x>": [delta]}, "for %r" % (phrase,))
+
+
+ def test_three_compound_units(self):
+ tree = datetimeparse.DurationSubtree("x")
+ # For the purposes of DurationSubtree, a month is 365/12 days, but the
+ # result is an integer number of days.
+ i = itertools.product(self._units, xrange(1, 3), repeat=3)
+ for ((unit1, months1, days1, secs1), n1, (unit2, months2, days2, secs2),
+ n2, (unit3, months3, days3, secs3), n3) in i:
+ units1 = unit1 if n1 == 1 else unit1 + "s"
+ units2 = unit2 if n2 == 1 else unit2 + "s"
+ units3 = unit3 if n3 == 1 else unit3 + "s"
+ n_days = (n1 * days1 + n2 * days2 + n3 * days3 +
+ int(((n1*months1 + n2*months2 + n3*months3) * 365.0) / 12.0))
+ delta = datetime.timedelta(n_days, n1*secs1 + n2*secs2 + n3*secs3)
+
+ phrase = (str(n1), units1, "and", str(n2), units2, "and", str(n3), units3)
+ fields = {}
+ self.assertEqual(tree.check_match(phrase, fields=fields), None)
+ self.assertEqual(fields, {"<x>": [delta]}, "for %r" % (phrase,))
+
+ phrase = (str(n1), units1 + ",", str(n2), units2, "and", str(n3), units3)
+ fields = {}
+ self.assertEqual(tree.check_match(phrase, fields=fields), None)
+ self.assertEqual(fields, {"<x>": [delta]}, "for %r" % (phrase,))
+
+ phrase = (str(n1), units1 + ",", str(n2), units2 + ",", str(n3), units3)
+ fields = {}
+ self.assertEqual(tree.check_match(phrase, fields=fields), None)
+ self.assertEqual(fields, {"<x>": [delta]}, "for %r" % (phrase,))
+
+ phrase = (str(n1), units1, str(n2), units2, str(n3), units3)
+ fields = {}
+ self.assertEqual(tree.check_match(phrase, fields=fields), None)
+ self.assertEqual(fields, {"<x>": [delta]}, "for %r" % (phrase,))
+
+
class TestPastCalendarPeriodSubtree(unittest.TestCase):
View
@@ -3,6 +3,7 @@
import atexit
import cmd
import datetime
+import itertools
import logging
import operator
import optparse
@@ -72,16 +73,25 @@ def multiline_input(prompt):
def format_duration(secs):
+ phrase = ""
+ if secs < 0:
+ phrase = "overdue: "
+ secs = -secs
if secs < 60:
- return "%d sec%s" % (secs, "" if secs == 1 else "s")
+ phrase += "%d sec%s" % (secs, "" if secs == 1 else "s")
elif secs < 3600:
- return "%d min%s" % (secs // 60, "" if (secs//60)==1 else "s")
+ phrase += "%d min%s" % (secs // 60, "" if (secs//60)==1 else "s")
+ elif secs < 86400:
+ phrase += ("%d hour%s %d min%s" % (secs // 3600,
+ "" if (secs//3600)==1 else "s",
+ (secs // 60) % 60,
+ "" if ((secs//60)%60)==1 else "s"))
else:
- return ("%d hour%s %d min%s" % (secs // 3600,
- "" if (secs//3600)==1 else "s",
- (secs // 60) % 60,
- "" if ((secs//60)%60)==1 else "s"))
-
+ phrase += ("%d day%s %d hour%s" % (secs // 86400,
+ "" if (secs//86400)==1 else "s",
+ (secs // 3600) % 24,
+ "" if ((secs//3600)%24)==1 else "s"))
+ return phrase
def format_duration_since_datetime(dt):
@@ -298,6 +308,8 @@ def cmd_token_factory(token_name):
return datetimeparse.PastCalendarPeriodSubtree("period")
elif token_name == "time":
return TTrackTimeSubtree("time")
+ elif token_name == "duration":
+ return datetimeparse.DurationSubtree("duration")
else:
return None
@@ -617,13 +629,16 @@ class CommandHandler(cmd.Cmd):
def do_show(self, args, fields):
"""
show ( [unused] ( tasks [<tag>] | tags )
- | ( todos | diary ) [ task <task> | tag <tag> ] )
+ | ( todos | diary ) [ task <task> | tag <tag> ]
+ | ( pending | completed ) [ <tag> ] )
Display available tasks or tags, or show diary entries or todo items.
- This command has two forms, one of which lists tasks or tags, and one
+ This command has three forms, one of which lists tasks or tags; one
of which shows todo items or diary entries for either all tasks or
- for a specified task or tag.
+ for a specified task or tag; and one of which shows either the
+ completed or pending (uncompleted) tasks remaining, optionally
+ filtered by a specific tag.
In the first form, the optional 'unused' keyword shows only tasks
which haven't been active in the past five weeks or tags which have
@@ -677,33 +692,73 @@ class CommandHandler(cmd.Cmd):
task = fields.get("<task>", [None])[0]
tag = fields.get("<tag>", [None])[0]
if task is not None:
- print "Outstanding todos for task " + task
+ print "Outstanding todos for task " + task + ":"
elif tag is not None:
- print "Outstanding todos for tag " + tag
+ print "Outstanding todos for tag " + tag + ":"
else:
- print "All outstanding todos"
+ print "All outstanding todos:"
entries = self.db.get_pending_todos(tag=tag, task=task)
display_diary({None: entries})
- else:
+ elif "diary" in fields:
# Calculate cutoff start time as 4 weeks ago, rounded to a day.
start = datetime.datetime.now() - datetime.timedelta(7 * 4)
start = start.replace(microsecond=0, second=0, minute=0, hour=0)
if "<task>" in fields:
- print "Diary entries for task " + fields["<task>"][0]
+ print "Diary entries for task " + fields["<task>"][0] + ":"
entry_gen = self.db.get_task_log_entries(
tasks=(fields["<task>"][0],), start=start)
elif "<tag>" in fields:
- print "Diary entries for tag " + fields["<tag>"][0]
+ print "Diary entries for tag " + fields["<tag>"][0] + ":"
entry_gen = self.db.get_task_log_entries(
tags=(fields["<tag>"][0],), start=start)
else:
- print "All diary entries"
+ print "All diary entries:"
entry_gen = self.db.get_task_log_entries(start=start)
summary_obj = tracklib.TaskSummaryGenerator()
summary_obj.read_entries(entry_gen, merge_diaries=True)
display_diary(summary_obj.diary_entries)
+ else:
+ prefix = "Pending" if "pending" in fields else "Completed"
+ suffix = ""
+ if "<tag>" in fields:
+ suffix = " with tag '%s'" % (fields["<tag>"][0],)
+ print "%s tasks%s:" % (prefix, suffix)
+ rows = []
+ for task in self.db.tasks:
+ tags = self.db.get_task_tags(task)
+ if "<tag>" in fields and fields["<tag>"][0] not in tags:
+ continue
+ summary = self.db.get_task_summary(task)
+ row = [" " + task]
+ if (("pending" in fields and summary[3] is None) or
+ ("completed" in fields and summary[3] is not None)):
+ if summary[0] is None:
+ row.append(" estimate: none")
+ else:
+ row.append(" estimate: %s [%s]" %
+ (format_duration(summary[0]),
+ format_duration(summary[0] - summary[1])))
+ if summary[2] is None:
+ row.append(" due: none")
+ else:
+ now = summary[3]
+ now = datetime.datetime.now() if now is None else now
+ delta = summary[2] - now
+ delta_secs = delta.days*86400 + delta.seconds
+ row.append(" due: %s [%s]" %
+ (format_datetime(summary[2]),
+ format_duration(delta_secs)))
+ if summary[3] is not None:
+ row.append("completed: " + format_datetime(summary[3]))
+ rows.append(row)
+ for row in sorted(rows):
+ first = True
+ for item in row[1:]:
+ print (row[0] if first else " "*len(row[0])) + " " + item
+ first = False
+
except tracklib.TimeTrackError, e:
self.logger.error("show error: %s", e)
@@ -769,7 +824,7 @@ class CommandHandler(cmd.Cmd):
@cmdparser.CmdMethodDecorator(token_factory=cmd_token_factory)
def do_stop(self, args, fields):
- """stop [<time>]
+ """stop [complete] [<time>]
Stops timer on current task.
@@ -780,11 +835,12 @@ class CommandHandler(cmd.Cmd):
"""
try:
+ complete = True if "complete" in fields else False
task = self.db.get_current_task()
dt = fields.get("<time>", [None])[0]
at_str = "" if dt is None else " at %s" % (format_datetime(dt),)
print "Stopping task '%s'%s" % (task, at_str)
- self.db.stop_task(at_datetime=dt)
+ self.db.stop_task(at_datetime=dt, completed=complete)
except tracklib.TimeTrackError, e:
self.logger.error("stop error: %s", e)
@@ -871,13 +927,15 @@ class CommandHandler(cmd.Cmd):
@cmdparser.CmdMethodDecorator(token_factory=cmd_token_factory)
def do_task(self, args, fields):
- """task ( <task> | current ) ( tag | untag ) <tag>
+ """task ( <task> | current ) ( ( tag | untag ) <tag>
+ | estimate ( none | <duration> )
+ | due ( none | <time> ) )
- Adds or removes a tag from the specified task.
+ Adds or removes a tag from the specified task; sets a completion
+ estimate; or specifies the time at which a task is due.
"""
- # Work out tag and task names.
- tag = fields["<tag>"][0]
+ # Work out task name.
if "current" in fields:
task = self.db.get_current_task()
if task is None:
@@ -886,13 +944,27 @@ class CommandHandler(cmd.Cmd):
else:
task = fields["<task>"][0]
+ # Process tag/untag options.
try:
- if "tag" in fields:
- self.db.add_task_tag(task, tag)
- print "Added tag '%s' to '%s'" % (tag, task)
- else:
- self.db.remove_task_tag(task, tag)
- print "Removed tag '%s' from '%s'" % (tag, task)
+ if "tag" in fields or "untag" in fields:
+ tag = fields["<tag>"][0]
+ if "tag" in fields:
+ self.db.add_task_tag(task, tag)
+ print "Added tag '%s' to '%s'" % (tag, task)
+ else:
+ self.db.remove_task_tag(task, tag)
+ print "Removed tag '%s' from '%s'" % (tag, task)
+ elif "estimate" in fields:
+ if "none" in fields:
+ self.db.set_task_estimate(task, None)
+ else:
+ delta = fields["<duration>"][0]
+ self.db.set_task_estimate(task, delta.days*86400 + delta.seconds)
+ elif "due" in fields:
+ if "none" in fields:
+ self.db.set_task_due(task, None)
+ else:
+ self.db.set_task_due(task, fields["<time>"][0])
except KeyError, e:
self.logger.error("no such tag/task (%s)", e)
except tracklib.TimeTrackError, e:
Oops, something went wrong.

0 comments on commit 127a4fe

Please sign in to comment.