WW0CJ

My First Real Bash Script - Timesheet

Introduction

In my professional life, I work as a contractor billed by the hour - and so I try and keep a good handle on the amount of hours I work each week so I can send it in to clients accordingly. For the past couple of years, my workflow has relied on the web app Toggl. There were a few things about this software that really started to get on my nerves though.

  1. The overall layout was not only constantly changing, but was very difficult to navigate when doing anything other than starting and stopping a timer. One specific issue I frequently encountered was fixing timers that were accidentally left running - it would lag, take multiple clicks to find the correct time to update, and sometimes would update to end on the wrong day if it was running too long!
  2. Several features are locked behind a paywall - including archiving old projects, invoice generation, and complete reports/dashboards/graphs. While I am using this as a part of my work, I'm also a college student that does not want to spend $9/month for simple feature additions.

After a while, I realized the only reason I was still using Toggl was because I was too lazy to switch, and I enjoyed the fact that I could manage it on-the-go from my phone (something I very rarely did). Since all of my work is done sitting at a desk in front of my laptop, I started researching programs that I could switch to that might also give me some additional flexibility with my data.

The New Tool

Enter timewarrior (or timew as the command is run) - a CLI for time tracking. As of last week, I've started using this instead of Toggl and have loved my time with it so far.

My workflow so far is very simple. Whenever I start work, I use timew start to begin tracking my time. Each time entry has a tag that is the name of the client that my current project is for. At the end of each entry, I use the annotate command to add a short description of what I worked on, before using stop to end my time and move on to something else.

My favorite part of using a CLI tool now, is that I've even managed to automate this workflow in small ways! For example: for my software work, I always have to start my day with getting my development environment setup in a few different steps. Now, I've got a shell alias that opens the work git repository, starts my time tracking, boots up my docker containers, and opens VS Code!

The Problem

So armed with a new tool and a random urge for some hobby coding instead of work coding, I set out to solve one of my biggest problems lately: invoicing. At the end of each week, I need to know my total hours and turn that into a final cost to send to a client.

There are two different ways I have to send this information in depending on the client. The first format is a simple total calculation. Say I worked 8 hours, at a rate of $10 per hour that week. Then, I would send in just a plain text 8 hours X $10/hour = $80.00. The second way is a CSV file with each date, the hours worked that day, and the total cost for that day (daily hours * hourly rate), with the totals for hours and cost at the very bottom.

When I was using Toggl, I couldn't access this information very easily since reports and invoices were under a paywall. My workflow then would be to scroll through individual time entries until I reached the last date I had billed, then scroll through and manually add up each time entry and process that data into one of the above formats. This was always tedious and annoying, to the point where some days I'd put it off because after a long day of work or school making a spreadsheet sounds like the worst way to spend one's free time!

Since timewarrior has a consistent report format and output, I decided to sit down and automate this in the form of a bash script.

The Script

The final script is 188 lines, and took 10 different revisions - so I'll try and stay away from the boring parts and I won't go through my whole revision process. If you want to see what each version looked like to get here, or see the script in its entirety, you can find it at this Gist on my GitHub.

I named the program timesheet - since that's what I'm trying to output with the information, and there's nothing else named that in my bin folder1...so hopefully I don't install anything with the same name down the line!

Usage

timesheet [-D] [-r] [-e csv,txt] <tag> <cost> [start_date] [end_date]

Above is a snippet from the usage output (which gets called whenever the required arguments aren't provided). The possible options for the script are:

The possible arguments to provide are:

Initial Startup

# Check if timew is installed, otherwise rest of program fails.
# from: https://stackoverflow.com/questions/592620/how-can-i-check-if-a-program-exists-from-a-bash-script
if ! [ -x "$(command -v timew)" ]; then
    echo 'Error: timewarrior (timew) is not installed.'
    echo 'Go to https://timewarrior.net/releases/ to install'
    exit 1
fi

I only built this script for myself, so I didn't make a huge effort to focus on user input validation or anything of that sort. With that being said, I did want to factor in using this script on other machines or even when I switch laptops down the line, and the possibility that I may not have installed timewarrior yet. So, with the help of a Stack Overflow answer on this exact topic, I added a brief check to make sure the timew command exists before moving on.

# Get script arguments with options and positional parameters.
# from: https://stackoverflow.com/a/63421397

script_args=()
while [ $OPTIND -le "$#" ]
do
    if getopts e:f:rD option
    then
        case $option
        in
            e) export_type="$OPTARG";;
            f) output_file="$OPTARG";;
            r) rounding=true;;
            D) daily=true;;
        esac
    else
        script_args+=("${!OPTIND}")
        ((OPTIND++))
    fi
done

Next, I needed to get the options and arguments that have been passed to the script. Similar to the last snippet, this is also from a Stack Overflow answer on a similar topic with only slight modifications for my use case.

getopts seems to be very optimal for getting options without writing a from-scratch solution. One change I would like to make here is adding "long options" (ex. --export as an alias for -e) which doesn't seem to be possible with getopts. Any suggestions on implementing this, or a better implementation for separating options and arguments is much appreciated.

