[index] [home]

Tweet

Simple task-timekeeper in Perl/Tk



This page describes a simple graphical tool, where one can allocate time to, and consume time spent on a number of tasks by means of mouse-clicks.

Per-task counters and a log-file keep track of cumulative totals.

This thing was made near the end of 2012 - why did I bother, the world was about to end anyway, right?

Background and summary

Sometimes, when time is spent on different tasks throughout a day or week, it can be useful to keep track of when and how much you worked on each task.

Inspired a bit by these little tally counters, I wanted a tool allowing me to "click time onto tasks".

In addition, it would be nice to be able to plan your day (i.e. allocate time to one or more tasks), and then "click it away" as you go along.

I generally don't like GUI-programming, but Perl/Tk (and Tcl/Tk for that matter) seems sort of ok - so that's what I used.

An example use-case is listed below, followed by the source-code.

Defining tasks and starting the timekeeper

The timekeeper uses a dedicated directory, with each task defined by a file, normally containing the cumulative total time spent on that task in human-readable numerical form.

To create a new task, simply create a file by the task's name:

    $ touch My_First_Task

This will create a task "My First Task" - underscores are replaced with whitespace at timekeeper-startup.

Subdirectories within the tasks-directory are ignored, and so are files and dirs containing a dot ('.'). Conveniently, this prevents the generated log-file (tasks.log) from being interpreted as a task.

To start the timekeeper, pass it the path of the task-directory:

    $ tasks.pl /path/to/tasks_dir

Learn by example: a typical workday

Let's imagine a workplace where time is divided between 4 tasks and an overall "administrative" timesink.

Tasks-dir could look as follows:

    $ ls -l
    total 24
    -rw-r--r-- 1 michai wheel   6 Oct  8 22:50 050
    -rw-r--r-- 1 michai wheel   6 Oct  8 22:51 055_ACCOUNTABLE
    -rw-r--r-- 1 michai wheel   5 Jun 29 18:42 055_other
    -rw-r--r-- 1 michai wheel   5 Oct  5 23:09 058
    -rw-r--r-- 1 michai wheel   6 Oct  8 22:51 administratie
    drwxr-xr-x 2 michai wheel 200 Jun 29 18:42 inactive
    -rw-r--r-- 1 michai wheel 594 Oct  8 23:14 tasks.log

Subdirectories such as inactive is where inactive task-files could be parked, as not to clutter the resulting GUI. The tasks.log file is the log, which is located in the tasks-directory.

Each of the task-files contains the time spent on that task so far. The value is unitless, but let's assume they are hours. (They could be pomodoros instead, for instance.)

Start of workday

Let's assume that at the start of the workday, 51.75 hours/units have been spent on task "050" so far:

    $ cat 050
    51.75

When starting the timekeeper like this, ...

    $ tasks.pl .

... a GUI is displayed:

The leftmost column displays the allocated time per task - that is, the time that is still expected to be spent on each task.

There is no time allocated yet, so this column contains only zeroes.

The column of numbers in between the yellow up/down buttons contains the cumulative total of time spent on each task, ever.

Allocating time / planning the day

Let's say today, the plan is to spend 4 hours/units on task "050", and 1 hour each on tasks "055 ACCOUNTABLE" and "administratie".

The user would fill in '4', '1' and '1' in the appropriate left-hand columns, or simply click the up/down buttons behind each field until the desired number appears:

