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.
- 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!
- 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:
-D- Uses daily log mode, providing hours and cost by day instead of only as a collective total.-r- Rounds all hours values to the nearest 1/2 value (ex. 3.4 becomes 3.5, 2.1 becomes 2).-e- Selects text (default) or CSV as the export type for the daily log.- (not shown)
-f- Sets the file name for CSV exports.
- (not shown)
The possible arguments to provide are:
tag(required) - The timewarrior tag to sort/filter by.cost(required) - The hourly rate to multiply by.start_date- The earliest date/time to search for in the format of YYYY-MM-DDTHH:MM:SS. (default: the latest Monday)end_date- The latest date/time to search for, in the same format asstart_date(default: the current time)
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.
- An additional column for daily mode that is a concatenated string of all annotations/descriptions for that day's entries.
- Smart padding to the daily mode text output table to make it look slightly better.
- "Long options" such as
--dailyfor-Dor--exportfor-e. - Cronjob that runs at the end of every week, giving me a desktop notification version of the information
Lastly, before I close out this post, I want to shout-out a few people.
- First, I want to again mention Juhis - who's help made it possible for this to get done in an afternoon and with a lot less frustration! His Mastodon is always full of interesting ideas and code, and his blog is always active and a great read. Also once again, here's a link to his write-up on implementing the daily parsing.
- Bri's response was a huge help, pointing me in the direction/rabbit hole of
bcfor math calculations. - R.L. Dane's notifications were too flooded to see my cry for help (🤣) but his frequent bash script posts are a part of the inspiration to get me more active in the terminal.
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
Since I wanted this to be an executable across my entire machine, I just stuck it there as
bin/timesheetandchmod +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!↩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↩
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!↩