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-05-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.