(Note that the cumulative totals didn't change yet, which makes sense.)

Nothing appears in the log-file yet - which only records spent, not allocated time.

Spending 15 minutes on a task

After having spent 15 minutes on task "055 ACCOUNTABLE", the user takes a well-deserved break, and marks these 15 minutes as "spent" by clicking the "+"-button next to the cumulative total for that task:

(The up/down buttons can be used at all time to increment/decrement the cumulative total; the button with single plus-sign ('+') increments by 0.25 units; the button with double plus-signs ('++') increments by 1 unit. Similar for decrementing.)

Notice that the allocated time for this task is decremented, and the cumulative total is incremented by the same amount.

The log-file reflects this:

    ...
    2015-10-08.22:50:25 - task '055 ACCOUNTABLE': +0.25 h

Only 5.75 hours to go!

Spending more time, thereby completing a task

More time is spent, registered by clicking the proper "up"-buttons:

Task "050" is done for that day, and so the left-hand column for that task is no longer displayed in red.

The log-file has recorded each individual increment:

    ...
    2015-10-08.22:50:46 - task '050': +1 h
    2015-10-08.22:50:49 - task '050': +1 h
    2015-10-08.22:50:50 - task '050': +1 h
    2015-10-08.22:50:53 - task '050': +1 h
    2015-10-08.22:50:55 - task '055 ACCOUNTABLE': +0.25 h
    2015-10-08.22:50:56 - task '055 ACCOUNTABLE': +0.25 h
    2015-10-08.22:50:57 - task 'administratie': +0.25 h
    2015-10-08.22:50:58 - task 'administratie': +0.25 h

As can be seen, this promotes a "click as you go along" workflow, using small increments.

Day finished - all work done!

The last 30 minutes of the day are spent on administration. After updating the timekeeper accordingly, there is no more work to be done:

The log-file at the end of the day:

    ...
    2015-10-08.22:51:16 - task '055 ACCOUNTABLE': +0.25 h
    2015-10-08.22:51:23 - task 'administratie': +0.25 h
    2015-10-08.22:51:24 - task 'administratie': +0.25 h

And that's all.

Source-code

Verbatim copy of the tasks.pl script:

    #!/usr/bin/perl

    use strict;
    use warnings;
    use Tk;
    use Data::Dumper;
    use POSIX;



    my %taskhours;
    my %tasktodo;
    my %taskfname;

    my $workdir;



    # Helpers for layouting

    my $g0 = [ '%0', 0 ];
    my $g100 = [ '%100', 0 ];
    my $g0p = [ '%0', 10 ];
    my $g100p = [ '%100', -10 ];



    sub max { $_[ 0 ] > $_[ 1 ] ? $_[ 0 ] : $_[ 1 ] }



    sub btn
    {
      my ( $parent, $text, %opts ) = @_;

      my %style = qw/ -relief solid -borderwidth 1 -bg lightyellow -activebackground white /;

      $parent->Button( -text => $text, %style, %opts );
    }



    sub spinbox
    {
      my ( $parent, $width, %opts ) = @_;

      my %style = qw/ -relief solid -borderwidth 1 -bg white -insertontime 0 /;

      $parent->Spinbox( -width => $width, %style, %opts );
    }



    sub entry
    {
      my ( $parent, $width, %opts ) = @_;

      my %style = qw/ -relief solid -borderwidth 1 -bg white /;

      $parent->Entry( -width => $width, %style, %opts );
    }



    sub label
    {
      my ( $parent, $width, %opts ) = @_;

      my %style = qw/ -relief solid -borderwidth 1 -bg white /;

      $parent->Label( -width => $width, %style, %opts );
    }



    sub format_todofield
    {
      my ( $spinbox ) = @_;

      my $var = $spinbox->cget( -textvariable );
      $var or return;
      my $val = $$var;

      my $bg = ( $val || 0 ) == 0 ? 'white' : '#ffcccc';
      $spinbox->configure( -bg => $bg );
    }



    sub adj_task_hours
    {
      my ( $name, $d, $todofield ) = @_;

      if ( $d < 0 ) {
        -$d > $taskhours{ $name } and $d = -$taskhours{ $name };
        $d or return;   # (don't go below 0)
      }

      $taskhours{ $name } += $d;

      # Sync to task-file

      my $fname = "$workdir/$taskfname{ $name }";
      open( my $fh, '>', $fname ) or die "cannot open task-file '$fname' for write";
      print $fh "$taskhours{ $name }\n";
      close $fh;

      # Add log-entry
      $fname = "$workdir/tasks.log";
      open( $fh, ">>", $fname ) or die "cannot open log-file '$fname' for append";
      my $now = strftime( "%Y-%m-%d.%H:%M:%S", localtime() );
      my $sign = ( $d > 0 ? '+' : "" );
      my $msg = "$now - task '$name': $sign$d h\n";
      print $fh $msg;
      close $fh;

      # Possibly adjust todo-field: always count down, never up

      if ( $d > 0 ) 
      {
        $tasktodo{ $name } = max( $tasktodo{ $name } - $d, 0 );
        format_todofield( $todofield );
      }
    }



    # Out: frame-widget
    sub add_task_frame
    {
      my ( $parent, $top_attach, $name ) = @_;

      # Create widgets

      my $f = $parent->Frame();

      my $todofield = spinbox( $f, 7, -from => 0, -to => 9999, -textvariable => \$tasktodo{ $name }, -validate => 'all' );
      $todofield->configure( -command => sub { format_todofield( $todofield ) } );
      $todofield->configure( -vcmd => sub { format_todofield( $todofield ); 1 } );
      my $tasklbl = $f->Label( -text => $name, -width => 30, -anchor => 'w', -bg => '#eeeeee' );
      my $tot_field = label( $f, 7, -textvariable => \$taskhours{ $name } );
      my $dec1h_btn = btn( $f, '- -', -command => sub { adj_task_hours( $name, -1,    $todofield ) } );
      my $inc1h_btn = btn( $f, '+ +', -command => sub { adj_task_hours( $name,  1,    $todofield ) } );
      my $dec15m_btn = btn( $f, '-',  -command => sub { adj_task_hours( $name, -0.25, $todofield ) } );
      my $inc15m_btn = btn( $f, '+',  -command => sub { adj_task_hours( $name,  0.25, $todofield ) } );

      # Layout widgets

      $f->form( -l => $g0, -t => $top_attach, -r => $g100p );

      $_ = $dec1h_btn;
      $todofield->form( -t => [ '&', $_ ], -b => [ '&', $_ ] );

      $dec1h_btn->form( -l => [ $todofield, 20 ] );
      $dec15m_btn->form( -l => $dec1h_btn );

      $_ = $dec15m_btn;
      $tot_field->form( -l => $_, -t => [ '&', $_, 1 ], -b => [ '&', $_, -1 ] );

      $inc15m_btn->form( -l => $tot_field );
      $inc1h_btn->form( -l => $inc15m_btn );

      $_ = $inc1h_btn;
      $tasklbl->form( -l => $_, -t => [ '&', $_, 2 ], -b => [ '&', $_, -2 ], -r => $g100 );

      $f;
    }



    sub read_tasks_from_disk
    {
      opendir( my $dh, $workdir ) or die "cannot open workdir '$workdir' for iteration";
      while ( readdir $dh ) 
      {
        my $fname = "$workdir/$_";

        -f $fname or next;
        $fname =~ /\./ and next;   # ignore dot-files (log and other things)

        my $taskname = $_;
        $taskname =~ s/_/ /g;
        $taskfname{ $taskname } = $_;

        open( my $fh, '<', $fname ) or die "cannot open task-file '$fname' for read";
        my $hours = <$fh> || "";
        chomp $hours;
        $hours ||= 0;
        close $fh;

        $taskhours{ $taskname } = $hours;
        $tasktodo{ $taskname } = 0;
      }
      closedir $dh;
    }



    sub add_tasks
    {
      my ( $parent ) = @_;

      read_tasks_from_disk();

      my $top_attach = $g0;
      $top_attach = add_task_frame( $parent, $top_attach, $_ ) foreach sort keys %taskhours;
    }



    ################################################################################



    $workdir = $ARGV[ 0 ] or die "use <workdir> as arg";

    # Create/layout widgets

    my $mw = MainWindow->new();

    my $main_frame = $mw->Frame();

    $main_frame->form( -l => $g0p, -t => $g0p, -r => $g100p, -b => $g100p );

    add_tasks( $main_frame );

    # Enter message-loop

    MainLoop;


Delivered to you by Vim, GNU Make, MultiMarkdown, bozohttpd, NetBSD, and 1 human.