#!/usr/bin/env perl # tt - time tracker # # Copyright(c) 2003 by wave++ "Yuri D'Elia" # This program is distributed under GNU LGPL, without ANY warranty. # This program is also SUPER-COW(tm) powered! use strict; use warnings; # use an external time parser use Date::Parse "str2time"; sub today(); # configuration $Cnf::workDir = $ENV{TT_WORKDIR}? $ENV{TT_WORKDIR}: "$ENV{HOME}/.tt"; $Cnf::active = "$Cnf::workDir/active"; $Cnf::tasks = "$Cnf::workDir/tasks"; $Cnf::logDir = "$Cnf::workDir/log"; $Cnf::log = "$Cnf::logDir/" . today(); $Cnf::version = "0.0.1"; # exit values $Exit::success = 0; $Exit::fail = 1; $Exit::noTask = 2; $Exit::args = 3; # log entries $Log::ad = "A"; $Log::rm = "R"; $Log::sw = "S"; $Log::int = "I"; # helper functions sub notice($) { $_ = shift; printf STDERR "$0: $_\n"; } sub fatal($$) { notice shift; exit shift; } # convert unix time to local y/m/d. sub ymd($) { my ($sec ,$min, $hour, $mday, $mon, $year, $wday, $yday, $isdst) = localtime shift; ($year + 1900, $mon + 1, $mday); } # format the specified date (without time) sub dateFmt($) { my ($year, $mon, $day) = ymd shift; sprintf "%04d-%02d-%02d", $year, $mon, $day; } # format the specified date/time sub timeFmt($) { my ($sec ,$min, $hour, $mday, $mon, $year, $wday, $yday, $isdst) = localtime shift; sprintf "%04d-%02d-%02d %02d:%02d", $year + 1900, $mon + 1, $mday, $hour, $min; } # determine the number of hours, minutes and seconds sub hms($) { my $tm = shift; my $h = $tm / 3600; $tm %= 3600; my $m = $tm / 60; ($h, $m, $tm % 60); } # format the specified h/m/s entry sub hmsFmt(@) { my ($h, $m, $s) = @_; sprintf "%d:%02d:%02d", $h, $m, $s; } # format a number of seconds sub elapsed($) { hmsFmt hms shift; } # return the current formatted date sub today() { dateFmt time; } # manipulate the state file sub getActive() { open FD, "<$Cnf::active" or return undef; $_ = ; close FD; /(\d+) (.*)/; } sub setActive($) { my $task = shift; open FD, ">$Cnf::active" or fatal $!, $Exit::fail; print FD time() . " $task\n"; close FD; } sub unsetActive() { unlink $Cnf::active or fatal $!, $Exit::fail if(-f $Cnf::active); } # load/save task files sub loadTasks(\%) { my $tasks = shift; open FD, "<$Cnf::tasks" or return undef; my $lines = 0; while() { my ($name, $desc) = /(\S+) (.*)/; if($name) { $desc = "" if(!$desc); $tasks->{$name} = $desc; ++$lines; } } close FD; $lines; } sub saveTasks(\%) { my $tasks = shift; open FD, ">$Cnf::tasks" or fatal $!, $Exit::fail; while(my ($task, $desc) = each %$tasks) { $desc = "" if(!$desc); print FD "$task $desc\n"; } close FD; } sub logEntry($$@) { # check for logDir only when needed, so we don't pollute the directory -d $Cnf::logDir or mkdir $Cnf::logDir or fatal $!, $Exit::fail; my ($type, $project, @desc) = @_; open FD, ">>$Cnf::log" or fatal $!, $Exit::fail; print FD time() . " $type $project " . (join " ", @desc) . "\n"; close FD; } sub ckTaskName($) { my $task = shift; fatal "a task name is required", $Exit::args if(!$task); fatal "task name `$task' is invalid", $Exit::args if($task =~ /[ ]/i); } # check for workDir if(!-d $Cnf::workDir) { notice "first execution, creating $Cnf::workDir"; mkdir $Cnf::workDir, 0700 or fatal $!, $Exit::fail; } # active task status sub st() { my ($tm, $task) = getActive; if($tm && $task) { print "$task: " . (elapsed time - $tm) . "\n"; exit $Exit::success; } notice "no active task"; exit $Exit::noTask; } # switch to another task sub sw(@) { my $task = shift; ckTaskName $task; my $desc = join " ", @_; my %tasks; loadTasks %tasks; if(defined $tasks{$task}) { setActive $task; logEntry $Log::sw, $task, $desc; exit $Exit::success; } notice "unknown task `$task'"; exit $Exit::noTask; } # add a valid task name sub ad(@) { my $task = shift; ckTaskName $task; my $desc = join " ", @_; my %tasks; loadTasks %tasks; fatal "a task named `$task' already exists", $Exit::fail if(defined $tasks{$task}); $tasks{$task} = $desc; saveTasks %tasks; logEntry $Log::ad, $task, $desc; exit $Exit::success; } # remove a task name sub rm(@) { my $task = shift; ckTaskName $task; my ($tm, $aTask) = getActive; fatal "cannot remove active task", $Exit::fail if($tm && $aTask && $aTask eq $task); my %tasks; loadTasks %tasks; if(defined $tasks{$task}) { delete $tasks{$task}; saveTasks %tasks; logEntry $Log::rm, $task; exit $Exit::success; } notice "unknown task `$task'"; exit $Exit::noTask; } # interrupt the active task sub intp(@) { my ($tm, $task) = getActive; if($tm && $task) { unsetActive; logEntry $Log::int, $task, (join " ", @_); exit $Exit::success; } notice "no active task"; exit $Exit::noTask; } # list avaible task names sub l() { my %tasks; loadTasks %tasks; foreach my $task(sort keys %tasks) { my $desc = $tasks{$task}; if($desc) { print "$task:\t$desc\n"; } else { print "$task\n"; } } exit $Exit::success; } # utility functions for report production # parse a logfile into a time-indexed hash sub parseLog(\%@) { my ($pLog, @files) = @_; my $lines = 0; foreach my $log(@files) { open FD, "<$log" or next; while() { my ($tm, $type, $task, $desc) = /(\d+) (\S+) (\S+) (.*)/; if($tm && $type) { $pLog->{$tm} = [$type, $task, $desc]; ++$lines; } } close FD; } $lines; } # parse a (possibly empty) string into unix into sub parseDate($) { my $date = shift; if($date) { str2time $date or fatal "unable to parse `$date'", $Exit::args; } else { time; } } # generate a list of files to read to parse the specified time range sub genRange($$) { my ($dFrom, $dTo) = @_; my @list; for(my $c = $dFrom; $c <= $dTo; $c += 86400) { my ($y, $m, $d) = ymd $c; push @list, (sprintf "%s/%s-%02s-%02s", $Cnf::logDir, $y, $m, $d); } @list; } # generic report funtion sub report(\%) { my $pLog = shift; # last event my @tasks; my $tm; my $task; # parse all events, completing and adding them to the list of tasks to include foreach my $cTm(sort keys %$pLog) { my ($type, $cTask, $cDesc) = @{$pLog->{$cTm}}; if($type eq $Log::sw || ($task && $type eq $Log::int)) { push @tasks, [$task, $tm, $cTm, $cDesc] if($task); if($type eq $Log::int) { undef $tm; undef $task; } else { $task = $cTask; $tm = $cTm; } } } # include last (active) task if any push @tasks, [$task, $tm, time(), "*"] if($task); # parse avaible tasks, compute totals return if(!@tasks); my %times; printf STDERR "%-16s %-16s %-09s %s\n", "From:", "To:", "Time:", "Task:"; foreach my $tData(@tasks) { my ($task, $sTm, $fTm, $desc) = @{$tData}; my $et = $fTm - $sTm; $times{$task} += $et; printf "%s %s %09s %s", timeFmt $sTm, timeFmt $fTm, elapsed $et, $task; print "($desc)" if($desc); print "\n"; } print "\n"; # display totals (sort by inverse times) printf STDERR "%-9s %s\n", "TTime:", "Task:"; foreach $task(sort {$times{$b} <=> $times{$a}} keys %times) { printf "%09s %s\n", elapsed $times{$task}, $task; } } # fast report sub r(@) { my ($dFrom, $dTo) = @_; $dFrom = parseDate $dFrom; $dTo = parseDate $dTo; my %pLog; parseLog %pLog, (genRange $dFrom, $dTo); report %pLog; exit $Exit::success; } # version number and copyright informations sub v() { print "$Cnf::version\n"; exit $Exit::success; } # main program my $command = shift; if(!$command || $command eq "h") { print "Usage: $0 [parameters] Commands: st Active task and time since the last context switch. sw [desc] Switch to task stopping the current one (if any). Use [desc] as an optional description for the old task. int [desc] Interrupt current task and use [desc] as a description. ad [desc] Add a new task named (described by [desc]). rm Remove the task named . You must stop the task if its actually in use. l List all tasks. r [date [date]] Fast daily report between the specified dates. Range is inclusive. If no parameters are specified, today is assumed. f [date [date]] Full report between the specified dates. Handle day wraps correctly (hence, it's slower). If no parameters are specified, today is assumed. h This help. v Version number and copyright informations. "; exit ($command? 0: $Exit::args); } # proceed with the requested command st if($command eq "st"); sw @ARGV if($command eq "sw"); ad @ARGV if($command eq "ad"); rm @ARGV if($command eq "rm"); intp @ARGV if($command eq "int"); l if($command eq "l"); r @ARGV if($command eq "r"); v if($command eq "v"); # the function didn't exit or a bad command were specified fatal "unknown command `$command'", $Exit::args;