#!/usr/bin/env perl
# tt - time tracker
#
# Copyright(c) 2003 by wave++ "Yuri D'Elia" <wavexx@thregr.org>
# 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;
  $_ = <FD>;
  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(<FD>)
  {
    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(<FD>)
    {
      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 <command> [parameters]
Commands:
  st                Active task and time since the last context switch.
  sw <name> [desc]  Switch to task <name> 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 <name> [desc]  Add a new task named <name> (described by [desc]).
  rm <name>         Remove the task named <name>. 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;