All arguments provided after the options are placed in the script_args array for later processing.

if [ ${#script_args[@]} -lt 2 ]; then
    echo "$usage"

Since tag and cost are required arguments, if there is not 2 arguments provided than a usage message will appear.

start=$(if ! [ -z "${script_args[2]}" ]; then echo ${script_args[2]}; else echo $(date -d'last monday' +%Y-%m-%dT00:00:00); fi)
end=$(if ! [ -z "${script_args[3]}" ]; then echo ${script_args[3]}; else echo $(date +%Y-%m-%dT%H:%M:%S); fi)

Before running the core functionality, I separate all the script arguments into their own variables so that they are easy to understand by name. I have left out the others, but wanted to showcase these date ones as they have some additional functionality for default dates.

The goal is to use the same range as timewarrior's :week hint as the default option, and allow for a broader range if needed. While a default end date isn't necessarily needed for this, it lets me display the date range to the user easily which is very helpful.

Total/Collective Summary

Starting with the much simpler total summary, this is the first section of the program that I created in its "v1". The goal, as stated earlier, is to get the total hours across the given timeframe, multiply it by the hourly rate provided, and provide that full calculation written out.

time=$(timew summary $start - $end $tag | grep "." | tail -1)
time="${time// /}"

The timew summary format is very helpful here, as it provides a line with only the total hours logged in that report (i.e. with that tag in that timeframe). However, some slight adjustments are needed because there is both an extra "buffer" line after that time total, as well as whitespace padding on the side of the time to make it look pretty in the terminal view.

if ! [[ $time =~ ^[0-9]{1,2}:[0-9]{2}:[0-9]{2}$ ]]; then
    echo "No data found for tag $tag within the given dates."
    exit 1
fi

If for some reason there are no logs found with the parameters given (the tag is wrong, nothing has been logged in the time given, etc.) - the $time variable will end up as a jumbled version of timewarrior's warning about it. So, we need to check and make sure that it really is a time format - so I wrote a regex check for a format like 00:00:002 and write my own error message if it fails.

IFS=':'
read -ra timearr <<< "$time"
hours=${timearr[0]}
minutes=${timearr[1]} 

Using a string splitting example from GeeksForGeeks - I use read to split that time into hours and minutes. Seconds is ignored for now, although I've left the code in just commented out.

I was really wishing for a better way to split a string, but this was the best I was able to find that works without an additional utility. I may end up writing a bash function I can save somewhere that does this quickly for ease of use, but something about this method feels...off.

if [[ $rounding = true ]]; then
    final_time=$(bc <<< "scale=1; a=$minutes/60; if(a >= 0.5 && a < 0.8) { b=0.5 } else { if(a >= 0.8) {b = 1} else {if (a >= 0.25 && a < 0.5) {b = 0.5} else {b = 0}}}; $hours + b")
else
    final_time=$(bc <<< "scale=1; $hours + ($minutes/60)")
fi

Now, we need to turn the minutes into a fraction of the hours and combine the two into one number. For all my math calculations, I've used the bc utility with scale set to 1 (rounding to tens place) for hours, and 2 for cost (rounding to hundreds place).

My rounding function here is quite poor and hard to read as a one liner, so here it is written out a little differently.

a = minutes/60
if (0.8 > a >= 0.5) || (0.5 > a >= 0.25) {
	out = hours + 0.5
} else if (a >= 0.8) {
	out = hours + 1
} else {
	out = hours
}

This is one of two different rounding implementations in this script and while this does work, I really prefer the one shown in the daily selector later on. This was mostly just an experiment in the bc scripting format, and taught me a lot but isn't the best practice going forward.

With all the processing and math done, the final cost is calculated and all the information is printed to the terminal. This is what the output looks like:

_Tag_ hours from 2025-12-29T00:00:00 to 2025-12-31T14:36:13
Hours worked: 2.0
Final cost: $30.00 x 2.0 = $60.00 

"_Tag_" would be replaced with the name of the specified timewarrior tag, and while it cannot be seen here - the final cost (in the example above "$60.00") is in a bold green color.

Daily Summary

The other type of output I mentioned is a daily log spreadsheet, with rows containing date, hours, and cost.

I knew that parsing this information would be one of the biggest struggles of the project, so I posted to the Fediverse with some questions and Juhis (@hamatti@mastodon.world) came back not only with some awesome suggestions but a whole attempt at implementing the script!

A lot of my work comes from his implementation, and he wrote his own write-up on how he implemented it so definitely give that a read. Here, I'll just share a brief description of the problem and solution and the modifications I made to his version.

Timewarrior's summary output provides a line by line list of individual time entries, but on the last entry of a day, provides a sum/total for that day. I need that value associated with the day it is for, parsed from their stylized table.

Juhis's solution searches for the line with 4 timestamps, as that will have the total, and then grabs the date and total time logging each in an array. Then, it goes through the array and rounds the time, and then makes one final loop and prints it out into a table format.

Changes Made

An obvious initial change to be made was that Juhis's version was built to have timew summary run and piped in, while my script runs summary itself. This was simple to add and was done by just grabbing the summary and adding <<< "$summary" to the first loop.

The first main change I made was making the rounding triggered by option rather than automatic/by default. My original plan as described in the post was constant rounding, but I decided against it in my latest version and modified it as such.

for key in "${!report[@]}"
do
    if [[ $(( $key % 2)) -eq 0 ]]; then
        formatted[$idx]="${report[$key]}"
    else
        minutes=$(echo "${report[$key]}" | cut -d':' -f2)
        hours=$(echo "${report[$key]}" | cut -d':' -f1)
        if [[ $rounding = true ]]; then
            if [[ $minutes -lt 15 ]]; then
                formatted[$idx]="$hours.0"
            elif [[ $minutes -lt 45 ]]; then
                formatted[$idx]="$hours.5"
            else
                formatted[$idx]="$((hours+1)).0"
            fi
        else
            formatted[$idx]=$(bc <<< "scale=1; $hours + ($minutes/60)")
        fi    
    fi
    idx=$((idx+1))
done

As you can see, all I did was add an additional if statement and an alternate, non-rounded, formatting. I really like Juhis's if/else rounding here, and may re-do the total summary rounding in this easy to understand way as well.

The next changes I made dealt with the formatting of the output provided to the user. Juhis's original solution was a simple text output of date|hours and then a total printed at the bottom.

case "$export_type" in
    txt)
        # This should be a markdown table style format if in txt.
        printf "| Date | Hours | Cost |\n"
        echo "|------|-------|------|"
        for key in "${!formatted[@]}"
        do
            if [[ $(( $key % 2)) -eq 0 ]]; then
                echo -n "| ${formatted[$key]} |"
            else
                daily_cost=$(bc <<< "scale=2;${formatted[$key]} * $cost")
                printf " %.1f | $%.2f |\n" ${formatted[$key]} $daily_cost
                total=$(bc <<< "scale=1;$total+${formatted[$key]}")
                total_cost=$(bc <<< "scale=1;$total_cost+$daily_cost")
            fi
        done
        printf "| Total | %.1f | $%.2f |\n" $total $total_cost
        ;;
    csv)
        # Write to a CSV file if set to CSV.
        printf "Date,Hours,Cost\n" >> $output_file
        for key in "${!formatted[@]}"
        do
            if [[ $(( $key % 2)) -eq 0 ]]; then
                printf "${formatted[$key]}," >> $output_file
            else
                daily_cost=$(bc <<< "scale=2;${formatted[$key]} * $cost")
                printf "%.1f,$%.2f\n" ${formatted[$key]} $daily_cost >> $output_file
                total=$(bc <<< "scale=1;$total+${formatted[$key]}")
            fi
        done
        total_cost=$(bc <<< "scale=2;$total * $cost")
        printf "Total,%.1f,$%.2f\n" $total $total_cost >> $output_file
        echo "Exported to $output_file"
        ;;
