My time tracking system
The need
When a software developer, I needed to track my worktime.
The customers’ demands
Invariably, at the place I worked, there was a system in place into which I was required to type my hours. Almost all with a UX that made me moan. Spreadsheets (loaded with macros) that needed to be filled out and mailed in were not the worst I’ve seen.
Those systems typically wanted being lied to. Sample requirements:
- “We need the time for the last three days of the month in advance.”
- “Don’t enter hours and minutes, but enter hours with one decimal place. That decimal place can only be either “.0” or “.5”. We want your time with a granularity of half an hour.”
- “You can’t book more than 9 hours each day, regardless how long you really worked”.
Which invariably leads to carry-overs: From one day to the next, or from one month to the next, time that I had worked but not (yet) booked, or time I had booked but not (yet) worked.
My wishes
- As the provided systems never supported stuff like carry-overs or other things required to lie to them without cheating them, I wanted my own system.
- I was on flextime. So I had a target of total hours to work throughout the month, but flexibility when to quit working on any given day. I wanted my system to answer the question: How many more hours do I need to work today, to be in good shape?
- When switching from task A to task B, I sometimes forget to do the bookkeeping. But I can usually figure out the time of switch in retrospect, or at least estimate. So I need to be able to book then.
- I want to be able to enter a switch time, even if it is not yet obvious what activity it is I’m switching to.
- They way I work, I have a text editor open almost always. So a straightforward text format is good for me.
My design
Times are recorded in a straightforward text format. There is one file
a day, the filenames code the day. As I’m writing this on May 15,
2025, today’s filename would be 2025-05-15. More generally, you
guessed it, YYYY-MM-DD.
The time I’ve worked is entered in a straightforward format. I’ll describe that format in a second.
A simple script summe reads any number of such files and produces
the report. That report is simply text output from that script.
The script is typically run with all files that have accumulated
during the month. A call could be summe 2025-05-??. But I tend to
generate files for the month in advance, so I would more often call
this like summe -2025-05-15, which shows everything in the month
2025-05 up to (including) 2025-05-15.
It is also possible to get the output of an entire year, e.g.,
summe 2024-??-??.
The input format
Here is a sample file:
9:33 - 12:00 bsp: design:
13:00 - 15:33 bsp: code:
17:46 - 19:21 frank:
- 20:10 bsp: code: finally fixed that bug!
# This was a pleasant home-office day.
# target: 6:20
The run-of-the-mill line has a format from - to project: sub-project:
with the from and to times given in 24 hour clock.
So, this file says: During that day, I was working from 9:33 to 12:00
on the bsp project, doing design work. From 13:00 to 15:33, I was
doing code work for the same project. Later, from 17:46 to 19:21, I
was tutoring Frank, an intern. From 19:21 to 20:10 I was again at the
bsp project, doing code work. As you can see, it is not needed to
type in a start-time if it is the same as the end-time of the previous
line. Also, projects can have sub-projects, but need not. (You could
even have sub-sub-projects and further on.) A tail of a time line that
doesn’t have a “:” is ignored.
Empty lines are ignored, as are lines starting with #.
Actually not quite ignored: A line starting with # and continuing
with the key-word target: contains the worktime I wanted to have
accumulated at the end of the present day. This line can occure
anywhere in the file. Only the first such line is interpreted.
When running my script on this input file, here is what it produces:
It first echoes the input file.
9:33 - 12:00 bsp: design:
13:00 - 15:33 bsp: code:
17:46 - 19:21 frank:
- 20:10 bsp: code: finally fixed that bug!
# This was a pleasant home-office day.
# target: 6:20
Then it contains the data that I would input into the official
time-keeping, plus some information about the carry-over (called
rest) not yet input:
TOTAL 7:24h = 7.40h
2025-05-02 7:00h = 7.00h
bsp 5:30h = 5.50h
frank 1:30h = 1.50h
rest 0:24h = 0.40h
bsp 0:19h = 0.32h
frank 0:05h = 0.08h
Next, a summary of the worktime spend, without lies, which also shows the sub-project times:
TOTAL 7:24h = 7.40h
bsp 5:49h = 5.82h
code 3:22h = 3.37h
design 2:27h = 2.45h
frank 1:35h = 1.58h
And finally, a summary. This tells me I have worked 7:24 hours where only 6:20 hours were asked for, so I should have finished work 1:04 hours earlier:
============================ total: 7:24h = 7.40h target: 6:20h = 6.33h was before: 1:04h = 1.07h
You probably see where this is going. For clarity, let me add a next day’s input file:
# Don't forget to call Ms. Red
# target: 12:40
10:38 - 12:23 bsp: code:
13:04 - 15:02 misc:
- 15:30 bsp:
- 16:09 frank:
The output (omitting the repetition of the input files) now is:
TOTAL 12:14h = 12.23h
2025-05-02 7:00h = 7.00h
bsp 5:30h = 5.50h
frank 1:30h = 1.50h
2025-05-03 4:30h = 4.50h
bsp 2:30h = 2.50h
frank 0:30h = 0.50h
misc 1:30h = 1.50h
rest 0:44h = 0.73h
bsp 0:02h = 0.03h
frank 0:14h = 0.23h
misc 0:28h = 0.47h
TOTAL 12:14h = 12.23h
bsp 8:02h = 8.03h
- 0:28h = 0.47h
code 5:07h = 5.12h
design 2:27h = 2.45h
frank 2:14h = 2.23h
misc 1:58h = 1.97h
============================
total: 12:14h = 12.23h
target: 12:40h = 12.67h
in: 0:26h = 0.43h
Carry-over from the first to the second day happens automatically.
The final in line tells me that I can precisely reach my target of
the day, namely 12:40 hours worked that month, by finishing work in
another 26 minutes (from the last activity booked).
Tricks:
Negative times
Negative times can be useful for carry-over of already booked time not
yet worked. This is straightforward to enter, via a time interval that
runs backwards. E.g., if you want to subtract 2:53 hours from the
bsp project, you’d simply do:
2:53 - 0:00 bsp:
Carry-overs
I typically use a file for the “0th” day of the month, e.g., 2025-05-00,
to contain carry-overs from the last month. Say, if I worked a total
of 3:17 hours longer than I was hired for, I’d enter:
0:00 - 3:17 carry-over:
Next, I check the individual project. If, during the last month, I
worked 2:45 for the bsp project that have not yet been booked, I
would (in the same file) add that time to the bsp project (to
smuggle it into the next days’ bookings manually), but also subtract
that amount from carry-over, leading to this content:
0:00 - 3:17 carry-over: 0:00 - 2:45 bsp: carry-over: 2:45 - 0:00 carry-over:
As you can see, I also mark those hours as a “carry-over” sub-project
of the bsp project.
Vacation
One way is to simply leave out days when you were on vacation.
I found it more convenient to use an explicit project vacation and
book a workday’s hours for each vacation day. This made it easy to
track how many days I still have left, over the year.
Holidays
For transparency, I use a file with just a comment line for public
holidays. Being startled with a (wrong) surprise “Oh, sh…, I
completely forgot to book anything for March, 8!” is much inferior,
compared with checking that 2025-03-08 file and reading #
international women's day (which indeed is a public holiday in my
state Berlin of federal Germany).
The script itself
Needs Python 3, but nothing else:
#!/usr/bin/env python3 # Copyright 2023,2025 Andreas Krüger, dj3ei@famsik.de # # 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 math import re from collections import defaultdict from glob import glob class Split: __file_marker_reg = re.compile(r"(?P<leading_hyphen>\-)?(?P<year>\d{4})\-(?P<month>\d{2})\-(?P<day>\d{2})") __splitting_reg = re.compile(r"\s*(?P<thisproject>[\w\d\-]+)\s*:(?P<rest>[^\#]*)(\#.*)?") __comment_line = re.compile(r"\s*(#.*)?\n?") __target_line = re.compile(r"\s*#\s+target:\s*(?P<th>\d+):(?P<tm>\d+)\s*\n?") __time_line = re.compile(r"\s*((?P<fh>\d+):(?P<fm>\d+)\s*)?\-\s*(?P<th>\d+):(?P<tm>\d+)\s+(?P<rest>.*)\n?") __indentation_per_level = 2 def __init__(self): self.my_root_time = 0.0 self.last_target_seen = None self.project2split = defaultdict(Split) def read(self, filename, key_prefix=None, print_lines=False): target_seen_this_file = False with open(filename, "r") as inf: last_timestamp = None for line in inf: if print_lines: print(line, end="") if Split.__comment_line.fullmatch(line): if target_seen_this_file: pass else: tmo = Split.__target_line.fullmatch(line) if tmo: self.last_target_seen = 3600 * int(tmo.group("th")) + 60 * int(tmo.group("tm")) target_seen_this_file = True else: mo = Split.__time_line.fullmatch(line) if mo: if mo.group("fh"): last_timestamp = int(mo.group("fh")) * 3600 + int(mo.group("fm")) * 60 if last_timestamp == None: raise SystemError(f"No previous timestamp: {line}") now = int(mo.group("th")) * 3600 + int(mo.group("tm")) * 60 if key_prefix: self.add(key_prefix + ": " + mo.group("rest"), now - last_timestamp) else: self.add(mo.group("rest"), now - last_timestamp) last_timestamp = now else: raise RuntimeError(f"file {filename} line \"{line}\"") def add(self, key, seconds): if key is None or 0 == len(key) or key.isspace() or ":" not in key: self.my_root_time += seconds else: mo = Split.__splitting_reg.fullmatch(key) if mo: self.project2split[mo.group("thisproject")].add(mo.group("rest"), seconds) else: raise RuntimeError(f"key: {key}") def total(self): result = self.my_root_time for project, split in self.project2split.items(): result += split.total() return result def timestring(self): return self.__timestrimg(self.total()) def __timestring(self, my_time): my_time_hours = my_time / 3600 my_time_full_hours = int(my_time_hours) my_time_minutes = round(60 * (my_time_hours - my_time_full_hours)) return f"{my_time_full_hours:3d}:{my_time_minutes:02d}h = {my_time_hours:6.2f}h" def indented(self, myname, level, length, more_levels=10): rest_space = length - len(myname) - level if 0 <= rest_space: result = " "*level + myname + " "*rest_space else: result = " "*level + myname + " " result += f"{self.__timestring(self.total())}\n" if 0 != len(self.project2split) and 0 != self.my_root_time: result += " "*(level + Split.__indentation_per_level) + \ "-" + " "*(length - 1 - level - Split.__indentation_per_level) + \ self.__timestring(self.my_root_time) + "\n" if 0 < more_levels: for project in sorted(self.project2split.keys()): result += self.project2split[project].indented(project, level + Split.__indentation_per_level, length, more_levels-1) if level == Split.__indentation_per_level: result += "\n" return result def _max_space_needed(self, myname, level): result = level + len(myname) + 1 for project in self.project2split.keys(): p_result = self.project2split[project]._max_space_needed(project, level + Split.__indentation_per_level) if result < p_result: result = p_result return result def __str__(self): msn = self._max_space_needed("TOTAL", 0) result = self.indented("TOTAL", 0, msn) if self.last_target_seen is not None: result += "\n" + "=" * (msn + 17) + "\n"# total = self.total() delta = self.last_target_seen - total result += f"total: {self.__timestring(total)}\n" + \ f"target: {self.__timestring(self.last_target_seen)}\n" if 0 <= delta: result += f"in: {self.__timestring(delta)}\n" else: result += f"was before: {self.__timestring(-delta)}\n" return result def read_files(file_marker_string, by_date, total): mo = Split.__file_marker_reg.fullmatch(file_marker_string) if mo: if mo.group("leading_hyphen") is not None: latest_file_name = file_marker_string[1:] for filename in sorted(glob(f"{mo.group('year')}-{mo.group('month')}-??")): if filename <= latest_file_name: total.read(filename) # print(f"{filename}:\n{total}") by_date.read(filename, filename, print_lines=True) else: print(f"\n# file {file_marker_string}") total.read(file_marker_string) by_date.read(file_marker_string, file_marker_string, print_lines=True) else: raise SystemError(f"File marker: \"{file_marker_string}\"") def convert2rounded(in_split_by_dates): """Convert times to full half hours.""" result = Split() def zero(): return 0 project2rest = defaultdict(zero) for date in sorted(in_split_by_dates.project2split.keys()): for project in in_split_by_dates.project2split[date].project2split.keys(): actually_worked = in_split_by_dates.project2split[date].project2split[project].total() want_to_book = actually_worked + project2rest[project] # I am supposed to book full half-hours only: to_book = math.floor(want_to_book / 1800) * 1800 project2rest[project] = want_to_book - to_book result.add(f"{date}: {project}:", to_book) # Times already worked, but not booked yet: for project in project2rest.keys(): result.add(f"rest:{project}:", project2rest[project]) return result if __name__ == "__main__": from sys import argv by_date = Split() total = Split() for arg in argv[1:]: Split.read_files(arg, by_date, total) print("\n") print(convert2rounded(by_date)) print(total)
If you want to comment or discuss this blog post, you can do so by getting a Fediverse account and posting a reply to https://mastodon.radio/@dj3ei/114512861048114564.