Skip to content

Commit

Permalink
Merge bbb4b50 into 08d27fe
Browse files Browse the repository at this point in the history
  • Loading branch information
riccardomurri committed Sep 17, 2014
2 parents 08d27fe + bbb4b50 commit fb75760
Show file tree
Hide file tree
Showing 4 changed files with 220 additions and 15 deletions.
15 changes: 15 additions & 0 deletions docs/index.rst
Expand Up @@ -53,6 +53,21 @@ Work activities can also include a category name, e.g.::

The tasks are grouped by category in the reports.

Each entry may be additionally labelled with multiple
(space-separated) tags, e.g.::

project3: upgrade webserver -- sysadmin www front-end
project3: restart mail server -- sysadmin mail

Reports will then include an additional breakdown by tag: for each
tag, the total time spent in entries marked with that tag is shown.
Note that these times will (likely) not add up to the total reporting
time, as each entry may be marked with several tags.

Tags must be separated from the rest of the entry by `` -- ``, i.e.,
double-dash surrounded by spaces. Tags will *not* be shown in the
main UI pane.
Tasks Pane
==========
Expand Down
2 changes: 1 addition & 1 deletion src/gtimelog/main.py
Expand Up @@ -412,7 +412,7 @@ def time_left_at_work(self, total_work):

def write_item(self, item):
buffer = self.log_buffer
start, stop, duration, entry = item
start, stop, duration, tags, entry = item
self.w(format_duration(duration), 'duration')
period = '\t({0}-{1})\t'.format(
start.strftime('%H:%M'), stop.strftime('%H:%M'))
Expand Down
107 changes: 103 additions & 4 deletions src/gtimelog/tests.py
@@ -1,5 +1,6 @@
"""Tests for gtimelog"""

