File Coverage

blib/lib/App/JobLog/Command/summary.pm
Criterion Covered Total %
statement 109 168 64.8
branch 19 52 36.5
condition 36 115 31.3
subroutine 18 21 85.7
pod 3 6 50.0
total 185 362 51.1


line stmt bran cond sub pod time code
1             package App::JobLog::Command::summary;
2             $App::JobLog::Command::summary::VERSION = '1.039';
3             # ABSTRACT: show what you did during a particular period
4              
5 2     2   2080 use App::JobLog -command;
  2         3  
  2         14  
6 2     2   587 use Modern::Perl;
  2         2  
  2         13  
7 2         12 use Class::Autouse qw(
8             App::JobLog::Log
9             App::JobLog::Log::Day
10 2     2   225 );
  2         5  
11 2     2   337 use autouse 'App::JobLog::TimeGrammar' => qw(parse daytime);
  2         4  
  2         12  
12 2     2   158 use autouse 'Carp' => qw(carp);
  2         2  
  2         9  
13 2     2   164 use autouse 'Getopt::Long::Descriptive' => qw(prog_name);
  2         3  
  2         9  
14 2         7 use autouse 'App::JobLog::Config' => qw(
15             columns
16             is_hidden
17             merge
18 2     2   112 );
  2         5  
19 2         8 use autouse 'App::JobLog::Log::Format' => qw(
20             display
21             single_interval
22             summary
23 2     2   192 );
  2         8  
24 2         10 use autouse 'App::JobLog::Log::Synopsis' => qw(
25             MERGE_ALL
26             MERGE_ADJACENT
27             MERGE_ADJACENT_SAME_TAGS
28             MERGE_SAME_TAGS
29             MERGE_SAME_DAY
30             MERGE_SAME_DAY_SAME_TAGS
31             MERGE_NONE
32 2     2   189 );
  2         2  
33 2     2   306 use autouse 'App::JobLog::Time' => qw(today);
  2         3  
  2         8  
34 2     2   161 no if $] >= 5.018, warnings => "experimental::smartmatch";
  2         3  
  2         14  
