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:

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

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.