Skip to content
This repository

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse code

Slurping up everything from my local repo

  • Loading branch information...
commit 29436d4b62a420766778258c94647f395f13f9b5 0 parents
Andy Lester authored
112 Changes
... ... @@ -0,0 +1,112 @@
  1 +Revision history for App-HWD
  2 +
  3 +0.16 Mon Feb 13 18:55:49 CST 2006
  4 + [ENHANCEMENTS]
  5 + * Work on tasks may now refer to the most recent task with a
  6 + task number of "^". This means you can do tracking without
  7 + having to have task numbers.
  8 +
  9 + [DOCUMENTATION]
  10 + * Much more documentation on the file format. See "hwd --man".
  11 +
  12 +
  13 +0.14 Thu Feb 9 17:03:15 CST 2006
  14 + * NO NEW FEATURES: Put out to get new package name in CPAN.
  15 + * Removed use of Date::Manip
  16 + * Added more docs to explain file format.
  17 +
  18 +Last release as App-HWD
  19 +0.12 Dec 08 2005
  20 + [ENHANCEMENTS]
  21 + * Now word-wraps the output based on the terminal size, or
  22 + at 72 if output is being redirected. This may be overridden
  23 + with --wrap.
  24 +
  25 + * Now includes notes in the output. Can be turned off with --nonotes.
  26 +
  27 + [FIXES]
  28 + * Added checks to make sure that tasks with estimates do not have children.
  29 +
  30 + [INTERNALS]
  31 + * get_tasks_and_work() now returns an error array, rather
  32 + than dying.
  33 +
  34 +0.10 Mon Oct 24 17:38:00 CDT 2005
  35 + [ENHANCEMENTS]
  36 + * Added top-level totaling on the main dump.
  37 + * --todo now respects subtasks and their doneness.
  38 + * Added --csv option for printing dumps in an importable form
  39 + * You can now have notes in a task. Anything that is indented
  40 + is assumed to be part of the notes for the task above it.
  41 +
  42 + [INTERNALS]
  43 + * Added parent()/children() to App::HWD::Task to support the
  44 + hierarchy reporting.
  45 +
  46 +0.08 Tue Aug 30 16:43:54 CDT 2005
  47 + [THINGS THAT MIGHT BREAK YOUR CODE]
  48 + * Dates must now be in YYYY-MM-DD format.
  49 +
  50 + [ENHANCEMENTS]
  51 + * Added --todo option to show only those items needing to be done.
  52 + * Now handles fractional estimates.
  53 + * Now handles deletion dates. We don't DO anything with them,
  54 + but they're legal, and burndown ignores them.
  55 + * Franctional estimates and velocities in the main listing now
  56 + show as "+" rather than the decimals. For example, "12"
  57 + shows as "12", but "12.5" shows as "12+".
  58 +
  59 + [FIXES]
  60 + * No longer double-counts tasks worked on by multiple people
  61 + in --started.
  62 + * Doesn't print a total when a --started person is specified.
  63 +
  64 +0.07_01 Wed Aug 17 15:03:12 CDT 2005
  65 + [THINGS THAT MIGHT BREAK YOUR CODE]
  66 + * Previously, a task that was added after coding started was
  67 + noted like this:
  68 +
  69 + --Implement widget (#251, 4hrs, @11/7/05)
  70 +
  71 + Now, we use the word "added" instead of "@"
  72 +
  73 + --Implement widget (#251, 4hrs, added 11/7/05)
  74 +
  75 + [ENHANCEMENTS]
  76 + * Added whitespace to --started output.
  77 + * Gives total points open on --started.
  78 +
  79 + [FIXES]
  80 + * Fixed potentially destructive bug in a test file:
  81 +
  82 + unlink($started, qr#Chimp is working on.+ 107 - Refactor \(1/1\)#s);
  83 +
  84 + That "unlink" is, of course, supposed to be "unlike". OOPS!
  85 +
  86 + [INTERNALS]
  87 + * Removed code for handling --detail_level
  88 + * bin/hwd now has no globals.
  89 + * Added many items to TODO list.
  90 +
  91 +0.06 Sun Aug 14 21:52:55 CDT 2005
  92 + [ENHANCEMENTS]
  93 + * Added a vim syntax file in etc/hwd.vim.
  94 + * Added --burndown and starting on the burndown graphic.
  95 + Thanks to Neil & Luke again.
  96 + * Added a $task->date_added()
  97 +
  98 +0.04 Tue Aug 2 15:47:23 CDT 2005
  99 + [ENHANCEMENTS]
  100 + * Added --started feature. Thanks to Neil Watkiss and Luke
  101 + Closs from Sophos.
  102 +
  103 +0.02 Mon Aug 1 14:32:29 PDT 2005
  104 + [FIXES]
  105 + * Fixes silly syntax bummers.
  106 +
  107 + [ENHANCEMENTS]
  108 + * Added --nextid
  109 +
  110 +0.01
  111 + First version, released on an unsuspecting world.
  112 +
149 HWD.pm
... ... @@ -0,0 +1,149 @@
  1 +package App::HWD;
  2 +
  3 +use warnings;
  4 +use strict;
  5 +
  6 +use App::HWD::Task;
  7 +use App::HWD::Work;
  8 +
  9 +=head1 NAME
  10 +
  11 +App::HWD - Support functions for How We Doin'?, the project estimation and tracking tool
  12 +
  13 +=head1 VERSION
  14 +
  15 +Version 0.16
  16 +
  17 +=cut
  18 +
  19 +our $VERSION = '0.16';
  20 +
  21 +=head1 SYNOPSIS
  22 +
  23 +This module is nothing more than a place-holder for the version info and the TODO list.
  24 +
  25 +=head1 FUNCTIONS
  26 +
  27 +These functions are used by F<hwd>, but are kept here so I can easily
  28 +test them.
  29 +
  30 +=head2 get_tasks_and_work( @tasks )
  31 +
  32 +Reads tasks and work, and applies the work to the tasks.
  33 +
  34 +Returns references to C<@tasks>, C<@work>, C<%tasks_by_id> and C<@errors>.
  35 +
  36 +=cut
  37 +
  38 +sub get_tasks_and_work {
  39 + my @tasks;
  40 + my @work;
  41 + my %tasks_by_id;
  42 + my @errors;
  43 +
  44 + my @parents;
  45 + my $curr_task;
  46 + my $lineno = 0;
  47 + for my $line ( @_ ) {
  48 + ++$lineno;
  49 + chomp $line;
  50 + next if $line =~ /^\s*#/;
  51 + next if $line !~ /./;
  52 +
  53 + if ( $line =~ /^(-+)/ ) {
  54 + my $level = length $1;
  55 + my $parent;
  56 + if ( $level > 1 ) {
  57 + $parent = $parents[ $level - 1 ];
  58 + if ( !$parent ) {
  59 + push( @errors, "Line $lineno has no parent: $line" );
  60 + next;
  61 + }
  62 + }
  63 + my $task = App::HWD::Task->parse( $line, $parent );
  64 + if ( !$task ) {
  65 + push( @errors, "Can't parse line $lineno: $line" );
  66 + next;
  67 + }
  68 + if ( $task->id ) {
  69 + if ( $tasks_by_id{ $task->id } ) {
  70 + push( @errors, "Dupe task ID on line $lineno: Task " . $task->id );
  71 + next;
  72 + }
  73 + $tasks_by_id{ $task->id } = $task;
  74 + }
  75 + push( @tasks, $task );
  76 + $curr_task = $task;
  77 + $parent->add_child( $task ) if $parent;
  78 +
  79 + @parents = @parents[0..$level-1]; # Clear any sub-parents
  80 + $parents[ $level ] = $task; # Set the new one
  81 + }
  82 + elsif ( $line =~ s/^\s+// ) {
  83 + $curr_task->add_notes( $line );
  84 + }
  85 + else {
  86 + my $work = App::HWD::Work->parse( $line );
  87 + push( @work, $work );
  88 + if ( $work->task eq "^" ) {
  89 + if ( $curr_task ) {
  90 + $curr_task->add_work( $work );
  91 + }
  92 + else {
  93 + push( @errors, "Can't apply work to current task, because there is no current task" );
  94 + }
  95 + }
  96 + }
  97 + } # while
  98 +
  99 + # Validate the structure
  100 + for my $task ( @tasks ) {
  101 + if ( $task->estimate && $task->children ) {
  102 + push( @errors, sprintf( "Task %d cannot have estimates, because it has children", $task->id ) );
  103 + }
  104 + }
  105 +
  106 + for my $work ( @work ) {
  107 + next if $work->task eq "^"; # Already handled inline
  108 + my $task = $tasks_by_id{ $work->task };
  109 + if ( !$task ) {
  110 + push( @errors, "No task ID " . $work->task );
  111 + next;
  112 + }
  113 + $task->add_work( $work );
  114 + }
  115 +
  116 + # Get the work done in date order for each of the tasks
  117 + $_->sort_work() for @tasks;
  118 +
  119 + return( \@tasks, \@work, \%tasks_by_id, \@errors );
  120 +}
  121 +
  122 +=head1 AUTHOR
  123 +
  124 +Andy Lester, C<< <andy at petdance.com> >>
  125 +
  126 +=head1 BUGS
  127 +
  128 +Please report any bugs or feature requests to
  129 +C<bug-app-hwd at rt.cpan.org>, or through the web interface at
  130 +L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=App-HWD>.
  131 +I will be notified, and then you'll automatically be notified of progress on
  132 +your bug as I make changes.
  133 +
  134 +=head1 ACKNOWLEDGEMENTS
  135 +
  136 +Thanks to
  137 +Neil Watkiss
  138 +and Luke Closs for features and patches.
  139 +
  140 +=head1 COPYRIGHT & LICENSE
  141 +
  142 +Copyright 2006 Andy Lester, all rights reserved.
  143 +
  144 +This program is free software; you can redistribute it and/or modify it
  145 +under the same terms as Perl itself.
  146 +
  147 +=cut
  148 +
  149 +1; # End of App::HWD
25 MANIFEST
... ... @@ -0,0 +1,25 @@
  1 +Changes
  2 +MANIFEST
  3 +Makefile.PL
  4 +README
  5 +HWD.pm
  6 +Task.pm
  7 +Work.pm
  8 +bin/hwd
  9 +bin/hwd-burnchart
  10 +eg/sked.hwd
  11 +etc/hwd.vim
  12 +t/00-load.t
  13 +t/burndown.t
  14 +t/hwd.t
  15 +t/pod-coverage.t
  16 +t/pod.t
  17 +t/simple.hwd
  18 +t/started.t
  19 +t/task.t
  20 +t/task-relationships.t
  21 +t/task-rollup-error.t
  22 +t/task-structure-error.t
  23 +t/task-subtasks.t
  24 +t/work.t
  25 +t/work-parents.t
42 Makefile.PL
... ... @@ -0,0 +1,42 @@
  1 +use strict;
  2 +use warnings;
  3 +use ExtUtils::MakeMaker;
  4 +
  5 +WriteMakefile(
  6 + NAME => 'hwd',
  7 + AUTHOR => 'Andy Lester <andy@petdance.com>',
  8 + VERSION_FROM => 'HWD.pm',
  9 + ABSTRACT => "How We Doin'?, the project estimation and tracking tool",
  10 + PL_FILES => {},
  11 + EXE_FILES => [ 'bin/hwd', 'bin/hwd-burnchart' ],
  12 + PM => {
  13 + 'HWD.pm' => '$(INST_LIBDIR)/App/HWD.pm',
  14 + 'Task.pm' => '$(INST_LIBDIR)/App/HWD/Task.pm',
  15 + 'Work.pm' => '$(INST_LIBDIR)/App/HWD/Work.pm',
  16 + },
  17 + PREREQ_PM => {
  18 + 'DateTime' => 0,
  19 + 'DateTime::Format::Strptime' => 0,
  20 + 'Getopt::Long' => 0,
  21 + 'Pod::Usage' => 0,
  22 + 'Term::ReadKey' => 0,
  23 + 'Test::More' => 0,
  24 + 'Test::Exception' => 0,
  25 + 'Text::CSV_XS' => 0,
  26 + },
  27 + MAN3PODS => { }, # no need for docs on these
  28 + dist => { COMPRESS => 'gzip -9f', SUFFIX => 'gz', },
  29 + clean => { FILES => 'hwd-* *.tmp' },
  30 +);
  31 +
  32 +sub MY::postamble {
  33 + return <<'MAKE_FRAG';
  34 +.PHONY: tags
  35 +
  36 +tags:
  37 + ctags -f tags --recurse --totals \
  38 + --exclude=blib --exclude=t/lib \
  39 + --exclude=.svn --exclude='*~' \
  40 + --languages=Perl --langmap=Perl:+.t \
  41 +MAKE_FRAG
  42 +}
29 README
... ... @@ -0,0 +1,29 @@
  1 +App-HWD
  2 +
  3 +The README is used to introduce the module and provide instructions on
  4 +how to install the module, any machine dependencies it may have (for
  5 +example C compilers and installed libraries) and any other information
  6 +that should be provided before the module is installed.
  7 +
  8 +A README file is required for CPAN modules since CPAN extracts the README
  9 +file from a module distribution so that people browsing the archive
  10 +can use it get an idea of the modules uses. It is usually a good idea
  11 +to provide version information here so that people can decide whether
  12 +fixes for the module are worth downloading.
  13 +
  14 +INSTALLATION
  15 +
  16 +To install this module, run the following commands:
  17 +
  18 + perl Makefile.PL
  19 + make
  20 + make test
  21 + make install
  22 +
  23 +
  24 +COPYRIGHT AND LICENCE
  25 +
  26 +Copyright (C) 2005 Andy Lester
  27 +
  28 +This program is free software; you can redistribute it and/or modify it
  29 +under the same terms as Perl itself.
368 Task.pm
... ... @@ -0,0 +1,368 @@
  1 +package App::HWD::Task;
  2 +
  3 +=head1 NAME
  4 +
  5 +App::HWD::Task - Tasks for HWD
  6 +
  7 +=head1 SYNOPSIS
  8 +
  9 +Used only by the F<hwd> application.
  10 +
  11 +Note that these functions are pretty fragile, and do almost no data
  12 +checking.
  13 +
  14 +=head1 FUNCTIONS
  15 +
  16 +=head2 App::HWD::Task->parse( $input_line, $parent_task )
  17 +
  18 +Returns an App::HWD::Task object from an input line
  19 +
  20 +=cut
  21 +
  22 +use warnings;
  23 +use strict;
  24 +use DateTime::Format::Strptime;
  25 +
  26 +sub parse {
  27 + my $class = shift;
  28 + my $line = shift;
  29 + my $parent = shift;
  30 +
  31 + my $line_regex = qr/
  32 + ^
  33 + (-+) # leading dashes
  34 + \s* # whitespace
  35 + (.+) # everything else
  36 + $
  37 + /x;
  38 +
  39 + if ( $line =~ $line_regex ) {
  40 + my $level = length $1;
  41 + my $name = $2;
  42 + my $id;
  43 + my $estimate;
  44 + my %date;
  45 +
  46 + if ( $name =~ s/\s*\(([^)]+)\)\s*$// ) {
  47 + my $parens = $1;
  48 + my $parser = DateTime::Format::Strptime->new( pattern => '%Y-%m-%d' );
  49 +
  50 + my @subfields = split /,/, $parens;
  51 + for ( @subfields ) {
  52 + s/^\s+//;
  53 + s/\s+$//;
  54 + /^#(\d+)$/ and $id = $1, next;
  55 + /^((\d*\.)?\d+)h$/ and $estimate = $1, next;
  56 + /^(added|deleted) (\S+)$/i and do {
  57 + my ($type,$date) = ($1,$2);
  58 + $date{$type} = $parser->parse_datetime($date);
  59 + next if $date{$type};
  60 + };
  61 + warn qq{I don't understand "$_"\n};
  62 + }
  63 + }
  64 +
  65 + my $task = $class->new( {
  66 + level => $level,
  67 + name => $name,
  68 + id => $id,
  69 + estimate => $estimate,
  70 + date_added_obj => $date{added},
  71 + date_deleted_obj => $date{deleted},
  72 + parent => $parent,
  73 + } );
  74 + }
  75 + else {
  76 + return;
  77 + }
  78 +}
  79 +
  80 +=head2 App::HWD::Task->new( { args } )
  81 +
  82 +Creates a new task from the args passed in. They should include at
  83 +least I<level>, I<name> and I<id>, even if I<id> is C<undef>.
  84 +
  85 + my $task = App::HWD::Task->new( {
  86 + level => $level,
  87 + name => $name,
  88 + id => $id,
  89 + estimate => $estimate,
  90 + } );
  91 +
  92 +=cut
  93 +
  94 +sub new {
  95 + my $class = shift;
  96 + my $args = shift;
  97 +
  98 + my $self = bless {
  99 + %$args,
  100 + work => [],
  101 + }, $class;
  102 +
  103 + return $self;
  104 +}
  105 +
  106 +=head2 $task->level()
  107 +
  108 +Returns the level of the task
  109 +
  110 +=head2 $task->name()
  111 +
  112 +Returns the name of the task
  113 +
  114 +=head2 $task->id()
  115 +
  116 +Returns the ID of the task, or the empty string if there isn't one.
  117 +
  118 +=head2 $task->estimate()
  119 +
  120 +Returns the estimate, or 0 if it's not set.
  121 +
  122 +=head2 $task->notes()
  123 +
  124 +Returns the list of notes for the task.
  125 +
  126 +=head2 $task->date_added()
  127 +
  128 +Returns a string showing the date the task was added, or empty string if it's not set.
  129 +
  130 +=head2 $task->date_added_obj()
  131 +
  132 +Returns a DateTime object representing the date the task was added, or C<undef> if it's not set.
  133 +
  134 +=head2 $task->date_deleted()
  135 +
  136 +Returns a string showing the date the task was deleted, or empty string if it's not set.
  137 +
  138 +=head2 $task->date_deleted_obj()
  139 +
  140 +Returns a DateTime object representing the date the task was deleted, or C<undef> if it's not set.
  141 +
  142 +=head2 $task->parent()
  143 +
  144 +Returns the parent of the task, or C<undef> if it's a top-level task.
  145 +
  146 +=head2 $task->children()
  147 +
  148 +Returns a list of child tasks.
  149 +
  150 +=head2 $task->work()
  151 +
  152 +Returns the array of App::HWD::Work applied to the task.
  153 +
  154 +=cut
  155 +
  156 +sub level { return shift->{level} }
  157 +sub name { return shift->{name} }
  158 +sub id { return shift->{id} || "" }
  159 +sub estimate { return shift->{estimate} || 0 }
  160 +sub work { return @{shift->{work}||[]} }
  161 +sub notes { return @{shift->{notes}||[]} }
  162 +sub date_added_obj { return shift->{date_added_obj} }
  163 +sub date_deleted_obj { return shift->{date_added_obj} }
  164 +sub parent { return shift->{parent} }
  165 +sub children { return @{shift->{children}||[]} }
  166 +
  167 +sub date_added {
  168 + my $self = shift;
  169 + my $obj = $self->{date_added_obj} or return '';
  170 +
  171 + return $obj->strftime( "%F" );
  172 +}
  173 +
  174 +sub date_deleted {
  175 + my $self = shift;
  176 + my $obj = $self->{date_deleted_obj} or return '';
  177 +
  178 + return $obj->strftime( "%F" );
  179 +}
  180 +
  181 +=head2 $task->is_todo()
  182 +
  183 +Returns true if the task still has things to be done on it. If the task
  184 +has no estimates, because it's a roll-up or milestone task, this is false.
  185 +
  186 +=cut
  187 +
  188 +sub is_todo {
  189 + my $self = shift;
  190 +
  191 + if ( $self->estimate ) {
  192 + return if $self->date_deleted;
  193 + return !$self->completed;
  194 + }
  195 +
  196 + for my $child ( $self->children ) {
  197 + return 1 if $child->is_todo;
  198 + }
  199 + return;
  200 +}
  201 +
  202 +=head2 $task->set( $key => $value )
  203 +
  204 +Sets the I<$key> field to I<$value>.
  205 +
  206 +=cut
  207 +
  208 +sub set {
  209 + my $self = shift;
  210 + my $key = shift;
  211 + my $value = shift;
  212 +
  213 + die "Dupe key $key" if exists $self->{$key};
  214 + $self->{$key} = $value;
  215 +}
  216 +
  217 +=head2 add_notes( @notes_lines )
  218 +
  219 +Adds the lines passed in to the notes lines for the task.
  220 +
  221 +=cut
  222 +
  223 +sub add_notes {
  224 + my $self = shift;
  225 +
  226 + push( @{$self->{notes}}, @_ );
  227 +}
  228 +
  229 +=head2 add_child( $task )
  230 +
  231 +Adds a child Task record to the task
  232 +
  233 +=cut
  234 +
  235 +sub add_child {
  236 + my $self = shift;
  237 + my $child = shift;
  238 +
  239 + push( @{$self->{children}}, $child );
  240 +}
  241 +
  242 +=head2 add_work( $work )
  243 +
  244 +Adds a Work record to the task, for later accumulating
  245 +
  246 +=cut
  247 +
  248 +sub add_work {
  249 + my $self = shift;
  250 + my $work = shift;
  251 +
  252 + push( @{$self->{work}}, $work );
  253 +}
  254 +
  255 +=head2 hours_worked()
  256 +
  257 +Returns the number of hours worked, but counting up all the work records added in L</add_work>.
  258 +
  259 +=cut
  260 +
  261 +sub hours_worked {
  262 + my $self = shift;
  263 +
  264 + my $hours = 0;
  265 + for my $work ( @{$self->{work}} ) {
  266 + $hours += $work->hours;
  267 + }
  268 + return $hours;
  269 +}
  270 +
  271 +=head2 started()
  272 +
  273 +Returns whether the task has been started. Doesn't address the question
  274 +of whether the task is completed or not, just whether work has been done
  275 +on it.
  276 +
  277 +=cut
  278 +
  279 +sub started {
  280 + my $self = shift;
  281 +
  282 + return @{$self->{work}} > 0;
  283 +}
  284 +
  285 +=head2 completed()
  286 +
  287 +Returns whether the task has been completed.
  288 +
  289 +=cut
  290 +
  291 +sub completed {
  292 + my $self = shift;
  293 +
  294 + my $completed = 0;
  295 + for my $work ( @{$self->{work}} ) {
  296 + $completed = $work->completed;
  297 + }
  298 +
  299 + return $completed;
  300 +}
  301 +
  302 +=head2 summary
  303 +
  304 +Returns a simple one line description of the Work.
  305 +
  306 +=cut
  307 +
  308 +sub summary {
  309 + my $self = shift;
  310 + my $sum;
  311 + $sum = $self->id . " - " if $self->id;
  312 + $sum .= sprintf( "%s (%s/%s)", $self->name, $self->estimate, $self->hours_worked );
  313 + return $sum;
  314 +}
  315 +
  316 +=head2 sort_work
  317 +
  318 +Make sure all the work for a task is sorted so we can tell what was done when.
  319 +
  320 +=cut
  321 +
  322 +sub sort_work {
  323 + my $self = shift;
  324 +
  325 + my $work = $self->{work};
  326 +
  327 + @$work = sort {
  328 + $a->when cmp $b->when
  329 + ||
  330 + $a->completed cmp $b->completed
  331 + ||
  332 + $a->who cmp $b->who
  333 + } @$work;
  334 +}
  335 +
  336 +=head2 subtask_walk( $callback )
  337 +
  338 +Recursively walks the tree of subtasks for the task, calling C<$callback>
  339 +for each subtask, like so:
  340 +
  341 + $callback->( $subtask )
  342 +
  343 +=cut
  344 +
  345 +sub subtask_walk {
  346 + my $self = shift;
  347 + my $callback = shift;
  348 +
  349 + for my $child ( $self->children ) {
  350 + $callback->( $child );
  351 + $child->subtask_walk( $callback );
  352 + }
  353 +}
  354 +
  355 +=head1 AUTHOR
  356 +
  357 +Andy Lester, C<< <andy at petdance.com> >>
  358 +
  359 +=head1 COPYRIGHT & LICENSE
  360 +
  361 +Copyright 2006 Andy Lester, all rights reserved.
  362 +
  363 +This program is free software; you can redistribute it and/or modify it
  364 +under the same terms as Perl itself.
  365 +
  366 +=cut
  367 +
  368 +"You got a killer scene there, man..."; # End of App::HWD::Task
152 Work.pm
... ... @@ -0,0 +1,152 @@
  1 +package App::HWD::Work;
  2 +
  3 +=head1 NAME
  4 +
  5 +App::HWD::Work - Work completed on HWD projects
  6 +
  7 +=head1 SYNOPSIS
  8 +
  9 +Used only by the F<hwd> application.
  10 +
  11 +Note that these functions are pretty fragile, and do almost no data
  12 +checking.
  13 +
  14 +=cut
  15 +
  16 +use warnings;
  17 +use strict;
  18 +use DateTime::Format::Strptime;
  19 +
  20 +=head1 FUNCTIONS
  21 +
  22 +=head2 App::HWD::Work->parse()
  23 +
  24 +Returns an App::HWD::Work object from an input line
  25 +
  26 +=cut
  27 +
  28 +sub parse {
  29 + my $class = shift;
  30 + my $line = shift;
  31 +
  32 + my @cols = split " ", $line, 5;
  33 + die "Invalid work line: $line" unless @cols >= 4;
  34 +
  35 + my ($who, $when, $task, $hours, $comment) = @cols;
  36 + my $parser = DateTime::Format::Strptime->new( pattern => '%Y-%m-%d' );
  37 + $when = $parser->parse_datetime( $when );
  38 + my $completed;
  39 + if ( defined $comment ) {
  40 + if ( $comment =~ s/\s*X\s*//i ) {
  41 + $completed = 1;
  42 + }
  43 + $comment =~ s/^#\s*//;
  44 + $comment =~ s/\s+$//;
  45 + }
  46 + else {
  47 + $comment = '';
  48 + }
  49 +
  50 + die "Invalid task: $task\n" unless ($task =~ /^\d+$/ || $task eq "^");
  51 +
  52 + my $self =
  53 + $class->new( {
  54 + who => $who,
  55 + when => $when,
  56 + task => $task,
  57 + hours => $hours,
  58 + comment => $comment,
  59 + completed => $completed,
  60 + } );
  61 +
  62 + return $self;
  63 +}
  64 +
  65 +=head2 App::HWD::Work->new( { args } )
  66 +
  67 +Creates a new task from the args passed in. They should include at
  68 +least I<level>, I<name> and I<id>, even if I<id> is C<undef>.
  69 +
  70 +=cut
  71 +
  72 +sub new {
  73 + my $class = shift;
  74 + my $args = shift;
  75 +
  76 + my $self = bless { %$args }, $class;
  77 +}
  78 +
  79 +
  80 +=head2 $work->set( $key => $value )
  81 +
  82 +Sets the I<$key> field to I<$value>.
  83 +
  84 +=cut
  85 +
  86 +sub set {
  87 + my $self = shift;
  88 + my $key = shift;
  89 + my $value = shift;
  90 +
  91 + die "Dupe key $key" if exists $self->{$key};
  92 + $self->{$key} = $value;
  93 +}
  94 +
  95 +=head2 $work->who()
  96 +
  97 +Returns who did the work
  98 +
  99 +=head2 $work->when()
  100 +
  101 +Returns the when of the work as a string.
  102 +
  103 +=head2 $work->when_obj()
  104 +
  105 +Returns the when of the work as a DateTime object.
  106 +
  107 +=head2 $work->task()
  108 +
  109 +Returns the ID of the work that was worked on.
  110 +
  111 +=head2 $work->hours()
  112 +
  113 +Returns the hours spent.
  114 +
  115 +=head2 $work->completed()
  116 +
  117 +Returns a boolean that says whether the work was completed or not.
  118 +
  119 +=head2 $work->comment()
  120 +
  121 +Returns the comment from the file, if any.
  122 +
  123 +=cut
  124 +
  125 +sub who { return shift->{who} }
  126 +sub task { return shift->{task} }
  127 +sub hours { return shift->{hours} }
  128 +sub completed { return shift->{completed} || 0 }
  129 +sub comment { return shift->{comment} }
  130 +sub when_obj { return shift->{when} }
  131 +sub when {
  132 + my $self = shift;
  133 +
  134 + my $obj = $self->{when} or return '';
  135 +
  136 + return $obj->strftime( "%F" );
  137 +}
  138 +
  139 +=head1 AUTHOR
  140 +
  141 +Andy Lester, C<< <andy at petdance.com> >>
  142 +
  143 +=head1 COPYRIGHT & LICENSE
  144 +
  145 +Copyright 2006 Andy Lester, all rights reserved.
  146 +
  147 +This program is free software; you can redistribute it and/or modify it
  148 +under the same terms as Perl itself.
  149 +
  150 +=cut
  151 +
  152 +1; # End of App::HWD::Task
614 bin/hwd
... ... @@ -0,0 +1,614 @@
  1 +#!/usr/bin/perl -w
  2 +
  3 +use strict;
  4 +use warnings;
  5 +use Getopt::Long;
  6 +use Pod::Usage;
  7 +use App::HWD;
  8 +use Text::CSV_XS;
  9 +use Text::Wrap;
  10 +
  11 +our $wrap = 72;
  12 +
  13 +if ( -t STDOUT ) { # If we're not redirecting
  14 + eval "use Term::ReadKey";
  15 + if ( !$@ ) {
  16 + $wrap = (Term::ReadKey::GetTerminalSize(*STDOUT))[0];
  17 + }
  18 +}
  19 +
  20 +MAIN: {
  21 + my $show_nextid;
  22 + my $show_started;
  23 + my $show_tasks;
  24 + my $show_burndown;
  25 + my $show_todo;
  26 + my $csv;
  27 + my $notes = 1;
  28 +
  29 + Getopt::Long::Configure( "no_ignore_case" );
  30 + Getopt::Long::Configure( "bundling" );
  31 + GetOptions(
  32 + 'nextid' => \$show_nextid,
  33 + 'todo' => \$show_todo,
  34 + 'started:s' => \$show_started,
  35 + 'tasks:s' => \$show_tasks,
  36 + 'burndown' => \$show_burndown,
  37 + 'wrap:i' => \$wrap,
  38 + 'csv!' => \$csv,
  39 + 'notes!' => \$notes,
  40 + 'h|help|?' => sub { pod2usage({-verbose => 1}); exit; },
  41 + 'H|man' => sub { pod2usage({-verbose => 2}); exit; },
  42 + 'V|version' => sub { print_version(); exit; },
  43 + ) or exit 1;
  44 + #die "Must specify input files\n" unless @ARGV;
  45 +
  46 + # XXX the --started and --tasks options with no argument eats the filename.
  47 + # Attempt to compensate.
  48 + for my $var ($show_started, $show_tasks) {
  49 + if ($var and -e $var) {
  50 + unshift @ARGV, $var;
  51 + $var = '';
  52 + }
  53 + }
  54 +
  55 + my ($tasks,$works,$tasks_by_id,$errors) = App::HWD::get_tasks_and_work( <> );
  56 + if ( @$errors ) {
  57 + print join( "\n", @$errors, "" );
  58 + die;
  59 + }
  60 +
  61 + if ( $show_nextid ) {
  62 + my $max = (sort {$a <=> $b} keys %$tasks_by_id )[-1];
  63 + $max = $max ? $max+1 : 101;
  64 + print "Next task ID: $max\n";
  65 + exit;
  66 + }
  67 +
  68 + my $show_full_dump = 1;
  69 + my $filter = undef;
  70 +
  71 + if ( $csv ) {
  72 + show_full_dump( $tasks, $filter, $wrap, $csv, $notes );
  73 + $show_full_dump = 0;
  74 + }
  75 +
  76 + if ( defined $show_tasks ) {
  77 + show_tasks( $show_tasks, $tasks, $works, $tasks_by_id );
  78 + $show_full_dump = 0;
  79 + }
  80 +
  81 + if ( $show_burndown ) {
  82 + show_burndown( $tasks, $works, $tasks_by_id );
  83 + $show_full_dump = 0;
  84 + }
  85 +
  86 + if ( defined $show_started ) {
  87 + show_started( $show_started, $tasks, $works, $tasks_by_id );
  88 + $show_full_dump = 0;
  89 + }
  90 +
  91 + if ( $show_todo ) {
  92 + $filter = sub {
  93 + my $task = shift;
  94 + return $task->is_todo;
  95 + };
  96 + show_full_dump( $tasks, $filter, $wrap, $csv, $notes );
  97 + $show_full_dump = 0;
  98 + }
  99 +
  100 + if ( $show_full_dump ) {
  101 + show_full_dump( $tasks, $filter, $wrap, $csv, $notes );
  102 + print "\n";
  103 + show_totals( $tasks );
  104 + }
  105 +}
  106 +
  107 +
  108 +sub show_full_dump {
  109 + my $tasks = shift;
  110 + my $filter = shift;
  111 + my $wrap = shift;
  112 + my $csv = shift;
  113 + my $notes = shift;
  114 + my @notes = shift;
  115 +
  116 + my @fields = qw( estimated velocity started unstarted deleted );
  117 +
  118 + my %total;
  119 + $total{$_} = 0 for @fields;
  120 +
  121 + for my $task ( @$tasks ) {
  122 + my $points = $task->estimate || 0;
  123 + if ( $task->date_deleted ) {
  124 + $total{deleted} += $points;
  125 + }
  126 + else {
  127 + if ( $points ) {
  128 + $total{estimated} += $points;
  129 + $total{velocity} += $points if $task->completed;
  130 + $total{started} += $points if $task->started && !$task->completed;
  131 + $total{unstarted} += $points if !$task->started;
  132 + }
  133 + if ( !$filter || $filter->( $task ) ) {
  134 + print_task( $task, $wrap, $csv, $notes );
  135 + }
  136 + }
  137 + }
  138 +
  139 + if ( !$csv ) {
  140 + print "\n";
  141 + for my $type ( @fields ) {
  142 + printf "%6.2f %s\n", $total{$type}, $type;
  143 + }
  144 + }
  145 +}
  146 +
  147 +sub print_task {
  148 + my $task = shift;
  149 + my $wrap = shift;
  150 + my $csv = shift;
  151 + my $notes = shift;
  152 +
  153 + my $level = $task->level;
  154 + my $name = $task->name;
  155 + my $id = $task->id;
  156 + my @notes = $notes ? $task->notes : ();
  157 +
  158 + if ( $id || $task->estimate ) {
  159 + my $worked = $task->hours_worked;
  160 + my $estimate = $task->estimate;
  161 +
  162 + unless ( $csv ) {
  163 + $worked = fractiony( $worked );
  164 + $estimate = fractiony( $estimate );
  165 + }
  166 + my $x = $task->completed ? "X" : " ";
  167 + print_cols( $wrap, $csv, $level, $id, $estimate, $worked, $x, $name, @notes );
  168 + }
  169 + else {
  170 + print_cols( $wrap, $csv, $level, ("") x 4, $name, @notes );
  171 + }
  172 +}
  173 +
  174 +sub print_cols {
  175 + my $wrap = shift;
  176 + my $csv = shift;
  177 + my $level = shift;
  178 + my @cols = splice( @_, 0, 5 );
  179 + my @notes = @_;
  180 +
  181 + for ( @cols[0..0] ) {
  182 + $_ = $_ ? sprintf( "%4d", $_ ) : "";
  183 + }
  184 + for ( @cols[2..5] ) {
  185 + $_ = "" unless defined $_;
  186 + }
  187 +
  188 + if ( $csv ) {
  189 + my $csv = Text::CSV_XS->new;
  190 + s/^\s+// for @cols;
  191 + s/\s+$// for @cols;
  192 + $csv->combine( @cols ) or die "Can't create a CSV string!";
  193 + print join( ",", $csv->string ), "\n";
  194 + }
  195 + else {
  196 + my $indent = " " x (($level-1)*4);
  197 + my $desc = $cols[4];
  198 +
  199 + my $leader1 = sprintf( "%4s %6.6s %6.6s %1s %s", @cols[0..3], $indent );
  200 + my $spacing = (" " x 21) . $indent;
  201 + if ( $wrap ) {
  202 +
  203 + local $Text::Wrap::columns = $wrap;
  204 + print wrap( $leader1, $spacing, $desc ), "\n";
  205 +
  206 + if ( @notes ) {
  207 + print wrap( "$spacing * ", "$spacing ", @notes ), "\n";
  208 + }
  209 + }
  210 + else {
  211 + print "$leader1$desc\n";
  212 + print "$spacing * @notes\n";
  213 + }
  214 + } # not CSV
  215 +}
  216 +
  217 +
  218 +sub fractiony {
  219 + my $n = shift;
  220 + my $str;
  221 +
  222 + if ( $n ) {
  223 + my $frac = $n - int($n);
  224 + $str = sprintf( "%4d", int($n) );
  225 + $str .= $frac ? "+" : " ";
  226 + }
  227 + else {
  228 + $str = "";
  229 + }
  230 + return $str;
  231 +}
  232 +
  233 +sub show_started {
  234 + my ( $who, $tasks, $works, $tasks_by_id ) = @_;
  235 +
  236 + my %started;
  237 + foreach my $w (@$works) {
  238 + next if $who && ($who ne $w->who);
  239 + my $t = $tasks_by_id->{$w->task};
  240 + if ( !$t->completed() ) {
  241 + $started{$w->who}{$t->id}++;
  242 + }
  243 + }
  244 + my %unique_tasks;
  245 + foreach my $w (sort keys %started) {
  246 + print "$w is working on...\n";
  247 + my $points = 0;
  248 + foreach my $key (sort { $a <=> $b } keys %{$started{$w}}) {
  249 + my $task = $tasks_by_id->{$key};
  250 + print " " . $task->summary . "\n";
  251 + $points += $task->estimate;
  252 + $unique_tasks{ $key } = $task->estimate;
  253 + }
  254 + print "$w has $points points open\n";
  255 + print "\n";
  256 + }
  257 + if ( !$who ) {
  258 + my $total_points = 0;
  259 + $total_points += $unique_tasks{$_} for keys %unique_tasks;
  260 + print "$total_points points open on the project\n";
  261 + }
  262 +} # show_started
  263 +
  264 +
  265 +sub show_tasks {
  266 + my ( $who, $tasks, $works, $tasks_by_id ) = @_;
  267 +
  268 + my %worker;
  269 + foreach my $t (@$tasks) {
  270 + foreach my $w ($t->work) {
  271 + $worker{ $w->who }{$t->id}++;
  272 + }
  273 + }
  274 +
  275 + my @who = $who ? ($who) : keys %worker;
  276 + foreach my $w (@who) {
  277 + if ( !$worker{$w} ) {
  278 + print "$w has no tasks!\n";
  279 + next;
  280 + }
  281 + print "$w worked on:\n";
  282 + foreach my $id (keys %{$worker{$w}}) {
  283 + my $task = $tasks_by_id->{$id};
  284 + print " ", $task->summary, "\n";
  285 + }
  286 + print "\n";
  287 + }
  288 +} # show_tasks
  289 +
  290 +
  291 +sub show_burndown {
  292 + my ( $tasks, $works, $tasks_by_id ) = @_;
  293 +
  294 + my %day;
  295 +
  296 + # ASSUMPTION: projects will finish before Jan 1, 2100
  297 + my $earliest = ParseDate("2100/1/1");
  298 +
  299 + # determine the earliest date work has been done and keep track
  300 + # of finished task points
  301 + foreach my $w (@$works) {
  302 + my $date = ParseDate($w->when)
  303 + or die "Work " . $w->task . " has an invalid date: " . $w->when;
  304 + if (Date_Cmp($date, $earliest) < 0) {
  305 + $earliest = $date;
  306 + }
  307 + if ( $w->completed ) {
  308 + my $est = $tasks_by_id->{ $w->task }->estimate;
  309 + $day{$date}{finished} += $est;
  310 + }
  311 + }
  312 +
  313 + # determine the total for each date
  314 + foreach my $t (@$tasks) {
  315 + next if $t->date_deleted;
  316 + my $date = ParseDate( $t->date_added ) || $earliest;
  317 + if ( !$date ) {
  318 + die "Task " . $t->name . " has no date!";
  319 + }
  320 + $day{$date}{total} += $t->estimate;
  321 + }
  322 +
  323 + # Print the running task and finished totals
  324 + my $total;
  325 + my $finished;
  326 + my $format = "\%10s\t\%-5s\t\%-s\n";
  327 + printf $format, qw(YYYY/MM/DD Total Todo);
  328 + foreach my $date (sort keys %day) {
  329 + $total += $day{$date}{total} || 0;
  330 + $finished += $day{$date}{finished} || 0;
  331 + $date =~ s#^(\d{4})(\d\d)(\d\d).+#$1/$2/$3#
  332 + or die "Invalid date ($date)";
  333 + printf $format, $date, $total, $total - $finished;
  334 + }
  335 +}
  336 +
  337 +sub show_totals {
  338 + my ( $tasks, $works, $tasks_by_id ) = @_;
  339 +
  340 + my @totals;
  341 + my $curr_total;
  342 +
  343 + for my $task ( @$tasks ) {
  344 + if ( $task->level eq 1 ) {
  345 + push( @totals, $curr_total = [ 0, $task->name ] );
  346 + }
  347 + if ( !$task->date_deleted ) {
  348 + $curr_total->[0] += $task->estimate;
  349 + }
  350 + }
  351 +
  352 + for $curr_total ( @totals ) {
  353 + printf( "%4d %s\n", $curr_total->[0], $curr_total->[1] );
  354 + }
  355 +}
  356 +
  357 +sub print_version {
  358 + printf( "hwd v%s\n", $App::HWD::VERSION, $^V );
  359 +}
  360 +
  361 +__END__
  362 +
  363 +=head1 NAME
  364 +
  365 +hwd -- The How We Doin'? project tracking tool
  366 +
  367 +=head1 SYNOPSIS
  368 +
  369 +hwd [options] schedule-file(s)
  370 +
  371 +Options:
  372 +
  373 + --nextid Display the next highest task ID
  374 + --todo Displays tasks left to do, started or not.
  375 + --started Displays tasks that have been started
  376 + --started=person
  377 + Displays tasks started by person
  378 + --tasks Displays tasks sorted by person
  379 + --tasks[=person]
  380 + Displays tasks for a given user
  381 + --burndown Display a burn-down table
  382 +
  383 + --wrap=n Wrap output at n columns, or 0 for no wrapping.
  384 + Default is 72, or terminal width if available.
  385 + --csv Output in CSV format
  386 + --nonotes Omit the notes from the output
  387 +
  388 + -h, --help Display this help
  389 + -H, --man Longer manpage for prove
  390 + -V, --version Display version info
  391 +
  392 +=head1 COMMAND LINE OPTIONS
  393 +
  394 +=head2 --todo
  395 +
  396 +Limit the dump of tasks to only those that are left to do, whether or
  397 +not they've been started.
  398 +
  399 +=head2 --started[=who]
  400 +