35              
36             sub execute {
37 15     15 1 54 my ( $self, $opt, $args ) = @_;
38              
39 15   100     118 my $tags = $opt->{tag} || [];
40 15   50     104 my $excluded_tags = $opt->{exclude_tag} || [];
41 15   50     89 my $match = $opt->{match} || [];
42 15   50     109 my $no_match = $opt->{no_match} || [];
43 15   33     65 my $time_expr = join( ' ', @$args ) || $opt->{date};
44 15         42 my $time = $opt->{time};
45              
46 15   33     44 $time_expr ||= $opt->{date};
47              
48             # validate regexes, if any, while generating test
49              
50             # NOTE: using $opt->{x} form rather than $opt->x to facilitate invoking summary
51             # from today command
52              
53 15         56 my $test = _make_test( $tags, $excluded_tags, $match, $no_match, $time );
54 15         30 my $merge_level;
55 15   100     75 for ( $opt->{merge} || '' ) {
56 15         55 when ('no_merge') {
57 11         53 $merge_level = MERGE_NONE
58             }
59 4         8 when ('merge_all') {
60 0         0 $merge_level = MERGE_ALL
61             }
62 4         8 when ('merge_adjacent') {
63 0         0 $merge_level = MERGE_ADJACENT
64             }
65 4         8 when ('merge_adjacent_same_tags') {
66 0         0 $merge_level = MERGE_ADJACENT_SAME_TAGS
67             }
68 4         7 when ('merge_same_tags') {
69 0         0 $merge_level = MERGE_SAME_TAGS
70             }
71 4         9 when ('merge_same_day') {
72 0         0 $merge_level = MERGE_SAME_DAY
73             }
74 4         8 when ('merge_same_day_same_tags') {
75 0         0 $merge_level = MERGE_SAME_DAY_SAME_TAGS
76             }
77 4         7 default {
78              
79             # some dark wizardry here
80 4         23 my $m = uc merge;
81 4         22 $m =~ s/ /_/g;
82 4         9 $m = \&{"MERGE_$m"};
  4         20  
83 4         21 $merge_level = &$m;
84             }
85             }
86 15   33     523 my $dateless = $merge_level == MERGE_ALL || $merge_level == MERGE_SAME_TAGS;
87 15 0 0     142 if (
      33        
      0        
      0        
      0        
      0        
      0        
      0        
      0        
      0        
88             $opt->{no_totals}
89             && ( $dateless || $opt->{no_date} || is_hidden('date') )
90             && ( !single_interval($merge_level)
91             || $opt->{no_time}
92             || is_hidden('time') )
93             && ( $opt->{no_duration} || is_hidden('duration') )
94             && ( $opt->{no_tags} || is_hidden('tags') )
95             && ( $opt->{no_description} || is_hidden('description') )
96             )
97             {
98 0         0 $self->usage_error('you have chosen not to display anything');
99             }
100              
101             # record hiding options in hash reference
102             my $hidden = {
103             vacation => $opt->{no_vacation} || $opt->{notes},
104             date => $dateless || $opt->{no_date} || is_hidden('date'),
105             time => $opt->{no_time} || is_hidden('time'),
106             duration => $opt->{notes} || $opt->{no_duration} || is_hidden('duration'),
107             tags => $opt->{no_tags} || is_hidden('tags'),
108             description => $opt->{no_description} || is_hidden('description'),
109             totals => $opt->{notes} || $opt->{no_totals},
110 15   33     222 };
      33        
      33        
      33        
      33        
      33        
      66        
111              
112             # parse time expression
113 15         33 my ( $days, $show_year );
114 15         32 eval {
115 15         99 ( $days, $show_year ) = summary $time_expr, $test, $hidden, $opt->{notes};
116             };
117 15 50       91 $self->usage_error($@) if $@;
118 15 50       82 unless ( $opt->{hidden} ) {
119              
120             # figure out how wide to make things
121 15         29 my $screen_width;
122 15 100       62 if ( $opt->{wrap} ) {
123 3 50       11 if ( $opt->{no_wrap} ) {
124 3         7 $screen_width = -1;
125             }
126             else {
127 0         0 $screen_width = $opt->columns;
128             }
129             }
130             else {
131 12         71 $screen_width = columns;
132             }
133 15 50       144 if ($dateless) {
134              
135             # create "day" containing all events
136 0         0 my $duck_day = App::JobLog::Log::Day->new(
137             start => $days->[0]->start->clone,
138             end => $days->[$#$days]->end->clone,
139             no_date => 1,
140             );
141 0         0 for my $d (@$days) {
142 0         0 push @{ $duck_day->events }, @{ $d->events };
  0         0  
  0         0  
143 0         0 push @{ $duck_day->vacation }, @{ $d->vacation };
  0         0  
  0         0  
144             }
145 0         0 display [$duck_day], $merge_level, $hidden, $screen_width;
146             }
147             else {
148 15         282 display $days, $merge_level, $hidden, $screen_width, $show_year;
149             }
150              
151             # check for long task
152 15         861 my ($last_e) = App::JobLog::Log->new->last_event;
153 15 50 33     102 if ( $last_e && $last_e->is_open ) {
154 15         75 my ( $then, $today ) = ( $last_e->start, today );
155 15 50 66     332 if (
156             !(
157             $then->year == $today->year
158             && $then->month == $today->month
159             && $then->day == $today->day
160             )
161             )
162             {
163 15         351 print <
164              
165             WARNING! The last event in the log has been open since before 12:00 am today!
166              
167             END
168             }
169             }
170             }
171             }
172              
173             # Construct a test from the tags, excluded-tags, match, no-match, and time options.
174             # The test determines what portion of what events are included in synopses.
175             sub _make_test {
176 15     15   36 my ( $tags, $excluded_tags, $match, $no_match, $time ) = @_;
177              
178 15         42 my %tags = map { $_ => 1 } @$tags;
  8         30  
179 15         49 my %excluded_tags = map { $_ => 1 } @$excluded_tags;
  0         0  
180 15         34 my @no_match = map { _re_test($_); qr/$_/ } @$no_match;
  0         0  
  0         0  
181 15         38 my @match = map { _re_test($_); qr/$_/ } @$match;
  0         0  
  0         0  
182 15         59 $time = _parse_time($time);
183 15 50 66     187 return unless %tags || %excluded_tags || @no_match || @match || $time;
      66        
      33        
      33        
184              
185             my $test = sub {
186 8     8   16 my ($e) = @_;
187 8 50 33     53 if ( %tags || %excluded_tags ) {
188 8         19 my $good = !%tags;
189 8         14 for my $t ( @{ $e->tags } ) {
  8         68  
190 9 50       27 return if $excluded_tags{$t};
191 9   100     49 $good ||= $tags{$t};
192             }
193 8 100       41 return unless $good;
194             }
195 6 50 33     57 if ( @no_match || @match ) {
196 0         0 my $good = !@match;
197 0         0 for my $d ( @{ $e->data->description } ) {
  0         0  
198 0         0 for my $re (@no_match) {
199 0 0       0 return if $d =~ $re;
200             }
201 0 0       0 unless ($good) {
202 0         0 for my $re (@match) {
203 0         0 $good = $d =~ $re;
204 0 0       0 last if $good;
205             }
206             }
207             }
208 0 0       0 return unless $good;
209             }
210 6 50       23 if ($time) {
211 0         0 my $start = $e->start->clone->set( %{ $time->{start} } );
  0         0  
212 0         0 my $end = $e->end->clone->set( %{ $time->{end} } );
  0         0  
213 0         0 return $e->overlap( $start, $end );
214             }
215 6         31 return $e;
216 6         43 };
217 6         28 return $test;
218             }
219              
220             # look for regular expressions with side effects
221             sub _re_test {
222 0 0   0   0 carp 'regex ' . $_[0] . '" appears to contain executable code'
223             if $_[0] =~ /\(\?{1,2}{/;
224             }
225              
226             # parse time expressions
227             our ( $b1, $b2 );
228             my $time_re = qr/
229             ^ \s*+ (?&start) (?&end) \s*+ $
230             (?(DEFINE)
231             (? (?&ba) | (?&time) )
232             (? (?:(?&before)|(?&after)) \s*+)
233             (? (?: b(?:e(?:f(?:o(?:r(?:e)?)?)?)?)? | < ) (?{$b1 = 'before'}))
234             (? (?: a(?:f(?:t(?:e(?:r)?)?)?)? | > ) (?{$b1 = 'after'}))
235             (?
236             (? (\S.*) (?{$b2 = $^N}))
237             )
238             /xi;
239              
240             sub _parse_time {
241 15     15   33 my ($time) = @_;
242 15         71 local ( $b1, $b2 );
243 15 50       60 return unless $time;
244 0 0       0 if ( $time =~ $time_re ) {
245 0         0 my ( $t1, $t2 );
246 0         0 for ($b1) {
247 0         0 when ('before') {
248 0         0 $t1 = {
249             hour => 0,
250             minute => 0,
251             second => 0
252             };
253 0         0 $t2 = { daytime $b2 };
254             }
255 0         0 when ('after') {
256 0         0 $t1 = { daytime $b2 };
257 0         0 $t2 = {
258             hour => 23,
259             minute => 59,
260             second => 59
261             };
262             }
263 0         0 default {
264 0         0 $t1 = { daytime $b1 };
265 0         0 $t2 = { daytime $b2 };
266             }
267             }
268 0 0 0     0 if ( $t2->{hour} < $t1->{hour}
      0        
269             || $t2->{minute} < $t1->{minute}
270             || $t2->{second} < $t1->{second} )
271             {
272 0 0 0     0 if ( $t2->{suffix} && $t2->{suffix} eq 'x' ) {
273 0         0 $t2->{hour} += 12;
274             }
275             else {
276 0         0 carp '"' . $time
277             . '" invalid time expression: endpoints out of order';
278             }
279             }
280 0         0 delete $t1->{suffix}, delete $t2->{suffix};
281 0         0 return { start => $t1, end => $t2 };
282             }
283             }
284              
285 4     4 1 60654 sub usage_desc { '%c ' . __PACKAGE__->name . ' %o []' }
286              
287             sub abstract {
288 0     0 1 0 'list tasks with certain properties in a particular time range';
289             }
290              
291             sub full_description {
292             <
293             List events or notes with certain properties in a particular time range. Only the notes or
294             portions of events falling within the range will be listed.
295              
296             Events and notes may be filtered in numerous ways: by tag, time of day, or terms used in descriptions.
297             If tags to match are provided, only those items that contain at least one such tag will be shown. If
298             tags not to match are provided, only those items that contain none of these tags will be shown.
299              
300             If you provide description filters to match or avoid, these will be interpreted as regexes. Try 'perldoc perlre'
301             for more details, or perhaps 'perldoc perlretut' (these will only work if you have the Perl documentation
302             installed on your machine). If you don't want to worry about regular expressions, simple strings will work.
303             Prefix your expression with '(?i)' to turn off case sensitivity. And don't enclose regexes in slashes or any other
304             sort of delimiter. Use 'ab', not '/ab/' or 'm!ab!', etc. Finally, you may need to enclose your regexes in quotes
305             to prevent the shell from trying to interpret them.
306              
307             Time subranges may be of the form '11-12pm', '1am-12:30:15', 'before 2', 'after 6:12pm', etc. Either 'before'
308             or 'after' (or some prefix of these such as 'bef' or 'aft') may be followed by a time or you may use two time
309             expressions separated by a dash. The code will attempt to infer the precise time of ambiguous time expressions,
310             but it's best to be explicit. Case is ignored. Whitespace is optional in the expected places.
311              
312             Note that any filtering of events specifying particular times for the start and end of the period in question,
313             e.g., "yesterday at 8:00 am until today", will cause all flex time vacation to be ignored. This is because, given
314             the flexible nature of this vacation, it is unclear how much should be accounted for when filtering events. Since
315             notes are not "on the clock", no consideration of vacation periods is used in filtering them.
316              
317 0         0 @{[__PACKAGE__->name]} provides many ways to consolidate events and notes. These are the "merge" options
318             By default items are grouped into days and within days into subgroups of adjacent items with the same tags.
319             All the merge options that require adjacency will also group by days but not vice versa.
320             END
321 0     0 0 0 }
322              
323             sub options {
324             return (
325             [
326 4     4 0 12 "Use '@{[prog_name]} help "
  4         16  
327             . __PACKAGE__->name
328             . '\' to see full details.'
329             ],
330             [],
331             [
332             'date|d=s',
333             'provide the time expression as an option instead of an argument'
334             ],
335             [ 'notes|n', 'show notes instead of events' ],
336             [
337             'tag|t=s@',
338             'filter events/notes to include only those with given tags; '
339             . 'multiple tags may be specified'
340             ],
341             [
342             'exclude-tag|T=s@',
343             'filter events/notes to exclude those with given tags; '
344             . 'multiple tags may be specified'
345             ],
346             [
347             'match|m=s@',
348             'filter events/notes to include only those one of whose descriptions matches the given regex; '
349             . 'multiple regexes may be specified'
350             ],
351             [
352             'no-match|M=s@',
353             'filter events/notes to include only those one of whose descriptions do not match the given regex; '
354             . 'multiple regexes may be specified'
355             ],
356             [
357             'time|i=s',
358             'consider only those portions of events/notes that overlap the given time range'
359             ],
360             [
361             "merge" => hidden => {
362             one_of => [
363             [
364             "merge-all|mall|ma" =>
365             "glom all events/notes into one synopsis"
366             ],
367             [ "merge-adjacent|madj" => "merge contiguous events" ],
368             [
369             "merge-adjacent-same-tags|mast" =>
370             "merge contiguous, identically-tagged events/notes (default)"
371             ],
372             [
373             "merge-same-tags|mst" =>
374             "merge all identically tagged events/notes"
375             ],
376             [
377             "merge-same-day|msd" =>
378             "merge all events/notes in a given day"
379             ],
380             [
381             "merge-same-day-same-tags|msdst" =>
382             "merge all events/notes in a given day"
383             ],
384             [ "no-merge|nm" => "keep all events/notes separate" ],
385             ]
386             }
387             ],
388             [ 'no-vacation|V', 'do not display vacation hours' ],
389             [ 'no-date', 'do not display a date before each distinct day' ],
390             [
391             'no-time',
392             'do not display event or note start times and event end times'
393             ],
394             [ 'no-duration', 'do not display event durations' ],
395             [ 'no-tags', 'do not display tags' ],
396             [ 'no-description', 'do not display event/note descriptions' ],
397             [
398             'no-totals',
399             'do not display the footer containing total hours worked, etc.'
400             ],
401             [
402             'wrap' => 'hidden' => {
403             one_of => [
404             [
405             'columns|c=i',
406             'limit the width of the report to the specified number of columns; '
407             . ' by default the width of the terminal is automatically detected and, if that fails, a width of 76 is used'
408             ],
409             [ 'no-wrap|W', 'do not wrap the text to fit columns' ],
410             ]
411             }
412             ],
413             [ 'hidden', 'display nothing', { hidden => 1 } ],
414             );
415             }
416              
417             sub validate {
418 4     4 0 11 my ( $self, $opt, $args ) = @_;
419              
420 4 50 33     19 $self->usage_error('no time expression provided')
421             unless @$args || $opt->date;
422 4 50 33     28 $self->usage_error('two time expression provided') if @$args && $opt->date;
423             $self->usage_error('columns must be positive')
424 4 50 33     63 if defined $opt->{columns} && $opt->columns < 1;
425             }
426              
427             1;
428              
429             __END__