Kevin Boone

Command-line hacking: countdown timer

display This is another in my series of articles on doing off-beat and (I hope) interesting things with standard Linux command-line tools. Although it's simple, the script I describe here demonstrates Bash arithmetic expansion, date calculations, and handling terminal interrupts in a script.

Suppose you want to display a running countdown to some significant event -- a vacation, perhaps. Or your retirement. I'm sure there are many utilities to do this, but in this article I'll use a simple Bash script, with some commonplace Linux utilities like date. The display should be in meaningful units: days, hours, minutes, seconds, not just seconds. We also want the target date to be configurable, so it is passed as an argument to the script. The program will continue running until the target time is reached, or the user hits ctrl+c. If the user does hit ctrl+c, we'll need to tidy up the terminal.

Why do we need to tidy up? It turns out that the terminal cursor (usually a flashing block) just looks really ugly and distracting in a continuously-updating display. So we'll need to turn it off -- and put it back when the script finishes.

screenshot
The output of the script in a Linux terminal.

Basic principle

It isn't easy to parse a date like "March 24 2025 6:13PM", nor to perform calculations on dates. Happily, it turns out that the GNU date utility can do both these things.

First, we can use the format argument +%s to make date write its output in seconds. This will be the number of seconds since 'the epoch' -- a date about fifty years in the past, with no particular significance here. We're only interested in time differences, so when we subtract two dates, the 'epoch' will cancel, and we'll be left with a time difference in seconds.

We can use the --date switch to have the utility parse a particular date and time. date will accept a variety of formats, that might depend on the particular platform.

So to get a particular point in time as value in seconds since the epoch, we can do this:

TARGET_EPOCH=`date --date "$TARGET_DATE" +%s`

If TARGET_DATE has come from user input, we'll need to check that it's a valid date. I don't know a better way to do that, than to check that date writes a number to stdout. Any error messages from date go to stderr, so if we assign stdout to a shell variable, it will end up either as a numeric string, or something that won't parse as a number. If it won't parse, it will evaluate arithmetically with zero, so we can test like this:

if (( TARGET_EPOCH == 0 )); then
  exit 1
fi

Incidentally, this test will fail if the target date is, in fact, the epoch date, since the correct numeric valus is zero. But since that's at date in the past, this should never happen.

Now we can calculate the number of seconds until the target date using a simple subtraction.

NOW_EPOCH=`date +%s`
EPOCH_DIFF=$((TARGET_EPOCH - NOW_EPOCH))

If now, or at any future time, EPOCH_DIFF ends up negative, the target date is in the past, and we need to stop the script.

Formatting the date

We need to break the time difference in seconds down into hours, minutes, etc. This is just a matter of arithmetic. Conventionally we did this using the expr utility, but the Bash script has decent (integer) arithmetic support built in. Here is the relevant math:

DAYS=$((EPOCH_DIFF / 86400)) # 86400 = seconds in one day
DAY_SECS=$((DAYS * 86400))
REM=$((EPOCH_DIFF - DAY_SECS))
HOURS=$((REM / 3600))
HOUR_SECS=$((HOURS * 3600))
REM=$((EPOCH_DIFF - DAY_SECS - HOUR_SECS))
MINS=$((REM / 60))
REM=$((EPOCH_DIFF - DAY_SECS - HOUR_SECS - MINS * 60))
SECS=$REM

We end up with the relevant time components in DAYS, HOURS, MINS, and SECS.

Note:
Within an arithmetic expression, we can refer to shell variables with or without the dollar-sign prefix. In most places in a script, however, we need the dollar sign to denote a variable

The slight complication with displaying this data is that it looks ugly to print '1 days', or even, '1 day(s)'; we want an 's' on the end where it's appropriate. I don't know a more elegant way to do this than a construction like this:

printf "%d day%.*s, " $DAYS $((DAYS != 1)) "s"

One final point concerning formatting: I want to print the remaining time every second, but I don't want to print many lines. Rather, I want each new line to over-write the previous one. This is easily accomplished by printing a carriage return character (ASCII 10) before each line of output, and no line-feed. On the Linux console, a carriage return does not imply a line feed. While this solution is simple, it might not work properly with, say, a serial terminal that conflates carriage return and line feed.

tput cr el is probably a more robust way to over-write lines but, of course, this would mean spawning a tput process every second.

Handling terminal interrupts

Since we're outputing onto a single line, we need to do something with the terminal cursor. We could park it somewhere, but it's nicer just to turn it off. We can do that using tput civis. However, most likely the script will stop by the user hitting ctrl+c before the target time is reached. So we need to be able to reverse the terminal change in that situation. And, of course, we need to reverse it if the script does, in fact, exit because the target time has been reached.

We can do this by attaching a function to the relevant console interrupts like this:

function cleanup_exit() 
  {
  stty echoctl # Show ctrl+c when pressed
  tput cnorm # Show the cursor
  exit 0
  }

# Start program
trap cleanup_exit INT TERM # Catch the INT and TERM signals
stty -echoctl # Prevent ctrl+c being printed when pressed
tput civis # Turn off the cursor
...

Further work

There are various ways in which the display might be improved. With a full console available, we could use block graphics to fill the console with large numbers, for example. Or make them scroll. We probably don't need to display seconds, or even update the display every second, if the target date is months or years in the future.

Download

The complete script is available in my GitHub repository.