import datetime
import doctest
import unittest
import os
Expand Down Expand Up @@ -404,7 +405,7 @@ def doctest_TimeWindow_last_entry():
>>> window.items = [
... (datetime(2013, 12, 4, 9, 0), 'started **'),
... ]
>>> start, stop, duration, entry = window.last_entry()
>>> start, stop, duration, tags, entry = window.last_entry()
>>> start == stop == datetime(2013, 12, 4, 9, 0)
True
>>> duration
Expand All @@ -418,7 +419,7 @@ def doctest_TimeWindow_last_entry():
... (datetime(2013, 12, 3, 12, 0), 'stuff'),
... (datetime(2013, 12, 4, 9, 0), 'started **'),
... ]
>>> start, stop, duration, entry = window.last_entry()
>>> start, stop, duration, tags, entry = window.last_entry()
>>> start == stop == datetime(2013, 12, 4, 9, 0)
True
>>> duration
Expand All @@ -433,7 +434,7 @@ def doctest_TimeWindow_last_entry():
... (datetime(2013, 12, 4, 9, 0), 'started **'),
... (datetime(2013, 12, 4, 9, 31), 'gtimelog: tests'),
... ]
>>> start, stop, duration, entry = window.last_entry()
>>> start, stop, duration, tags, entry = window.last_entry()
>>> start
datetime.datetime(2013, 12, 4, 9, 0)
>>> stop
Expand Down Expand Up @@ -943,7 +944,6 @@ def doctest_TaskList_real_file():
"""


class TestSettings(unittest.TestCase):

def setUp(self):
Expand Down Expand Up @@ -1046,6 +1046,105 @@ def test_save(self):
self.settings.save(os.path.join(tempdir, 'config'))


class TestTagging (unittest.TestCase):

TEST_TIMELOG = """
2014-05-27 10:03: arrived
2014-05-27 10:13: edx: introduce topic to new sysadmins -- edx
2014-05-27 10:30: email
2014-05-27 12:11: meeting: how to support new courses? -- edx meeting
2014-05-27 15:12: edx: write test procedure for EdX instances -- edx sysadmin
2014-05-27 17:03: cluster: set-up accounts, etc. -- sysadmin hpc
2014-05-27 17:14: support: how to run statistics on Hydra? -- support hydra
2014-05-27 17:36: off: pause **
2014-05-27 17:38: email
2014-05-27 19:06: off: dinner & family **
2014-05-27 22:19: cluster: fix shmmax-shmall issue -- sysadmin hpc
"""

def setUp(self):
from gtimelog.timelog import TimeWindow
self.tw = TimeWindow(
filename=StringIO(self.TEST_TIMELOG),
min_timestamp=datetime.datetime(2014, 5, 27, 9, 0),
max_timestamp=datetime.datetime(2014, 5, 27, 23, 59),
virtual_midnight=datetime.time(2, 0))

def test_TimeWindow_set_of_all_tags(self):
tags = self.tw.set_of_all_tags()
self.assertEqual(tags,
set(['edx', 'hpc', 'hydra',
'meeting', 'support', 'sysadmin']))

def test_TimeWindow_totals_per_tag1(self):
"""Test aggregate time per tag, 1 entry only"""
result = self.tw.totals('meeting')
self.assertEqual(len(result), 2)
work, slack = result
self.assertEqual(work, (
# start/end times are manually extracted from the TEST_TIMELOG sample
(datetime.timedelta(hours=12, minutes=11) - datetime.timedelta(hours=10, minutes=30))
))
self.assertEqual(slack, datetime.timedelta(0))

def test_TimeWindow_totals_per_tag2(self):
"""Test aggregate time per tag, several entries"""
result = self.tw.totals('hpc')
self.assertEqual(len(result), 2)
work, slack = result
self.assertEqual(work, (
# start/end times are manually extracted from the TEST_TIMELOG sample
(datetime.timedelta(hours=17, minutes=3) - datetime.timedelta(hours=15, minutes=12))
+ (datetime.timedelta(hours=22, minutes=19) - datetime.timedelta(hours=19, minutes=6))
))
self.assertEqual(slack, datetime.timedelta(0))

def test_TimeWindow__split_entry_and_tags1(self):
"""Test `TimeWindow._split_entry_and_tags` with simple entry"""
result = self.tw._split_entry_and_tags('email')
self.assertEqual(len(result), 2)
self.assertEqual(result[0], 'email')
self.assertEqual(result[1], set())

def test_TimeWindow__split_entry_and_tags2(self):
"""Test `TimeWindow._split_entry_and_tags` with simple entry and tags"""
result = self.tw._split_entry_and_tags('restart CFEngine server -- sysadmin cfengine issue327')
self.assertEqual(len(result), 2)
self.assertEqual(result[0], 'restart CFEngine server')
self.assertEqual(result[1], set(['sysadmin', 'cfengine', 'issue327']))

def test_TimeWindow__split_entry_and_tags3(self):
"""Test `TimeWindow._split_entry_and_tags` with category, entry, and tags"""
result = self.tw._split_entry_and_tags('tooling: tagging support in gtimelog -- tooling gtimelog')
self.assertEqual(len(result), 2)
self.assertEqual(result[0], 'tooling: tagging support in gtimelog')
self.assertEqual(result[1], set(['tooling', 'gtimelog']))

def test_TimeWindow__split_entry_and_tags4(self):
"""Test `TimeWindow._split_entry_and_tags` with slack-type entry"""
result = self.tw._split_entry_and_tags('read news -- reading **')
self.assertEqual(len(result), 2)
self.assertEqual(result[0], 'read news **')
self.assertEqual(result[1], set(['reading']))

def test_Reports__report_tags(self):
from gtimelog.timelog import Reports
rp = Reports(self.tw)
txt = StringIO()
# use same tags as in tests above, so we know the totals
rp._report_tags(txt, ['meeting', 'hpc'])
self.assertEqual(
txt.getvalue().strip(),
"""
Time spent in each area:
hpc 5:04
meeting 1:41
Note that area totals may not add up to the period totals,
as each entry may be belong to multiple areas (or none at all).
""".strip())

def additional_tests(): # for setup.py
return doctest.DocTestSuite(optionflags=doctest.NORMALIZE_WHITESPACE)

Expand Down
111 changes: 101 additions & 10 deletions src/gtimelog/timelog.py
Expand Up @@ -189,10 +189,37 @@ def last_time(self):
return None
return self.items[-1][0]

@staticmethod
def _split_entry_and_tags(entry):
"""
Split the entry title (proper) from the trailing tags.
Tags are separated from the title by a `` -- `` marker:
anything *before* the marker is the entry title,
anything *following* it is the (space-separated) set of tags.
Return a tuple consisting of entry title and set of tags.
"""
if ' -- ' in entry:
entry, tags_bundle = entry.split(' -- ', 1)
# there might be spaces preceding ' -- '
entry = entry.rstrip()
tags = set(tags_bundle.split())
# put back '**' and '***' if they were in the tags part
if '***' in tags:
entry += ' ***'
tags.remove('***')
elif '**' in tags:
entry += ' **'
tags.remove('**')
else:
tags = set()
return entry, tags

def all_entries(self):
"""Iterate over all entries.
Yields (start, stop, duration, entry) tuples. The first entry
Yields (start, stop, duration, tags, entry) tuples. The first entry
has a duration of 0.
"""
stop = None
Expand All @@ -204,13 +231,24 @@ def all_entries(self):
self.virtual_midnight):
start = stop
duration = stop - start
yield start, stop, duration, entry
# tags are appended to the entry title, separated by ' -- '
entry, tags = self._split_entry_and_tags(entry)
yield start, stop, duration, tags, entry

def set_of_all_tags(self):
"""
Return set of all tags mentioned in entries.
"""
all_tags = set()
for _, _, _, entry_tags, _ in self.all_entries():
all_tags.update(entry_tags)
return all_tags

def count_days(self):
"""Count days that have entries."""
count = 0
last = None
for start, stop, duration, entry in self.all_entries():
for start, stop, duration, tags, entry in self.all_entries():
if last is None or different_days(last, start,
self.virtual_midnight):
last = start
Expand All @@ -236,7 +274,8 @@ def last_entry(self):
if different_days(start, stop, self.virtual_midnight):
start = stop
duration = stop - start
return start, stop, duration, entry
entry, tags = self._split_entry_and_tags(entry)
return start, stop, duration, tags, entry

def grouped_entries(self, skip_first=True):
"""Return consolidated entries (grouped by entry title).
Expand All @@ -247,7 +286,7 @@ def grouped_entries(self, skip_first=True):
"""
work = {}
slack = {}
for start, stop, duration, entry in self.all_entries():
for start, stop, duration, tags, entry in self.all_entries():
if skip_first:
skip_first = False
continue
Expand Down Expand Up @@ -297,9 +336,12 @@ def categorized_work_entries(self, skip_first=True):
None, datetime.timedelta(0)) + duration
return entries, totals

def totals(self):
def totals(self, tag=None):
"""Calculate total time of work and slacking entries.
If optional argument `tag` is given, only compute
totals for entries marked with the given tag.
Returns (total_work, total_slacking) tuple.
Slacking entries are identified by finding two asterisks in the title.
Expand All @@ -318,7 +360,9 @@ def totals(self):
(that is, it would be true if sum could operate on timedeltas).
"""
total_work = total_slacking = datetime.timedelta(0)
for start, stop, duration, entry in self.all_entries():
for start, stop, duration, tags, entry in self.all_entries():
if tag is not None and tag not in tags:
continue
if '**' in entry:
total_slacking += duration
else:
Expand All @@ -336,7 +380,7 @@ def icalendar(self, output):
except: # can it actually ever fail?
idhost = 'localhost'
dtstamp = datetime.datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
for start, stop, duration, entry in self.all_entries():
for start, stop, duration, tags, entry in self.all_entries():
output.write("BEGIN:VEVENT\n")
output.write("UID:%s@%s\n" % (hash((start, stop, entry)), idhost))
output.write("SUMMARY:%s\n" % (entry.replace('\\', '\\\\'))
Expand Down Expand Up @@ -380,7 +424,7 @@ def to_csv_daily(self, output, title_row=True):
d0 = datetime.timedelta(0)
days = {} # date -> [time_started, slacking, work]
dmin = None
for start, stop, duration, entry in self.all_entries():
for start, stop, duration, tags, entry in self.all_entries():
if dmin is None:
dmin = start.date()
day = days.setdefault(start.date(),
Expand Down Expand Up @@ -508,6 +552,45 @@ def _categorizing_report(self, output, email, who, subject, period_name,
for time, cat in ordered_by_time:
output.write(line_format % (cat, format_duration_short(time)))

tags = self.window.set_of_all_tags()
if tags:
self._report_tags(output, tags)

def _report_tags(self, output, tags):
"""Helper method that lists time spent per tag.
Use this to add a section in a report looks similar to this:
sysadmin: 2 hours 1 min
www: 18 hours 45 min
mailserver: 3 hours
Note that duration may not add up to the total working time,
as a single entry can have multiple or no tags at all!
Argument `tags` is a set of tags (string). It is not modified.
"""
output.write('\n')
output.write('Time spent in each area:\n')
output.write('\n')
# sum work and slacking time per tag; we do not care in this report
tags_totals = { }
for tag in tags:
spent_working, spent_slacking = self.window.totals(tag)
tags_totals[tag] = spent_working + spent_slacking
# compute width of tag label column
max_tag_length = max([len(tag) for tag in tags_totals.keys()])
line_format = ' %-' + str(max_tag_length + 4) + 's %+5s\n'
# sort by time spent (descending)
for tag, spent in sorted(tags_totals.items(),
key=(lambda it: it[1]),
reverse=True):
output.write(line_format % (tag, format_duration_short(spent)))
output.write('\n')
output.write(
'Note that area totals may not add up to the period totals,\n'
'as each entry may be belong to multiple areas (or none at all).\n')

def _report_categories(self, output, categories):
"""A helper method that lists time spent per category.
Expand Down Expand Up @@ -585,6 +668,10 @@ def _plain_report(self, output, email, who, subject, period_name,
if categories:
self._report_categories(output, categories)

tags = self.window.set_of_all_tags()
if tags:
self._report_tags(output, tags)

def weekly_report_categorized(self, output, email, who,
estimated_column=False):
"""Format a weekly report with entries displayed under categories."""
Expand Down Expand Up @@ -652,7 +739,7 @@ def daily_report(self, output, email, who):
if not items:
output.write("No work done today.\n")
return
start, stop, duration, entry = items[0]
start, stop, duration, tags, entry = items[0]
entry = entry[:1].upper() + entry[1:]
output.write("%s at %s\n" % (entry, start.strftime('%H:%M')))
output.write('\n')
Expand Down Expand Up @@ -689,6 +776,10 @@ def daily_report(self, output, email, who):
output.write("Time spent slacking: %s\n" %
format_duration_long(total_slacking))

tags = self.window.set_of_all_tags()
if tags:
self._report_tags(output, tags)


class TimeLog(object):
"""Time log.
Expand Down

0 comments on commit fb75760

Please sign in to comment.