esac

My modifications provides two different export types: text/txt (Markdown table), and CSV file. This output also factors in the cost calculation as an additional column in each table. I also calculate the total as a part of this final output, while the original version sums the total as a part of the initial data collection loop. My final output, in text format, looks as follows.

_Tag_ hours from 2025-12-29T00:00:00 to 2025-12-31T14:53:39
Daily mode active - getting day by day summary.

| Date | Hours | Cost |
|------|-------|------|
| 2025-12-29 | 1.5 | $45.00 |
| 2025-12-31 | 0.4 | $12.00 |
| Total | 1.9 | $57.00 |

Conclusion

With the help of a lot of Googling, caffeine, and the Fediverse, I managed to finish the bulk of this almost 200 line script in one afternoon. Having only written one bash script3 before with a lot more help, this taught me a ton about bash and automation and was definitely a useful experience.

Since this is a script I'll be using regularly in my life for the foreseeable future, I'm sure that I will continue to expand upon it. That being said, here's a few ideas I'm currently thinking about for improvements.

Lastly, before I close out this post, I want to shout-out a few people.

I hope you found this post somewhat interesting, even if it may have been a fairly basic project. I learned a ton and really enjoyed writing about it here!

73, WW0CJ


  1. Since I wanted this to be an executable across my entire machine, I just stuck it there as bin/timesheet and chmod +x timesheet. In the past, I've seen people upload scripts they've made like this to their dotfiles but not sure what the convention is for custom scripts like this. I'd be interested to hear from frequent bash scripters on their process/workflow!

  2. Yes, I know this will fail if it ever has to check for an output of 100 hours. But if I'm ever clocking 100 hours at my work in a week...I think there's a bigger problem to be resolved before this XD

  3. My other adventure into bash scripting was a very short function for pulling exercises from Exercism's CLI. Basically I give a challenge name, it checks if a folder already exists, if not it downloads it, and then cds and opens the exercise in VS Code. If I remember correctly, running the script with an already downloaded challenge ran tests and submitted if all passed. It was fairly straight-forward and took a lot of help, to the point where I completely forgot about it...oops!

#'Linux' #'Programming' #'bash'