Skip to content

Instantly share code, notes, and snippets.

@chadwhitacre
Created November 1, 2012 16:36
Show Gist options
  • Save chadwhitacre/3994887 to your computer and use it in GitHub Desktop.
Save chadwhitacre/3994887 to your computer and use it in GitHub Desktop.
Time tracking script.
#!/usr/bin/env python
"""This script prints time tracking reports to stdout.
The program takes its configuration from ~/.ttrc, which is in INI format
with one section and two keys:
[main]
timesheet=~/.timesheet
timezone=US/Eastern
It needs pytz:
http://pytz.sourceforge.net/
The timesheet format that timetough is designed to work with is as follows:
0 First Personal Thing
1 Second Personal Thing
3 Third Personal Thing
a A Work Thing
--==START==--
03/17/2009
09:33 0 12:00 comments go here
12:00 a 13:30
--==END==--
03/18/2009
08:30 0 11:13
11:13 3 11:58
13:00 3 15:33
Only the lines between the START and END markers are processed. Allowable lines
between these markers are of three types:
o blank lines
o dates in MM/DD/YYYY format
o entries in <start-time> <category> <end-time> format; 24 hour clock,
and must be preceded by a date line; anything after <end-time> is ignored
TODO:
- test!
- document!
===done===
- account for timezone
- account for overlapping entries
- figure out minute boundary overlaps for sure (ok in FB ui?)
- parse overlaps on client rather than hitting server N times
The name comes from "Time Tough", a song by Toots and the Maytals:
http://www.youtube.com/watch?v=TXxOU49TVKA
http://songza.com/z/mgj2hg (RIP, Songza)
Legal (MIT License; http://www.opensource.org/licenses/mit-license.php):
Copyright (c) 2009 Zeta Design & Development, LLC
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
import ConfigParser
import datetime
import os
import re
import stat
import sys
from collections import defaultdict
CONF = '~/.ttrc'
START = '--==START==--'
END = '--==END==--'
timezone = None
# Helpers
# =======
def die(msg):
print >> sys.stderr, msg
raise SystemExit(1)
# Import pytz
# ===========
# This is here so we have die().
try:
import pytz
except ImportError:
die("This program depends on pytz: http://pytz.sourceforge.net/")
# The Thing Itself
# ================
utc = pytz.timezone('UTC')
class Interval(object):
def __init__(self, category, start, end, date=None):
"""Takes three strings and a datetime.datetime object.
Or take an int and two datetime objects.
"""
self.category = category
if isinstance(start, basestring):
parse_time = lambda t: [int(x) for x in t.split(':')]
def make_time(h, m):
dt = datetime.datetime(date.year, date.month, date.day, h, m)
dt = timezone.localize(dt)
#dt = dt.astimezone(utc)
return dt
start_h, start_m = parse_time(start)
end_h, end_m = parse_time(end)
start = make_time(start_h, start_m)
if end_h < start_h: # somebody's working late ;^)
date += datetime.timedelta(days=1)
end = make_time(end_h, end_m)
self.start = start
self.end = end
def __cmp__(self, other):
return cmp(self.start, other.start)
def __repr__(self):
return "<%2.2f hrs on %d>" % (self.to_hours(), self.category)
def to_hours(self):
return (self.end - self.start).seconds / 60.0 / 60.0
def process(timesheet):
"""Given a timesheet path, return four data structures.
We want to be careful not to duplicate intervals. So first we batch all
intervals by day, and ask the user for confirmation if they already have
any intervals posted for that day. Then check each interval for an interval
matching the exact start and end times, and skip-and-warn those.
"""
# Read in the timesheet.
# ======================
timesheet = open(timesheet)
# read in categories
personal = {}
work = {}
for line in timesheet:
line = line.strip()
if not line:
break
k, v = line.split(None, 1)
categories = personal if k.isdigit() else work
categories[k] = [v, 0]
# set up month data structure
by_month = defaultdict(float)
# set up week data structure
def populate_week_with_categories():
by_category = {}
for code, (name, hours) in personal.items(): by_category[code] = 0.0
for code, (name, hours) in work.items(): by_category[code] = 0.0
return [0.0, by_category]
by_week = defaultdict(populate_week_with_categories)
# skip down to start marker
for line in timesheet:
line = line.strip()
if line == START:
break
date = earliest = latest = None
date_pattern = re.compile(r'^\d\d\d\d-\d\d-\d\d$')
for line in timesheet:
line = line.strip()
if not line: continue
if line == END: break
# 2012-02-29 -- date line
m = date_pattern.match(line)
if m is not None:
year, month, day = [int(x) for x in m.group(0).split('-')]
date = datetime.date(year, month, day)
sunday = date - datetime.timedelta(date.weekday() + 1)
continue
# 09:00 0 17:00 -- interval line
if date is None:
die("no date set")
start, category_id, end = line.split(None, 2)
if ' ' in end:
end, comment = end.split(None, 1)
interval = Interval(category_id, start, end, date)
hours = interval.to_hours()
if (earliest is None) or (interval.start < earliest):
earliest = interval.start
if (latest is None) or (interval.end > latest):
latest = interval.end
categories = personal if interval.category.isdigit() else work
categories[interval.category][1] += hours
by_month[(year, month)] += hours
by_week[sunday][0] += hours
by_week[sunday][1][interval.category] += hours
return personal, work, earliest, latest, by_month, by_week
def main(codes):
global timezone
# Find a secure conf file
# =======================
conf_path = os.path.expanduser(CONF)
if not os.path.isfile(conf_path):
die("you need a configuration file at %s" % CONF)
perms = stat.S_IMODE(os.stat(conf_path)[stat.ST_MODE])
if (perms ^ 0600) > 0:
die("permissions on %s must be 0600, not 0%o" % (CONF, perms))
# Load configuration
# ==================
conf = ConfigParser.RawConfigParser()
conf.read([conf_path])
timesheet = os.path.expanduser(conf.get('main', 'timesheet'))
try:
_timezone = conf.get('main', 'timezone')
timezone = pytz.timezone(_timezone)
except pytz.UnknownTimeZoneError:
die("unknown timezone: %s" % _timezone)
# Process timesheet
# =================
personal, work, earliest, latest, by_month, by_week = process(timesheet)
# Print report
# ============
buf = ['']
def w(s):
if buf[-1].endswith(os.linesep):
buf.append(s)
else:
buf[-1] += s
def br():
if buf[-1].endswith(os.linesep):
buf.append(os.linesep)
else:
buf[-1] += os.linesep
def display():
width = max([len(line) for line in buf]) - 1 # don't include newline
for line in buf:
line = line.rstrip(os.linesep).center(width) + os.linesep
sys.stdout.write(line)
key = lambda item: item[1][1] # total hours
category_map = {}
category_map.update(personal)
category_map.update(work)
categories = sorted(category_map.items(), key=key, reverse=True)
grand_total = 0.0
br()
br()
w("Hours by Category and Week".center(160))
br()
br()
w(" " * 19)
for code, (name, total) in categories:
width = max(len(name) + 2, 10)
category_map[code].append(width)
if (not codes and total == 0) or (codes and code not in codes):
continue
assert name != '-'
w(name.center(width))
br()
w(" " * 19)
for code, (name, total, width) in categories:
if (not codes and total == 0) or (codes and code not in codes):
continue
assert name != '-'
w(code.center(width))
br()
br()
for sunday, (total, by_category) in sorted(by_week.items(), reverse=True):
if codes:
total = sum([h for c, h in by_category.items() if c in codes])
w("%s %7.02f" % (sunday, total))
for code, (name, total, width) in categories:
if (not codes and total == 0) or (codes and code not in codes):
continue
assert name != '-'
hours = round(by_category[code], 2)
grand_total += hours
cell = ("%6.02f" % hours) if hours > 0 else " - "
w(cell.center(width))
br()
br()
w(" " * 12)
w("%7.02f" % grand_total)
for code, (name, total, width) in categories:
if (not codes and total == 0) or (codes and code not in codes):
continue
assert name != '-'
w(("%7.02f" % total).center(width))
br()
display()
if __name__ == '__main__':
codes = sys.argv[1:]
main(codes)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment