I got nerd-sniped tonight. An internet friend CJ asked help with bash scripting and you know I can’t resist.
The problem
Timewarrior software outputs a summary that looks like:
Wk Date Day ID Tags Annotation Start End Time Total
W52 2025-12-28 Sun @4 Work1 0:00:00 0:00:00 24:00:00 24:00:00
W1 2025-12-29 Mon @3 Work1 11:45:15 12:19:13 0:33:58
@2 Wk2 12:24:53 12:33:35 0:08:42
@1 Wk2 12:35:15 12:48:13 0:12:58 0:55:38
24:55:38and he wanted a few things:
First, I need to be able to grab the “total” time on the last line of the timew summary output (https://timewarrior.net/docs/summary/). I assume I can use grep for this - but what’s the best way? (I even begrudgingly asked AI for help here and it didn’t understand, since there’s no “keyword” to lock in on/look for 🙄)
What about if I want to get the total per day from that summary report? Is that difficult? Would I be better off writing an extension for the timewarrior tool to do that?
What’s the best way to parse a string like “03:45:01” into something like “3.75 hours”?
Last thing, I want to be able to floor that 3.75, for example, to the closest half number (ex. 3.75→4, 3.3→3.5, etc.) What’s the best way to do this?
The output wasn’t the easiest one to parse with bash though.
Python solution
I started with Python actually as I’m more comfortable with that, to grasp better what it is that I want to do:
import fileinput
import re
TIME_PATTERN = re.compile(r'(\d+:\d+:\d+)')
DATE_PATTERN = re.compile(r'(?P<date>\d{4}-\d{2}-\d{2})')
def read_data() -> tuple[dict[str, str], str]:
current_date = None
daily_total = None
data = {}
for line in fileinput.input():
date_match = re.search(DATE_PATTERN, line)
if date_match:
current_date = date_match.groupdict()['date']
daily_total = None
times = re.findall(TIME_PATTERN, line)
if len(times) == 4:
daily_total = times[-1]
data[current_date] = daily_total
return data, line.strip()
def print_report(data: dict[str, str], total: str):
for date, daily_total in sorted(data.items(), key=lambda x: x[0]):
print(f'{date}|{daily_total}|{format_hours(daily_total)}')
print()
print(f'Total: {total}')
def format_hours(timestamp: str) -> str:
hours, minutes, _ = timestamp.split(':')
hours = int(hours)
if int(minutes) < 15:
return f'{hours}.0'
elif int(minutes) < 45:
return f'{hours}.5'
else:
return f'{hours+1}.0'
if __name__ == '__main__':
data, total = read_data()
print_report(data, total)Isn’t Python just lovely?
Bash solution
Alright so time to jump into bash. First, I declared some variables
#!/bin/bash
date=0
idx=0
total=0
declare -a reportI realised from the output that a daily total is on a line with exactly four timestamps. It’s a bit hacky but as long as nobody uses timestamps in annotation, it should hold nicely.
To get the amount of patterns in a line, I use awk’s gsub to replace all timestamps with nothing because “the gsub() function returns the number of substitutions made.”
So let’s loop through stdin and do that:
while read -r line; do
timestamps=$(awk -- 'BEGIN{print gsub(ARGV[2], "&", ARGV[1])}' "$line" '[0-9]+:[0-9]+:[0-9]+')Next, I want to know when a date changes. To do that, I check if the second value is a date:
if [[ $(echo $line | cut -d' ' -f 2 ) =~ [0-9]{4}-[0-9]{2}-[0-9]{2} ]]; then
date=$(echo $line | cut -d' ' -f 2 )
fiThis matches date format of 2025-12-29 and if it’s found, stores that to a global date variable.
If there are exactly 4 timestamps, we’re on the line with total hours value and we can grab that:
if [[ $timestamps -eq 4 ]]; then
daily_total=$(echo $line | rev | cut -d' ' -f 1 | rev)
report[$idx]=$date
idx=$((idx+1))
report[$idx]=$daily_total
idx=$((idx+1))
fiI use a rev | cut | rev bash trick to get the last field. First rev reverses the entire line, then cut finds the first field (separated by spaces) and second rev reverses it back to original.
I then store the date into a report array, increase index and add the daily total. I’m not very familiar with bash arrays so I made it like this to move on to more interesting bits. There’s probably a more elegant way to append to an array.
Finally, I store the current line into variable total so that eventually, it will contain the total final value.
total=$line
doneNow we have read our initial data: we have an array with alternating dates and their daily totals in report.
CJ then wanted the timestamp converted into hours, rounded to nearest half an hour. My second loop does exactly that (you could arguably do that already in the first part and put this into a function to make it nicer but hey, one-off scripting is enough today).
In even indices, we have the date and in odd indices, we have the timestamp.
We grab hours and minutes and convert it to a decimal number by looking at where minutes lands: if it’s 0-15, we round down to exact hour. If it’s between 15 and 45, we round to half an hour and if it’s 45 and up, we round to next full hour.
idx=0
declare -a formatted
for key in "${!report[@]}"
do
if [[ $(( $key % 2)) -eq 0 ]]; then
formatted[$idx]="${report[$key]}"
else
hours=$(echo "${report[$key]}" | cut -d':' -f1)
minutes=$(echo "${report[$key]}" | cut -d':' -f2)
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
fi
idx=$((idx+1))
doneFinally, we print the report:
idx=0
for key in "${!formatted[@]}"
do
if [[ $(( $key % 2)) -eq 0 ]]; then
echo -n "${formatted[$key]}|"
else
echo "${formatted[$key]}"
fi
done
echo "Total: $total"When saved as report.sh and given execute permissions with chmod +x report.sh, you can pipe timew summary output into it and it prints out
$ timew summary | ./report.sh
2025-12-28|24.0
2025-12-29|3.5
Total: 24:55:38Addendum A: Improved
I went back and turned formatting into a function and saved a full loop. You could also just print them out instead of storing to an array but I feel this is more flexible if you want to do some further processing.
#!/bin/bash
function format_time() {
minutes=$(echo "$1" | cut -d':' -f2)
hours=$(echo "$1" | cut -d':' -f1)
if [[ $minutes -lt 15 ]]; then
echo "$hours.0"
elif [[ $minutes -lt 45 ]]; then
echo "$hours.5"
else
echo "$((hours+1)).0"
fi
}
date=0
idx=0
total=0
declare -a report
while read -r line; do
timestamps=$(awk -- 'BEGIN{print gsub(ARGV[2], "&", ARGV[1])}' "$line" '[0-9]+:[0-9]+:[0-9]+')
if [[ $(echo $line | cut -d' ' -f 2 ) =~ [0-9]{4}-[0-9]{2}-[0-9]{2} ]]; then
date=$(echo $line | cut -d' ' -f 2 )
fi
if [[ $timestamps -eq 4 ]]; then
daily_total=$(echo $line | rev | cut -d' ' -f 1 | rev)
report[$idx]=$date
idx=$((idx+1))
report[$idx]=$(format_time $daily_total)
idx=$((idx+1))
fi
total=$line
done
idx=0
for key in "${!report[@]}"
do
if [[ $(( $key % 2)) -eq 0 ]]; then
echo -n "${report[$key]}|"
else
echo "${report[$key]}"
fi
done
echo "Total: $total"