[Rt-commit] rt branch, 4.2/charts, created. rt-4.1.13-155-gceb0055

Ruslan Zakirov ruz at bestpractical.com
Sat Jun 29 08:37:04 EDT 2013


The branch, 4.2/charts has been created
        at  ceb0055f40b4423aa0d57b4fa242e1a3be97a528 (commit)

- Log -----------------------------------------------------------------
commit 49a0d6217e08d6773e2d31cd311291a55ca5babe
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Fri May 20 12:56:28 2011 +0400

    list possible groupings by date in a variable

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 43e7fba..bf9a936 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -54,6 +54,18 @@ use RT::Report::Tickets::Entry;
 use strict;
 use warnings;
 
+our %GROUPINGS = (
+    Date => [qw(
+        Time
+        Hourly Hour
+        Date Daily
+        DayOfWeek Day DayOfMonth DayOfYear
+        Month Monthly
+        Year Annually
+        WeekOfYear
+    )],
+);
+
 sub Groupings {
     my $self = shift;
     my %args = (@_);

commit 0670126702e64b1217a015bebf9ee098edbcc6eb
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Fri May 20 12:57:14 2011 +0400

    switch over . joined grouping for date fields
    
    we use regexp to parse it later with copied list of possible
    functions, list grows, harder to maintain regexp.
    
    Also, dot joined is consistent with TicketSQL and we'll need
    Created.DayOfWeek = 'Monday'

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index bf9a936..49069ee 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -85,11 +85,11 @@ sub Groupings {
 
 
     for my $field (qw(Due Resolved Created LastUpdated Started Starts Told)) { # loc_qw
-        for my $frequency (qw(Hourly Daily Monthly Annually)) { # loc_qw
+        for my $frequency (@{ $GROUPINGS{'Date'} }) {
             push @fields,
               $self->CurrentUser->loc($field)
-              . $self->CurrentUser->loc($frequency),
-              $field . $frequency;
+              .' '. $self->CurrentUser->loc($frequency),
+              "$field.$frequency";
         }
     }
 

commit 3d143b76d8940419bd702b16152ea6e6ee08be86
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Fri May 20 13:02:37 2011 +0400

    use DateTimeFunction, new method in SB::Handle
    
    we port our custom implementations of the following features
    into DBIx::SearchBuilder
    
    * timezones in DB
    * extracting parts of DateTime columns using DB's functions

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 49069ee..d5e927c 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -204,53 +204,23 @@ sub _FieldToFunction {
 
     my $field = $args{'FIELD'};
 
-    if ($field =~ /^(.*)(Hourly|Daily|Monthly|Annually)$/) {
-        my ($field, $grouping) = ($1, $2);
-        my $alias = $args{'ALIAS'} || 'main';
+    my ($key, $subkey) = split /\./, $field, 2;
 
-        my $func = "$alias.$field";
-
-        my $db_type = RT->Config->Get('DatabaseType');
+    if ( $subkey && grep $_ eq $subkey, @{ $GROUPINGS{'Date'} } ) {
+        my $tz;
         if ( RT->Config->Get('ChartsTimezonesInDB') ) {
-            my $tz = $self->CurrentUser->UserObj->Timezone
-                || RT->Config->Get('Timezone')
-                || 'UTC';
-            if ( lc $tz eq 'utc' ) {
-                # do nothing
-            }
-            elsif ( $db_type eq 'Pg' ) {
-                $func = "timezone('UTC', $func)";
-                $func = "timezone(". $self->_Handle->dbh->quote($tz) .", $func)";
-            }
-            elsif ( $db_type eq 'mysql' ) {
-                $func = "CONVERT_TZ($func, 'UTC', "
-                    . $self->_Handle->dbh->quote($tz)
-                    .")";
-            }
-            else {
-                $RT::Logger->warning(
-                    "ChartsTimezonesInDB config option"
-                    ." is not supported on $db_type."
-                );
-            }
+            my $to = $self->CurrentUser->UserObj->Timezone
+                || RT->Config->Get('Timezone');
+            $tz = { From => 'UTC', To => $to }
+                if $to && lc $to ne 'utc';
         }
 
-        # Pg 8.3 requires explicit casting
-        $func .= '::text' if $db_type eq 'Pg';
-
-        if ( $grouping eq 'Hourly' ) {
-            $func = "SUBSTR($func,1,13)";
-        }
-        if ( $grouping eq 'Daily' ) {
-            $func = "SUBSTR($func,1,10)";
-        }
-        elsif ( $grouping eq 'Monthly' ) {
-            $func = "SUBSTR($func,1,7)";
-        }
-        elsif ( $grouping eq 'Annually' ) {
-            $func = "SUBSTR($func,1,4)";
-        }
-        $args{'FUNCTION'} = $func;
+        $args{'FUNCTION'} = $RT::Handle->DateTimeFunction(
+            Type => $subkey,
+            Field => "?",
+            Timezone => $tz,
+        );
+        $args{'FIELD'} = $key;
     } elsif ( $field =~ /^(?:CF|CustomField)\.{(.*)}$/ ) { #XXX: use CFDecipher method
         my $cf_name = $1;
         my $cf = RT::CustomField->new( $self->CurrentUser );

commit 1ff64458d8a18ebfda6020b024897cec5443c397
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Thu May 26 02:21:42 2011 +0400

    add searches by DateField.Xxx, for ex. Created.Day

diff --git a/lib/RT/Tickets.pm b/lib/RT/Tickets.pm
index 57aa68e..2a0541e 100644
--- a/lib/RT/Tickets.pm
+++ b/lib/RT/Tickets.pm
@@ -533,7 +533,7 @@ Meta Data:
 =cut
 
 sub _DateLimit {
-    my ( $sb, $field, $op, $value, @rest ) = @_;
+    my ( $sb, $field, $op, $value, %rest ) = @_;
 
     die "Invalid Date Op: $op"
         unless $op =~ /^(=|>|<|>=|<=)$/;
@@ -542,6 +542,29 @@ sub _DateLimit {
     die "Incorrect Meta Data for $field"
         unless ( defined $meta->[1] );
 
+    if ( my $subkey = $rest{SUBKEY} ) {
+        my $tz;
+        if ( RT->Config->Get('ChartsTimezonesInDB') ) {
+            my $to = $sb->CurrentUser->UserObj->Timezone
+                || RT->Config->Get('Timezone');
+            $tz = { From => 'UTC', To => $to }
+                if $to && lc $to ne 'utc';
+        }
+        my $function = $RT::Handle->DateTimeFunction(
+            Type     => $subkey,
+            Field    => '?',
+            Timezone => $tz,
+        );
+
+        return $sb->Limit(
+            FUNCTION => $function,
+            FIELD    => $meta->[1],
+            OPERATOR => $op,
+            VALUE    => $value,
+            %rest,
+        );
+    }
+
     my $date = RT::Date->new( $sb->CurrentUser );
     $date->Set( Format => 'unknown', Value => $value );
 
@@ -562,14 +585,14 @@ sub _DateLimit {
             FIELD    => $meta->[1],
             OPERATOR => ">=",
             VALUE    => $daystart,
-            @rest,
+            %rest,
         );
 
         $sb->Limit(
             FIELD    => $meta->[1],
             OPERATOR => "<",
             VALUE    => $dayend,
-            @rest,
+            %rest,
             ENTRYAGGREGATOR => 'AND',
         );
 
@@ -581,7 +604,7 @@ sub _DateLimit {
             FIELD    => $meta->[1],
             OPERATOR => $op,
             VALUE    => $date->ISO,
-            @rest,
+            %rest,
         );
     }
 }

commit eedce1294a5768a80a23ed8bb129040dab0d00b8
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Fri May 27 22:58:42 2011 +0400

    delete split of FUNCTION into ALIAS/FIELD arguments
    
    it was only way to write a function on left hand side,
    now Limit in SearchBuilder has FUNCTION argumnt and we
    can avoid this mispractice

diff --git a/lib/RT/SearchBuilder.pm b/lib/RT/SearchBuilder.pm
index f9894e9..4fc9ee2 100644
--- a/lib/RT/SearchBuilder.pm
+++ b/lib/RT/SearchBuilder.pm
@@ -787,9 +787,7 @@ sub Limit {
         $ARGS{'VALUE'} = 'NULL';
     }
 
-    if ($ARGS{FUNCTION}) {
-        ($ARGS{ALIAS}, $ARGS{FIELD}) = split /\./, delete $ARGS{FUNCTION}, 2;
-    } elsif ($ARGS{FIELD} =~ /\W/
+    if ($ARGS{FIELD} =~ /\W/
           or $ARGS{OPERATOR} !~ /^(=|<|>|!=|<>|<=|>=
                                   |(NOT\s*)?LIKE
                                   |(NOT\s*)?(STARTS|ENDS)WITH

commit e60885aa24c5009e801b23c9bdac148fbe2a6276
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Sun May 29 02:45:01 2011 +0400

    convert not set dates to NULLs
    
    introduce new NotSetDateToNullFunction method that generates CASE SQL
    to convert not set dates to NULLs and use it in searches and charts

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index d5e927c..da8f6cd 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -217,7 +217,7 @@ sub _FieldToFunction {
 
         $args{'FUNCTION'} = $RT::Handle->DateTimeFunction(
             Type => $subkey,
-            Field => "?",
+            Field => $self->NotSetDateToNullFunction,
             Timezone => $tz,
         );
         $args{'FIELD'} = $key;
diff --git a/lib/RT/Report/Tickets/Entry.pm b/lib/RT/Report/Tickets/Entry.pm
index eb38993..90951d0 100644
--- a/lib/RT/Report/Tickets/Entry.pm
+++ b/lib/RT/Report/Tickets/Entry.pm
@@ -66,21 +66,7 @@ and ensuring that dates are in local not DB timezones.
 
 sub LabelValue {
     my $self  = shift;
-    my $field = shift;
-    my $value = $self->__Value( $field );
-
-    if ( $field =~ /(Daily|Monthly|Annually|Hourly)$/ ) {
-        my $re;
-        # it's not just 1970-01-01 00:00:00 because of timezone shifts
-        # and conversion from UTC to user's TZ
-        $re = qr{19(?:70-01-01|69-12-31) [0-9]{2}} if $field =~ /Hourly$/;
-        $re = qr{19(?:70-01-01|69-12-31)} if $field =~ /Daily$/;
-        $re = qr{19(?:70-01|69-12)} if $field =~ /Monthly$/;
-        $re = qr{19(?:70|69)} if $field =~ /Annually$/;
-        $value =~ s/^$re/Not Set/;
-    }
-
-    return $value;
+    return $self->__Value( @_ );
 }
 
 sub ObjectType {
diff --git a/lib/RT/SearchBuilder.pm b/lib/RT/SearchBuilder.pm
index 4fc9ee2..f8b0615 100644
--- a/lib/RT/SearchBuilder.pm
+++ b/lib/RT/SearchBuilder.pm
@@ -908,6 +908,17 @@ sub ColumnMapClassName {
     return $Class;
 }
 
+sub NotSetDateToNullFunction {
+    my $self = shift;
+    my %args = ( FIELD => undef, @_ );
+
+    my $res = "CASE WHEN ? BETWEEN '1969-12-31 11:59:59' AND '1970-01-01 12:00:01' THEN NULL ELSE ? END";
+    if ( $args{FIELD} ) {
+        $res = $self->CombineFunctionWithField( %args, FUNCTION => $res );
+    }
+    return $res;
+}
+
 RT::Base->_ImportOverlays();
 
 1;
diff --git a/lib/RT/Tickets.pm b/lib/RT/Tickets.pm
index 2a0541e..f152f6f 100644
--- a/lib/RT/Tickets.pm
+++ b/lib/RT/Tickets.pm
@@ -552,7 +552,7 @@ sub _DateLimit {
         }
         my $function = $RT::Handle->DateTimeFunction(
             Type     => $subkey,
-            Field    => '?',
+            Field    => $sb->NotSetDateToNullFunction,
             Timezone => $tz,
         );
 
@@ -601,6 +601,7 @@ sub _DateLimit {
     }
     else {
         $sb->Limit(
+            FUNCTION => $sb->NotSetDateToNullFunction,
             FIELD    => $meta->[1],
             OPERATOR => $op,
             VALUE    => $date->ISO,

commit 68c3906e4e322b87523dfa342180ebf810ebcea0
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Tue May 31 03:13:20 2011 +0400

    add users' fields to our %GROUPINGS hash

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index da8f6cd..6fc52f7 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -55,6 +55,12 @@ use strict;
 use warnings;
 
 our %GROUPINGS = (
+    User => [qw(
+        Name RealName NickName
+        EmailAddress
+        Organization
+        Lang City Country Timezone
+    )],
     Date => [qw(
         Time
         Hourly Hour
@@ -73,13 +79,10 @@ sub Groupings {
       map { $self->CurrentUser->loc($_), $_ } qw( Status Queue );    # loc_qw
 
     foreach my $type ( qw(Owner Creator LastUpdatedBy Requestor Cc AdminCc Watcher) ) { # loc_qw
-        for my $field (
-            qw( Name EmailAddress RealName NickName Organization Lang City Country Timezone ) # loc_qw
-          )
-        {
+        for my $field ( @{ $GROUPINGS{'User'} } ) {
             push @fields,
               $self->CurrentUser->loc($type) . ' '
-              . $self->CurrentUser->loc($field), $type . '.' . $field;
+              . $self->CurrentUser->loc($field), "$type.$field";
         }
     }
 

commit 78e524d3fe7cbffcda328391dc830e51d1d62b09
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Tue May 31 05:13:39 2011 +0400

    make it possible to search by day and month names
    
    for example:
        Created.DayOfWeek = 'Thu'
        Started.Month = 'Jun'

diff --git a/lib/RT/Tickets.pm b/lib/RT/Tickets.pm
index f152f6f..8f96284 100644
--- a/lib/RT/Tickets.pm
+++ b/lib/RT/Tickets.pm
@@ -543,6 +543,25 @@ sub _DateLimit {
         unless ( defined $meta->[1] );
 
     if ( my $subkey = $rest{SUBKEY} ) {
+        if ( $subkey eq 'DayOfWeek' && $op !~ /IS/i && $value =~ /[^0-9]/ ) {
+            for ( my $i = 0; $i < @RT::Date::DAYS_OF_WEEK; $i++ ) {
+                next unless lc $RT::Date::DAYS_OF_WEEK[ $i ] eq lc $value;
+
+                $value = $i; last;
+            }
+            return $sb->Limit( FIELD => 'id', VALUE => 0, %rest )
+                if $value =~ /[^0-9]/;
+        }
+        elsif ( $subkey eq 'Month' && $op !~ /IS/i && $value =~ /[^0-9]/ ) {
+            for ( my $i = 0; $i < @RT::Date::MONTHS; $i++ ) {
+                next unless lc $RT::Date::MONTHS[ $i ] eq lc $value;
+
+                $value = $i + 1; last;
+            }
+            return $sb->Limit( FIELD => 'id', VALUE => 0, %rest )
+                if $value =~ /[^0-9]/;
+        }
+
         my $tz;
         if ( RT->Config->Get('ChartsTimezonesInDB') ) {
             my $to = $sb->CurrentUser->UserObj->Timezone
@@ -550,6 +569,7 @@ sub _DateLimit {
             $tz = { From => 'UTC', To => $to }
                 if $to && lc $to ne 'utc';
         }
+
         my $function = $RT::Handle->DateTimeFunction(
             Type     => $subkey,
             Field    => $sb->NotSetDateToNullFunction,

commit a09f21ca64086bd5ed7359f3a8cbd0f4f6241413
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Tue May 31 18:28:52 2011 +0400

    store grouping properties by column alias

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 6fc52f7..b597baa 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -141,15 +141,28 @@ sub SetupGroupings {
     my %args = (Query => undef, GroupBy => undef, @_);
 
     $self->FromSQL( $args{'Query'} );
+
     my @group_by = ref( $args{'GroupBy'} )? @{ $args{'GroupBy'} } : ($args{'GroupBy'});
     $self->GroupBy( map { {FIELD => $_} } @group_by );
 
     # UseSQLForACLChecks may add late joins
     my $joined = ($self->_isJoined || RT->Config->Get('UseSQLForACLChecks')) ? 1 : 0;
 
+    my %column_type;
+
     my @res;
+
     push @res, $self->Column( FUNCTION => ($joined? 'DISTINCT COUNT' : 'COUNT'), FIELD => 'id' );
-    push @res, map $self->Column( FIELD => $_ ), @group_by;
+    $column_type{ $res[-1] } = { FUNCTION => ($joined? 'DISTINCT COUNT' : 'COUNT'), FIELD => 'id' };
+
+    foreach my $group_by ( @group_by ) {
+        my $alias = $self->Column( FIELD => $group_by );
+        $column_type{ $alias } = { FIELD => $group_by };
+        push @res, $alias;
+    }
+
+    $self->{'column_types'} = \%column_type;
+
     return @res;
 }
 

commit aa1362992eb49465442ad52686ce66ab0d1376b0
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Tue May 31 18:31:38 2011 +0400

    pass properties of our report to individual entries

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index b597baa..7047ad2 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -287,7 +287,9 @@ sub Next {
 
 sub NewItem {
     my $self = shift;
-    return RT::Report::Tickets::Entry->new(RT->SystemUser); # $self->CurrentUser);
+    my $res = RT::Report::Tickets::Entry->new(RT->SystemUser); # $self->CurrentUser);
+    $res->{'column_types'} = $self->{'column_types'};
+    return $res;
 }
 
 # This is necessary since normally NewItem (above) is used to intuit the

commit 1e85def7fbb617fc7545fe3f1586383f6c0ffdd8
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Tue May 31 18:34:18 2011 +0400

    upgrade DayOfWeek and Month to words in LabelValue

diff --git a/lib/RT/Report/Tickets/Entry.pm b/lib/RT/Report/Tickets/Entry.pm
index 90951d0..6b30bdc 100644
--- a/lib/RT/Report/Tickets/Entry.pm
+++ b/lib/RT/Report/Tickets/Entry.pm
@@ -56,6 +56,14 @@ use base qw/RT::Record/;
 # XXX TODO: how the heck do we acl a report?
 sub CurrentUserHasRight {1}
 
+sub ColumnType {
+    my $self = shift;
+    my $column = shift;
+
+    return $self->{'column_types'}{$column};
+}
+
+
 =head2 LabelValue
 
 If you're pulling a value out of this collection and using it as a label,
@@ -66,7 +74,32 @@ and ensuring that dates are in local not DB timezones.
 
 sub LabelValue {
     my $self  = shift;
-    return $self->__Value( @_ );
+    my $name = shift;
+
+    my $raw = $self->RawValue( $name, @_ );
+
+    my $type = $self->ColumnType( $name );
+    return $raw unless $type;
+
+    my $field = $type->{'FIELD'};
+    return $raw unless $field;
+
+    my ($key, $subkey) = split /\./, $field, 2;
+    if ( $subkey && grep $_ eq $subkey, @{ $RT::Report::Tickets::GROUPINGS{'Date'} } ) {
+        return $raw unless defined $raw;
+        if ( $subkey eq 'DayOfWeek' ) {
+            return $RT::Date::DAYS_OF_WEEK[ int $raw ];
+        }
+        elsif ( $subkey eq 'Month' ) {
+            return $RT::Date::MONTHS[ int($raw) - 1 ];
+        }
+    }
+
+    return $raw;
+}
+
+sub RawValue {
+    return (shift)->__Value( @_ );
 }
 
 sub ObjectType {

commit 02d7a7b01d6360d52a8cd4d2bd22f983a0ff1083
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Wed Jun 1 02:55:38 2011 +0400

    component to pick a function calculated in charts

diff --git a/share/html/Search/Elements/SelectChartFunction b/share/html/Search/Elements/SelectChartFunction
new file mode 100644
index 0000000..cff9bd9
--- /dev/null
+++ b/share/html/Search/Elements/SelectChartFunction
@@ -0,0 +1,68 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2011 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<select name="<% $Name %>">
+% my @tmp = @functions;
+% while ( my ($display, $value) = splice @tmp, 0, 2 ) {
+<option value="<% $value %>"<% $value eq $Default ? qq[ selected="selected"] : '' |n %>><% loc( $display ) %></option>
+% }
+</select>
+<%ARGS>
+$Name => 'ChartFunction'
+$Default => 'COUNT'
+</%ARGS>
+<%ONCE>
+my @functions = (
+    'Count of tickets'  => 'COUNT id',
+);
+foreach my $field (qw(Worked Estimated Left)) {
+    push @functions, "Total time \L$field" => "SUM Time$field",
+        "Average time \L$field" => "AVG Time$field",
+        "Minimum time \L$field" => "MIN Time$field",
+        "Maximum time \L$field" => "MAX Time$field";
+}
+</%ONCE>

commit 25e35201ead1975db3f7e25a5287739520e40208
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Wed Jun 1 02:57:06 2011 +0400

    Allow to pick chart's function and pass it around
    
    At the end pass it into SetupGrouping, but there is
    no implementation

diff --git a/share/html/Search/Chart b/share/html/Search/Chart
index 5d1ec64..6d834f5 100644
--- a/share/html/Search/Chart
+++ b/share/html/Search/Chart
@@ -49,6 +49,7 @@
 $Query => "id > 0"
 $PrimaryGroupBy => 'Queue'
 $ChartStyle => 'bars'
+$ChartFunction => 'COUNT id'
 </%args>
 <%init>
 my $chart_class;
@@ -68,7 +69,9 @@ my $tix = RT::Report::Tickets->new( $session{'CurrentUser'} );
 my %AllowedGroupings = reverse $tix->Groupings( Query => $Query );
 $PrimaryGroupBy = 'Queue' unless exists $AllowedGroupings{$PrimaryGroupBy};
 my ($count_name, $value_name) = $tix->SetupGroupings(
-    Query => $Query, GroupBy => $PrimaryGroupBy,
+    Query => $Query,
+    GroupBy => $PrimaryGroupBy,
+    Function => $ChartFunction,
 );
 
 my %class = (
diff --git a/share/html/Search/Chart.html b/share/html/Search/Chart.html
index 6ec5789..3544395 100644
--- a/share/html/Search/Chart.html
+++ b/share/html/Search/Chart.html
@@ -48,6 +48,7 @@
 <%args>
 $PrimaryGroupBy => 'Queue'
 $ChartStyle => 'bars'
+$ChartFunction => 'COUNT id'
 $Description => undef
 </%args>
 <%init>
@@ -124,8 +125,11 @@ my %query;
 <input type="hidden" class="hidden" name="Query" value="<% $ARGS{Query} %>" />
 <input type="hidden" class="hidden" name="SavedChartSearchId" value="<% $saved_search->{SearchId} || 'new' %>" />
 
-<&|/l_unsafe, $m->scomp('Elements/SelectChartType', Name => 'ChartStyle', Default => $ChartStyle), $m->scomp('Elements/SelectGroupBy', Name => 'PrimaryGroupBy', Query => $ARGS{Query}, Default => $PrimaryGroupBy) 
-&>[_1] chart by [_2]</&><input type="submit" class="button" value="<%loc('Update Chart')%>" />
+<&|/l_unsafe,
+  $m->scomp('Elements/SelectChartFunction', Default => $ChartFunction),
+  $m->scomp('Elements/SelectChartType', Default => $ChartStyle),
+  $m->scomp('Elements/SelectGroupBy', Name => 'PrimaryGroupBy', Query => $ARGS{Query}, Default => $PrimaryGroupBy) 
+&>[_1] [_2] chart by [_3]</&><input type="submit" class="button" value="<%loc('Update Chart')%>" />
 </form>
 </&>
 </div>
diff --git a/share/html/Search/Elements/Chart b/share/html/Search/Elements/Chart
index 7343900..225a4ea 100644
--- a/share/html/Search/Elements/Chart
+++ b/share/html/Search/Elements/Chart
@@ -49,16 +49,21 @@
 $Query => "id > 0"
 $PrimaryGroupBy => 'Queue'
 $ChartStyle => 'bars'
+$ChartFunction => 'COUNT id'
 </%args>
 <%init>
 use RT::Report::Tickets;
 $PrimaryGroupBy ||= 'Queue'; # make sure PrimaryGroupBy is not undef
 
 my $tix = RT::Report::Tickets->new( $session{'CurrentUser'} );
+
 my %AllowedGroupings = reverse $tix->Groupings( Query => $Query );
 $PrimaryGroupBy = 'Queue' unless exists $AllowedGroupings{$PrimaryGroupBy};
+
 my ($count_name, $value_name) = $tix->SetupGroupings(
-    Query => $Query, GroupBy => $PrimaryGroupBy,
+    Query => $Query,
+    GroupBy => $PrimaryGroupBy,
+    Function => $ChartFunction,
 );
 
 my %class = (

commit 54dfec479f3eb2930e2967ecd7c9c36b8e66c7b6
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Wed Jun 1 02:58:16 2011 +0400

    implement Function argument in ChartGrouping

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 7047ad2..bc72443 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -138,7 +138,12 @@ sub Label {
 
 sub SetupGroupings {
     my $self = shift;
-    my %args = (Query => undef, GroupBy => undef, @_);
+    my %args = (
+        Query => undef,
+        GroupBy => undef,
+        Function => undef,
+        @_
+    );
 
     $self->FromSQL( $args{'Query'} );
 
@@ -148,12 +153,15 @@ sub SetupGroupings {
     # UseSQLForACLChecks may add late joins
     my $joined = ($self->_isJoined || RT->Config->Get('UseSQLForACLChecks')) ? 1 : 0;
 
-    my %column_type;
+    my (@res, %column_type);
 
-    my @res;
-
-    push @res, $self->Column( FUNCTION => ($joined? 'DISTINCT COUNT' : 'COUNT'), FIELD => 'id' );
-    $column_type{ $res[-1] } = { FUNCTION => ($joined? 'DISTINCT COUNT' : 'COUNT'), FIELD => 'id' };
+    my @function = ref( $args{'Function'} )? @{ $args{'Function'} } : ($args{'Function'});
+    foreach my $e ( @function ) {
+        my ($function, $field) = split /\s+/, $e, 2;
+        $function = 'DISTINCT COUNT' if $joined && lc($function) eq 'count';
+        push @res, $self->Column( FUNCTION => $function, FIELD => $field );
+        $column_type{ $res[-1] } = { FUNCTION => $function, FIELD => $field };
+    }
 
     foreach my $group_by ( @group_by ) {
         my $alias = $self->Column( FIELD => $group_by );

commit abcc3ca8d436e0e07158741d9d36456e1a447ce7
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Tue Jun 7 21:48:03 2011 +0400

    new @GROUPINGS, %GROUPINGS_META variables
    
    * purpose like in TicketSQL
    * handle grouping to function mapping
    * handle sub keys generation

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index bc72443..c0e2b0b 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -54,70 +54,125 @@ use RT::Report::Tickets::Entry;
 use strict;
 use warnings;
 
-our %GROUPINGS = (
-    User => [qw(
-        Name RealName NickName
-        EmailAddress
-        Organization
-        Lang City Country Timezone
-    )],
-    Date => [qw(
-        Time
-        Hourly Hour
-        Date Daily
-        DayOfWeek Day DayOfMonth DayOfYear
-        Month Monthly
-        Year Annually
-        WeekOfYear
-    )],
+our @GROUPINGS = (
+    Status => 'Enum',
+
+    Queue  => 'Queue',
+
+    Owner         => 'User',
+    Creator       => 'User',
+    LastUpdatedBy => 'User',
+
+    Requestor     => 'Watcher',
+    Cc            => 'Watcher',
+    AdminCc       => 'Watcher',
+    Watcher       => 'Watcher',
+
+    Created       => 'Date',
+    Starts        => 'Date',
+    Started       => 'Date',
+    Resolved      => 'Date',
+    Due           => 'Date',
+    Told          => 'Date',
+    LastUpdated   => 'Date',
+
+    CF            => 'CustomField',
+);
+our %GROUPINGS;
+
+our %GROUPINGS_META = (
+    Queue => {
+    },
+    User => {
+        SubFields => [qw(
+            Name RealName NickName
+            EmailAddress
+            Organization
+            Lang City Country Timezone
+        )],
+        Function => 'GenerateUserFunction',
+    },
+    Watcher => {
+        SubFields => [qw(
+            Name RealName NickName
+            EmailAddress
+            Organization
+            Lang City Country Timezone
+        )],
+        Function => 'GenerateWatcherFunction',
+    },
+    Date => {
+        SubFields => [qw(
+            Time
+            Hourly Hour
+            Date Daily
+            DayOfWeek Day DayOfMonth DayOfYear
+            Month Monthly
+            Year Annually
+            WeekOfYear
+        )],
+        Function => 'GenerateDateFunction',
+    },
+    CustomField => {
+        SubFields => sub {
+            my $self = shift;
+            my $args = shift;
+
+
+            my $queues = $args->{'Queues'};
+            if ( !$queues && $args->{'Query'} ) {
+                require RT::Interface::Web::QueryBuilder::Tree;
+                my $tree = RT::Interface::Web::QueryBuilder::Tree->new('AND');
+                $tree->ParseSQL( Query => $args->{'Query'}, CurrentUser => $self->CurrentUser );
+                $queues = $args->{'Queues'} = $tree->GetReferencedQueues;
+            }
+            return () unless $queues;
+
+            my @res;
+
+            my $CustomFields = RT::CustomFields->new( $self->CurrentUser );
+            foreach my $id (keys %$queues) {
+                my $queue = RT::Queue->new( $self->CurrentUser );
+                $queue->Load($id);
+                next unless $queue->id;
+
+                $CustomFields->LimitToQueue($queue->id);
+            }
+            $CustomFields->LimitToGlobal;
+            while ( my $CustomField = $CustomFields->Next ) {
+                push @res, "Custom field '". $CustomField->Name ."'", "CF.{". $CustomField->id ."}";
+            }
+            return @res;
+        },
+        Function => 'GenerateCustomFieldFunction',
+    },
+    Enum => {
+    },
 );
 
 sub Groupings {
     my $self = shift;
     my %args = (@_);
-    my @fields =
-      map { $self->CurrentUser->loc($_), $_ } qw( Status Queue );    # loc_qw
-
-    foreach my $type ( qw(Owner Creator LastUpdatedBy Requestor Cc AdminCc Watcher) ) { # loc_qw
-        for my $field ( @{ $GROUPINGS{'User'} } ) {
-            push @fields,
-              $self->CurrentUser->loc($type) . ' '
-              . $self->CurrentUser->loc($field), "$type.$field";
-        }
-    }
 
+    my @fields;
 
-    for my $field (qw(Due Resolved Created LastUpdated Started Starts Told)) { # loc_qw
-        for my $frequency (@{ $GROUPINGS{'Date'} }) {
-            push @fields,
-              $self->CurrentUser->loc($field)
-              .' '. $self->CurrentUser->loc($frequency),
-              "$field.$frequency";
+    my @tmp = @GROUPINGS;
+    while ( my ($field, $type) = splice @tmp, 0, 2 ) {
+        my $meta = $GROUPINGS_META{ $type } || {};
+        unless ( $meta->{'SubFields'} ) {
+            push @fields, $field, $field;
         }
-    }
-
-    my $queues = $args{'Queues'};
-    if ( !$queues && $args{'Query'} ) {
-        require RT::Interface::Web::QueryBuilder::Tree;
-        my $tree = RT::Interface::Web::QueryBuilder::Tree->new('AND');
-        $tree->ParseSQL( Query => $args{'Query'}, CurrentUser => $self->CurrentUser );
-        $queues = $tree->GetReferencedQueues;
-    }
-
-    if ( $queues ) {
-        my $CustomFields = RT::CustomFields->new( $self->CurrentUser );
-        foreach my $id (keys %$queues) {
-            my $queue = RT::Queue->new( $self->CurrentUser );
-            $queue->Load($id);
-            $CustomFields->LimitToQueue($queue->Id) if $queue->Id;
+        elsif ( ref( $meta->{'SubFields'} ) eq 'ARRAY' ) {
+            push @fields, map { ("$field $_", "$field.$_") } @{ $meta->{'SubFields'} };
         }
-        $CustomFields->LimitToGlobal;
-        while ( my $CustomField = $CustomFields->Next ) {
-            push @fields, $self->CurrentUser->loc(
-                "Custom field '[_1]'",
-                $CustomField->Name
-              ),
-              "CF.{" . $CustomField->id . "}";
+        elsif ( ref( $meta->{'SubFields'} ) eq 'CODE' ) {
+            push @fields, $meta->{'SubFields'}->(
+                $self,
+                \%args,
+            );
+        }
+        else {
+            $RT::Logger->error("%GROUPINGS_META for $type has unsupported SubFields");
         }
     }
     return @fields;
@@ -226,59 +281,32 @@ sub _FieldToFunction {
     my $self = shift;
     my %args = (@_);
 
-    my $field = $args{'FIELD'};
+    @args{'FIELD', 'SUBKEY'} = split /\./, $args{'FIELD'}, 2;
 
-    my ($key, $subkey) = split /\./, $field, 2;
+    %GROUPINGS = @GROUPINGS unless keys %GROUPINGS;
 
-    if ( $subkey && grep $_ eq $subkey, @{ $GROUPINGS{'Date'} } ) {
-        my $tz;
-        if ( RT->Config->Get('ChartsTimezonesInDB') ) {
-            my $to = $self->CurrentUser->UserObj->Timezone
-                || RT->Config->Get('Timezone');
-            $tz = { From => 'UTC', To => $to }
-                if $to && lc $to ne 'utc';
-        }
+    my $meta = $GROUPINGS_META{ $GROUPINGS{ $args{'FIELD'} } };
+    return ('FUNCTION' => 'NULL') unless $meta;
 
-        $args{'FUNCTION'} = $RT::Handle->DateTimeFunction(
-            Type => $subkey,
-            Field => $self->NotSetDateToNullFunction,
-            Timezone => $tz,
-        );
-        $args{'FIELD'} = $key;
-    } elsif ( $field =~ /^(?:CF|CustomField)\.{(.*)}$/ ) { #XXX: use CFDecipher method
-        my $cf_name = $1;
-        my $cf = RT::CustomField->new( $self->CurrentUser );
-        $cf->Load($cf_name);
-        unless ( $cf->id ) {
-            $RT::Logger->error("Couldn't load CustomField #$cf_name");
-        } else {
-            my ($ticket_cf_alias, $cf_alias) = $self->_CustomFieldJoin($cf->id, $cf);
-            @args{qw(ALIAS FIELD)} = ($ticket_cf_alias, 'Content');
-        }
-    } elsif ( $field =~ /^(?:(Owner|Creator|LastUpdatedBy))(?:\.(.*))?$/ ) {
-        my $type = $1 || '';
-        my $column = $2 || 'Name';
-        my $u_alias = $self->{"_sql_report_${type}_users_${column}"}
-            ||= $self->Join(
-                TYPE   => 'LEFT',
-                ALIAS1 => 'main',
-                FIELD1 => $type,
-                TABLE2 => 'Users',
-                FIELD2 => 'id',
-            );
-        @args{qw(ALIAS FIELD)} = ($u_alias, $column);
-    } elsif ( $field =~ /^(?:Watcher|(Requestor|Cc|AdminCc))(?:\.(.*))?$/ ) {
-        my $type = $1 || '';
-        my $column = $2 || 'Name';
-        my $u_alias = $self->{"_sql_report_watcher_users_alias_$type"};
-        unless ( $u_alias ) {
-            my ($g_alias, $gm_alias);
-            ($g_alias, $gm_alias, $u_alias) = $self->_WatcherJoin( Name => $type );
-            $self->{"_sql_report_watcher_users_alias_$type"} = $u_alias;
+    return %args unless $meta->{'Function'};
+
+    my $code;
+    unless ( ref $meta->{'Function'} ) {
+        $code = $self->can( $meta->{'Function'} );
+        unless ( $code ) {
+            $RT::Logger->error("No method ". $meta->{'Function'} );
+            return ('FUNCTION' => 'NULL');
         }
-        @args{qw(ALIAS FIELD)} = ($u_alias, $column);
     }
-    return %args;
+    elsif ( ref( $meta->{'Function'} ) eq 'CODE' ) {
+        $code = $meta->{'Function'};
+    }
+    else {
+        $RT::Logger->error("%GROUPINGS_META for $args{FIELD} has unsupported Function");
+        return ('FUNCTION' => 'NULL');
+    }
+
+    return $code->( $self, %args );
 }
 
 1;
@@ -329,6 +357,80 @@ sub AddEmptyRows {
     }
 }
 
+sub GenerateDateFunction {
+    my $self = shift;
+    my %args = @_;
+
+    my $tz;
+    if ( RT->Config->Get('ChartsTimezonesInDB') ) {
+        my $to = $self->CurrentUser->UserObj->Timezone
+            || RT->Config->Get('Timezone');
+        $tz = { From => 'UTC', To => $to }
+            if $to && lc $to ne 'utc';
+    }
+
+    $args{'FUNCTION'} = $RT::Handle->DateTimeFunction(
+        Type     => delete $args{'SUBKEY'},
+        Field    => $self->NotSetDateToNullFunction,
+        Timezone => $tz,
+    );
+    return %args;
+}
+
+sub GenerateCustomFieldFunction {
+    my $self = shift;
+    my %args = @_;
+
+    my ($name) = ( (delete $args{'SUBKEY'}) =~ /^\.{(.*)}$/ );
+    my $cf = RT::CustomField->new( $self->CurrentUser );
+    $cf->Load($name);
+    unless ( $cf->id ) {
+        $RT::Logger->error("Couldn't load CustomField #$name");
+        @args{qw(FUNCTION FIELD)} = ('NULL', undef);
+    } else {
+        my ($ticket_cf_alias, $cf_alias) = $self->_CustomFieldJoin($cf->id, $cf);
+        @args{qw(ALIAS FIELD)} = ($ticket_cf_alias, 'Content');
+    }
+    return %args;
+}
+
+sub GenerateUserFunction {
+    my $self = shift;
+    my %args = @_;
+
+    my $column = delete $args{'SUBKEY'} || 'Name';
+    my $u_alias = $self->{"_sql_report_$args{FIELD}_users_$column"}
+        ||= $self->Join(
+            TYPE   => 'LEFT',
+            ALIAS1 => 'main',
+            FIELD1 => $args{'FIELD'},
+            TABLE2 => 'Users',
+            FIELD2 => 'id',
+        );
+    @args{qw(ALIAS FIELD)} = ($u_alias, $column);
+    return %args;
+}
+
+sub GenerateWatcherFunction {
+    my $self = shift;
+    my %args = @_;
+
+    my $type = $args{'FIELD'};
+    $type = '' if $type eq 'Watcher';
+
+    my $column = delete $args{'SUBKEY'} || 'Name';
+
+    my $u_alias = $self->{"_sql_report_watcher_users_alias_$type"};
+    unless ( $u_alias ) {
+        my ($g_alias, $gm_alias);
+        ($g_alias, $gm_alias, $u_alias) = $self->_WatcherJoin( Name => $type );
+        $self->{"_sql_report_watcher_users_alias_$type"} = $u_alias;
+    }
+    @args{qw(ALIAS FIELD)} = ($u_alias, $column);
+
+    return %args;
+}
+
 RT::Base->_ImportOverlays();
 
 1;

commit 6157ebcf349f8c0e0f4b788ed308b6c129f2bce6
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Tue Jun 7 22:20:41 2011 +0400

    move value upgrade code into META

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index c0e2b0b..fa703aa 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -112,6 +112,22 @@ our %GROUPINGS_META = (
             WeekOfYear
         )],
         Function => 'GenerateDateFunction',
+        Display => sub {
+            my $self = shift;
+            my %args = (@_);
+
+            my $raw = $args{'VALUE'};
+            return $raw unless defined $raw;
+
+            my ($field, $subkey) = split /\./, $args{'FIELD'}, 2;
+            if ( $subkey eq 'DayOfWeek' ) {
+                return $RT::Date::DAYS_OF_WEEK[ int $raw ];
+            }
+            elsif ( $subkey eq 'Month' ) {
+                return $RT::Date::MONTHS[ int($raw) - 1 ];
+            }
+            return $raw;
+        },
     },
     CustomField => {
         SubFields => sub {
diff --git a/lib/RT/Report/Tickets/Entry.pm b/lib/RT/Report/Tickets/Entry.pm
index 6b30bdc..224b6c2 100644
--- a/lib/RT/Report/Tickets/Entry.pm
+++ b/lib/RT/Report/Tickets/Entry.pm
@@ -79,23 +79,25 @@ sub LabelValue {
     my $raw = $self->RawValue( $name, @_ );
 
     my $type = $self->ColumnType( $name );
-    return $raw unless $type;
-
-    my $field = $type->{'FIELD'};
-    return $raw unless $field;
-
-    my ($key, $subkey) = split /\./, $field, 2;
-    if ( $subkey && grep $_ eq $subkey, @{ $RT::Report::Tickets::GROUPINGS{'Date'} } ) {
-        return $raw unless defined $raw;
-        if ( $subkey eq 'DayOfWeek' ) {
-            return $RT::Date::DAYS_OF_WEEK[ int $raw ];
-        }
-        elsif ( $subkey eq 'Month' ) {
-            return $RT::Date::MONTHS[ int($raw) - 1 ];
+    my $meta = $self->FieldMeta( $type->{'FIELD'} );
+    return $raw unless $meta && $meta->{'Display'};
+
+    my $code;
+    unless ( ref $meta->{'Display'} ) {
+        $code = $self->can( $meta->{'Display'} );
+        unless ( $code ) {
+            $RT::Logger->error("No method ". $meta->{'Display'} );
+            return $raw;
         }
     }
+    elsif ( ref( $meta->{'Display'} ) eq 'CODE' ) {
+        $code = $meta->{'Display'};
+    }
+    else {
+        return $raw;
+    }
 
-    return $raw;
+    return $code->( $self, %$type, VALUE => $raw );
 }
 
 sub RawValue {
@@ -106,6 +108,21 @@ sub ObjectType {
     return 'RT::Ticket';
 }
 
+sub FieldMeta {
+    my $self = shift;
+    my $field = shift or return undef;
+
+    ($field) = split /\./, $field, 2;
+
+    %RT::Report::Tickets::GROUPINGS
+        = @RT::Report::Tickets::GROUPINGS
+        unless keys %RT::Report::Tickets::GROUPINGS;
+
+    return $RT::Report::Tickets::GROUPINGS_META{
+        $RT::Report::Tickets::GROUPINGS{ $field }
+    };
+}
+
 RT::Base->_ImportOverlays();
 
 1;

commit e8cce975142faa0e2eff325d10bd603a2c1678a0
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Wed Jun 8 02:11:52 2011 +0400

    make SetupGrouping mandatory
    
    * call _FieldToFunction asap
    * use KEY/SUBKEY to store original FIELD value splitted
    * no need in GroupBy and custom Column
    * no need in continuouse split

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index fa703aa..2bc462c 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -119,11 +119,10 @@ our %GROUPINGS_META = (
             my $raw = $args{'VALUE'};
             return $raw unless defined $raw;
 
-            my ($field, $subkey) = split /\./, $args{'FIELD'}, 2;
-            if ( $subkey eq 'DayOfWeek' ) {
+            if ( $args{'SUBKEY'} eq 'DayOfWeek' ) {
                 return $RT::Date::DAYS_OF_WEEK[ int $raw ];
             }
-            elsif ( $subkey eq 'Month' ) {
+            elsif ( $args{'SUBKEY'} eq 'Month' ) {
                 return $RT::Date::MONTHS[ int($raw) - 1 ];
             }
             return $raw;
@@ -219,7 +218,11 @@ sub SetupGroupings {
     $self->FromSQL( $args{'Query'} );
 
     my @group_by = ref( $args{'GroupBy'} )? @{ $args{'GroupBy'} } : ($args{'GroupBy'});
-    $self->GroupBy( map { {FIELD => $_} } @group_by );
+    foreach my $e ( @group_by ) {
+        my ($key, $subkey) = split /\./, $e, 2;
+        $e = { $self->_FieldToFunction( KEY => $key, SUBKEY => $subkey ) };
+    }
+    $self->GroupBy( @group_by );
 
     # UseSQLForACLChecks may add late joins
     my $joined = ($self->_isJoined || RT->Config->Get('UseSQLForACLChecks')) ? 1 : 0;
@@ -235,8 +238,8 @@ sub SetupGroupings {
     }
 
     foreach my $group_by ( @group_by ) {
-        my $alias = $self->Column( FIELD => $group_by );
-        $column_type{ $alias } = { FIELD => $group_by };
+        my $alias = $self->Column( %$group_by );
+        $column_type{ $alias } = $group_by;
         push @res, $alias;
     }
 
@@ -245,27 +248,6 @@ sub SetupGroupings {
     return @res;
 }
 
-sub GroupBy {
-    my $self = shift;
-    my @args = ref $_[0]? @_ : { @_ };
-
-    @{ $self->{'_group_by_field'} ||= [] } = map $_->{'FIELD'}, @args;
-    $_ = { $self->_FieldToFunction( %$_ ) } foreach @args;
-
-    $self->SUPER::GroupBy( @args );
-}
-
-sub Column {
-    my $self = shift;
-    my %args = (@_);
-
-    if ( $args{'FIELD'} && !$args{'FUNCTION'} ) {
-        %args = $self->_FieldToFunction( %args );
-    }
-
-    return $self->SUPER::Column( %args );
-}
-
 =head2 _DoSearch
 
 Subclass _DoSearch from our parent so we can go through and add in empty 
@@ -297,11 +279,11 @@ sub _FieldToFunction {
     my $self = shift;
     my %args = (@_);
 
-    @args{'FIELD', 'SUBKEY'} = split /\./, $args{'FIELD'}, 2;
+    $args{'FIELD'} ||= $args{'KEY'};
 
     %GROUPINGS = @GROUPINGS unless keys %GROUPINGS;
 
-    my $meta = $GROUPINGS_META{ $GROUPINGS{ $args{'FIELD'} } };
+    my $meta = $GROUPINGS_META{ $GROUPINGS{ $args{'KEY'} } };
     return ('FUNCTION' => 'NULL') unless $meta;
 
     return %args unless $meta->{'Function'};
@@ -386,7 +368,7 @@ sub GenerateDateFunction {
     }
 
     $args{'FUNCTION'} = $RT::Handle->DateTimeFunction(
-        Type     => delete $args{'SUBKEY'},
+        Type     => $args{'SUBKEY'},
         Field    => $self->NotSetDateToNullFunction,
         Timezone => $tz,
     );
@@ -397,7 +379,7 @@ sub GenerateCustomFieldFunction {
     my $self = shift;
     my %args = @_;
 
-    my ($name) = ( (delete $args{'SUBKEY'}) =~ /^\.{(.*)}$/ );
+    my ($name) = ( $args{'SUBKEY'} =~ /^\.{(.*)}$/ );
     my $cf = RT::CustomField->new( $self->CurrentUser );
     $cf->Load($name);
     unless ( $cf->id ) {
@@ -414,7 +396,7 @@ sub GenerateUserFunction {
     my $self = shift;
     my %args = @_;
 
-    my $column = delete $args{'SUBKEY'} || 'Name';
+    my $column = $args{'SUBKEY'} || 'Name';
     my $u_alias = $self->{"_sql_report_$args{FIELD}_users_$column"}
         ||= $self->Join(
             TYPE   => 'LEFT',
@@ -434,7 +416,7 @@ sub GenerateWatcherFunction {
     my $type = $args{'FIELD'};
     $type = '' if $type eq 'Watcher';
 
-    my $column = delete $args{'SUBKEY'} || 'Name';
+    my $column = $args{'SUBKEY'} || 'Name';
 
     my $u_alias = $self->{"_sql_report_watcher_users_alias_$type"};
     unless ( $u_alias ) {
diff --git a/lib/RT/Report/Tickets/Entry.pm b/lib/RT/Report/Tickets/Entry.pm
index 224b6c2..2655823 100644
--- a/lib/RT/Report/Tickets/Entry.pm
+++ b/lib/RT/Report/Tickets/Entry.pm
@@ -79,7 +79,7 @@ sub LabelValue {
     my $raw = $self->RawValue( $name, @_ );
 
     my $type = $self->ColumnType( $name );
-    my $meta = $self->FieldMeta( $type->{'FIELD'} );
+    my $meta = $self->FieldMeta( $type->{'KEY'} );
     return $raw unless $meta && $meta->{'Display'};
 
     my $code;
@@ -112,8 +112,6 @@ sub FieldMeta {
     my $self = shift;
     my $field = shift or return undef;
 
-    ($field) = split /\./, $field, 2;
-
     %RT::Report::Tickets::GROUPINGS
         = @RT::Report::Tickets::GROUPINGS
         unless keys %RT::Report::Tickets::GROUPINGS;

commit d3654e25c097d9015a9a689a997721000a2ea3d6
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Wed Jun 8 03:39:57 2011 +0400

    store META and type around for re-use

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 2bc462c..6a337ad 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -217,10 +217,14 @@ sub SetupGroupings {
 
     $self->FromSQL( $args{'Query'} );
 
+    %GROUPINGS = @GROUPINGS unless keys %GROUPINGS;
+
     my @group_by = ref( $args{'GroupBy'} )? @{ $args{'GroupBy'} } : ($args{'GroupBy'});
     foreach my $e ( @group_by ) {
         my ($key, $subkey) = split /\./, $e, 2;
         $e = { $self->_FieldToFunction( KEY => $key, SUBKEY => $subkey ) };
+        $e->{'TYPE'} = $GROUPINGS{ $key };
+        $e->{'META'} = $GROUPINGS_META{ $e->{'TYPE'} };
     }
     $self->GroupBy( @group_by );
 
@@ -281,8 +285,6 @@ sub _FieldToFunction {
 
     $args{'FIELD'} ||= $args{'KEY'};
 
-    %GROUPINGS = @GROUPINGS unless keys %GROUPINGS;
-
     my $meta = $GROUPINGS_META{ $GROUPINGS{ $args{'KEY'} } };
     return ('FUNCTION' => 'NULL') unless $meta;
 
diff --git a/lib/RT/Report/Tickets/Entry.pm b/lib/RT/Report/Tickets/Entry.pm
index 2655823..87f70a2 100644
--- a/lib/RT/Report/Tickets/Entry.pm
+++ b/lib/RT/Report/Tickets/Entry.pm
@@ -78,8 +78,9 @@ sub LabelValue {
 
     my $raw = $self->RawValue( $name, @_ );
 
-    my $type = $self->ColumnType( $name );
-    my $meta = $self->FieldMeta( $type->{'KEY'} );
+    my $info = $self->ColumnType( $name );
+
+    my $meta = $info->{'META'};
     return $raw unless $meta && $meta->{'Display'};
 
     my $code;
@@ -97,7 +98,7 @@ sub LabelValue {
         return $raw;
     }
 
-    return $code->( $self, %$type, VALUE => $raw );
+    return $code->( $self, %$info, VALUE => $raw );
 }
 
 sub RawValue {
@@ -108,19 +109,6 @@ sub ObjectType {
     return 'RT::Ticket';
 }
 
-sub FieldMeta {
-    my $self = shift;
-    my $field = shift or return undef;
-
-    %RT::Report::Tickets::GROUPINGS
-        = @RT::Report::Tickets::GROUPINGS
-        unless keys %RT::Report::Tickets::GROUPINGS;
-
-    return $RT::Report::Tickets::GROUPINGS_META{
-        $RT::Report::Tickets::GROUPINGS{ $field }
-    };
-}
-
 RT::Base->_ImportOverlays();
 
 1;

commit a41f71068ace70ad62ed280eb0b03273140cec91
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Wed Jun 8 16:31:06 2011 +0400

    Display function for grouping by Queue

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 6a337ad..fd5aebd 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -82,6 +82,14 @@ our %GROUPINGS;
 
 our %GROUPINGS_META = (
     Queue => {
+        Display => sub {
+            my $self = shift;
+            my %args = (@_);
+
+            my $queue = RT::Queue->new( $self->CurrentUser );
+            $queue->Load( $args{'VALUE'} );
+            return $queue->Name;
+        },
     },
     User => {
         SubFields => [qw(

commit cd5fda36475464fd9e49610e21ed37cbd5733067
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Wed Jun 8 16:31:53 2011 +0400

    drop code, it's now covered in libs
    
    LabelValue method in ::Entry deals with it now

diff --git a/share/html/Search/Chart b/share/html/Search/Chart
index 6d834f5..259d08f 100644
--- a/share/html/Search/Chart
+++ b/share/html/Search/Chart
@@ -74,28 +74,12 @@ my ($count_name, $value_name) = $tix->SetupGroupings(
     Function => $ChartFunction,
 );
 
-my %class = (
-    Queue => 'RT::Queue',
-    Owner => 'RT::User',
-    Creator => 'RT::User',
-    LastUpdatedBy => 'RT::User',
-);
-my $class = $class{ $PrimaryGroupBy };
-
 my %data;
 my $max_value = 0;
 my $max_key_length = 0;
 while ( my $entry = $tix->Next ) {
-    my $key;
-    if ( $class ) {
-        my $q = $class->new( $session{'CurrentUser'} );
-        $q->Load( $entry->LabelValue( $value_name ) );
-        $key = $q->Name;
-    }
-    else {
-        $key = $entry->LabelValue($value_name);
-    }
-    $key ||= '(no value)';
+    my $key = $entry->LabelValue($value_name)
+        || '(no value)';
     
     my $value = $entry->__Value( $count_name );
     if ($chart_class eq 'GD::Graph::pie') {
diff --git a/share/html/Search/Elements/Chart b/share/html/Search/Elements/Chart
index 225a4ea..fe387e3 100644
--- a/share/html/Search/Elements/Chart
+++ b/share/html/Search/Elements/Chart
@@ -66,25 +66,10 @@ my ($count_name, $value_name) = $tix->SetupGroupings(
     Function => $ChartFunction,
 );
 
-my %class = (
-    Queue => 'RT::Queue',
-    Owner => 'RT::User',
-    Creator => 'RT::User',
-    LastUpdatedBy => 'RT::User',
-);
-my $class = $class{ $PrimaryGroupBy };
-
 my (@keys, @values);
 while ( my $entry = $tix->Next ) {
-    if ($class) {
-        my $q = $class->new( $session{'CurrentUser'} );
-        $q->Load( $entry->LabelValue( $value_name ) );
-        push @keys, $q->Name;
-    }
-    else {
-        push @keys, $entry->LabelValue( $value_name );
-    }
-    $keys[-1] ||= loc('(no value)');
+    push @keys, $entry->LabelValue( $value_name )
+        || loc('(no value)');
     push @values, $entry->__Value( $count_name );
 }
 

commit a3bae3d2d707713ee4604a0cae2da678c59cc65c
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Wed Jun 8 22:54:25 2011 +0400

    IsValidGrouping method for checks

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index fd5aebd..701552f 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -201,6 +201,34 @@ sub Groupings {
     return @fields;
 }
 
+sub IsValidGrouping {
+    my $self = shift;
+    my %args = (@_);
+    return 0 unless $args{'GroupBy'};
+
+    my ($key, $subkey) = split /\./, $args{'GroupBy'}, 2;
+
+    %GROUPINGS = @GROUPINGS unless keys %GROUPINGS;
+    my $type = $GROUPINGS{$key};
+    return 0 unless $type;
+    return 1 unless $subkey;
+
+    my $meta = $GROUPINGS_META{ $type } || {};
+    unless ( $meta->{'SubFields'} ) {
+        return 0;
+    }
+    elsif ( ref( $meta->{'SubFields'} ) eq 'ARRAY' ) {
+        return 1 if grep $_ eq $subkey, @{ $meta->{'SubFields'} };
+    }
+    elsif ( ref( $meta->{'SubFields'} ) eq 'CODE' ) {
+        return 1 if grep $_ eq "$key.$subkey", $meta->{'SubFields'}->(
+            $self,
+            \%args,
+        );
+    }
+    return 0;
+}
+
 sub Label {
     my $self = shift;
     my $field = shift;
diff --git a/share/html/Search/Chart b/share/html/Search/Chart
index 259d08f..6215f9d 100644
--- a/share/html/Search/Chart
+++ b/share/html/Search/Chart
@@ -66,8 +66,10 @@ if ($ChartStyle eq 'pie') {
 
 use RT::Report::Tickets;
 my $tix = RT::Report::Tickets->new( $session{'CurrentUser'} );
-my %AllowedGroupings = reverse $tix->Groupings( Query => $Query );
-$PrimaryGroupBy = 'Queue' unless exists $AllowedGroupings{$PrimaryGroupBy};
+
+$PrimaryGroupBy = 'Queue'
+    unless $tix->IsValidGrouping( Query => $Query, GroupBy => $PrimaryGroupBy );
+
 my ($count_name, $value_name) = $tix->SetupGroupings(
     Query => $Query,
     GroupBy => $PrimaryGroupBy,
diff --git a/share/html/Search/Elements/Chart b/share/html/Search/Elements/Chart
index fe387e3..e8e09b7 100644
--- a/share/html/Search/Elements/Chart
+++ b/share/html/Search/Elements/Chart
@@ -57,8 +57,8 @@ $PrimaryGroupBy ||= 'Queue'; # make sure PrimaryGroupBy is not undef
 
 my $tix = RT::Report::Tickets->new( $session{'CurrentUser'} );
 
-my %AllowedGroupings = reverse $tix->Groupings( Query => $Query );
-$PrimaryGroupBy = 'Queue' unless exists $AllowedGroupings{$PrimaryGroupBy};
+$PrimaryGroupBy = 'Queue'
+    unless $tix->IsValidGrouping( Query => $Query, GroupBy => $PrimaryGroupBy );
 
 my ($count_name, $value_name) = $tix->SetupGroupings(
     Query => $Query,

commit eaf36218f6014a3d5d69d93f94a5e0543d914113
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Fri Jun 10 18:48:32 2011 +0400

    @STATISTICS and Co. to track what we can calculate
    
    we need more flexibility, counting number of tickets is simple,
    new functions need post processing, labels and more

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 701552f..a733d5f 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -173,6 +173,82 @@ our %GROUPINGS_META = (
     },
 );
 
+our @STATISTICS = (
+    COUNT             => ['Tickets', 'Count', 'id'],
+
+    'SUM(TimeWorked)' => ['Total time worked',   'Simple', 'SUM', 'TimeWorked' ],
+    'AVG(TimeWorked)' => ['Average time worked', 'Simple', 'AVG', 'TimeWorked' ],
+    'MIN(TimeWorked)' => ['Minimum time worked', 'Simple', 'MIN', 'TimeWorked' ],
+    'MAX(TimeWorked)' => ['Maximum time worked', 'Simple', 'MAX', 'TimeWorked' ],
+
+    'SUM(TimeEstimated)' => ['Total time estimated',   'Simple', 'SUM', 'TimeEstimated' ],
+    'AVG(TimeEstimated)' => ['Average time estimated', 'Simple', 'AVG', 'TimeEstimated' ],
+    'MIN(TimeEstimated)' => ['Minimum time estimated', 'Simple', 'MIN', 'TimeEstimated' ],
+    'MAX(TimeEstimated)' => ['Maximum time estimated', 'Simple', 'MAX', 'TimeEstimated' ],
+
+    'SUM(TimeLeft)' => ['Total time left',   'Simple', 'SUM', 'TimeLeft' ],
+    'AVG(TimeLeft)' => ['Average time left', 'Simple', 'AVG', 'TimeLeft' ],
+    'MIN(TimeLeft)' => ['Minimum time left', 'Simple', 'MIN', 'TimeLeft' ],
+    'MAX(TimeLeft)' => ['Maximum time left', 'Simple', 'MAX', 'TimeLeft' ],
+
+    'SUM(Created-Resolved)'
+        => ['Summary of Created-Resolved', 'DateTimeInterval', 'SUM', 'Created', 'Resolved' ],
+    'AVG(Created-Resolved)'
+        => ['Average Created-Resolved', 'DateTimeInterval', 'AVG', 'Created', 'Resolved' ],
+    'MIN(Created-Resolved)'
+        => ['Minimum Created-Resolved', 'DateTimeInterval', 'MIN', 'Created', 'Resolved' ],
+    'MAX(Created-Resolved)'
+        => ['Maximum Created-Resolved', 'DateTimeInterval', 'MAX', 'Created', 'Resolved' ],
+
+    'SUM(Created-LastUpdated)'
+        => ['Summary of Created-LastUpdated', 'DateTimeInterval', 'SUM', 'Created', 'LastUpdated' ],
+    'AVG(Created-LastUpdated)'
+        => ['Average Created-LastUpdated', 'DateTimeInterval', 'AVG', 'Created', 'LastUpdated' ],
+    'MIN(Created-LastUpdated)'
+        => ['Minimum Created-LastUpdated', 'DateTimeInterval', 'MIN', 'Created', 'LastUpdated' ],
+    'MAX(Created-LastUpdated)'
+        => ['Maximum Created-LastUpdated', 'DateTimeInterval', 'MAX', 'Created', 'LastUpdated' ],
+);
+our %STATISTICS;
+
+our %STATISTICS_META = (
+    Count => {
+        Function => sub {
+            my $self = shift;
+            my $field = shift || 'id';
+
+            # UseSQLForACLChecks may add late joins
+            my $joined = ($self->_isJoined || RT->Config->Get('UseSQLForACLChecks')) ? 1 : 0;
+            return (
+                FUNCTION => ($joined ? 'DISTINCT COUNT' : 'COUNT'),
+                FIELD    => 'id'
+            );
+        },
+
+    },
+    Simple => {
+        Function => sub {
+            my $self = shift;
+            my ($function, $field) = @_;
+            return (FUNCTION => $function, FIELD => $field);
+        },
+    },
+    DateTimeInterval => {
+        Function => sub {
+            my $self = shift;
+            my ($function, $from, $to) = @_;
+
+            my $interval = $self->_Handle->DateTimeIntervalFunction(
+                From => { FUNCTION => $self->NotSetDateToNullFunction( FIELD => $from ) },
+                To   => { FUNCTION => $self->NotSetDateToNullFunction( FIELD => $to ) },
+            );
+
+            return (FUNCTION => "$function($interval)");
+        },
+    },
+
+);
+
 sub Groupings {
     my $self = shift;
     my %args = (@_);
@@ -229,6 +305,11 @@ sub IsValidGrouping {
     return 0;
 }
 
+sub Statistics {
+    my $self = shift;
+    return map { ref($_)? $_->[0] : $_ } @STATISTICS;
+}
+
 sub Label {
     my $self = shift;
     my $field = shift;

commit 88b80480163b19546eb8aae51bbf6d22bec46eaf
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Fri Jun 10 18:52:13 2011 +0400

    _StatsToFunction, turns stat's name into SQL

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index a733d5f..b24ba7e 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -426,8 +426,23 @@ sub _FieldToFunction {
     return $code->( $self, %args );
 }
 
-1;
+sub _StatsToFunction {
+    my $self = shift;
+    my ($stat) = (@_);
+
+    %STATISTICS = @STATISTICS unless keys %STATISTICS;
+
+    my ($display, $type, @args) = @{ $STATISTICS{ $stat } || [] };
+    unless ( $type ) {
+        $RT::Logger->error("'$stat' is not valid statistics for report");
+        return ('FUNCTION' => 'NULL');
+    }
 
+    my $meta = $STATISTICS_META{ $type };
+    return ('FUNCTION' => 'NULL') unless $meta;
+    return ('FUNCTION' => 'NULL') unless $meta->{'Function'};
+    return $meta->{'Function'}->( $self, @args );
+}
 
 
 # Gotta skip over RT::Tickets->Next, since it does all sorts of crazy magic we 

commit f43442871fa53cac4301043ae6ba5620b7786d96
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Fri Jun 10 18:53:42 2011 +0400

    switch over _StatsToFunction from naive approach

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index b24ba7e..0ac6303 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -345,17 +345,13 @@ sub SetupGroupings {
     }
     $self->GroupBy( @group_by );
 
-    # UseSQLForACLChecks may add late joins
-    my $joined = ($self->_isJoined || RT->Config->Get('UseSQLForACLChecks')) ? 1 : 0;
-
     my (@res, %column_type);
 
     my @function = ref( $args{'Function'} )? @{ $args{'Function'} } : ($args{'Function'});
     foreach my $e ( @function ) {
-        my ($function, $field) = split /\s+/, $e, 2;
-        $function = 'DISTINCT COUNT' if $joined && lc($function) eq 'count';
-        push @res, $self->Column( FUNCTION => $function, FIELD => $field );
-        $column_type{ $res[-1] } = { FUNCTION => $function, FIELD => $field };
+        my %args = $self->_StatsToFunction( $e );
+        push @res, $self->Column( %args );
+        $column_type{ $res[-1] } = \%args;
     }
 
     foreach my $group_by ( @group_by ) {

commit b0bb95b33021bf0361f595a0d843f58a4e2b9b1d
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Fri Jun 10 18:54:30 2011 +0400

    use new method to get list of available stats

diff --git a/share/html/Search/Elements/SelectChartFunction b/share/html/Search/Elements/SelectChartFunction
index cff9bd9..1ee9db9 100644
--- a/share/html/Search/Elements/SelectChartFunction
+++ b/share/html/Search/Elements/SelectChartFunction
@@ -46,8 +46,7 @@
 %#
 %# END BPS TAGGED BLOCK }}}
 <select name="<% $Name %>">
-% my @tmp = @functions;
-% while ( my ($display, $value) = splice @tmp, 0, 2 ) {
+% while ( my ($value, $display) = splice @functions, 0, 2 ) {
 <option value="<% $value %>"<% $value eq $Default ? qq[ selected="selected"] : '' |n %>><% loc( $display ) %></option>
 % }
 </select>
@@ -55,14 +54,6 @@
 $Name => 'ChartFunction'
 $Default => 'COUNT'
 </%ARGS>
-<%ONCE>
-my @functions = (
-    'Count of tickets'  => 'COUNT id',
-);
-foreach my $field (qw(Worked Estimated Left)) {
-    push @functions, "Total time \L$field" => "SUM Time$field",
-        "Average time \L$field" => "AVG Time$field",
-        "Minimum time \L$field" => "MIN Time$field",
-        "Maximum time \L$field" => "MAX Time$field";
-}
-</%ONCE>
+<%INIT>
+my @functions = RT::Report::Tickets->Statistics;
+</%INIT>

commit 945624601c283e7f2f35941cef2e3f606cc0ee9d
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Sat Jun 11 00:44:38 2011 +0400

    rename ColumnType -> ColumnInfo

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 0ac6303..f95d0f4 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -340,27 +340,31 @@ sub SetupGroupings {
     foreach my $e ( @group_by ) {
         my ($key, $subkey) = split /\./, $e, 2;
         $e = { $self->_FieldToFunction( KEY => $key, SUBKEY => $subkey ) };
-        $e->{'TYPE'} = $GROUPINGS{ $key };
-        $e->{'META'} = $GROUPINGS_META{ $e->{'TYPE'} };
+        $e->{'TYPE'} = 'grouping';
+        $e->{'INFO'} = $GROUPINGS{ $key };
+        $e->{'META'} = $GROUPINGS_META{ $e->{'INFO'} };
     }
     $self->GroupBy( @group_by );
 
-    my (@res, %column_type);
+    my (@res, %column_info);
 
     my @function = ref( $args{'Function'} )? @{ $args{'Function'} } : ($args{'Function'});
     foreach my $e ( @function ) {
         my %args = $self->_StatsToFunction( $e );
+        $args{'TYPE'} = 'statistic';
+        $args{'INFO'} = $STATISTICS{ $e };
+        $args{'META'} = $STATISTICS_META{ $args{'INFO'}[1] };
         push @res, $self->Column( %args );
-        $column_type{ $res[-1] } = \%args;
+        $column_info{ $res[-1] } = \%args;
     }
 
     foreach my $group_by ( @group_by ) {
         my $alias = $self->Column( %$group_by );
-        $column_type{ $alias } = $group_by;
+        $column_info{ $alias } = $group_by;
         push @res, $alias;
     }
 
-    $self->{'column_types'} = \%column_type;
+    $self->{'column_info'} = \%column_info;
 
     return @res;
 }
@@ -452,7 +456,7 @@ sub Next {
 sub NewItem {
     my $self = shift;
     my $res = RT::Report::Tickets::Entry->new(RT->SystemUser); # $self->CurrentUser);
-    $res->{'column_types'} = $self->{'column_types'};
+    $res->{'column_info'} = $self->{'column_info'};
     return $res;
 }
 
diff --git a/lib/RT/Report/Tickets/Entry.pm b/lib/RT/Report/Tickets/Entry.pm
index 87f70a2..365c31e 100644
--- a/lib/RT/Report/Tickets/Entry.pm
+++ b/lib/RT/Report/Tickets/Entry.pm
@@ -56,11 +56,11 @@ use base qw/RT::Record/;
 # XXX TODO: how the heck do we acl a report?
 sub CurrentUserHasRight {1}
 
-sub ColumnType {
+sub ColumnInfo {
     my $self = shift;
     my $column = shift;
 
-    return $self->{'column_types'}{$column};
+    return $self->{'column_info'}{$column};
 }
 
 
@@ -78,7 +78,7 @@ sub LabelValue {
 
     my $raw = $self->RawValue( $name, @_ );
 
-    my $info = $self->ColumnType( $name );
+    my $info = $self->ColumnInfo( $name );
 
     my $meta = $info->{'META'};
     return $raw unless $meta && $meta->{'Display'};

commit 59370443dcf60489429e0994697067f4be9a4f3b
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Sat Jun 11 00:45:38 2011 +0400

    change $report->Label, we need labels for stats
    
    get column name instead of grouping. Fetch data
    from META hashes

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index f95d0f4..19caf66 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -168,6 +168,19 @@ our %GROUPINGS_META = (
             return @res;
         },
         Function => 'GenerateCustomFieldFunction',
+        Label => sub {
+            my $self = shift;
+            my %args = (@_);
+
+            my ($cf) = ( $args{'SUBKEY'} =~ /^{(.*)}$/ );
+            if ( $cf =~ /^\d+$/ ) {
+                my $obj = RT::CustomField->new( $self->CurrentUser );
+                $obj->Load( $cf );
+                $cf = $obj->Name;
+            }
+
+            return 'Custom field [_1]', $self->CurrentUser->loc( $cf );
+        },
     },
     Enum => {
     },
@@ -224,7 +237,6 @@ our %STATISTICS_META = (
                 FIELD    => 'id'
             );
         },
-
     },
     Simple => {
         Function => sub {
@@ -246,7 +258,6 @@ our %STATISTICS_META = (
             return (FUNCTION => "$function($interval)");
         },
     },
-
 );
 
 sub Groupings {
@@ -312,15 +323,48 @@ sub Statistics {
 
 sub Label {
     my $self = shift;
-    my $field = shift;
-    if ( $field =~ /^(?:CF|CustomField)\.{(.*)}$/ ) {
-        my $cf = $1;
-        return $self->CurrentUser->loc( "Custom field '[_1]'", $cf ) if $cf =~ /\D/;
-        my $obj = RT::CustomField->new( $self->CurrentUser );
-        $obj->Load( $cf );
-        return $self->CurrentUser->loc( "Custom field '[_1]'", $obj->Name );
+    my $column = shift;
+
+    my $info = $self->ColumnInfo( $column );
+    unless ( $info ) {
+        $RT::Logger->error("Unknown column '$column'");
+        return $self->CurrentUser->loc('(Incorrect data)');
+    }
+
+    my $label = $info->{'META'}{'Label'};
+    unless ( $label ) {
+        my $res = '';
+        if ( $info->{'TYPE'} eq 'statistic' ) {
+            $res = $info->{'INFO'}[0];
+        }
+        else {
+            $res = join ' ', grep defined && length, @{ $info }{'KEY', 'SUBKEY'};
+        }
+        return $self->CurrentUser->loc( $res );
+    }
+
+    my $code;
+    unless ( ref $label ) {
+        $code = $self->can( $label );
+        unless ( $code ) {
+            $RT::Logger->error("No method ". $label );
+            return $self->CurrentUser->loc('(Incorrect data)');
+        }
+    }
+    elsif ( ref( $label ) eq 'CODE' ) {
+        $code = $label;
     }
-    return $self->CurrentUser->loc($field);
+    else {
+        return $self->CurrentUser->loc('(Incorrect data)');
+    }
+    return $self->CurrentUser->loc( $code->( $self, %$info ) );
+}
+
+sub ColumnInfo {
+    my $self = shift;
+    my $column = shift;
+
+    return $self->{'column_info'}{$column};
 }
 
 sub SetupGroupings {
diff --git a/share/html/Search/Chart b/share/html/Search/Chart
index 6215f9d..755ef2d 100644
--- a/share/html/Search/Chart
+++ b/share/html/Search/Chart
@@ -137,8 +137,8 @@ if ($tix->Count == 0) {
 if ($chart_class eq "GD::Graph::bars") {
     my $count = keys %data;
     $chart->set(
-        x_label => $tix->Label( $PrimaryGroupBy ),
-        y_label => loc('Tickets'),
+        x_label => $tix->Label( $value_name ),
+        y_label => $tix->Label( $count_name ),
         show_values => 1,
         x_label_position => 0.6,
         y_label_position => 0.6,
diff --git a/share/html/Search/Elements/Chart b/share/html/Search/Elements/Chart
index e8e09b7..014d0c5 100644
--- a/share/html/Search/Elements/Chart
+++ b/share/html/Search/Elements/Chart
@@ -92,10 +92,8 @@ my ($i,$total);
 </span>
 <table class="collection-as-table chart">
 <tr>
-<th class="collection-as-table"><% loc($tix->Label($PrimaryGroupBy)) %>
-</th>
-<th class="collection-as-table"><&|/l&>Tickets</&>
-</th>
+<th class="collection-as-table"><% $tix->Label( $value_name ) %></th>
+<th class="collection-as-table"><% $tix->Label( $count_name ) %></th>
 </tr>
 <%perl>
  while (my $key = shift @sorted_keys) {

commit 0a6fca96562b4ddcdeceee3f74fa99113f856343
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Tue Jun 14 20:28:48 2011 +0400

    ColumnList - list of columns in the report and report entry

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 19caf66..c9c0b3e 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -367,6 +367,11 @@ sub ColumnInfo {
     return $self->{'column_info'}{$column};
 }
 
+sub ColumnsList {
+    my $self = shift;
+    return keys %{ $self->{'column_info'} || {} };
+}
+
 sub SetupGroupings {
     my $self = shift;
     my %args = (
diff --git a/lib/RT/Report/Tickets/Entry.pm b/lib/RT/Report/Tickets/Entry.pm
index 365c31e..420252e 100644
--- a/lib/RT/Report/Tickets/Entry.pm
+++ b/lib/RT/Report/Tickets/Entry.pm
@@ -63,6 +63,11 @@ sub ColumnInfo {
     return $self->{'column_info'}{$column};
 }
 
+sub ColumnsList {
+    my $self = shift;
+    return keys %{ $self->{'column_info'} || {} };
+}
+
 
 =head2 LabelValue
 

commit 0fc3e2a1d6fd24e50e528223ee4895186d2427e6
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Tue Jun 14 20:29:24 2011 +0400

    FindImplementationCode - resolve entries to code ref

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index c9c0b3e..fe2df91 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -275,14 +275,14 @@ sub Groupings {
         elsif ( ref( $meta->{'SubFields'} ) eq 'ARRAY' ) {
             push @fields, map { ("$field $_", "$field.$_") } @{ $meta->{'SubFields'} };
         }
-        elsif ( ref( $meta->{'SubFields'} ) eq 'CODE' ) {
-            push @fields, $meta->{'SubFields'}->(
-                $self,
-                \%args,
-            );
+        elsif ( my $code = $self->FindImplementationCode( $meta->{'SubFields'} ) ) {
+            push @fields, $code->( $self, \%args );
         }
         else {
-            $RT::Logger->error("%GROUPINGS_META for $type has unsupported SubFields");
+            $RT::Logger->error(
+                "$type has unsupported SubFields."
+                ." Not an array, a method name or a code reference"
+            );
         }
     }
     return @fields;
@@ -307,11 +307,8 @@ sub IsValidGrouping {
     elsif ( ref( $meta->{'SubFields'} ) eq 'ARRAY' ) {
         return 1 if grep $_ eq $subkey, @{ $meta->{'SubFields'} };
     }
-    elsif ( ref( $meta->{'SubFields'} ) eq 'CODE' ) {
-        return 1 if grep $_ eq "$key.$subkey", $meta->{'SubFields'}->(
-            $self,
-            \%args,
-        );
+    elsif ( my $code = $self->FindImplementationCode( $meta->{'SubFields'}, 'silent' ) ) {
+        return 1 if grep $_ eq "$key.$subkey", $code->( $self, \%args );
     }
     return 0;
 }
@@ -331,33 +328,20 @@ sub Label {
         return $self->CurrentUser->loc('(Incorrect data)');
     }
 
-    my $label = $info->{'META'}{'Label'};
-    unless ( $label ) {
-        my $res = '';
-        if ( $info->{'TYPE'} eq 'statistic' ) {
-            $res = $info->{'INFO'}[0];
-        }
-        else {
-            $res = join ' ', grep defined && length, @{ $info }{'KEY', 'SUBKEY'};
-        }
-        return $self->CurrentUser->loc( $res );
+    if ( $info->{'META'}{'Label'} ) {
+        my $code = $self->FindImplementationCode( $info->{'META'}{'Label'} );
+        return $self->CurrentUser->loc( $code->( $self, %$info ) )
+            if $code;
     }
 
-    my $code;
-    unless ( ref $label ) {
-        $code = $self->can( $label );
-        unless ( $code ) {
-            $RT::Logger->error("No method ". $label );
-            return $self->CurrentUser->loc('(Incorrect data)');
-        }
-    }
-    elsif ( ref( $label ) eq 'CODE' ) {
-        $code = $label;
+    my $res = '';
+    if ( $info->{'TYPE'} eq 'statistic' ) {
+        $res = $info->{'INFO'}[0];
     }
     else {
-        return $self->CurrentUser->loc('(Incorrect data)');
+        $res = join ' ', grep defined && length, @{ $info }{'KEY', 'SUBKEY'};
     }
-    return $self->CurrentUser->loc( $code->( $self, %$info ) );
+    return $self->CurrentUser->loc( $res );
 }
 
 sub ColumnInfo {
@@ -456,21 +440,8 @@ sub _FieldToFunction {
 
     return %args unless $meta->{'Function'};
 
-    my $code;
-    unless ( ref $meta->{'Function'} ) {
-        $code = $self->can( $meta->{'Function'} );
-        unless ( $code ) {
-            $RT::Logger->error("No method ". $meta->{'Function'} );
-            return ('FUNCTION' => 'NULL');
-        }
-    }
-    elsif ( ref( $meta->{'Function'} ) eq 'CODE' ) {
-        $code = $meta->{'Function'};
-    }
-    else {
-        $RT::Logger->error("%GROUPINGS_META for $args{FIELD} has unsupported Function");
-        return ('FUNCTION' => 'NULL');
-    }
+    my $code = $self->FindImplementationCode( $meta->{'Function'} );
+    return ('FUNCTION' => 'NULL') unless $code;
 
     return $code->( $self, %args );
 }
@@ -612,6 +583,35 @@ sub GenerateWatcherFunction {
     return %args;
 }
 
+sub FindImplementationCode {
+    my $self = shift;
+    my $value = shift;
+    my $silent = shift;
+
+    my $code;
+    unless ( $value ) {
+        $RT::Logger->error("Value is not defined. Should be method name or code reference")
+            unless $silent;
+        return undef;
+    }
+    elsif ( !ref $value ) {
+        $code = $self->can( $value );
+        unless ( $code ) {
+            $RT::Logger->error("No method $value in ". (ref $self || $self) ." class" )
+                unless $silent;
+            return undef;
+        }
+    }
+    elsif ( ref( $value ) eq 'CODE' ) {
+        $code = $value;
+    }
+    else {
+        $RT::Logger->error("$value is not method name or code reference")
+            unless $silent;
+        return undef;
+    }
+    return $code;
+}
 RT::Base->_ImportOverlays();
 
 1;
diff --git a/lib/RT/Report/Tickets/Entry.pm b/lib/RT/Report/Tickets/Entry.pm
index 420252e..53cc9a3 100644
--- a/lib/RT/Report/Tickets/Entry.pm
+++ b/lib/RT/Report/Tickets/Entry.pm
@@ -88,20 +88,8 @@ sub LabelValue {
     my $meta = $info->{'META'};
     return $raw unless $meta && $meta->{'Display'};
 
-    my $code;
-    unless ( ref $meta->{'Display'} ) {
-        $code = $self->can( $meta->{'Display'} );
-        unless ( $code ) {
-            $RT::Logger->error("No method ". $meta->{'Display'} );
-            return $raw;
-        }
-    }
-    elsif ( ref( $meta->{'Display'} ) eq 'CODE' ) {
-        $code = $meta->{'Display'};
-    }
-    else {
-        return $raw;
-    }
+    my $code = $self->FindImplementationCode( $meta->{'Display'} );
+    return $raw unless $code;
 
     return $code->( $self, %$info, VALUE => $raw );
 }
@@ -114,6 +102,10 @@ sub ObjectType {
     return 'RT::Ticket';
 }
 
+sub FindImplementationCode {
+    return RT::Report::Tickets->can('FindImplementationCode')->(@_);
+}
+
 RT::Base->_ImportOverlays();
 
 1;

commit 658a3b827913c0630ec67bb00f72c662d77e1066
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Tue Jun 14 20:30:34 2011 +0400

    ::Query - turn entry of the report into a query

diff --git a/lib/RT/Report/Tickets/Entry.pm b/lib/RT/Report/Tickets/Entry.pm
index 53cc9a3..509d2d1 100644
--- a/lib/RT/Report/Tickets/Entry.pm
+++ b/lib/RT/Report/Tickets/Entry.pm
@@ -102,6 +102,37 @@ sub ObjectType {
     return 'RT::Ticket';
 }
 
+sub Query {
+    my $self = shift;
+
+    my @parts;
+    foreach my $column ( $self->ColumnsList ) {
+        my $info = $self->ColumnInfo( $column );
+        next unless $info->{'TYPE'} eq 'grouping';
+
+        my $custom = $info->{'META'}{'Query'};
+        if ( $custom and my $code = $self->FindImplementationCode( $custom ) ) {
+            push @parts, $code->( $self, COLUMN => $column, %$info );
+        }
+        else {
+            my $field = join '.', grep $_, $info->{KEY}, $info->{SUBKEY};
+            my $value = $self->RawValue( $column );
+            my $op = '=';
+            if ( defined $value ) {
+                unless ( $value =~ /^\d+$/ ) {
+                    $value =~ s/(['\\])/\\$1/g;
+                    $value = "'$value'";
+                }
+            }
+            else {
+                ($op, $value) = ('IS', 'NULL');
+            }
+            push @parts, "$field $op $value";
+        }
+    }
+    return join ' AND ', grep defined && length, @parts;
+}
+
 sub FindImplementationCode {
     return RT::Report::Tickets->can('FindImplementationCode')->(@_);
 }

commit 15c07226f3d15e444ce4f69dc21458678ca162f0
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Tue Jun 14 23:55:01 2011 +0400

    sort reports in libs

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index fe2df91..df47b82 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -135,6 +135,7 @@ our %GROUPINGS_META = (
             }
             return $raw;
         },
+        Sort => 'raw',
     },
     CustomField => {
         SubFields => sub {
@@ -388,12 +389,14 @@ sub SetupGroupings {
         $args{'INFO'} = $STATISTICS{ $e };
         $args{'META'} = $STATISTICS_META{ $args{'INFO'}[1] };
         push @res, $self->Column( %args );
-        $column_info{ $res[-1] } = \%args;
+        $args{'NAME'} = $res[-1];
+        $column_info{ $args{'NAME'} } = \%args;
     }
 
     foreach my $group_by ( @group_by ) {
         my $alias = $self->Column( %$group_by );
         $column_info{ $alias } = $group_by;
+        $group_by->{'NAME'} = $alias;
         push @res, $alias;
     }
 
@@ -509,6 +512,53 @@ sub AddEmptyRows {
     }
 }
 
+sub SortEntries {
+    my $self = shift;
+
+    # XXX: at the begining let's support only grouping by one column
+    my ($group_by) =
+        grep $_->{'TYPE'} eq 'grouping',
+        map $self->ColumnInfo($_),
+        $self->ColumnsList;
+    return unless $group_by;
+
+    my $order = $group_by->{'META'}{Sort} || 'label';
+    if ( $order eq 'label' ) {
+        $self->{'items'} = [
+            map $_->[0],
+            sort { $a->[1] cmp $b->[1] }
+            map [ $_, $_->LabelValue( $group_by->{'NAME'} ) ],
+            @{ $self->{'items'} || [] }
+        ];
+    }
+    elsif ( $order eq 'numeric label' ) {
+        $self->{'items'} = [
+            map $_->[0],
+            sort { $a->[1] <=> $b->[1] }
+            map [ $_, $_->LabelValue( $group_by->{'NAME'} ) ],
+            @{ $self->{'items'} || [] }
+        ];
+    }
+    elsif ( $order eq 'raw' ) {
+        $self->{'items'} = [
+            map $_->[0],
+            sort { $a->[1] cmp $b->[1] }
+            map [ $_, $_->RawValue( $group_by->{'NAME'} ) ],
+            @{ $self->{'items'} || [] }
+        ];
+    }
+    elsif ( $order eq 'numeric raw' ) {
+        $self->{'items'} = [
+            map $_->[0],
+            sort { $a->[1] <=> $b->[1] }
+            map [ $_, $_->RawValue( $group_by->{'NAME'} ) ],
+            @{ $self->{'items'} || [] }
+        ];
+    } else {
+        $RT::Logger->error("Unknown sorting function '$order'");
+    }
+}
+
 sub GenerateDateFunction {
     my $self = shift;
     my %args = @_;

commit 5532b316ac29707a25429ce2e665531adcbffbe2
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Tue Jun 14 23:56:47 2011 +0400

    use sorting from libs

diff --git a/share/html/Search/Elements/Chart b/share/html/Search/Elements/Chart
index 014d0c5..c0e8b15 100644
--- a/share/html/Search/Elements/Chart
+++ b/share/html/Search/Elements/Chart
@@ -66,21 +66,7 @@ my ($count_name, $value_name) = $tix->SetupGroupings(
     Function => $ChartFunction,
 );
 
-my (@keys, @values);
-while ( my $entry = $tix->Next ) {
-    push @keys, $entry->LabelValue( $value_name )
-        || loc('(no value)');
-    push @values, $entry->__Value( $count_name );
-}
-
-my %data;
-my %loc_keys;
-foreach my $key (@keys) { $data{$key} = shift @values; $loc_keys{$key} = loc($key); }
-my @sorted_keys = map { $loc_keys{$_}} sort { $loc_keys{$a} cmp $loc_keys{$b} } keys %loc_keys;
-my @sorted_values = map { $data{$_}} sort { $loc_keys{$a} cmp $loc_keys{$b} } keys %loc_keys;
 my $query_string = $m->comp('/Elements/QueryString', %ARGS);
-
-my ($i,$total);
 </%init>
 <div class="chart-wrapper">
 <span class="chart image">
@@ -91,49 +77,45 @@ my ($i,$total);
 % }
 </span>
 <table class="collection-as-table chart">
+
 <tr>
 <th class="collection-as-table"><% $tix->Label( $value_name ) %></th>
 <th class="collection-as-table"><% $tix->Label( $count_name ) %></th>
 </tr>
-<%perl>
- while (my $key = shift @sorted_keys) {
- $i++;
- my $value = shift @sorted_values;
- $total += $value;
-</%perl>
-<tr class="<% $i%2 ? 'evenline' : 'oddline' %>">
-<%perl>
-# TODO sadly we don't have "creator.city is null" or alike support yet
-# so no link if the key is undef for now
- if ( $PrimaryGroupBy !~ /(Hourly|Daily|Monthly|Annually)$/
-        && $key ne loc('(no value)') ) {
- my $group = $PrimaryGroupBy; $group =~ s! !.!;
- my %orig_keys = reverse %loc_keys;
- my $QueryString = $m->comp('/Elements/QueryString',
-           Query => "$Query and $group = '$orig_keys{$key}'",
-                           Format  => $ARGS{Format},
-                           Rows    => $ARGS{Rows},
-                           OrderBy => $ARGS{OrderBy},
-                           Order   => $ARGS{Order},
-                         );
-</%perl>
+
+% my $base_query = $m->comp('/Elements/QueryString',
+%     Format  => $ARGS{Format},
+%     Rows    => $ARGS{Rows},
+%     OrderBy => $ARGS{OrderBy},
+%     Order   => $ARGS{Order},
+% );
+
+% $tix->SortEntries;
+
+% my ($i,$total) = (0, 0);
+% while ( my $entry = $tix->Next ) {
+<tr class="<% ++$i%2 ? 'evenline' : 'oddline' %>">
+
 <td class="label collection-as-table">
-<a href=<% RT->Config->Get('WebPath') %>/Search/Results.html?<%$QueryString%>><%$key%></a>
-</td>
-<td class="value collection-as-table">
-<a href=<% RT->Config->Get('WebPath') %>/Search/Results.html?<%$QueryString%>><%$value%></a>
-</td>
+% my $key = $entry->LabelValue( $value_name ) || loc('(no value)');
+% my $entry_query = $entry->Query;
+% if ( $entry_query ) {
+<a href="<% RT->Config->Get('WebPath') %>/Search/Results.html?Query=<% "$Query AND $entry_query" |u %>&<% $base_query |hn %>"><% $key %></a>
 % } else {
-<td class="label collection-as-table"><% $key %></td>
-<td class="value collection-as-table"><% $value %></td>
+<% $key %>
 % }
+</td>
+
+% $total += my $value = $entry->RawValue( $count_name );
+<td class="value collection-as-table"><%$value%></td>
+
 </tr>
 % }
 
-%$i++;
-<tr class="<%$i%2 ? 'evenline' : 'oddline' %> total">
-<td class="label collection-as-table"><%loc('Total')%></td>
-<td class="value collection-as-table"><%$total||'0'%></td>
+% $i++;
+<tr class="<% $i%2 ? 'evenline' : 'oddline' %> total">
+<td class="label collection-as-table"><% loc('Total') %></td>
+<td class="value collection-as-table"><% $total || '' %></td>
 </tr>
 
 </table>

commit 50ee65f01526ba9840e4a3eae411426822f232a8
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Wed Jun 15 12:22:16 2011 +0400

    localize reports' values in libs

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index df47b82..418c270 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -90,6 +90,7 @@ our %GROUPINGS_META = (
             $queue->Load( $args{'VALUE'} );
             return $queue->Name;
         },
+        Localize => 1,
     },
     User => {
         SubFields => [qw(
@@ -128,10 +129,10 @@ our %GROUPINGS_META = (
             return $raw unless defined $raw;
 
             if ( $args{'SUBKEY'} eq 'DayOfWeek' ) {
-                return $RT::Date::DAYS_OF_WEEK[ int $raw ];
+                return $self->loc($RT::Date::DAYS_OF_WEEK[ int $raw ]);
             }
             elsif ( $args{'SUBKEY'} eq 'Month' ) {
-                return $RT::Date::MONTHS[ int($raw) - 1 ];
+                return $self->loc($RT::Date::MONTHS[ int($raw) - 1 ]);
             }
             return $raw;
         },
@@ -184,6 +185,7 @@ our %GROUPINGS_META = (
         },
     },
     Enum => {
+        Localize => 1,
     },
 );
 
@@ -478,7 +480,7 @@ sub Next {
 
 sub NewItem {
     my $self = shift;
-    my $res = RT::Report::Tickets::Entry->new(RT->SystemUser); # $self->CurrentUser);
+    my $res = RT::Report::Tickets::Entry->new($self->CurrentUser);
     $res->{'column_info'} = $self->{'column_info'};
     return $res;
 }
diff --git a/lib/RT/Report/Tickets/Entry.pm b/lib/RT/Report/Tickets/Entry.pm
index 509d2d1..e34e5e5 100644
--- a/lib/RT/Report/Tickets/Entry.pm
+++ b/lib/RT/Report/Tickets/Entry.pm
@@ -82,16 +82,19 @@ sub LabelValue {
     my $name = shift;
 
     my $raw = $self->RawValue( $name, @_ );
-
     my $info = $self->ColumnInfo( $name );
-
     my $meta = $info->{'META'};
-    return $raw unless $meta && $meta->{'Display'};
 
-    my $code = $self->FindImplementationCode( $meta->{'Display'} );
-    return $raw unless $code;
+    if (
+        $meta and $meta->{'Display'}
+        and my $code = $self->FindImplementationCode( $meta->{'Display'} )
+    ) {
+        return $code->( $self, %$info, VALUE => $raw );
+    }
 
-    return $code->( $self, %$info, VALUE => $raw );
+    return $self->loc('(no value)') unless defined $raw && length $raw;
+    return $self->loc($raw) if $info->{'META'}{'Localize'};
+    return $raw;
 }
 
 sub RawValue {
diff --git a/share/html/Search/Chart b/share/html/Search/Chart
index 755ef2d..369f44c 100644
--- a/share/html/Search/Chart
+++ b/share/html/Search/Chart
@@ -80,14 +80,11 @@ my %data;
 my $max_value = 0;
 my $max_key_length = 0;
 while ( my $entry = $tix->Next ) {
-    my $key = $entry->LabelValue($value_name)
-        || '(no value)';
+    my $key = $entry->LabelValue($value_name);
     
-    my $value = $entry->__Value( $count_name );
+    my $value = $entry->RawValue( $count_name );
     if ($chart_class eq 'GD::Graph::pie') {
-        $key = loc($key) ." - ". $value;
-    } else {
-        $key = loc($key);
+        $key .= ' - '. $value;
     }
     $data{ $key } = $value;
     $max_value = $value if $max_value < $value;
diff --git a/share/html/Search/Elements/Chart b/share/html/Search/Elements/Chart
index c0e8b15..d62b319 100644
--- a/share/html/Search/Elements/Chart
+++ b/share/html/Search/Elements/Chart
@@ -97,7 +97,7 @@ my $query_string = $m->comp('/Elements/QueryString', %ARGS);
 <tr class="<% ++$i%2 ? 'evenline' : 'oddline' %>">
 
 <td class="label collection-as-table">
-% my $key = $entry->LabelValue( $value_name ) || loc('(no value)');
+% my $key = $entry->LabelValue( $value_name );
 % my $entry_query = $entry->Query;
 % if ( $entry_query ) {
 <a href="<% RT->Config->Get('WebPath') %>/Search/Results.html?Query=<% "$Query AND $entry_query" |u %>&<% $base_query |hn %>"><% $key %></a>

commit 5fbdd35222dc241eb24ad0c191f17516dad0160f
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Wed Jun 15 12:50:50 2011 +0400

    deal with 'no data' earlier

diff --git a/share/html/Search/Chart b/share/html/Search/Chart
index 369f44c..5edc65c 100644
--- a/share/html/Search/Chart
+++ b/share/html/Search/Chart
@@ -91,31 +91,15 @@ while ( my $entry = $tix->Next ) {
     $max_key_length = length $key if $max_key_length < length $key;
 }
 
-unless (keys %data) {
-    $data{''} = 0;
-}
-
-
-my $chart = $chart_class->new( 600 => 400 );
-$chart->set( pie_height => 60 ) if $chart_class eq 'GD::Graph::pie';
 my %font_config = RT->Config->Get('ChartFont');
 my $font = $font_config{ $session{CurrentUser}->UserObj->Lang || '' }
-  || $font_config{'others'};
-$chart->set_title_font( $font, 16 ) if $chart->can('set_title_font');
-$chart->set_legend_font( $font, 16 ) if $chart->can('set_legend_font');
-$chart->set_x_label_font( $font, 14 ) if $chart->can('set_x_label_font');
-$chart->set_y_label_font( $font, 14 ) if $chart->can('set_y_label_font');
-$chart->set_label_font( $font, 14 ) if $chart->can('set_label_font');
-$chart->set_x_axis_font( $font, 12 ) if $chart->can('set_x_axis_font');
-$chart->set_y_axis_font( $font, 12 ) if $chart->can('set_y_axis_font');
-$chart->set_values_font( $font, 12 ) if $chart->can('set_values_font');
-$chart->set_value_font( $font, 12 ) if $chart->can('set_value_font');
+    || $font_config{'others'};
 
 # Pie charts don't like having no input, so we show a special image
 # that indicates an error message. Because this is used in an <img>
 # context, it can't be a simple error message. Without this check,
 # the chart will just be a non-loading image.
-if ($tix->Count == 0) {
+unless ( $tix->Count ) {
     my $plot = GD::Image->new(600 => 400);
     $plot->colorAllocate(255, 255, 255); # background
     my $black = $plot->colorAllocate(0, 0, 0);
@@ -131,6 +115,18 @@ if ($tix->Count == 0) {
     $m->comp( 'SELF:Plot', plot => $plot, %ARGS );
 }
 
+my $chart = $chart_class->new( 600 => 400 );
+$chart->set( pie_height => 60 ) if $chart_class eq 'GD::Graph::pie';
+$chart->set_title_font( $font, 16 ) if $chart->can('set_title_font');
+$chart->set_legend_font( $font, 16 ) if $chart->can('set_legend_font');
+$chart->set_x_label_font( $font, 14 ) if $chart->can('set_x_label_font');
+$chart->set_y_label_font( $font, 14 ) if $chart->can('set_y_label_font');
+$chart->set_label_font( $font, 14 ) if $chart->can('set_label_font');
+$chart->set_x_axis_font( $font, 12 ) if $chart->can('set_x_axis_font');
+$chart->set_y_axis_font( $font, 12 ) if $chart->can('set_y_axis_font');
+$chart->set_values_font( $font, 12 ) if $chart->can('set_values_font');
+$chart->set_value_font( $font, 12 ) if $chart->can('set_value_font');
+
 if ($chart_class eq "GD::Graph::bars") {
     my $count = keys %data;
     $chart->set(

commit fc6a42d2fbd4916602816a443489d5fb83608eee
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Wed Jun 15 13:05:20 2011 +0400

    use new sorting method for image chart

diff --git a/share/html/Search/Chart b/share/html/Search/Chart
index 5edc65c..aaa51d7 100644
--- a/share/html/Search/Chart
+++ b/share/html/Search/Chart
@@ -76,17 +76,21 @@ my ($count_name, $value_name) = $tix->SetupGroupings(
     Function => $ChartFunction,
 );
 
-my %data;
+$tix->SortEntries;
+
+my @data = ([],[]);
 my $max_value = 0;
 my $max_key_length = 0;
 while ( my $entry = $tix->Next ) {
     my $key = $entry->LabelValue($value_name);
-    
     my $value = $entry->RawValue( $count_name );
     if ($chart_class eq 'GD::Graph::pie') {
         $key .= ' - '. $value;
     }
-    $data{ $key } = $value;
+
+    push @{ $data[0] }, $key;
+    push @{ $data[1] }, $value;
+
     $max_value = $value if $max_value < $value;
     $max_key_length = length $key if $max_key_length < length $key;
 }
@@ -128,7 +132,7 @@ $chart->set_values_font( $font, 12 ) if $chart->can('set_values_font');
 $chart->set_value_font( $font, 12 ) if $chart->can('set_value_font');
 
 if ($chart_class eq "GD::Graph::bars") {
-    my $count = keys %data;
+    my $count = @{ $data[0] };
     $chart->set(
         x_label => $tix->Label( $value_name ),
         y_label => $tix->Label( $count_name ),
@@ -164,7 +168,7 @@ $chart->{dclrs} = [
     };
 }
 
-my $plot = $chart->plot( [ [sort keys %data], [map $data{$_}, sort keys %data] ] ) or die $chart->error;
+my $plot = $chart->plot( \@data ) or die $chart->error;
 $m->comp( 'SELF:Plot', plot => $plot, %ARGS );
 </%init>
 

commit 704fa93ff796ba72b7254a368a3bf2492234150c
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Wed Jun 15 23:05:45 2011 +0400

    make it possible to calculate multiple functions
    
    * pictures only show first function
    * had to change return value of SetupGroupings

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 418c270..b92dc58 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -372,7 +372,8 @@ sub SetupGroupings {
 
     %GROUPINGS = @GROUPINGS unless keys %GROUPINGS;
 
-    my @group_by = ref( $args{'GroupBy'} )? @{ $args{'GroupBy'} } : ($args{'GroupBy'});
+    my @group_by = grep defined && length,
+        ref( $args{'GroupBy'} )? @{ $args{'GroupBy'} } : ($args{'GroupBy'});
     foreach my $e ( @group_by ) {
         my ($key, $subkey) = split /\./, $e, 2;
         $e = { $self->_FieldToFunction( KEY => $key, SUBKEY => $subkey ) };
@@ -382,29 +383,30 @@ sub SetupGroupings {
     }
     $self->GroupBy( @group_by );
 
-    my (@res, %column_info);
+    my %res = (Groups => [], Functions => []);
+    my %column_info;
 
-    my @function = ref( $args{'Function'} )? @{ $args{'Function'} } : ($args{'Function'});
+    foreach my $group_by ( @group_by ) {
+        $group_by->{'NAME'} = $self->Column( %$group_by );
+        $column_info{ $group_by->{'NAME'} } = $group_by;
+        push @{ $res{'Groups'} }, $group_by->{'NAME'};
+    }
+
+    my @function = grep defined && length,
+        ref( $args{'Function'} )? @{ $args{'Function'} } : ($args{'Function'});
     foreach my $e ( @function ) {
         my %args = $self->_StatsToFunction( $e );
         $args{'TYPE'} = 'statistic';
         $args{'INFO'} = $STATISTICS{ $e };
         $args{'META'} = $STATISTICS_META{ $args{'INFO'}[1] };
-        push @res, $self->Column( %args );
-        $args{'NAME'} = $res[-1];
+        $args{'NAME'} = $self->Column( %args );
+        push @{ $res{'Functions'} }, $args{'NAME'};
         $column_info{ $args{'NAME'} } = \%args;
     }
 
-    foreach my $group_by ( @group_by ) {
-        my $alias = $self->Column( %$group_by );
-        $column_info{ $alias } = $group_by;
-        $group_by->{'NAME'} = $alias;
-        push @res, $alias;
-    }
-
     $self->{'column_info'} = \%column_info;
 
-    return @res;
+    return %res;
 }
 
 =head2 _DoSearch
diff --git a/share/html/Search/Chart b/share/html/Search/Chart
index aaa51d7..0ba1851 100644
--- a/share/html/Search/Chart
+++ b/share/html/Search/Chart
@@ -49,7 +49,7 @@
 $Query => "id > 0"
 $PrimaryGroupBy => 'Queue'
 $ChartStyle => 'bars'
-$ChartFunction => 'COUNT id'
+ at ChartFunction => 'COUNT'
 </%args>
 <%init>
 my $chart_class;
@@ -70,10 +70,10 @@ my $tix = RT::Report::Tickets->new( $session{'CurrentUser'} );
 $PrimaryGroupBy = 'Queue'
     unless $tix->IsValidGrouping( Query => $Query, GroupBy => $PrimaryGroupBy );
 
-my ($count_name, $value_name) = $tix->SetupGroupings(
+my %columns = $tix->SetupGroupings(
     Query => $Query,
     GroupBy => $PrimaryGroupBy,
-    Function => $ChartFunction,
+    Function => (grep defined && length, @ChartFunction)[0],
 );
 
 $tix->SortEntries;
@@ -82,8 +82,8 @@ my @data = ([],[]);
 my $max_value = 0;
 my $max_key_length = 0;
 while ( my $entry = $tix->Next ) {
-    my $key = $entry->LabelValue($value_name);
-    my $value = $entry->RawValue( $count_name );
+    my $key = join ' - ', map $entry->LabelValue( $_ ), @{ $columns{'Groups'} };
+    my $value = $entry->RawValue( $columns{'Functions'}[0] );
     if ($chart_class eq 'GD::Graph::pie') {
         $key .= ' - '. $value;
     }
@@ -134,8 +134,8 @@ $chart->set_value_font( $font, 12 ) if $chart->can('set_value_font');
 if ($chart_class eq "GD::Graph::bars") {
     my $count = @{ $data[0] };
     $chart->set(
-        x_label => $tix->Label( $value_name ),
-        y_label => $tix->Label( $count_name ),
+        x_label => join( ' - ', map $tix->Label( $_ ), @{ $columns{'Groups'} } ),
+        y_label => $tix->Label( $columns{'Functions'}[0] ),
         show_values => 1,
         x_label_position => 0.6,
         y_label_position => 0.6,
diff --git a/share/html/Search/Chart.html b/share/html/Search/Chart.html
index 3544395..2d4dd90 100644
--- a/share/html/Search/Chart.html
+++ b/share/html/Search/Chart.html
@@ -48,7 +48,7 @@
 <%args>
 $PrimaryGroupBy => 'Queue'
 $ChartStyle => 'bars'
-$ChartFunction => 'COUNT id'
+ at ChartFunction => ('COUNT')
 $Description => undef
 </%args>
 <%init>
@@ -120,18 +120,31 @@ my %query;
 
 <div class="chart-meta">
 <div class="chart-type">
-<&| /Widgets/TitleBox, title => loc('Chart Properties')&>
-<form method="get" action="<%RT->Config->Get('WebPath')%>/Search/Chart.html">
+
+<form method="get" action="<% RT->Config->Get('WebPath') %>/Search/Chart.html">
 <input type="hidden" class="hidden" name="Query" value="<% $ARGS{Query} %>" />
 <input type="hidden" class="hidden" name="SavedChartSearchId" value="<% $saved_search->{SearchId} || 'new' %>" />
 
-<&|/l_unsafe,
-  $m->scomp('Elements/SelectChartFunction', Default => $ChartFunction),
-  $m->scomp('Elements/SelectChartType', Default => $ChartStyle),
-  $m->scomp('Elements/SelectGroupBy', Name => 'PrimaryGroupBy', Query => $ARGS{Query}, Default => $PrimaryGroupBy) 
-&>[_1] [_2] chart by [_3]</&><input type="submit" class="button" value="<%loc('Update Chart')%>" />
-</form>
+<&| /Widgets/TitleBox, title => loc('Properties') &>
+<% loc('Group by') %> <& Elements/SelectGroupBy,
+    Name => 'PrimaryGroupBy',
+    Query => $ARGS{Query},
+    Default => $PrimaryGroupBy
+&><br />
+
+<% loc('Count') %> <& Elements/SelectChartFunction, Default => $ChartFunction[0] &>
+<& Elements/SelectChartFunction, Default => $ChartFunction[1], ShowEmpty => 1 &>
+<& Elements/SelectChartFunction, Default => $ChartFunction[2], ShowEmpty => 1 &><br />
+
 </&>
+
+<&| /Widgets/TitleBox, title => loc('Picture') &>
+<% loc('Style') %> <& Elements/SelectChartType, Default => $ChartStyle, Name => 'ChartStyle' &>
+</&>
+
+<& /Elements/Submit, Label => loc('Update Chart'), Name => 'Update' &>
+</form>
+
 </div>
 <div class="saved-search">
     <& /Widgets/SavedSearch:show, %ARGS, Action => 'Chart.html', self => $saved_search, Title => loc('Saved charts') &>
diff --git a/share/html/Search/Elements/Chart b/share/html/Search/Elements/Chart
index d62b319..e9b0e77 100644
--- a/share/html/Search/Elements/Chart
+++ b/share/html/Search/Elements/Chart
@@ -49,7 +49,7 @@
 $Query => "id > 0"
 $PrimaryGroupBy => 'Queue'
 $ChartStyle => 'bars'
-$ChartFunction => 'COUNT id'
+ at ChartFunction => 'COUNT'
 </%args>
 <%init>
 use RT::Report::Tickets;
@@ -60,10 +60,10 @@ my $tix = RT::Report::Tickets->new( $session{'CurrentUser'} );
 $PrimaryGroupBy = 'Queue'
     unless $tix->IsValidGrouping( Query => $Query, GroupBy => $PrimaryGroupBy );
 
-my ($count_name, $value_name) = $tix->SetupGroupings(
+my %columns = $tix->SetupGroupings(
     Query => $Query,
     GroupBy => $PrimaryGroupBy,
-    Function => $ChartFunction,
+    Function => \@ChartFunction,
 );
 
 my $query_string = $m->comp('/Elements/QueryString', %ARGS);
@@ -79,8 +79,9 @@ my $query_string = $m->comp('/Elements/QueryString', %ARGS);
 <table class="collection-as-table chart">
 
 <tr>
-<th class="collection-as-table"><% $tix->Label( $value_name ) %></th>
-<th class="collection-as-table"><% $tix->Label( $count_name ) %></th>
+% foreach my $column ( @{ $columns{'Groups'} }, @{ $columns{'Functions'} } ) {
+<th class="collection-as-table"><% $tix->Label( $column ) %></th>
+% }
 </tr>
 
 % my $base_query = $m->comp('/Elements/QueryString',
@@ -92,30 +93,37 @@ my $query_string = $m->comp('/Elements/QueryString', %ARGS);
 
 % $tix->SortEntries;
 
-% my ($i,$total) = (0, 0);
+% my $i = 0;
+% my %total = map { $_ => 0 } @{ $columns{Functions} };
 % while ( my $entry = $tix->Next ) {
 <tr class="<% ++$i%2 ? 'evenline' : 'oddline' %>">
 
-<td class="label collection-as-table">
-% my $key = $entry->LabelValue( $value_name );
+% foreach my $column ( @{ $columns{'Groups'} } ) {
+<td class="label collection-as-table"><% $entry->LabelValue( $column ) %></td>
+% }
+
 % my $entry_query = $entry->Query;
+
+% foreach my $column ( @{ $columns{'Functions'} } ) {
+<td class="value collection-as-table">
+% $total{ $column } += my $value = $entry->RawValue( $column );
 % if ( $entry_query ) {
-<a href="<% RT->Config->Get('WebPath') %>/Search/Results.html?Query=<% "$Query AND $entry_query" |u %>&<% $base_query |hn %>"><% $key %></a>
+<a href="<% RT->Config->Get('WebPath') %>/Search/Results.html?Query=<% "$Query AND $entry_query" |un %>&<% $base_query %>"><% $value %></a>
 % } else {
-<% $key %>
+<% $value %>
 % }
 </td>
-
-% $total += my $value = $entry->RawValue( $count_name );
-<td class="value collection-as-table"><%$value%></td>
+% }
 
 </tr>
 % }
 
 % $i++;
 <tr class="<% $i%2 ? 'evenline' : 'oddline' %> total">
-<td class="label collection-as-table"><% loc('Total') %></td>
-<td class="value collection-as-table"><% $total || '' %></td>
+<td class="label collection-as-table" colspan="<% scalar @{ $columns{'Groups'} } %>"><% loc('Total') %></td>
+% foreach my $column ( @{ $columns{'Functions'} } ) {
+<td class="value collection-as-table"><% $total{ $column } %></td>
+% }
 </tr>
 
 </table>
diff --git a/share/html/Search/Elements/SelectChartFunction b/share/html/Search/Elements/SelectChartFunction
index 1ee9db9..37e0b40 100644
--- a/share/html/Search/Elements/SelectChartFunction
+++ b/share/html/Search/Elements/SelectChartFunction
@@ -46,6 +46,9 @@
 %#
 %# END BPS TAGGED BLOCK }}}
 <select name="<% $Name %>">
+% if ( $ShowEmpty ) {
+<option value=""> </option>
+% }
 % while ( my ($value, $display) = splice @functions, 0, 2 ) {
 <option value="<% $value %>"<% $value eq $Default ? qq[ selected="selected"] : '' |n %>><% loc( $display ) %></option>
 % }
@@ -53,7 +56,9 @@
 <%ARGS>
 $Name => 'ChartFunction'
 $Default => 'COUNT'
+$ShowEmpty => 0
 </%ARGS>
 <%INIT>
 my @functions = RT::Report::Tickets->Statistics;
+$Default = '' unless defined $Default;
 </%INIT>

commit 3c6fede42a35638b8951a29255edea5888b8f3be
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Fri Jun 17 21:50:03 2011 +0400

    comment situation with picturing only one function

diff --git a/share/html/Search/Chart b/share/html/Search/Chart
index 0ba1851..ade72e4 100644
--- a/share/html/Search/Chart
+++ b/share/html/Search/Chart
@@ -73,6 +73,7 @@ $PrimaryGroupBy = 'Queue'
 my %columns = $tix->SetupGroupings(
     Query => $Query,
     GroupBy => $PrimaryGroupBy,
+    # TODO: We don't picture more than one function at the moment
     Function => (grep defined && length, @ChartFunction)[0],
 );
 

commit 7e4b83ca7e0f2c03f49a43e1c0ed310d15bf5ea3
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Fri Jun 17 23:19:41 2011 +0400

    MUST not sort arguments before joining with '&'
    
    we sorted keys earlier, so things that are independant from
    order are sorted and the rest is order sensitive.
    
    X => [qw(foo bar)] resulted in X=bar&X=foo that is
    very broken

diff --git a/share/html/Elements/QueryString b/share/html/Elements/QueryString
index bb5cf91..0be8e35 100644
--- a/share/html/Elements/QueryString
+++ b/share/html/Elements/QueryString
@@ -60,5 +60,5 @@ for my $key (sort keys %ARGS) {
     }
 }
 
-return join '&', sort(@params);
+return join '&', @params;
 </%INIT>

commit 60069cea2c20a5f85977c3b726841a68521a1a20
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Fri Jun 17 23:25:40 2011 +0400

    support groupings by multiple fields
    
    at the moment tables are in one dimmension
    
    values in pictures are joined with ' - '

diff --git a/share/html/Search/Chart b/share/html/Search/Chart
index ade72e4..4234fe3 100644
--- a/share/html/Search/Chart
+++ b/share/html/Search/Chart
@@ -47,7 +47,7 @@
 %# END BPS TAGGED BLOCK }}}
 <%args>
 $Query => "id > 0"
-$PrimaryGroupBy => 'Queue'
+ at GroupBy => 'Queue'
 $ChartStyle => 'bars'
 @ChartFunction => 'COUNT'
 </%args>
@@ -67,12 +67,15 @@ if ($ChartStyle eq 'pie') {
 use RT::Report::Tickets;
 my $tix = RT::Report::Tickets->new( $session{'CurrentUser'} );
 
-$PrimaryGroupBy = 'Queue'
-    unless $tix->IsValidGrouping( Query => $Query, GroupBy => $PrimaryGroupBy );
+foreach my $e ( splice @GroupBy ) {
+    next unless defined $e && length $e;
+    next unless $tix->IsValidGrouping( Query => $Query, GroupBy => $e );
+    push @GroupBy, $e;
+}
 
 my %columns = $tix->SetupGroupings(
     Query => $Query,
-    GroupBy => $PrimaryGroupBy,
+    GroupBy => \@GroupBy,
     # TODO: We don't picture more than one function at the moment
     Function => (grep defined && length, @ChartFunction)[0],
 );
diff --git a/share/html/Search/Chart.html b/share/html/Search/Chart.html
index 2d4dd90..2b8141e 100644
--- a/share/html/Search/Chart.html
+++ b/share/html/Search/Chart.html
@@ -46,7 +46,7 @@
 %#
 %# END BPS TAGGED BLOCK }}}
 <%args>
-$PrimaryGroupBy => 'Queue'
+ at GroupBy => 'Queue'
 $ChartStyle => 'bars'
 @ChartFunction => ('COUNT')
 $Description => undef
@@ -54,26 +54,11 @@ $Description => undef
 <%init>
 $ARGS{Query} ||= 'id > 0';
 
-# FIXME: should be factored with RT::Report::Tickets::Label :(
-my $PrimaryGroupByLabel;
-if ( $PrimaryGroupBy =~ /^(?:CF|CustomField)\.{(.*)}$/ ) {
-    my $cf = $1;
-    if ( $cf =~ /\D/ ) {
-        $PrimaryGroupByLabel = loc( "custom field '[_1]'", $cf );
-    } else {
-        my $obj = RT::CustomField->new( $session{'CurrentUser'} );
-        $obj->Load( $cf );
-        $PrimaryGroupByLabel = loc( "custom field '[_1]'", $obj->Name );
-    }
-} else {
-    $PrimaryGroupByLabel = loc( $PrimaryGroupBy );
-}
-
-my $title = loc( "Search results grouped by [_1]", $PrimaryGroupByLabel );
+my $title = loc( "Grouped search results");
 
 my $saved_search = $m->comp( '/Widgets/SavedSearch:new',
     SearchType   => 'Chart',
-    SearchFields => [qw(Query PrimaryGroupBy ChartStyle)] );
+    SearchFields => [qw(Query GroupBy ChartStyle ChartFunction)] );
 
 my @actions = $m->comp( '/Widgets/SavedSearch:process', args => \%ARGS, self => $saved_search );
 
@@ -127,9 +112,21 @@ my %query;
 
 <&| /Widgets/TitleBox, title => loc('Properties') &>
 <% loc('Group by') %> <& Elements/SelectGroupBy,
-    Name => 'PrimaryGroupBy',
+    Name => 'GroupBy',
+    Query => $ARGS{Query},
+    Default => $GroupBy[0],
+&>
+<& Elements/SelectGroupBy,
+    Name => 'GroupBy',
+    Query => $ARGS{Query},
+    Default => $GroupBy[1],
+    ShowEmpty => 1,
+&>
+<& Elements/SelectGroupBy,
+    Name => 'GroupBy',
     Query => $ARGS{Query},
-    Default => $PrimaryGroupBy
+    Default => $GroupBy[2],
+    ShowEmpty => 1,
 &><br />
 
 <% loc('Count') %> <& Elements/SelectChartFunction, Default => $ChartFunction[0] &>
diff --git a/share/html/Search/Elements/Chart b/share/html/Search/Elements/Chart
index e9b0e77..6d02182 100644
--- a/share/html/Search/Elements/Chart
+++ b/share/html/Search/Elements/Chart
@@ -47,26 +47,28 @@
 %# END BPS TAGGED BLOCK }}}
 <%args>
 $Query => "id > 0"
-$PrimaryGroupBy => 'Queue'
+ at GroupBy => 'Queue'
 $ChartStyle => 'bars'
 @ChartFunction => 'COUNT'
 </%args>
 <%init>
 use RT::Report::Tickets;
-$PrimaryGroupBy ||= 'Queue'; # make sure PrimaryGroupBy is not undef
 
 my $tix = RT::Report::Tickets->new( $session{'CurrentUser'} );
 
-$PrimaryGroupBy = 'Queue'
-    unless $tix->IsValidGrouping( Query => $Query, GroupBy => $PrimaryGroupBy );
+foreach my $e ( splice @GroupBy ) {
+    next unless defined $e && length $e;
+    next unless $tix->IsValidGrouping( Query => $Query, GroupBy => $e );
+    push @GroupBy, $e;
+}
 
 my %columns = $tix->SetupGroupings(
     Query => $Query,
-    GroupBy => $PrimaryGroupBy,
+    GroupBy => \@GroupBy,
     Function => \@ChartFunction,
 );
 
-my $query_string = $m->comp('/Elements/QueryString', %ARGS);
+my $query_string = $m->comp('/Elements/QueryString', %ARGS, GroupBy => \@GroupBy );
 </%init>
 <div class="chart-wrapper">
 <span class="chart image">
diff --git a/share/html/Search/Elements/SelectGroupBy b/share/html/Search/Elements/SelectGroupBy
index 8b436c8..532cadc 100644
--- a/share/html/Search/Elements/SelectGroupBy
+++ b/share/html/Search/Elements/SelectGroupBy
@@ -49,11 +49,14 @@
 $Name => 'GroupBy'
 $Default => 'Status'
 $Query   => ''
+$ShowEmpty => 0
 </%args>
-<select id="<% $Name %>" name="<% $Name %>">
-% while (@options) {
-% my ($text, $value) = (shift @options, shift @options);
-<option value="<% $value %>" <% $value eq $Default ? 'selected="selected"' : '' |n%>><% $text %></option>
+<select name="<% $Name %>">
+% if ( $ShowEmpty ) {
+<option value=""> </option>
+% }
+% while ( my ($text, $value) = splice @options, 0, 2 ) {
+<option value="<% $value %>" <% $value eq ($Default||'') ? 'selected="selected"' : '' |n %>><% $text %></option>
 % }
 </select>
 <%init>

commit 52ac9803e5966a7438b68788aba9d80abfbca41f
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Tue Jun 21 16:55:57 2011 +0400

    s/tix/report/. it's a report, isn't it?

diff --git a/share/html/Search/Chart b/share/html/Search/Chart
index 4234fe3..348e367 100644
--- a/share/html/Search/Chart
+++ b/share/html/Search/Chart
@@ -65,27 +65,27 @@ if ($ChartStyle eq 'pie') {
 }
 
 use RT::Report::Tickets;
-my $tix = RT::Report::Tickets->new( $session{'CurrentUser'} );
+my $report = RT::Report::Tickets->new( $session{'CurrentUser'} );
 
 foreach my $e ( splice @GroupBy ) {
     next unless defined $e && length $e;
-    next unless $tix->IsValidGrouping( Query => $Query, GroupBy => $e );
+    next unless $report->IsValidGrouping( Query => $Query, GroupBy => $e );
     push @GroupBy, $e;
 }
 
-my %columns = $tix->SetupGroupings(
+my %columns = $report->SetupGroupings(
     Query => $Query,
     GroupBy => \@GroupBy,
     # TODO: We don't picture more than one function at the moment
     Function => (grep defined && length, @ChartFunction)[0],
 );
 
-$tix->SortEntries;
+$report->SortEntries;
 
 my @data = ([],[]);
 my $max_value = 0;
 my $max_key_length = 0;
-while ( my $entry = $tix->Next ) {
+while ( my $entry = $report->Next ) {
     my $key = join ' - ', map $entry->LabelValue( $_ ), @{ $columns{'Groups'} };
     my $value = $entry->RawValue( $columns{'Functions'}[0] );
     if ($chart_class eq 'GD::Graph::pie') {
@@ -107,7 +107,7 @@ my $font = $font_config{ $session{CurrentUser}->UserObj->Lang || '' }
 # that indicates an error message. Because this is used in an <img>
 # context, it can't be a simple error message. Without this check,
 # the chart will just be a non-loading image.
-unless ( $tix->Count ) {
+unless ( $report->Count ) {
     my $plot = GD::Image->new(600 => 400);
     $plot->colorAllocate(255, 255, 255); # background
     my $black = $plot->colorAllocate(0, 0, 0);
@@ -138,8 +138,8 @@ $chart->set_value_font( $font, 12 ) if $chart->can('set_value_font');
 if ($chart_class eq "GD::Graph::bars") {
     my $count = @{ $data[0] };
     $chart->set(
-        x_label => join( ' - ', map $tix->Label( $_ ), @{ $columns{'Groups'} } ),
-        y_label => $tix->Label( $columns{'Functions'}[0] ),
+        x_label => join( ' - ', map $report->Label( $_ ), @{ $columns{'Groups'} } ),
+        y_label => $report->Label( $columns{'Functions'}[0] ),
         show_values => 1,
         x_label_position => 0.6,
         y_label_position => 0.6,
diff --git a/share/html/Search/Elements/Chart b/share/html/Search/Elements/Chart
index 6d02182..647d031 100644
--- a/share/html/Search/Elements/Chart
+++ b/share/html/Search/Elements/Chart
@@ -54,15 +54,15 @@ $ChartStyle => 'bars'
 <%init>
 use RT::Report::Tickets;
 
-my $tix = RT::Report::Tickets->new( $session{'CurrentUser'} );
+my $report = RT::Report::Tickets->new( $session{'CurrentUser'} );
 
 foreach my $e ( splice @GroupBy ) {
     next unless defined $e && length $e;
-    next unless $tix->IsValidGrouping( Query => $Query, GroupBy => $e );
+    next unless $report->IsValidGrouping( Query => $Query, GroupBy => $e );
     push @GroupBy, $e;
 }
 
-my %columns = $tix->SetupGroupings(
+my %columns = $report->SetupGroupings(
     Query => $Query,
     GroupBy => \@GroupBy,
     Function => \@ChartFunction,
@@ -82,7 +82,7 @@ my $query_string = $m->comp('/Elements/QueryString', %ARGS, GroupBy => \@GroupBy
 
 <tr>
 % foreach my $column ( @{ $columns{'Groups'} }, @{ $columns{'Functions'} } ) {
-<th class="collection-as-table"><% $tix->Label( $column ) %></th>
+<th class="collection-as-table"><% $report->Label( $column ) %></th>
 % }
 </tr>
 
@@ -93,11 +93,11 @@ my $query_string = $m->comp('/Elements/QueryString', %ARGS, GroupBy => \@GroupBy
 %     Order   => $ARGS{Order},
 % );
 
-% $tix->SortEntries;
+% $report->SortEntries;
 
 % my $i = 0;
 % my %total = map { $_ => 0 } @{ $columns{Functions} };
-% while ( my $entry = $tix->Next ) {
+% while ( my $entry = $report->Next ) {
 <tr class="<% ++$i%2 ? 'evenline' : 'oddline' %>">
 
 % foreach my $column ( @{ $columns{'Groups'} } ) {

commit 1846886d40310d6e69108364500bc6007fbcdc16
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Tue Jun 21 20:05:24 2011 +0400

    multiple functions in the charts, but bars only

diff --git a/share/html/Search/Chart b/share/html/Search/Chart
index 348e367..e83b905 100644
--- a/share/html/Search/Chart
+++ b/share/html/Search/Chart
@@ -52,10 +52,13 @@ $ChartStyle => 'bars'
 @ChartFunction => 'COUNT'
 </%args>
 <%init>
-my $chart_class;
 use GD;
 use GD::Text;
 
+ at ChartFunction = grep defined && length, @ChartFunction;
+$ChartStyle = 'bars' if @ChartFunction > 1;
+
+my $chart_class;
 if ($ChartStyle eq 'pie') {
     require GD::Graph::pie;
     $chart_class = "GD::Graph::pie";
@@ -76,8 +79,7 @@ foreach my $e ( splice @GroupBy ) {
 my %columns = $report->SetupGroupings(
     Query => $Query,
     GroupBy => \@GroupBy,
-    # TODO: We don't picture more than one function at the moment
-    Function => (grep defined && length, @ChartFunction)[0],
+    Function => \@ChartFunction,
 );
 
 $report->SortEntries;
@@ -87,15 +89,20 @@ my $max_value = 0;
 my $max_key_length = 0;
 while ( my $entry = $report->Next ) {
     my $key = join ' - ', map $entry->LabelValue( $_ ), @{ $columns{'Groups'} };
-    my $value = $entry->RawValue( $columns{'Functions'}[0] );
+
+    my @values = map $entry->RawValue($_), @{ $columns{'Functions'} };
     if ($chart_class eq 'GD::Graph::pie') {
-        $key .= ' - '. $value;
+        $key .= ' - '. $values[0];
     }
 
     push @{ $data[0] }, $key;
-    push @{ $data[1] }, $value;
 
-    $max_value = $value if $max_value < $value;
+    my $i = 0;
+    push @{ $data[++$i] }, $_ foreach @values;
+
+    foreach my $v ( @values ) {
+        $max_value = $v if $max_value < $v;
+    }
     $max_key_length = length $key if $max_key_length < length $key;
 }
 

commit 2012462b0303ea050e9b8bb9999bab9b9ad40238
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Tue Jun 21 20:06:17 2011 +0400

    bargroup_spacing five times bigger than bar spacing

diff --git a/share/html/Search/Chart b/share/html/Search/Chart
index e83b905..daedc04 100644
--- a/share/html/Search/Chart
+++ b/share/html/Search/Chart
@@ -144,6 +144,12 @@ $chart->set_value_font( $font, 12 ) if $chart->can('set_value_font');
 
 if ($chart_class eq "GD::Graph::bars") {
     my $count = @{ $data[0] };
+    my $bar_spacing =
+        $count > 30 ? 1
+        : $count > 20 ? 2
+        : $count > 10 ? 3
+        : 5
+    ;
     $chart->set(
         x_label => join( ' - ', map $report->Label( $_ ), @{ $columns{'Groups'} } ),
         y_label => $report->Label( $columns{'Functions'}[0] ),
@@ -157,9 +163,8 @@ if ($chart_class eq "GD::Graph::bars") {
         y_max_value => 5*(int($max_value/5) + 2),
 # if there're too many bars or at least one key is too long, use vertical
         x_labels_vertical => ( $count * $max_key_length > 60 ) ? 1 : 0,
-        $count > 30 ? ( bar_spacing => 1 ) : ( $count > 20 ? ( bar_spacing => 2 ) : 
-            ( $count > 10 ? ( bar_spacing => 3 ) : ( bar_spacing => 5 ) )
-        ),
+        bar_spacing => $bar_spacing,
+        bargroup_spacing => $bar_spacing*5,
     );
 }
 

commit 3370c9150ebac71e54e0b804ec0db548e7d16961
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Tue Jun 21 22:27:24 2011 +0400

    sort by all groupings

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index b92dc58..29739c9 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -516,52 +516,60 @@ sub AddEmptyRows {
     }
 }
 
+{ our @SORT_OPS;
+sub __sort_function_we_need_named($$) {
+    for my $f ( @SORT_OPS ) {
+        my $r = $f->($_[0], $_[1]);
+        return $r if $r;
+    }
+}
 sub SortEntries {
     my $self = shift;
 
-    # XXX: at the begining let's support only grouping by one column
-    my ($group_by) =
+    $self->_DoSearch if $self->{'must_redo_search'};
+    return unless $self->{'items'} && @{ $self->{'items'} };
+
+    my @groups =
         grep $_->{'TYPE'} eq 'grouping',
         map $self->ColumnInfo($_),
         $self->ColumnsList;
-    return unless $group_by;
-
-    my $order = $group_by->{'META'}{Sort} || 'label';
-    if ( $order eq 'label' ) {
-        $self->{'items'} = [
-            map $_->[0],
-            sort { $a->[1] cmp $b->[1] }
-            map [ $_, $_->LabelValue( $group_by->{'NAME'} ) ],
-            @{ $self->{'items'} || [] }
-        ];
-    }
-    elsif ( $order eq 'numeric label' ) {
-        $self->{'items'} = [
-            map $_->[0],
-            sort { $a->[1] <=> $b->[1] }
-            map [ $_, $_->LabelValue( $group_by->{'NAME'} ) ],
-            @{ $self->{'items'} || [] }
-        ];
-    }
-    elsif ( $order eq 'raw' ) {
-        $self->{'items'} = [
-            map $_->[0],
-            sort { $a->[1] cmp $b->[1] }
-            map [ $_, $_->RawValue( $group_by->{'NAME'} ) ],
-            @{ $self->{'items'} || [] }
-        ];
-    }
-    elsif ( $order eq 'numeric raw' ) {
-        $self->{'items'} = [
-            map $_->[0],
-            sort { $a->[1] <=> $b->[1] }
-            map [ $_, $_->RawValue( $group_by->{'NAME'} ) ],
-            @{ $self->{'items'} || [] }
-        ];
-    } else {
-        $RT::Logger->error("Unknown sorting function '$order'");
+    return unless @groups;
+
+    local @SORT_OPS;
+    my @data = map [$_], @{ $self->{'items'} };
+
+    for ( my $i = 0; $i < @groups; $i++ ) {
+        my $group_by = $groups[$i];
+        my $idx = $i+1;
+        my $method;
+
+        my $order = $group_by->{'META'}{Sort} || 'label';
+        if ( $order eq 'label' ) {
+            push @SORT_OPS, sub { $_[0][$idx] cmp $_[1][$idx] };
+            $method = 'LabelValue';
+        }
+        elsif ( $order eq 'numeric label' ) {
+            push @SORT_OPS, sub { $_[0][$idx] <=> $_[1][$idx] };
+            $method = 'LabelValue';
+        }
+        elsif ( $order eq 'raw' ) {
+            push @SORT_OPS, sub { $_[0][$idx] cmp $_[1][$idx] };
+            $method = 'RawValue';
+        }
+        elsif ( $order eq 'numeric raw' ) {
+            push @SORT_OPS, sub { $_[0][$idx] <=> $_[1][$idx] };
+            $method = 'RawValue';
+        } else {
+            $RT::Logger->error("Unknown sorting function '$order'");
+            next;
+        }
+        $_->[$idx] = $_->[0]->$method( $group_by->{'NAME'} ) for @data;
     }
-}
+    $self->{'items'} = [
+        map $_->[0],
+        sort __sort_function_we_need_named @data
+    ];
+} }
 
 sub GenerateDateFunction {
     my $self = shift;

commit b8f6c68c282e0b6604533139cdeaa5a28715bfb6
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Wed Jun 22 04:28:57 2011 +0400

    pass only required things into GroupBy and Column
    
    these methods may store all passed attributes internaly
    this makes sreialization harder to do

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 29739c9..5e36b85 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -381,7 +381,11 @@ sub SetupGroupings {
         $e->{'INFO'} = $GROUPINGS{ $key };
         $e->{'META'} = $GROUPINGS_META{ $e->{'INFO'} };
     }
-    $self->GroupBy( @group_by );
+    $self->GroupBy( map { {
+        ALIAS    => $_->{'ALIAS'},
+        FIELD    => $_->{'FIELD'},
+        FUNCTION => $_->{'FUNCTION'},
+    } } @group_by );
 
     my %res = (Groups => [], Functions => []);
     my %column_info;
@@ -399,7 +403,11 @@ sub SetupGroupings {
         $args{'TYPE'} = 'statistic';
         $args{'INFO'} = $STATISTICS{ $e };
         $args{'META'} = $STATISTICS_META{ $args{'INFO'}[1] };
-        $args{'NAME'} = $self->Column( %args );
+        $args{'NAME'} = $self->Column(
+            ALIAS    => $args{'ALIAS'},
+            FIELD    => $args{'FIELD'},
+            FUNCTION => $args{'FUNCTION'},
+        );
         push @{ $res{'Functions'} }, $args{'NAME'};
         $column_info{ $args{'NAME'} } = \%args;
     }

commit 0e830cdb5b10f00f27f85a8a1db34d8f7b4ff07b
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Wed Jun 22 04:31:28 2011 +0400

    sort report earlier, want to serialize sorted report

diff --git a/share/html/Search/Elements/Chart b/share/html/Search/Elements/Chart
index 647d031..4eb5763 100644
--- a/share/html/Search/Elements/Chart
+++ b/share/html/Search/Elements/Chart
@@ -68,6 +68,8 @@ my %columns = $report->SetupGroupings(
     Function => \@ChartFunction,
 );
 
+$report->SortEntries;
+
 my $query_string = $m->comp('/Elements/QueryString', %ARGS, GroupBy => \@GroupBy );
 </%init>
 <div class="chart-wrapper">
@@ -93,8 +95,6 @@ my $query_string = $m->comp('/Elements/QueryString', %ARGS, GroupBy => \@GroupBy
 %     Order   => $ARGS{Order},
 % );
 
-% $report->SortEntries;
-
 % my $i = 0;
 % my %total = map { $_ => 0 } @{ $columns{Functions} };
 % while ( my $entry = $report->Next ) {

commit 7bd405b96c1bc3f57f476e6c5143179ee40e1e59
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Wed Jun 22 04:33:27 2011 +0400

    Serialize and Deserialize methods for reports

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 5e36b85..1235167 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -682,6 +682,40 @@ sub FindImplementationCode {
     }
     return $code;
 }
+
+sub Serialize {
+    my $self = shift;
+
+    my %clone = %$self;
+# current user, handle and column_info
+    delete @clone{'user', 'DBIxHandle', 'column_info'};
+    $clone{'items'} = [ map $_->{'values'}, @{ $clone{'items'} || [] } ];
+    $clone{'column_info'} = {};
+    while ( my ($k, $v) = each %{ $self->{'column_info'} } ) {
+        $clone{'column_info'}{$k} = { %$v };
+        delete $clone{'column_info'}{$k}{'META'};
+    }
+    return \%clone;
+}
+
+sub Deserialize {
+    my $self = shift;
+    my $data = shift;
+
+    $self->CleanSlate;
+    %$self = (%$self, %$data);
+
+    $self->{'items'} = [
+        map { my $r = $self->NewItem; $r->LoadFromHash( $_ ); $r }
+        @{ $self->{'items'} }
+    ];
+    foreach my $e ( values %{ $self->{column_info} } ) {
+        $e->{'META'} = $e->{'TYPE'} eq 'grouping'
+            ? $GROUPINGS_META{ $e->{'INFO'} }
+            : $STATISTICS_META{ $e->{'INFO'}[1] }
+    }
+}
+
 RT::Base->_ImportOverlays();
 
 1;

commit c492ac54707c6e53c49a5884949f22f4c51b945e
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Wed Jun 22 04:36:53 2011 +0400

    cache report's data for pictures to re-use

diff --git a/share/html/Search/Chart b/share/html/Search/Chart
index daedc04..e7b6abb 100644
--- a/share/html/Search/Chart
+++ b/share/html/Search/Chart
@@ -46,6 +46,7 @@
 %#
 %# END BPS TAGGED BLOCK }}}
 <%args>
+$Cache => undef
 $Query => "id > 0"
 @GroupBy => 'Queue'
 $ChartStyle => 'bars'
@@ -70,19 +71,27 @@ if ($ChartStyle eq 'pie') {
 use RT::Report::Tickets;
 my $report = RT::Report::Tickets->new( $session{'CurrentUser'} );
 
-foreach my $e ( splice @GroupBy ) {
-    next unless defined $e && length $e;
-    next unless $report->IsValidGrouping( Query => $Query, GroupBy => $e );
-    push @GroupBy, $e;
-}
+my %columns;
+
+if ( $Cache and my $data = delete $session{'charts_cache'}{ $Cache } ) {
+    %columns = %{ $data->{'columns'} };
+    $report->Deserialize( $data->{'report'} );
+    $session{'i'}++;
+} else {
+    foreach my $e ( splice @GroupBy ) {
+        next unless defined $e && length $e;
+        next unless $report->IsValidGrouping( Query => $Query, GroupBy => $e );
+        push @GroupBy, $e;
+    }
 
-my %columns = $report->SetupGroupings(
-    Query => $Query,
-    GroupBy => \@GroupBy,
-    Function => \@ChartFunction,
-);
+    %columns = $report->SetupGroupings(
+        Query => $Query,
+        GroupBy => \@GroupBy,
+        Function => \@ChartFunction,
+    );
 
-$report->SortEntries;
+    $report->SortEntries;
+}
 
 my @data = ([],[]);
 my $max_value = 0;
diff --git a/share/html/Search/Elements/Chart b/share/html/Search/Elements/Chart
index 4eb5763..7a7a269 100644
--- a/share/html/Search/Elements/Chart
+++ b/share/html/Search/Elements/Chart
@@ -77,7 +77,10 @@ my $query_string = $m->comp('/Elements/QueryString', %ARGS, GroupBy => \@GroupBy
 % if (RT->Config->Get('DisableGD')) {
 <% loc('Graphical charts are not available.') %><br />
 % } else {
-<img src="<%RT->Config->Get('WebPath')%>/Search/Chart?<%$query_string|n%>" />
+% my $key = Digest::MD5::md5_hex( rand(1024) );
+% $session{'charts_cache'}{$key} = { columns => \%columns, report => $report->Serialize };
+% $session{'i'}++;
+<img src="<% RT->Config->Get('WebPath') %>/Search/Chart?Cache=<% $key |un %>&<% $query_string |n %>" />
 % }
 </span>
 <table class="collection-as-table chart">

commit 115f373f5c874a7418fd8c98dab440166ec2055d
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Wed Jun 22 17:13:08 2011 +0400

    LabelValueCode - fector out it for re-use
    
    we need direct references to code that turns raw
    value of a statistic into a user friendly thing

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 1235167..78e54bd 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -653,6 +653,17 @@ sub GenerateWatcherFunction {
     return %args;
 }
 
+
+sub LabelValueCode {
+    my $self = shift;
+    my $name = shift;
+
+    my $display = $self->ColumnInfo( $name )->{'META'}{'Display'};
+    return undef unless $display;
+    return $self->FindImplementationCode( $display );
+}
+
+
 sub FindImplementationCode {
     my $self = shift;
     my $value = shift;
diff --git a/lib/RT/Report/Tickets/Entry.pm b/lib/RT/Report/Tickets/Entry.pm
index e34e5e5..35e9cef 100644
--- a/lib/RT/Report/Tickets/Entry.pm
+++ b/lib/RT/Report/Tickets/Entry.pm
@@ -82,18 +82,13 @@ sub LabelValue {
     my $name = shift;
 
     my $raw = $self->RawValue( $name, @_ );
-    my $info = $self->ColumnInfo( $name );
-    my $meta = $info->{'META'};
-
-    if (
-        $meta and $meta->{'Display'}
-        and my $code = $self->FindImplementationCode( $meta->{'Display'} )
-    ) {
-        return $code->( $self, %$info, VALUE => $raw );
+
+    if ( my $code = $self->LabelCode( $name ) ) {
+        return $code->( $self, %{ $self->ColumnInfo( $name ) }, VALUE => $raw );
     }
 
     return $self->loc('(no value)') unless defined $raw && length $raw;
-    return $self->loc($raw) if $info->{'META'}{'Localize'};
+    return $self->loc($raw) if $self->ColumnInfo( $name )->{'META'}{'Localize'};
     return $raw;
 }
 
@@ -136,6 +131,10 @@ sub Query {
     return join ' AND ', grep defined && length, @parts;
 }
 
+sub LabelCode {
+    return RT::Report::Tickets->can('LabelValueCode')->(@_);
+}
+
 sub FindImplementationCode {
     return RT::Report::Tickets->can('FindImplementationCode')->(@_);
 }

commit 50ab54fddd8ffca1f85fc6053a105425818f6050
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Wed Jun 22 17:16:26 2011 +0400

    convert intervals from seconds

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 78e54bd..00df4df 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -260,6 +260,13 @@ our %STATISTICS_META = (
 
             return (FUNCTION => "$function($interval)");
         },
+        Display => sub {
+            my $self = shift;
+            my %args = @_;
+            my $v = $args{'VALUE'};
+            return $self->loc("(no value)") unless defined $v && length $v;
+            return RT::Date->new( $self->CurrentUser )->DurationAsString( $v );
+        },
     },
 );
 

commit 0e0f6359e5ffd5f909d9df922998feeb1f222c80
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Wed Jun 22 17:19:28 2011 +0400

    apply conversion on stats in tables and pictures

diff --git a/share/html/Search/Chart b/share/html/Search/Chart
index e7b6abb..fcb906d 100644
--- a/share/html/Search/Chart
+++ b/share/html/Search/Chart
@@ -101,7 +101,7 @@ while ( my $entry = $report->Next ) {
 
     my @values = map $entry->RawValue($_), @{ $columns{'Functions'} };
     if ($chart_class eq 'GD::Graph::pie') {
-        $key .= ' - '. $values[0];
+        $key .= ' - '. $entry->LabelValue( $columns{'Functions'}[0] );
     }
 
     push @{ $data[0] }, $key;
@@ -152,19 +152,29 @@ $chart->set_values_font( $font, 12 ) if $chart->can('set_values_font');
 $chart->set_value_font( $font, 12 ) if $chart->can('set_value_font');
 
 if ($chart_class eq "GD::Graph::bars") {
+    my %args;
     my $count = @{ $data[0] };
-    my $bar_spacing =
+    $args{'bar_spacing'} =
         $count > 30 ? 1
         : $count > 20 ? 2
         : $count > 10 ? 3
         : 5
     ;
+    if ( my $code = $report->First->LabelCode( $columns{'Functions'}[0] ) ) {
+        my %info = %{ $report->ColumnInfo( $columns{'Functions'}[0] ) };
+        $args{'values_format'} = $args{'y_number_format'} = sub {
+            return $code->($report, %info, VALUE => shift );
+        };
+    }
+    $report->GotoFirstItem;
+
     $chart->set(
+        %args,
         x_label => join( ' - ', map $report->Label( $_ ), @{ $columns{'Groups'} } ),
-        y_label => $report->Label( $columns{'Functions'}[0] ),
-        show_values => 1,
         x_label_position => 0.6,
+        y_label => $report->Label( $columns{'Functions'}[0] ),
         y_label_position => 0.6,
+        show_values => 1,
         values_space => -1,
 # use a top margin enough to display values over the top line if needed
         t_margin => 18,
@@ -172,8 +182,7 @@ if ($chart_class eq "GD::Graph::bars") {
         y_max_value => 5*(int($max_value/5) + 2),
 # if there're too many bars or at least one key is too long, use vertical
         x_labels_vertical => ( $count * $max_key_length > 60 ) ? 1 : 0,
-        bar_spacing => $bar_spacing,
-        bargroup_spacing => $bar_spacing*5,
+        bargroup_spacing => $args{'bar_spacing'}*5,
     );
 }
 
diff --git a/share/html/Search/Elements/Chart b/share/html/Search/Elements/Chart
index 7a7a269..7dbd7c0 100644
--- a/share/html/Search/Elements/Chart
+++ b/share/html/Search/Elements/Chart
@@ -111,7 +111,8 @@ my $query_string = $m->comp('/Elements/QueryString', %ARGS, GroupBy => \@GroupBy
 
 % foreach my $column ( @{ $columns{'Functions'} } ) {
 <td class="value collection-as-table">
-% $total{ $column } += my $value = $entry->RawValue( $column );
+% $total{ $column } += $entry->RawValue( $column );
+% my $value = $entry->LabelValue( $column );
 % if ( $entry_query ) {
 <a href="<% RT->Config->Get('WebPath') %>/Search/Results.html?Query=<% "$Query AND $entry_query" |un %>&<% $base_query %>"><% $value %></a>
 % } else {
@@ -127,8 +128,13 @@ my $query_string = $m->comp('/Elements/QueryString', %ARGS, GroupBy => \@GroupBy
 <tr class="<% $i%2 ? 'evenline' : 'oddline' %> total">
 <td class="label collection-as-table" colspan="<% scalar @{ $columns{'Groups'} } %>"><% loc('Total') %></td>
 % foreach my $column ( @{ $columns{'Functions'} } ) {
+% if ( my $code = $report->LabelValueCode( $column ) ) {
+% my $info = $report->ColumnInfo( $column );
+<td class="value collection-as-table"><% $code->( $report, %$info, VALUE => $total{ $column } ) %></td>
+% } else {
 <td class="value collection-as-table"><% $total{ $column } %></td>
 % }
+% }
 </tr>
 
 </table>

commit c556fba6d352352f28da4031592844742e2129b8
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Wed Jun 22 18:34:53 2011 +0400

    put back grouping by Queue if all are invalid

diff --git a/share/html/Search/Chart b/share/html/Search/Chart
index fcb906d..1866701 100644
--- a/share/html/Search/Chart
+++ b/share/html/Search/Chart
@@ -83,6 +83,7 @@ if ( $Cache and my $data = delete $session{'charts_cache'}{ $Cache } ) {
         next unless $report->IsValidGrouping( Query => $Query, GroupBy => $e );
         push @GroupBy, $e;
     }
+    @GroupBy = ('Queue') unless @GroupBy;
 
     %columns = $report->SetupGroupings(
         Query => $Query,
diff --git a/share/html/Search/Elements/Chart b/share/html/Search/Elements/Chart
index 7dbd7c0..e32dfab 100644
--- a/share/html/Search/Elements/Chart
+++ b/share/html/Search/Elements/Chart
@@ -61,6 +61,7 @@ foreach my $e ( splice @GroupBy ) {
     next unless $report->IsValidGrouping( Query => $Query, GroupBy => $e );
     push @GroupBy, $e;
 }
+ at GroupBy = ('Queue') unless @GroupBy;
 
 my %columns = $report->SetupGroupings(
     Query => $Query,

commit c59d4eeb001d3b2f30a2140e9ddeec4ffd2ce023
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Wed Jun 22 18:36:08 2011 +0400

    update tests with the latest changes in code

diff --git a/t/web/charting.t b/t/web/charting.t
index 32d95d9..57551fe 100644
--- a/t/web/charting.t
+++ b/t/web/charting.t
@@ -45,34 +45,34 @@ ok( length($m->content), "Has content" );
 
 
 # Group by Queue
-$m->get_ok( "/Search/Chart.html?Query=id>0&PrimaryGroupBy=Queue" );
+$m->get_ok( "/Search/Chart.html?Query=id>0&GroupBy=Queue" );
 $m->content_like(qr{<th[^>]*>Queue\s*</th>\s*<th[^>]*>Tickets\s*</th>}, "Grouped by queue");
 $m->content_like(qr{General</a>\s*</td>\s*<td[^>]*>\s*<a[^>]*>7</a>}, "Found results in table");
 $m->content_like(qr{<img src="/Search/Chart\?}, "Found image");
 
-$m->get_ok( "/Search/Chart?Query=id>0&PrimaryGroupBy=Queue" );
+$m->get_ok( "/Search/Chart?Query=id>0&GroupBy=Queue" );
 is( $m->content_type, "image/png" );
 ok( length($m->content), "Has content" );
 
 
 # Group by Requestor email
-$m->get_ok( "/Search/Chart.html?Query=id>0&PrimaryGroupBy=Requestor.EmailAddress" );
-$m->content_like(qr{<th[^>]*>Requestor\.EmailAddress\s*</th>\s*<th[^>]*>Tickets\s*</th>},
+$m->get_ok( "/Search/Chart.html?Query=id>0&GroupBy=Requestor.EmailAddress" );
+$m->content_like(qr{<th[^>]*>Requestor\s+EmailAddress</th>\s*<th[^>]*>Tickets\s*</th>},
                  "Grouped by requestor");
 $m->content_like(qr{root0\@localhost</a>\s*</td>\s*<td[^>]*>\s*<a[^>]*>3</a>}, "Found results in table");
 $m->content_like(qr{<img src="/Search/Chart\?}, "Found image");
 
-$m->get_ok( "/Search/Chart?Query=id>0&PrimaryGroupBy=Requestor.Email" );
+$m->get_ok( "/Search/Chart?Query=id>0&GroupBy=Requestor.Email" );
 is( $m->content_type, "image/png" );
 ok( length($m->content), "Has content" );
 
 
 # Group by Requestor phone -- which is bogus, and falls back to queue
-$m->get_ok( "/Search/Chart.html?Query=id>0&PrimaryGroupBy=Requestor.Phone" );
+$m->get_ok( "/Search/Chart.html?Query=id>0&GroupBy=Requestor.Phone" );
 $m->content_like(qr{General</a>\s*</td>\s*<td[^>]*>\s*<a[^>]*>7</a>},
                  "Found queue results in table, as a default");
 $m->content_like(qr{<img src="/Search/Chart\?}, "Found image");
 
-$m->get_ok( "/Search/Chart?Query=id>0&PrimaryGroupBy=Requestor.Phone" );
+$m->get_ok( "/Search/Chart?Query=id>0&GroupBy=Requestor.Phone" );
 is( $m->content_type, "image/png" );
 ok( length($m->content), "Has content" );
diff --git a/t/web/saved_search_chart.t b/t/web/saved_search_chart.t
index 70111b9..3737b51 100644
--- a/t/web/saved_search_chart.t
+++ b/t/web/saved_search_chart.t
@@ -58,7 +58,7 @@ $m->submit_form(
     form_name => 'SaveSearch',
     fields    => {
         Query          => 'id=2',
-        PrimaryGroupBy => 'Status',
+        GroupBy        => 'Status',
         ChartStyle     => 'pie',
     },
     button => 'SavedSearchSave',
@@ -67,13 +67,13 @@ $m->submit_form(
 $m->content_contains("Chart first chart updated", 'found updated message' );
 $m->content_contains("id=2",                      'Query is updated' );
 $m->content_like( qr/value="Status"\s+selected="selected"/,
-    'PrimaryGroupBy is updated' );
+    'GroupBy is updated' );
 $m->content_like( qr/value="pie"\s+selected="selected"/,
     'ChartType is updated' );
 ok( $search->Load($id) );
 is( $search->SubValue('Query'), 'id=2', 'Query is indeed updated' );
-is( $search->SubValue('PrimaryGroupBy'),
-    'Status', 'PrimaryGroupBy is indeed updated' );
+is( $search->SubValue('GroupBy'),
+    'Status', 'GroupBy is indeed updated' );
 is( $search->SubValue('ChartStyle'), 'pie', 'ChartStyle is indeed updated' );
 
 # finally, let's test delete

commit bdb225a77f2cbecc38ebd37ee95fd9cf32aa8f7b
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Wed Jun 22 18:44:15 2011 +0400

    Min/max y-axis endpoints ±10% of min/max values

diff --git a/share/html/Search/Chart b/share/html/Search/Chart
index 1866701..a2682f1 100644
--- a/share/html/Search/Chart
+++ b/share/html/Search/Chart
@@ -96,6 +96,7 @@ if ( $Cache and my $data = delete $session{'charts_cache'}{ $Cache } ) {
 
 my @data = ([],[]);
 my $max_value = 0;
+my $min_value;
 my $max_key_length = 0;
 while ( my $entry = $report->Next ) {
     my $key = join ' - ', map $entry->LabelValue( $_ ), @{ $columns{'Groups'} };
@@ -112,6 +113,7 @@ while ( my $entry = $report->Next ) {
 
     foreach my $v ( @values ) {
         $max_value = $v if $max_value < $v;
+        $min_value = $v if !defined $min_value || $min_value > $v;
     }
     $max_key_length = length $key if $max_key_length < length $key;
 }
@@ -180,7 +182,8 @@ if ($chart_class eq "GD::Graph::bars") {
 # use a top margin enough to display values over the top line if needed
         t_margin => 18,
 # the following line to make sure there's enough space for values to show
-        y_max_value => 5*(int($max_value/5) + 2),
+        y_max_value => $max_value * 1.10,
+        y_min_value => $min_value * 0.9,
 # if there're too many bars or at least one key is too long, use vertical
         x_labels_vertical => ( $count * $max_key_length > 60 ) ? 1 : 0,
         bargroup_spacing => $args{'bar_spacing'}*5,

commit 696a5d935a59bb5ee95dcfced8f82f4698ec58af
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Sat Jun 25 00:14:29 2011 +0400

    "Time" statistic instead of "Simple" for time fields

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 00df4df..917e56e 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -192,20 +192,20 @@ our %GROUPINGS_META = (
 our @STATISTICS = (
     COUNT             => ['Tickets', 'Count', 'id'],
 
-    'SUM(TimeWorked)' => ['Total time worked',   'Simple', 'SUM', 'TimeWorked' ],
-    'AVG(TimeWorked)' => ['Average time worked', 'Simple', 'AVG', 'TimeWorked' ],
-    'MIN(TimeWorked)' => ['Minimum time worked', 'Simple', 'MIN', 'TimeWorked' ],
-    'MAX(TimeWorked)' => ['Maximum time worked', 'Simple', 'MAX', 'TimeWorked' ],
+    'SUM(TimeWorked)' => ['Total time worked',   'Time', 'SUM', 'TimeWorked' ],
+    'AVG(TimeWorked)' => ['Average time worked', 'Time', 'AVG', 'TimeWorked' ],
+    'MIN(TimeWorked)' => ['Minimum time worked', 'Time', 'MIN', 'TimeWorked' ],
+    'MAX(TimeWorked)' => ['Maximum time worked', 'Time', 'MAX', 'TimeWorked' ],
 
-    'SUM(TimeEstimated)' => ['Total time estimated',   'Simple', 'SUM', 'TimeEstimated' ],
-    'AVG(TimeEstimated)' => ['Average time estimated', 'Simple', 'AVG', 'TimeEstimated' ],
-    'MIN(TimeEstimated)' => ['Minimum time estimated', 'Simple', 'MIN', 'TimeEstimated' ],
-    'MAX(TimeEstimated)' => ['Maximum time estimated', 'Simple', 'MAX', 'TimeEstimated' ],
+    'SUM(TimeEstimated)' => ['Total time estimated',   'Time', 'SUM', 'TimeEstimated' ],
+    'AVG(TimeEstimated)' => ['Average time estimated', 'Time', 'AVG', 'TimeEstimated' ],
+    'MIN(TimeEstimated)' => ['Minimum time estimated', 'Time', 'MIN', 'TimeEstimated' ],
+    'MAX(TimeEstimated)' => ['Maximum time estimated', 'Time', 'MAX', 'TimeEstimated' ],
 
-    'SUM(TimeLeft)' => ['Total time left',   'Simple', 'SUM', 'TimeLeft' ],
-    'AVG(TimeLeft)' => ['Average time left', 'Simple', 'AVG', 'TimeLeft' ],
-    'MIN(TimeLeft)' => ['Minimum time left', 'Simple', 'MIN', 'TimeLeft' ],
-    'MAX(TimeLeft)' => ['Maximum time left', 'Simple', 'MAX', 'TimeLeft' ],
+    'SUM(TimeLeft)' => ['Total time left',   'Time', 'SUM', 'TimeLeft' ],
+    'AVG(TimeLeft)' => ['Average time left', 'Time', 'AVG', 'TimeLeft' ],
+    'MIN(TimeLeft)' => ['Minimum time left', 'Time', 'MIN', 'TimeLeft' ],
+    'MAX(TimeLeft)' => ['Maximum time left', 'Time', 'MAX', 'TimeLeft' ],
 
     'SUM(Created-Resolved)'
         => ['Summary of Created-Resolved', 'DateTimeInterval', 'SUM', 'Created', 'Resolved' ],
@@ -248,6 +248,20 @@ our %STATISTICS_META = (
             return (FUNCTION => $function, FIELD => $field);
         },
     },
+    Time => {
+        Function => sub {
+            my $self = shift;
+            my ($function, $field) = @_;
+            return (FUNCTION => $function, FIELD => $field);
+        },
+        Display => sub {
+            my $self = shift;
+            my %args = @_;
+            my $v = $args{'VALUE'};
+            return $self->loc("(no value)") unless defined $v && length $v;
+            return RT::Date->new( $self->CurrentUser )->DurationAsString( $v*60 );
+        },
+    },
     DateTimeInterval => {
         Function => sub {
             my $self = shift;

commit 87559a8f86d3f66f656930dbfc93cd6071718999
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Mon Jun 27 15:16:17 2011 +0400

    don't print zero totals

diff --git a/share/html/Search/Elements/Chart b/share/html/Search/Elements/Chart
index e32dfab..37e442b 100644
--- a/share/html/Search/Elements/Chart
+++ b/share/html/Search/Elements/Chart
@@ -129,7 +129,9 @@ my $query_string = $m->comp('/Elements/QueryString', %ARGS, GroupBy => \@GroupBy
 <tr class="<% $i%2 ? 'evenline' : 'oddline' %> total">
 <td class="label collection-as-table" colspan="<% scalar @{ $columns{'Groups'} } %>"><% loc('Total') %></td>
 % foreach my $column ( @{ $columns{'Functions'} } ) {
-% if ( my $code = $report->LabelValueCode( $column ) ) {
+% if ( !$total{ $column } ) {
+<td class="value collection-as-table"> </td>
+% } elsif ( my $code = $report->LabelValueCode( $column ) ) {
 % my $info = $report->ColumnInfo( $column );
 <td class="value collection-as-table"><% $code->( $report, %$info, VALUE => $total{ $column } ) %></td>
 % } else {

commit 3b1b3f2ae247c5b8d5d2c7e370a8a745a5c8529e
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Mon Jun 27 15:17:19 2011 +0400

    get rid _StatsToFunction so we can implement post calc functions

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 917e56e..85a56cc 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -417,20 +417,42 @@ sub SetupGroupings {
         push @{ $res{'Groups'} }, $group_by->{'NAME'};
     }
 
+    %STATISTICS = @STATISTICS unless keys %STATISTICS;
+
     my @function = grep defined && length,
         ref( $args{'Function'} )? @{ $args{'Function'} } : ($args{'Function'});
     foreach my $e ( @function ) {
-        my %args = $self->_StatsToFunction( $e );
-        $args{'TYPE'} = 'statistic';
-        $args{'INFO'} = $STATISTICS{ $e };
-        $args{'META'} = $STATISTICS_META{ $args{'INFO'}[1] };
-        $args{'NAME'} = $self->Column(
-            ALIAS    => $args{'ALIAS'},
-            FIELD    => $args{'FIELD'},
-            FUNCTION => $args{'FUNCTION'},
-        );
-        push @{ $res{'Functions'} }, $args{'NAME'};
-        $column_info{ $args{'NAME'} } = \%args;
+        $e = {
+            TYPE => 'statistic',
+            KEY  => $e,
+            INFO => $STATISTICS{ $e },
+            META => $STATISTICS_META{ $STATISTICS{ $e }[1] },
+
+        };
+        unless ( $e->{'INFO'} && $e->{'META'} ) {
+            $RT::Logger->error("'". $e->{'KEY'} ."' is not valid statistic for report");
+            $e->{'FUNCTION'} = 'NULL';
+            $e->{'NAME'} = $self->Column( FUNCTION => 'NULL' );
+        }
+        elsif ( $e->{'META'}{'Function'} ) {
+            my $code = $self->FindImplementationCode( $e->{'META'}{'Function'} );
+            unless ( $code ) {
+                $e->{'FUNCTION'} = 'NULL';
+                $e->{'NAME'} = $self->Column( FUNCTION => 'NULL' );
+            }
+            else {
+                my %tmp = $code->( $self, @{ $e->{INFO} }[2 .. scalar @{ $e->{INFO} } -1 ] );
+                $e->{'NAME'} = $self->Column( %tmp );
+                @{ $e }{'FUNCTION', 'ALIAS', 'FIELD'} = @tmp{'FUNCTION', 'ALIAS', 'FIELD'};
+            }
+        }
+        elsif ( $e->{'META'}{'Calculate'} ) {
+            # ....
+        }
+        else {
+        }
+        push @{ $res{'Functions'} }, $e->{'NAME'};
+        $column_info{ $e->{'NAME'} } = $e;
     }
 
     $self->{'column_info'} = \%column_info;
@@ -482,24 +504,6 @@ sub _FieldToFunction {
     return $code->( $self, %args );
 }
 
-sub _StatsToFunction {
-    my $self = shift;
-    my ($stat) = (@_);
-
-    %STATISTICS = @STATISTICS unless keys %STATISTICS;
-
-    my ($display, $type, @args) = @{ $STATISTICS{ $stat } || [] };
-    unless ( $type ) {
-        $RT::Logger->error("'$stat' is not valid statistics for report");
-        return ('FUNCTION' => 'NULL');
-    }
-
-    my $meta = $STATISTICS_META{ $type };
-    return ('FUNCTION' => 'NULL') unless $meta;
-    return ('FUNCTION' => 'NULL') unless $meta->{'Function'};
-    return $meta->{'Function'}->( $self, @args );
-}
-
 
 # Gotta skip over RT::Tickets->Next, since it does all sorts of crazy magic we 
 # don't want.

commit 59ec3a43fe99016fade26ca567b0738677700e83
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Mon Jun 27 15:22:00 2011 +0400

    drop AddEmptyRow as it doesn't work

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 85a56cc..5b15815 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -476,7 +476,6 @@ sub _DoSearch {
         );
     }
     else {
-        $self->AddEmptyRows;
     }
 }
 
@@ -524,31 +523,6 @@ sub NewItem {
 # correct class.  However, since we're abusing a subclass, it's incorrect.
 sub _RoleGroupClass { "RT::Ticket" }
 
-
-=head2 AddEmptyRows
-
-If we're grouping on a criterion we know how to add zero-value rows
-for, do that.
-
-=cut
-
-sub AddEmptyRows {
-    my $self = shift;
-    if ( @{ $self->{'_group_by_field'} || [] } == 1 && $self->{'_group_by_field'}[0] eq 'Status' ) {
-        my %has = map { $_->__Value('Status') => 1 } @{ $self->ItemsArrayRef || [] };
-
-        foreach my $status ( grep !$has{$_}, RT::Queue->new($self->CurrentUser)->StatusArray ) {
-
-            my $record = $self->NewItem;
-            $record->LoadFromHash( {
-                id     => 0,
-                status => $status
-            } );
-            $self->AddRecord($record);
-        }
-    }
-}
-
 { our @SORT_OPS;
 sub __sort_function_we_need_named($$) {
     for my $f ( @SORT_OPS ) {

commit 74eb701ab450625da462d8063ee59427c9d12f4f
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Mon Jun 27 22:33:34 2011 +0400

    move chart's table formatting into libs

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 5b15815..9a4e987 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -726,6 +726,64 @@ sub Deserialize {
     }
 }
 
+
+sub FormatTable {
+    my $self = shift;
+    my %columns = @_;
+
+    my (@head, @body, @footer);
+
+    @head = ({ cells => []});
+    foreach my $column ( @{ $columns{'Groups'} }, @{ $columns{'Functions'} } ) {
+        push @{ $head[0]{'cells'} }, { type => 'head', value => $self->Label($column) };
+    }
+
+    my $i = 0;
+    while ( my $entry = $self->Next ) {
+        $body[ $i ] = { even => ($i+1)%2, cells => [] };
+        $i++;
+    }
+    @footer = ({ even => ++$i%2, cells => []});
+
+    foreach my $column ( @{ $columns{'Groups'} } ) {
+        $i = 0;
+        while ( my $entry = $self->Next ) {
+            push @{ $body[ $i++ ]{'cells'} }, {
+                type => 'label',
+                value => $entry->LabelValue( $column )
+            };
+        }
+    }
+    push @{ $footer[0]{'cells'} }, {
+        type => 'label',
+        value => $self->loc('Total'),
+        colspan => scalar @{ $columns{'Groups'} },
+    };
+
+    foreach my $column ( @{ $columns{'Functions'} } ) {
+        $i = 0;
+        my $total;
+        while ( my $entry = $self->Next ) {
+            my $raw = $entry->RawValue( $column );
+            $total += $raw if defined $raw && length $raw;
+
+            my $value = $entry->LabelValue( $column );
+            push @{ $body[ $i++ ]{'cells'} }, {
+                type => 'value',
+                value => $value,
+                query => $entry->Query,
+            };
+        }
+        if ( $total and my $code = $self->LabelValueCode( $column ) ) {
+            my $info = $self->ColumnInfo( $column );
+            $total = $code->( $self, %$info, VALUE => $total );
+        }
+        push @{ $footer[0]{'cells'} }, { type => 'value', value => $total };
+    }
+
+    return thead => \@head, tbody => \@body, tfoot => \@footer;
+}
+
 RT::Base->_ImportOverlays();
 
 1;
diff --git a/share/html/Search/Elements/Chart b/share/html/Search/Elements/Chart
index 37e442b..b6c939c 100644
--- a/share/html/Search/Elements/Chart
+++ b/share/html/Search/Elements/Chart
@@ -84,62 +84,6 @@ my $query_string = $m->comp('/Elements/QueryString', %ARGS, GroupBy => \@GroupBy
 <img src="<% RT->Config->Get('WebPath') %>/Search/Chart?Cache=<% $key |un %>&<% $query_string |n %>" />
 % }
 </span>
-<table class="collection-as-table chart">
-
-<tr>
-% foreach my $column ( @{ $columns{'Groups'} }, @{ $columns{'Functions'} } ) {
-<th class="collection-as-table"><% $report->Label( $column ) %></th>
-% }
-</tr>
-
-% my $base_query = $m->comp('/Elements/QueryString',
-%     Format  => $ARGS{Format},
-%     Rows    => $ARGS{Rows},
-%     OrderBy => $ARGS{OrderBy},
-%     Order   => $ARGS{Order},
-% );
-
-% my $i = 0;
-% my %total = map { $_ => 0 } @{ $columns{Functions} };
-% while ( my $entry = $report->Next ) {
-<tr class="<% ++$i%2 ? 'evenline' : 'oddline' %>">
-
-% foreach my $column ( @{ $columns{'Groups'} } ) {
-<td class="label collection-as-table"><% $entry->LabelValue( $column ) %></td>
-% }
-
-% my $entry_query = $entry->Query;
-
-% foreach my $column ( @{ $columns{'Functions'} } ) {
-<td class="value collection-as-table">
-% $total{ $column } += $entry->RawValue( $column );
-% my $value = $entry->LabelValue( $column );
-% if ( $entry_query ) {
-<a href="<% RT->Config->Get('WebPath') %>/Search/Results.html?Query=<% "$Query AND $entry_query" |un %>&<% $base_query %>"><% $value %></a>
-% } else {
-<% $value %>
-% }
-</td>
-% }
-
-</tr>
-% }
-
-% $i++;
-<tr class="<% $i%2 ? 'evenline' : 'oddline' %> total">
-<td class="label collection-as-table" colspan="<% scalar @{ $columns{'Groups'} } %>"><% loc('Total') %></td>
-% foreach my $column ( @{ $columns{'Functions'} } ) {
-% if ( !$total{ $column } ) {
-<td class="value collection-as-table"> </td>
-% } elsif ( my $code = $report->LabelValueCode( $column ) ) {
-% my $info = $report->ColumnInfo( $column );
-<td class="value collection-as-table"><% $code->( $report, %$info, VALUE => $total{ $column } ) %></td>
-% } else {
-<td class="value collection-as-table"><% $total{ $column } %></td>
-% }
-% }
-</tr>
-
-</table>
+<& ChartTable, %ARGS, Table => { $report->FormatTable( %columns ) } &>
 <div class="query"><span class="label"><% loc('Query') %>:</span><span class="value"><% $Query %></span></div>
 </div>
diff --git a/share/html/Search/Elements/ChartTable b/share/html/Search/Elements/ChartTable
new file mode 100644
index 0000000..0dad6b3
--- /dev/null
+++ b/share/html/Search/Elements/ChartTable
@@ -0,0 +1,67 @@
+<%ARGS>
+%Table => ()
+$Query => undef
+</%ARGS>
+<%INIT>
+
+my $base_query = $m->comp('/Elements/QueryString',
+    Format  => $ARGS{Format},
+    Rows    => $ARGS{Rows},
+    OrderBy => $ARGS{OrderBy},
+    Order   => $ARGS{Order},
+);
+
+my $interp = $m->interp;
+my $eh  = sub { $interp->apply_escapes( @_, 'h' ) };
+my $eu  = sub { $interp->apply_escapes( @_, 'u' ) };
+
+$m->out('<table class="collection-as-table chart">'. "\n");
+foreach my $section (qw(thead tbody tfoot)) {
+    next unless $Table{ $section } && @{ $Table{ $section } };
+
+    $m->out("<$section>\n");
+    foreach my $row ( @{ $Table{ $section } } ) {
+        $m->out('  <tr');
+        $m->out(' class="'. ($row->{'even'}? 'evenline' : 'oddline') .'"')
+            if defined $row->{'even'};
+        $m->out(">");
+
+        foreach my $cell ( @{ $row->{'cells'} } ) {
+            my $tag = $cell->{'type'} eq 'value'? 'td' : 'th';
+            $m->out("<$tag");
+
+            my @class = ('collection-as-table');
+            push @class, ($cell->{'type'}) unless $cell->{'type'} eq 'head';
+            $m->out(' class="'. $eh->( join ' ', @class ) .'"');
+
+            foreach my $dir ( grep $cell->{$_}, qw(rowspan colspan) ) {
+                my $value = int $cell->{ $dir };
+                $m->out(qq{ $dir="$value"});
+            }
+            $m->out('>');
+            if ( defined $cell->{'value'} ) {
+                if ( my $q = $cell->{'query'} ) {
+                    $m->out(
+                        '<a href="'. $eh->(RT->Config->Get('WebPath')) .'/Search/Results.html'
+                        .'?Query='. $eu->(join ' AND ', grep defined && length, $Query, $q)
+                        . $eh->('&') . $base_query
+                        . '">'
+                    );
+                    $m->out( $eh->( $cell->{'value'} ) );
+                    $m->out('</a>');
+                }
+                else {
+                    $m->out( $eh->( $cell->{'value'} ) );
+                }
+            }
+            else {
+                $m->out(' ');
+            }
+            $m->out("</$tag>");
+        }
+        $m->out("</tr>\n");
+    }
+    $m->out("</$section>\n\n");
+}
+$m->out("</table>");
+</%INIT>

commit 9f0521176943f23435b968ee18313359280dc74a
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Mon Jun 27 23:19:41 2011 +0400

    first pass at caculating stats after querying DB

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 9a4e987..dbd1cb7 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -447,9 +447,7 @@ sub SetupGroupings {
             }
         }
         elsif ( $e->{'META'}{'Calculate'} ) {
-            # ....
-        }
-        else {
+            $e->{'NAME'} = 'postfunction'. $self->{'postfunctions'}++;
         }
         push @{ $res{'Functions'} }, $e->{'NAME'};
         $column_info{ $e->{'NAME'} } = $e;
@@ -476,6 +474,7 @@ sub _DoSearch {
         );
     }
     else {
+        $self->PostProcessRecords;
     }
 }
 
@@ -578,6 +577,40 @@ sub SortEntries {
     ];
 } }
 
+sub PostProcessRecords {
+    my $self = shift;
+
+    my $info = $self->{'column_info'};
+    foreach my $column ( values %$info ) {
+        next unless $column->{'TYPE'} eq 'statistic';
+        next unless $column->{'META'}{'Calculate'};
+
+        $self->CalculatePostFunction( $column );
+    }
+}
+
+sub CalculatePostFunction {
+    my $self = shift;
+    my $info = shift;
+
+    my $code = $self->FindImplementationCode( $info->{'META'}{'Calculate'} );
+    unless ( $code ) {
+        # TODO: fill in undefs
+        return;
+    }
+
+    my $column = $info->{'NAME'};
+
+    my $base_query = $self->{'_sql_query'};
+    foreach my $item ( @{ $self->{'items'} } ) {
+        $item->{'values'}{$column} = $code->(
+            $self,
+            Query => join( ' AND ', grep defined && length, $base_query, $item->Query ),
+        );
+        $item->{'fetched'}{$column} = 1;
+    }
+}
+
 sub GenerateDateFunction {
     my $self = shift;
     my %args = @_;
diff --git a/lib/RT/Report/Tickets/Entry.pm b/lib/RT/Report/Tickets/Entry.pm
index 35e9cef..49a229a 100644
--- a/lib/RT/Report/Tickets/Entry.pm
+++ b/lib/RT/Report/Tickets/Entry.pm
@@ -128,6 +128,7 @@ sub Query {
             push @parts, "$field $op $value";
         }
     }
+    return () unless @parts;
     return join ' AND ', grep defined && length, @parts;
 }
 

commit a16828dd2b7cb5a076d28179b21cb00853d8c7cb
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Tue Jun 28 04:51:55 2011 +0400

    don't process references in RT::Record::__Value
    
    Nothing stops LoadFromHash or something else to
    set Value to a hash reference or array. We use
    it in reporting for compound functions.

diff --git a/lib/RT/Record.pm b/lib/RT/Record.pm
index 02f548e..8ac4a59 100644
--- a/lib/RT/Record.pm
+++ b/lib/RT/Record.pm
@@ -649,6 +649,7 @@ sub __Value {
     }
 
     my $value = $self->SUPER::__Value($field);
+    return $value if ref $value;
 
     return undef if (!defined $value);
 

commit a9e8bb8127620cb43acc2b238a521960b99f87fa
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Tue Jun 28 07:20:59 2011 +0400

    position columns, so sorting entries works

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index dbd1cb7..5b23728 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -377,7 +377,8 @@ sub ColumnInfo {
 
 sub ColumnsList {
     my $self = shift;
-    return keys %{ $self->{'column_info'} || {} };
+    return sort { $self->{'column_info'}{$a}{'POSITION'} <=> $self->{'column_info'}{$b}{'POSITION'} }
+        keys %{ $self->{'column_info'} || {} };
 }
 
 sub SetupGroupings {
@@ -393,6 +394,8 @@ sub SetupGroupings {
 
     %GROUPINGS = @GROUPINGS unless keys %GROUPINGS;
 
+    my $i = 0;
+
     my @group_by = grep defined && length,
         ref( $args{'GroupBy'} )? @{ $args{'GroupBy'} } : ($args{'GroupBy'});
     foreach my $e ( @group_by ) {
@@ -401,6 +404,7 @@ sub SetupGroupings {
         $e->{'TYPE'} = 'grouping';
         $e->{'INFO'} = $GROUPINGS{ $key };
         $e->{'META'} = $GROUPINGS_META{ $e->{'INFO'} };
+        $e->{'POSITION'} = $i++;
     }
     $self->GroupBy( map { {
         ALIAS    => $_->{'ALIAS'},
@@ -427,7 +431,7 @@ sub SetupGroupings {
             KEY  => $e,
             INFO => $STATISTICS{ $e },
             META => $STATISTICS_META{ $STATISTICS{ $e }[1] },
-
+            POSITION => $i++,
         };
         unless ( $e->{'INFO'} && $e->{'META'} ) {
             $RT::Logger->error("'". $e->{'KEY'} ."' is not valid statistic for report");

commit b9660bea6e145091612e77194cdf603d879f2776
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Tue Jun 28 07:22:12 2011 +0400

    support compex functions in Entry->LabelValue

diff --git a/lib/RT/Report/Tickets/Entry.pm b/lib/RT/Report/Tickets/Entry.pm
index 49a229a..f28c058 100644
--- a/lib/RT/Report/Tickets/Entry.pm
+++ b/lib/RT/Report/Tickets/Entry.pm
@@ -87,9 +87,20 @@ sub LabelValue {
         return $code->( $self, %{ $self->ColumnInfo( $name ) }, VALUE => $raw );
     }
 
-    return $self->loc('(no value)') unless defined $raw && length $raw;
-    return $self->loc($raw) if $self->ColumnInfo( $name )->{'META'}{'Localize'};
-    return $raw;
+    unless ( ref $raw ) {
+        return $self->loc('(no value)') unless defined $raw && length $raw;
+        return $self->loc($raw) if $self->ColumnInfo( $name )->{'META'}{'Localize'};
+        return $raw;
+    } else {
+        my $loc = $self->ColumnInfo( $name )->{'META'}{'Localize'};
+        my %res = %$raw;
+        if ( $loc ) {
+            $res{ $self->loc($_) } = delete $res{ $_ } foreach keys %res;
+            $_ = $self->loc($_) foreach values %res;
+        }
+        $_ = $self->loc('(no value)') foreach grep !defined || !length, values %res;
+        return \%res;
+    }
 }
 
 sub RawValue {

commit f3737d2c6f25e9c14a45b75653d509361a4aeec0
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Tue Jun 28 07:24:35 2011 +0400

    table rendering of complex functions with SubValues

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 5b23728..76b47f9 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -771,8 +771,8 @@ sub FormatTable {
     my (@head, @body, @footer);
 
     @head = ({ cells => []});
-    foreach my $column ( @{ $columns{'Groups'} }, @{ $columns{'Functions'} } ) {
-        push @{ $head[0]{'cells'} }, { type => 'head', value => $self->Label($column) };
+    foreach my $column ( @{ $columns{'Groups'} } ) {
+        push @{ $head[0]{'cells'} }, { type => 'head', value => $self->Label( $column ) };
     }
 
     my $i = 0;
@@ -799,23 +799,80 @@ sub FormatTable {
 
     foreach my $column ( @{ $columns{'Functions'} } ) {
         $i = 0;
-        my $total;
+
+        my $info = $self->ColumnInfo( $column );
+
+        my @subs = ('');
+        if ( $info->{'META'}{'SubValues'} ) {
+            @subs = $self->FindImplementationCode( $info->{'META'}{'SubValues'} )->(
+                $self
+            );
+        }
+
+        my %total;
         while ( my $entry = $self->Next ) {
-            my $raw = $entry->RawValue( $column );
-            $total += $raw if defined $raw && length $raw;
+            my $raw = $entry->RawValue( $column ) || {};
+            $raw = { '' => $raw } unless ref $raw;
+            $total{ $_ } += $raw->{ $_ } foreach grep $raw->{$_}, @subs;
+        }
+        @subs = grep $total{$_}, @subs;
 
-            my $value = $entry->LabelValue( $column );
-            push @{ $body[ $i++ ]{'cells'} }, {
-                type => 'value',
-                value => $value,
-                query => $entry->Query,
+        my $label = $self->Label( $column );
+
+        unless (@subs) {
+            while ( my $entry = $self->Next ) {
+                push @{ $body[ $i++ ]{'cells'} }, {
+                    type => 'value',
+                    value => undef,
+                    query => $entry->Query,
+                };
+            }
+            push @{ $head[0]{'cells'} }, {
+                type => 'head',
+                value => $label,
+                rowspan => scalar @head,
+            };
+            push @{ $footer[0]{'cells'} }, { type => 'value', value => undef };
+            next;
+        }
+
+        if ( @subs > 1 && @head == 1 ) {
+            $_->{rowspan} = 2 foreach @{ $head[0]{'cells'} };
+        }
+
+        if ( @subs == 1 ) {
+            push @{ $head[0]{'cells'} }, {
+                type => 'head',
+                value => $label,
+                rowspan => scalar @head,
             };
+        } else {
+            push @{ $head[0]{'cells'} }, { type => 'head', value => $label, colspan => scalar @subs };
+            push @{ $head[1]{'cells'} }, { type => 'head', value => $_ }
+                foreach @subs;
         }
-        if ( $total and my $code = $self->LabelValueCode( $column ) ) {
-            my $info = $self->ColumnInfo( $column );
-            $total = $code->( $self, %$info, VALUE => $total );
+
+        while ( my $entry = $self->Next ) {
+            my $query = $entry->Query;
+            my $value = $entry->LabelValue( $column ) || {};
+            $value = { '' => $value } unless ref $value;
+            foreach my $e ( @subs ) {
+                push @{ $body[ $i ]{'cells'} }, {
+                    type => 'value',
+                    value => $value->{ $e },
+                    query => $query,
+                };
+            }
+            $i++;
+        }
+
+        my $total_code = $self->LabelValueCode( $column );
+        foreach my $e ( @subs ) {
+            my $total = $total{ $e };
+            $total = $total_code->( $self, %$info, VALUE => $total )
+                if $total_code;
+            push @{ $footer[0]{'cells'} }, { type => 'value', value => $total };
         }
-        push @{ $footer[0]{'cells'} }, { type => 'value', value => $total };
     }
 
     return thead => \@head, tbody => \@body, tfoot => \@footer;

commit 9f7736f6d105020f76e0e9972e9215c6f54481ea
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Tue Jun 28 07:44:57 2011 +0400

    rowspan equal grouping values

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 76b47f9..d28a44d 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -784,11 +784,16 @@ sub FormatTable {
 
     foreach my $column ( @{ $columns{'Groups'} } ) {
         $i = 0;
+        my $last;
         while ( my $entry = $self->Next ) {
-            push @{ $body[ $i++ ]{'cells'} }, {
-                type => 'label',
-                value => $entry->LabelValue( $column )
-            };
+            my $value = $entry->LabelValue( $column );
+            if ( !$last || $last->{'value'} ne $value ) {
+                push @{ $body[ $i++ ]{'cells'} }, $last = { type => 'label', value => $value, };
+            }
+            else {
+                $i++;
+                $last->{rowspan} = ($last->{rowspan}||1) + 1;
+            }
         }
     }
     push @{ $footer[0]{'cells'} }, {

commit d386ec08432483555196554defa70b12719feb3a
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Wed Jun 29 00:19:28 2011 +0400

    support picturing complex functions with subvalues

diff --git a/share/html/Search/Chart b/share/html/Search/Chart
index a2682f1..a3457d6 100644
--- a/share/html/Search/Chart
+++ b/share/html/Search/Chart
@@ -56,23 +56,10 @@ $ChartStyle => 'bars'
 use GD;
 use GD::Text;
 
- at ChartFunction = grep defined && length, @ChartFunction;
-$ChartStyle = 'bars' if @ChartFunction > 1;
-
-my $chart_class;
-if ($ChartStyle eq 'pie') {
-    require GD::Graph::pie;
-    $chart_class = "GD::Graph::pie";
-} else {
-    require GD::Graph::bars;
-    $chart_class = "GD::Graph::bars";
-}
-
 use RT::Report::Tickets;
 my $report = RT::Report::Tickets->new( $session{'CurrentUser'} );
 
 my %columns;
-
 if ( $Cache and my $data = delete $session{'charts_cache'}{ $Cache } ) {
     %columns = %{ $data->{'columns'} };
     $report->Deserialize( $data->{'report'} );
@@ -100,14 +87,22 @@ my $min_value;
 my $max_key_length = 0;
 while ( my $entry = $report->Next ) {
     my $key = join ' - ', map $entry->LabelValue( $_ ), @{ $columns{'Groups'} };
+    push @{ $data[0] }, $key;
 
-    my @values = map $entry->RawValue($_), @{ $columns{'Functions'} };
-    if ($chart_class eq 'GD::Graph::pie') {
-        $key .= ' - '. $entry->LabelValue( $columns{'Functions'}[0] );
+    my @values;
+    foreach my $column ( @{ $columns{'Functions'} } ) {
+        my $v = $entry->RawValue( $column );
+        unless ( ref $v ) {
+            push @values, $v;
+            next;
+        }
+
+        my @subs = $report->FindImplementationCode(
+            $report->ColumnInfo( $column )->{'META'}{'SubValues'}
+        )->( $report );
+        push @values, map $v->{$_}, @subs;
     }
 
-    push @{ $data[0] }, $key;
-
     my $i = 0;
     push @{ $data[++$i] }, $_ foreach @values;
 
@@ -118,6 +113,23 @@ while ( my $entry = $report->Next ) {
     $max_key_length = length $key if $max_key_length < length $key;
 }
 
+$ChartStyle = 'bars' if @data > 2;
+if ( $ChartStyle eq 'pie' ) {
+    my $i = 0;
+    while ( my $entry = $report->Next ) {
+        $data[0][$i++] .= ' - '. $entry->LabelValue( $columns{'Functions'}[0] );
+    }
+}
+
+my $chart_class;
+if ($ChartStyle eq 'pie') {
+    require GD::Graph::pie;
+    $chart_class = "GD::Graph::pie";
+} else {
+    require GD::Graph::bars;
+    $chart_class = "GD::Graph::bars";
+}
+
 my %font_config = RT->Config->Get('ChartFont');
 my $font = $font_config{ $session{CurrentUser}->UserObj->Lang || '' }
     || $font_config{'others'};

commit 1ef4c5e9616c6a1c5e22d05e91b65806a7764c2f
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Wed Jun 29 00:20:19 2011 +0400

    NoTotals and NoHideEmpty

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index d28a44d..f353ca9 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -815,12 +815,15 @@ sub FormatTable {
         }
 
         my %total;
-        while ( my $entry = $self->Next ) {
-            my $raw = $entry->RawValue( $column ) || {};
-            $raw = { '' => $raw } unless ref $raw;
-            $total{ $_ } += $raw->{ $_ } foreach grep $raw->{$_}, @subs;
+        unless ( $info->{'META'}{'NoTotals'} ) {
+            while ( my $entry = $self->Next ) {
+                my $raw = $entry->RawValue( $column ) || {};
+                $raw = { '' => $raw } unless ref $raw;
+                $total{ $_ } += $raw->{ $_ } foreach grep $raw->{$_}, @subs;
+            }
+            @subs = grep $total{$_}, @subs
+                unless $info->{'META'}{'NoHideEmpty'};
         }
-        @subs = grep $total{$_}, @subs;
 
         my $label = $self->Label( $column );
 
@@ -871,12 +874,19 @@ sub FormatTable {
             $i++;
         }
 
-        my $total_code = $self->LabelValueCode( $column );
-        foreach my $e ( @subs ) {
-            my $total = $total{ $e };
-            $total = $total_code->( $self, %$info, VALUE => $total )
-                if $total_code;
-            push @{ $footer[0]{'cells'} }, { type => 'value', value => $total };
+        unless ( $info->{'META'}{'NoTotals'} ) {
+            my $total_code = $self->LabelValueCode( $column );
+            foreach my $e ( @subs ) {
+                my $total = $total{ $e };
+                $total = $total_code->( $self, %$info, VALUE => $total )
+                    if $total_code;
+                push @{ $footer[0]{'cells'} }, { type => 'value', value => $total };
+            }
+        }
+        else {
+            foreach my $e ( @subs ) {
+                push @{ $footer[0]{'cells'} }, { type => 'value', value => undef };
+            }
         }
     }
 

commit 7cf22661971aeb598ec0062bc5c25f9f77c0be8f
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Fri Jul 1 16:57:37 2011 +0400

    fill in @STATISTICS with foreach loops

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index f353ca9..3fe8a7c 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -191,40 +191,36 @@ our %GROUPINGS_META = (
 
 our @STATISTICS = (
     COUNT             => ['Tickets', 'Count', 'id'],
-
-    'SUM(TimeWorked)' => ['Total time worked',   'Time', 'SUM', 'TimeWorked' ],
-    'AVG(TimeWorked)' => ['Average time worked', 'Time', 'AVG', 'TimeWorked' ],
-    'MIN(TimeWorked)' => ['Minimum time worked', 'Time', 'MIN', 'TimeWorked' ],
-    'MAX(TimeWorked)' => ['Maximum time worked', 'Time', 'MAX', 'TimeWorked' ],
-
-    'SUM(TimeEstimated)' => ['Total time estimated',   'Time', 'SUM', 'TimeEstimated' ],
-    'AVG(TimeEstimated)' => ['Average time estimated', 'Time', 'AVG', 'TimeEstimated' ],
-    'MIN(TimeEstimated)' => ['Minimum time estimated', 'Time', 'MIN', 'TimeEstimated' ],
-    'MAX(TimeEstimated)' => ['Maximum time estimated', 'Time', 'MAX', 'TimeEstimated' ],
-
-    'SUM(TimeLeft)' => ['Total time left',   'Time', 'SUM', 'TimeLeft' ],
-    'AVG(TimeLeft)' => ['Average time left', 'Time', 'AVG', 'TimeLeft' ],
-    'MIN(TimeLeft)' => ['Minimum time left', 'Time', 'MIN', 'TimeLeft' ],
-    'MAX(TimeLeft)' => ['Maximum time left', 'Time', 'MAX', 'TimeLeft' ],
-
-    'SUM(Created-Resolved)'
-        => ['Summary of Created-Resolved', 'DateTimeInterval', 'SUM', 'Created', 'Resolved' ],
-    'AVG(Created-Resolved)'
-        => ['Average Created-Resolved', 'DateTimeInterval', 'AVG', 'Created', 'Resolved' ],
-    'MIN(Created-Resolved)'
-        => ['Minimum Created-Resolved', 'DateTimeInterval', 'MIN', 'Created', 'Resolved' ],
-    'MAX(Created-Resolved)'
-        => ['Maximum Created-Resolved', 'DateTimeInterval', 'MAX', 'Created', 'Resolved' ],
-
-    'SUM(Created-LastUpdated)'
-        => ['Summary of Created-LastUpdated', 'DateTimeInterval', 'SUM', 'Created', 'LastUpdated' ],
-    'AVG(Created-LastUpdated)'
-        => ['Average Created-LastUpdated', 'DateTimeInterval', 'AVG', 'Created', 'LastUpdated' ],
-    'MIN(Created-LastUpdated)'
-        => ['Minimum Created-LastUpdated', 'DateTimeInterval', 'MIN', 'Created', 'LastUpdated' ],
-    'MAX(Created-LastUpdated)'
-        => ['Maximum Created-LastUpdated', 'DateTimeInterval', 'MAX', 'Created', 'LastUpdated' ],
 );
+
+foreach my $field (qw(TimeWorked TimeEstimated TimeLeft)) {
+    my $friendly = lc join ' ', split /(?<=[a-z])(?=[A-Z])/, $field;
+    push @STATISTICS, (
+        "SUM($field)" => ["Total $friendly",   'Time', 'SUM', $field ],
+        "AVG($field)" => ["Average $friendly", 'Time', 'AVG', $field ],
+        "MIN($field)" => ["Minimum $friendly", 'Time', 'MIN', $field ],
+        "MAX($field)" => ["Maximum $friendly", 'Time', 'MAX', $field ],
+    );
+}
+
+
+foreach my $pair (qw(
+    Created-Started
+    Created-Resolved
+    Created-LastUpdated
+    Starts-Started
+    Due-Resolved
+    Started-Resolved
+)) {
+    my ($from, $to) = split /-/, $pair;
+    push @STATISTICS, (
+        "SUM($pair)" => ["Summary of $pair", 'DateTimeInterval', 'SUM', $from, $to ],
+        "AVG($pair)" => ["Average $pair", 'DateTimeInterval', 'AVG', $from, $to ],
+        "MIN($pair)" => ["Minimum $pair", 'DateTimeInterval', 'MIN', $from, $to ],
+        "MAX($pair)" => ["Maximum $pair", 'DateTimeInterval', 'MAX', $from, $to ],
+    );
+}
+
 our %STATISTICS;
 
 our %STATISTICS_META = (

commit 83d5bd6d0ef55ee814fe4dce3350bd8d714b651c
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Fri Jul 1 17:06:46 2011 +0400

    abstract seconds -> string conversion

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 3fe8a7c..37068c9 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -223,6 +223,24 @@ foreach my $pair (qw(
 
 our %STATISTICS;
 
+my $duration_to_string_cb = sub {
+    my $self = shift;
+    my %args = @_;
+    my $v = $args{'VALUE'};
+    unless ( ref $v ) {
+        return $self->loc("(no value)") unless defined $v && length $v;
+        return RT::Date->new( $self->CurrentUser )->DurationAsString( $v );
+    }
+
+    my $date = RT::Date->new( $self->CurrentUser );
+    my %res = %$v;
+    foreach my $e ( values %res ) {
+        $e = $date->DurationAsString( $e ) if defined $e && length $e;
+        $e = $self->loc("(no value)") unless defined $e && length $e;
+    }
+    return \%res;
+};
+
 our %STATISTICS_META = (
     Count => {
         Function => sub {
@@ -248,15 +266,9 @@ our %STATISTICS_META = (
         Function => sub {
             my $self = shift;
             my ($function, $field) = @_;
-            return (FUNCTION => $function, FIELD => $field);
-        },
-        Display => sub {
-            my $self = shift;
-            my %args = @_;
-            my $v = $args{'VALUE'};
-            return $self->loc("(no value)") unless defined $v && length $v;
-            return RT::Date->new( $self->CurrentUser )->DurationAsString( $v*60 );
+            return (FUNCTION => "$function(?)*60", FIELD => $field);
         },
+        Display => $duration_to_string_cb,
     },
     DateTimeInterval => {
         Function => sub {
@@ -270,13 +282,7 @@ our %STATISTICS_META = (
 
             return (FUNCTION => "$function($interval)");
         },
-        Display => sub {
-            my $self = shift;
-            my %args = @_;
-            my $v = $args{'VALUE'};
-            return $self->loc("(no value)") unless defined $v && length $v;
-            return RT::Date->new( $self->CurrentUser )->DurationAsString( $v );
-        },
+        Display => $duration_to_string_cb,
     },
 );
 

commit 5ffd77f2f6bf1eda093aaa1693ab1fcdc01491b3
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Fri Jul 1 17:12:00 2011 +0400

    SubValues for stats with SQL only
    
    This allows to group several stats like calculatables

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 37068c9..fff9cdc 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -446,6 +446,15 @@ sub SetupGroupings {
                 $e->{'FUNCTION'} = 'NULL';
                 $e->{'NAME'} = $self->Column( FUNCTION => 'NULL' );
             }
+            elsif ( $e->{'META'}{'SubValues'} ) {
+                my %tmp = $code->( $self, @{ $e->{INFO} }[2 .. scalar @{ $e->{INFO} } -1 ] );
+                $e->{'NAME'} = 'postfunction'. $self->{'postfunctions'}++;
+                while ( my ($k, $v) = each %tmp ) {
+                    $e->{'MAP'}{ $k }{'NAME'} = $self->Column( %$v );
+                    @{ $e->{'MAP'}{ $k } }{'FUNCTION', 'ALIAS', 'FIELD'} =
+                        @{ $v }{'FUNCTION', 'ALIAS', 'FIELD'};
+                }
+            }
             else {
                 my %tmp = $code->( $self, @{ $e->{INFO} }[2 .. scalar @{ $e->{INFO} } -1 ] );
                 $e->{'NAME'} = $self->Column( %tmp );
@@ -589,9 +598,12 @@ sub PostProcessRecords {
     my $info = $self->{'column_info'};
     foreach my $column ( values %$info ) {
         next unless $column->{'TYPE'} eq 'statistic';
-        next unless $column->{'META'}{'Calculate'};
-
-        $self->CalculatePostFunction( $column );
+        if ( $column->{'META'}{'Calculate'} ) {
+            $self->CalculatePostFunction( $column );
+        }
+        elsif ( $column->{'META'}{'SubValues'} ) {
+            $self->MapSubValues( $column );
+        }
     }
 }
 
@@ -617,6 +629,23 @@ sub CalculatePostFunction {
     }
 }
 
+sub MapSubValues {
+    my $self = shift;
+    my $info = shift;
+
+    my $to = $info->{'NAME'};
+    my $map = $info->{'MAP'};
+
+    foreach my $item ( @{ $self->{'items'} } ) {
+        my $dst = $item->{'values'}{ $to } = { };
+        while (my ($k, $v) = each %{ $map } ) {
+            $dst->{ $k } = delete $item->{'values'}{ $v->{'NAME'} };
+            delete $item->{'fetched'}{ $v->{'NAME'} };
+        }
+        $item->{'fetched'}{ $to } = 1;
+    }
+}
+
 sub GenerateDateFunction {
     my $self = shift;
     my %args = @_;

commit 8b1d371b41979d9ffaa290194b23691978bacd40
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Fri Jul 1 17:23:57 2011 +0400

    TimeAll and DateTimeIntervalAll statistics

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index fff9cdc..a3d02d1 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -196,6 +196,7 @@ our @STATISTICS = (
 foreach my $field (qw(TimeWorked TimeEstimated TimeLeft)) {
     my $friendly = lc join ' ', split /(?<=[a-z])(?=[A-Z])/, $field;
     push @STATISTICS, (
+        "ALL($field)" => [ucfirst $friendly,   'TimeAll',     $field ],
         "SUM($field)" => ["Total $friendly",   'Time', 'SUM', $field ],
         "AVG($field)" => ["Average $friendly", 'Time', 'AVG', $field ],
         "MIN($field)" => ["Minimum $friendly", 'Time', 'MIN', $field ],
@@ -214,6 +215,7 @@ foreach my $pair (qw(
 )) {
     my ($from, $to) = split /-/, $pair;
     push @STATISTICS, (
+        "ALL($pair)" => [$pair, 'DateTimeIntervalAll', $from, $to ],
         "SUM($pair)" => ["Summary of $pair", 'DateTimeInterval', 'SUM', $from, $to ],
         "AVG($pair)" => ["Average $pair", 'DateTimeInterval', 'AVG', $from, $to ],
         "MIN($pair)" => ["Minimum $pair", 'DateTimeInterval', 'MIN', $from, $to ],
@@ -270,6 +272,20 @@ our %STATISTICS_META = (
         },
         Display => $duration_to_string_cb,
     },
+    TimeAll => {
+        SubValues => sub { return ('Minimum', 'Average', 'Maximum', 'Summary') },
+        Function => sub {
+            my $self = shift;
+            my $field = shift;
+            return (
+                Minimum => { FUNCTION => "MIN(?)*60", FIELD => $field },
+                Average => { FUNCTION => "AVG(?)*60", FIELD => $field },
+                Maximum => { FUNCTION => "MAX(?)*60", FIELD => $field },
+                Summary => { FUNCTION => "SUM(?)*60", FIELD => $field },
+            );
+        },
+        Display => $duration_to_string_cb,
+    },
     DateTimeInterval => {
         Function => sub {
             my $self = shift;
@@ -284,6 +300,26 @@ our %STATISTICS_META = (
         },
         Display => $duration_to_string_cb,
     },
+    DateTimeIntervalAll => {
+        SubValues => sub { return ('Minimum', 'Average', 'Maximum', 'Summary') },
+        Function => sub {
+            my $self = shift;
+            my ($from, $to) = @_;
+
+            my $interval = $self->_Handle->DateTimeIntervalFunction(
+                From => { FUNCTION => $self->NotSetDateToNullFunction( FIELD => $from ) },
+                To   => { FUNCTION => $self->NotSetDateToNullFunction( FIELD => $to ) },
+            );
+
+            return (
+                Minimum => { FUNCTION => "MIN($interval)" },
+                Average => { FUNCTION => "AVG($interval)" },
+                Maximum => { FUNCTION => "MAX($interval)" },
+                Summary => { FUNCTION => "SUM($interval)" },
+            );
+        },
+        Display => $duration_to_string_cb,
+    },
 );
 
 sub Groupings {

commit 2e1b70c47921e739ab871bb5c00859c16df7746d
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Tue Aug 9 01:13:53 2011 +0400

    elementary tests for charts

diff --git a/t/charts/basics.t b/t/charts/basics.t
new file mode 100644
index 0000000..21c723d
--- /dev/null
+++ b/t/charts/basics.t
@@ -0,0 +1,103 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+
+use RT::Test tests => 13;
+use RT::Ticket;
+
+my $q = RT::Test->load_or_create_queue( Name => 'Default' );
+ok $q && $q->id, 'loaded or created queue';
+my $queue = $q->Name;
+
+my (%test) = (0, ());
+
+my @tickets = add_tix_from_data(
+    { Subject => 'n', Status => 'new' },
+    { Subject => 'o', Status => 'open' },
+    { Subject => 'o', Status => 'open' },
+    { Subject => 's', Status => 'stalled' },
+    { Subject => 's', Status => 'stalled' },
+    { Subject => 's', Status => 'stalled' },
+    { Subject => 'r', Status => 'resolved' },
+    { Subject => 'r', Status => 'resolved' },
+    { Subject => 'r', Status => 'resolved' },
+    { Subject => 'r', Status => 'resolved' },
+);
+
+use_ok 'RT::Report::Tickets';
+
+{
+    my $report = RT::Report::Tickets->new( RT->SystemUser );
+    my %columns = $report->SetupGroupings(
+        Query    => 'Queue = '. $q->id,
+        GroupBy  => ['Status'],
+        Function => ['COUNT'],
+    );
+
+    my $expected = {
+        'thead' => [ {
+                'cells' => [
+                    { 'value' => 'Status', 'type' => 'head' },
+                    { 'rowspan' => 1, 'value' => 'Tickets', 'type' => 'head' },
+                ],
+        } ],
+       'tfoot' => [ {
+            'cells' => [
+                { 'colspan' => 1, 'value' => 'Total', 'type' => 'label' },
+                { 'value' => 10, 'type' => 'value' },
+            ],
+            'even' => 1
+        } ],
+       'tbody' => [
+            {
+                'cells' => [
+                    { 'value' => 'new', 'type' => 'label' },
+                    { 'query' => 'Status = \'new\'', 'value' => '1', 'type' => 'value' },
+                ],
+                'even' => 1
+            },
+            {
+                'cells' => [
+                    { 'value' => 'open', 'type' => 'label' },
+                    { 'query' => 'Status = \'open\'', 'value' => '2', 'type' => 'value' }
+                ],
+                'even' => 0
+            },
+            {
+                'cells' => [
+                    { 'value' => 'resolved', 'type' => 'label' },
+                    { 'query' => 'Status = \'resolved\'', 'value' => '4', 'type' => 'value' }
+                ],
+                'even' => 1
+            },
+            {
+                'cells' => [
+                    { 'value' => 'stalled', 'type' => 'label' },
+                    { 'query' => 'Status = \'stalled\'', 'value' => '3', 'type' => 'value' }
+                ],
+                'even' => 0
+            }
+        ]
+    };
+
+    my %table = $report->FormatTable( %columns );
+    is_deeply( \%table, $expected, "basic table" );
+}
+
+
+sub add_tix_from_data {
+    my @data = @_;
+    my @res = ();
+    while (@data) {
+        my $t = RT::Ticket->new($RT::SystemUser);
+        my ( $id, undef $msg ) = $t->Create(
+            Queue => $q->id,
+            %{ shift(@data) },
+        );
+        ok( $id, "ticket created" ) or diag("error: $msg");
+        push @res, $t;
+    }
+    return @res;
+}
+

commit 6ec5437c01c0d062399038dd667e101896e30cc2
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Tue Aug 9 01:43:23 2011 +0400

    don't call FromSQL if Query is not passed
    
    let caller do UnLimit or custom limits

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index a3d02d1..103f819 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -428,7 +428,7 @@ sub SetupGroupings {
         @_
     );
 
-    $self->FromSQL( $args{'Query'} );
+    $self->FromSQL( $args{'Query'} ) if $args{'Query'};
 
     %GROUPINGS = @GROUPINGS unless keys %GROUPINGS;
 

commit 4cf816d416df911e9acb99337c7c473848d4d51d
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Tue Aug 30 10:03:06 2011 +0400

    query may have "naked OR", so wrap with ()

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 103f819..c0b0685 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -659,7 +659,9 @@ sub CalculatePostFunction {
     foreach my $item ( @{ $self->{'items'} } ) {
         $item->{'values'}{$column} = $code->(
             $self,
-            Query => join( ' AND ', grep defined && length, $base_query, $item->Query ),
+            Query => join(
+                ' AND ', map "($_)", grep defined && length, $base_query, $item->Query,
+            ),
         );
         $item->{'fetched'}{$column} = 1;
     }
diff --git a/lib/RT/Report/Tickets/Entry.pm b/lib/RT/Report/Tickets/Entry.pm
index f28c058..8978c84 100644
--- a/lib/RT/Report/Tickets/Entry.pm
+++ b/lib/RT/Report/Tickets/Entry.pm
@@ -140,7 +140,7 @@ sub Query {
         }
     }
     return () unless @parts;
-    return join ' AND ', grep defined && length, @parts;
+    return join ' AND ', map "($_)", grep defined && length, @parts;
 }
 
 sub LabelCode {
diff --git a/share/html/Search/Elements/ChartTable b/share/html/Search/Elements/ChartTable
index 0dad6b3..790f0bf 100644
--- a/share/html/Search/Elements/ChartTable
+++ b/share/html/Search/Elements/ChartTable
@@ -43,7 +43,7 @@ foreach my $section (qw(thead tbody tfoot)) {
                 if ( my $q = $cell->{'query'} ) {
                     $m->out(
                         '<a href="'. $eh->(RT->Config->Get('WebPath')) .'/Search/Results.html'
-                        .'?Query='. $eu->(join ' AND ', grep defined && length, $Query, $q)
+                        .'?Query='. $eu->(join ' AND ', map "($_)", grep defined && length, $Query, $q)
                         . $eh->('&') . $base_query
                         . '">'
                     );

commit d81a4fa7a533128e7724e661b25f622c2a5854be
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Sun Sep 11 18:01:09 2011 +0400

    test charting with compound SQL function

diff --git a/t/charts/compound-sql-function.t b/t/charts/compound-sql-function.t
new file mode 100644
index 0000000..b8dffce
--- /dev/null
+++ b/t/charts/compound-sql-function.t
@@ -0,0 +1,121 @@
+#!/usr/bin/perl -w
+
+use strict;
+use warnings;
+
+use RT::Test tests => 14;
+use RT::Ticket;
+
+my $q1 = RT::Test->load_or_create_queue( Name => 'One' );
+ok $q1 && $q1->id, 'loaded or created queue';
+
+my $q2 = RT::Test->load_or_create_queue( Name => 'Two' );
+ok $q2 && $q2->id, 'loaded or created queue';
+
+my @tickets = add_tix_from_data(
+    { Queue => $q1->id, Resolved => 3*60 },
+    { Queue => $q1->id, Resolved => 3*60*60 },
+    { Queue => $q1->id, Resolved => 3*24*60*60 },
+    { Queue => $q1->id, Resolved => 3*30*24*60*60 },
+    { Queue => $q1->id, Resolved => 9*30*24*60*60 },
+    { Queue => $q2->id, Resolved => 7*60 },
+    { Queue => $q2->id, Resolved => 7*60*60 },
+    { Queue => $q2->id, Resolved => 7*24*60*60 },
+    { Queue => $q2->id, Resolved => 7*30*24*60*60 },
+    { Queue => $q2->id, Resolved => 24*30*24*60*60 },
+);
+
+use_ok 'RT::Report::Tickets';
+
+{
+    my $report = RT::Report::Tickets->new( RT->SystemUser );
+    my %columns = $report->SetupGroupings(
+        Query    => 'id > 0',
+        GroupBy  => ['Queue'],
+        Function => ['ALL(Created-Resolved)'],
+    );
+
+    my $expected = {
+           'thead' => [
+                        {
+                          'cells' => [
+                               { 'rowspan' => 2, 'value' => 'Queue', 'type' => 'head' },
+                               { 'colspan' => 4, 'value' => 'Created-Resolved', 'type' => 'head' }
+                             ]
+                        },
+                        {
+                          'cells' => [
+                               { 'value' => 'Minimum', 'type' => 'head' },
+                               { 'value' => 'Average', 'type' => 'head' },
+                               { 'value' => 'Maximum', 'type' => 'head' },
+                               { 'value' => 'Summary', 'type' => 'head' }
+                             ]
+                        }
+                      ],
+           'tfoot' => [
+                        {
+                          'cells' => [
+                               { 'colspan' => 1, 'value' => 'Total', 'type' => 'label' },
+                               { 'value' => '10 min', 'type' => 'value' },
+                               { 'value' => '9 months', 'type' => 'value' },
+                               { 'value' => '3 years', 'type' => 'value' },
+                               { 'value' => '4 years', 'type' => 'value' }
+                             ],
+                          'even' => 1
+                        }
+                      ],
+           'tbody' => [
+                        {
+                          'cells' => [
+                               { 'value' => 'One', 'type' => 'label' },
+                               { 'query' => '(Queue = 3)', 'value' => '3 min', 'type' => 'value' },
+                               { 'query' => '(Queue = 3)', 'value' => '2 months', 'type' => 'value' },
+                               { 'query' => '(Queue = 3)', 'value' => '9 months', 'type' => 'value' },
+                               { 'query' => '(Queue = 3)', 'value' => '12 months', 'type' => 'value' }
+                             ],
+                          'even' => 1
+                        },
+                        {
+                          'cells' => [
+                               { 'value' => 'Two', 'type' => 'label' },
+                               { 'query' => '(Queue = 4)', 'value' => '7 min', 'type' => 'value' },
+                               { 'query' => '(Queue = 4)', 'value' => '6 months', 'type' => 'value' },
+                               { 'query' => '(Queue = 4)', 'value' => '2 years', 'type' => 'value' },
+                               { 'query' => '(Queue = 4)', 'value' => '3 years', 'type' => 'value' }
+                             ],
+                          'even' => 0
+                        }
+                      ]
+         };
+
+
+    my %table = $report->FormatTable( %columns );
+    is_deeply( \%table, $expected, "basic table" );
+}
+
+
+sub add_tix_from_data {
+    my @data = @_;
+    my @res = ();
+
+    my $created = RT::Date->new( $RT::SystemUser );
+    $created->SetToNow;
+
+    my $resolved = RT::Date->new( $RT::SystemUser );
+
+    while (@data) {
+        $resolved->Set( Value => $created->Unix );
+        $resolved->AddSeconds( $data[0]{'Resolved'} );
+        my $t = RT::Ticket->new($RT::SystemUser);
+        my ( $id, undef $msg ) = $t->Create(
+            %{ shift(@data) },
+            Created => $created->ISO,
+            Resolved => $resolved->ISO,
+        );
+        ok( $id, "ticket created" ) or diag("error: $msg");
+        push @res, $t;
+    }
+    return @res;
+}
+
+

commit c8bdf1ffcfd81c7920370e04f2f7cfc0e1566f36
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Tue Sep 13 18:25:06 2011 +0400

    normalize names of statistics displayed in the UI

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index c0b0685..971454b 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -196,7 +196,7 @@ our @STATISTICS = (
 foreach my $field (qw(TimeWorked TimeEstimated TimeLeft)) {
     my $friendly = lc join ' ', split /(?<=[a-z])(?=[A-Z])/, $field;
     push @STATISTICS, (
-        "ALL($field)" => [ucfirst $friendly,   'TimeAll',     $field ],
+        "ALL($field)" => ["Summary of $friendly",   'TimeAll',     $field ],
         "SUM($field)" => ["Total $friendly",   'Time', 'SUM', $field ],
         "AVG($field)" => ["Average $friendly", 'Time', 'AVG', $field ],
         "MIN($field)" => ["Minimum $friendly", 'Time', 'MIN', $field ],
@@ -215,8 +215,8 @@ foreach my $pair (qw(
 )) {
     my ($from, $to) = split /-/, $pair;
     push @STATISTICS, (
-        "ALL($pair)" => [$pair, 'DateTimeIntervalAll', $from, $to ],
-        "SUM($pair)" => ["Summary of $pair", 'DateTimeInterval', 'SUM', $from, $to ],
+        "ALL($pair)" => ["Summary of $pair", 'DateTimeIntervalAll', $from, $to ],
+        "SUM($pair)" => ["Total $pair", 'DateTimeInterval', 'SUM', $from, $to ],
         "AVG($pair)" => ["Average $pair", 'DateTimeInterval', 'AVG', $from, $to ],
         "MIN($pair)" => ["Minimum $pair", 'DateTimeInterval', 'MIN', $from, $to ],
         "MAX($pair)" => ["Maximum $pair", 'DateTimeInterval', 'MAX', $from, $to ],

commit 717aa384c9ad66f51e944c0a06ebf60a5e978650
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Wed Sep 21 14:30:11 2011 +0400

    adjust charts' tests for lifecycles and last changes

diff --git a/t/charts/basics.t b/t/charts/basics.t
index 21c723d..118d31b 100644
--- a/t/charts/basics.t
+++ b/t/charts/basics.t
@@ -3,7 +3,7 @@
 use strict;
 use warnings;
 
-use RT::Test tests => 13;
+use RT::Test tests => 9;
 use RT::Ticket;
 
 my $q = RT::Test->load_or_create_queue( Name => 'Default' );
@@ -16,10 +16,6 @@ my @tickets = add_tix_from_data(
     { Subject => 'n', Status => 'new' },
     { Subject => 'o', Status => 'open' },
     { Subject => 'o', Status => 'open' },
-    { Subject => 's', Status => 'stalled' },
-    { Subject => 's', Status => 'stalled' },
-    { Subject => 's', Status => 'stalled' },
-    { Subject => 'r', Status => 'resolved' },
     { Subject => 'r', Status => 'resolved' },
     { Subject => 'r', Status => 'resolved' },
     { Subject => 'r', Status => 'resolved' },
@@ -45,39 +41,32 @@ use_ok 'RT::Report::Tickets';
        'tfoot' => [ {
             'cells' => [
                 { 'colspan' => 1, 'value' => 'Total', 'type' => 'label' },
-                { 'value' => 10, 'type' => 'value' },
+                { 'value' => 6, 'type' => 'value' },
             ],
-            'even' => 1
+            'even' => 0
         } ],
        'tbody' => [
             {
                 'cells' => [
                     { 'value' => 'new', 'type' => 'label' },
-                    { 'query' => 'Status = \'new\'', 'value' => '1', 'type' => 'value' },
+                    { 'query' => '(Status = \'new\')', 'value' => '1', 'type' => 'value' },
                 ],
                 'even' => 1
             },
             {
                 'cells' => [
                     { 'value' => 'open', 'type' => 'label' },
-                    { 'query' => 'Status = \'open\'', 'value' => '2', 'type' => 'value' }
+                    { 'query' => '(Status = \'open\')', 'value' => '2', 'type' => 'value' }
                 ],
                 'even' => 0
             },
             {
                 'cells' => [
                     { 'value' => 'resolved', 'type' => 'label' },
-                    { 'query' => 'Status = \'resolved\'', 'value' => '4', 'type' => 'value' }
+                    { 'query' => '(Status = \'resolved\')', 'value' => '3', 'type' => 'value' }
                 ],
                 'even' => 1
             },
-            {
-                'cells' => [
-                    { 'value' => 'stalled', 'type' => 'label' },
-                    { 'query' => 'Status = \'stalled\'', 'value' => '3', 'type' => 'value' }
-                ],
-                'even' => 0
-            }
         ]
     };
 
@@ -91,7 +80,7 @@ sub add_tix_from_data {
     my @res = ();
     while (@data) {
         my $t = RT::Ticket->new($RT::SystemUser);
-        my ( $id, undef $msg ) = $t->Create(
+        my ( $id, undef, $msg ) = $t->Create(
             Queue => $q->id,
             %{ shift(@data) },
         );
diff --git a/t/charts/compound-sql-function.t b/t/charts/compound-sql-function.t
index b8dffce..a9f4b08 100644
--- a/t/charts/compound-sql-function.t
+++ b/t/charts/compound-sql-function.t
@@ -40,7 +40,7 @@ use_ok 'RT::Report::Tickets';
                         {
                           'cells' => [
                                { 'rowspan' => 2, 'value' => 'Queue', 'type' => 'head' },
-                               { 'colspan' => 4, 'value' => 'Created-Resolved', 'type' => 'head' }
+                               { 'colspan' => 4, 'value' => 'Summary of Created-Resolved', 'type' => 'head' }
                              ]
                         },
                         {

commit 2ef014f419c2f7b52d056ed11e7f2b6f35eacb8b
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Fri Sep 23 22:14:02 2011 +0400

    take care of encoding when we set value to hash
    
    values as data structures is an experimental feature
    that only required in reports. We don't want to
    put everything into RT::Record::__Value method, so
    if something uses this feature then it should take
    care of upgrading data to perl strings.

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 971454b..5314cb5 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -678,6 +678,9 @@ sub MapSubValues {
         my $dst = $item->{'values'}{ $to } = { };
         while (my ($k, $v) = each %{ $map } ) {
             $dst->{ $k } = delete $item->{'values'}{ $v->{'NAME'} };
+            utf8::decode( $dst->{ $k } )
+                if defined $dst->{ $k }
+               and not utf8::is_utf8( $dst->{ $k } );
             delete $item->{'fetched'}{ $v->{'NAME'} };
         }
         $item->{'fetched'}{ $to } = 1;

commit ea68c171ff90b3bd5ac362080ae96655a5c3affa
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Fri Sep 23 22:20:58 2011 +0400

    make COUNT to be stat calculated by default
    
    this fixes Quicksearch box without changing it,
    so we are backwards compatible enough.

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 5314cb5..2cf98b6f 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -463,6 +463,7 @@ sub SetupGroupings {
 
     my @function = grep defined && length,
         ref( $args{'Function'} )? @{ $args{'Function'} } : ($args{'Function'});
+    push @function, 'COUNT' unless @function;
     foreach my $e ( @function ) {
         $e = {
             TYPE => 'statistic',

commit 7256b2b5064439c6935b3e6bc2959b8a3ae3b946
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Fri Mar 8 01:50:32 2013 +0400

    branch TODO

diff --git a/TODO.charts b/TODO.charts
new file mode 100644
index 0000000..66c289b
--- /dev/null
+++ b/TODO.charts
@@ -0,0 +1,56 @@
+move abuse protection code from callers to SetupGrouping
+    IsValidGrouping, etc.
+
+protect Function in SetupGrouping from abuse
+
+upgrade script for saved charts
+* switch from space separator to dot
+* switch from PrimaryGroupBy to GroupBy name
+
+it'd be nice if full day and month names worked in 
+"Created.DayOfWeek = 'Thu'" searches.
+
+support more than one calculated function in diagrams,
+at least in bars
+
+support putting grouppings as columns of the table
+support horizontal totals when there are gropings as columns
+
+support links with partial queries in tables, for example
+table:
+
+                          Owner
+    Queue      Status     Nobody [3]
+    General[1] open[2]    10[4]
+
+    [1] - $query AND Queue = 'General'
+    [2] - [1] AND Status = 'open'
+    [3] - $query AND Owner = 'Nobody'
+    [4] - [2] AND [3]
+
+
+On my machine Created-LastUpdated interval is 1 second
+for newely created tickets. It's either bug or just
+operation is long enough to span over two seconds. It
+would be nice to ignore difference up to 3 seconds or
+something like that.
+
+During rebase localization of "group by" was lost
+
+Check for abusable ->Column and ->GroupBy calls
+
+ at SORT_OPTS and associated ugly function needs to be better
+
+Look at Excel/OO for how it does multiple groupings/sortings?
+
+Option groups for groupings/count
+
+Rename "Count" label
+
+Chart labels can go haywire
+
+Fix max width problems
+
+Configurable default grouping since Queue is outrageous on rt.cpan.org
+
+Integer y-axis ticks, rounded to nearest multiple of 5?

commit 42059c2b0a4bc316bc88802d65ea6ab986290199
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Tue Mar 12 01:27:13 2013 +0400

    make sure status was set correctly

diff --git a/t/charts/basics.t b/t/charts/basics.t
index 118d31b..0b5095d 100644
--- a/t/charts/basics.t
+++ b/t/charts/basics.t
@@ -3,15 +3,13 @@
 use strict;
 use warnings;
 
-use RT::Test tests => 9;
+use RT::Test tests => undef;
 use RT::Ticket;
 
-my $q = RT::Test->load_or_create_queue( Name => 'Default' );
+my $q = RT::Test->load_or_create_queue( Name => 'General' );
 ok $q && $q->id, 'loaded or created queue';
 my $queue = $q->Name;
 
-my (%test) = (0, ());
-
 my @tickets = add_tix_from_data(
     { Subject => 'n', Status => 'new' },
     { Subject => 'o', Status => 'open' },
@@ -74,17 +72,18 @@ use_ok 'RT::Report::Tickets';
     is_deeply( \%table, $expected, "basic table" );
 }
 
+done_testing;
+
 
 sub add_tix_from_data {
     my @data = @_;
     my @res = ();
     while (@data) {
+        my %info = %{ shift(@data) };
         my $t = RT::Ticket->new($RT::SystemUser);
-        my ( $id, undef, $msg ) = $t->Create(
-            Queue => $q->id,
-            %{ shift(@data) },
-        );
+        my ( $id, undef, $msg ) = $t->Create( Queue => $q->id, %info );
         ok( $id, "ticket created" ) or diag("error: $msg");
+        is $t->Status, $info{'Status'}, 'correct status';
         push @res, $t;
     }
     return @res;

commit 153d7b8f4e232d4a44de51abf554735cb14dc8c2
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Fri May 17 16:49:41 2013 -0700

    Remove broken method delegation from the report entry → report
    
    The original methods LabelCode and FindImplementationCode in
    RT::Report::Tickets::Entry delegated to methods in RT::Report::Tickets,
    but called the methods on the entry object itself rather than a report
    object.  This could lead to unexpected behaviour or errors.
    
    In particular, the FindImplementationCode method (used by
    LabelValueCode) takes a value and returns a code ref.  If the value is a
    plain string, it looks up a method by that name on, crucially, the
    calling object.  These method names come from data structures defined in
    RT::Report::Tickets, and are expected to refer to methods in that class
    as well, not RT::Report::Tickets::Entry.
    
    It hasn't manifested as a problem yet since all the keys that
    RT::Report::Tickets::Entry uses happen to point to coderefs instead of
    function names.  Future usage of other keys or value switches to strings
    would break some reports with runtime errors.
    
    Switch to storing a weak reference to the report object in each entry.
    This gives the entry access to methods and data on the report object
    itself, removing the need for a delegation scheme and/or redefinition of
    report methods in the entry (e.g.  ColumnInfo and ColumnsLists).
    
    The convenience of not going through ->Report-> is minor compared to the
    flexibility of having the report object available.

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 2cf98b6f..b8925d9 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -54,6 +54,8 @@ use RT::Report::Tickets::Entry;
 use strict;
 use warnings;
 
+use Scalar::Util qw(weaken);
+
 our @GROUPINGS = (
     Status => 'Enum',
 
@@ -566,7 +568,8 @@ sub Next {
 sub NewItem {
     my $self = shift;
     my $res = RT::Report::Tickets::Entry->new($self->CurrentUser);
-    $res->{'column_info'} = $self->{'column_info'};
+    $res->{'report'} = $self;
+    weaken $res->{'report'};
     return $res;
 }
 
diff --git a/lib/RT/Report/Tickets/Entry.pm b/lib/RT/Report/Tickets/Entry.pm
index 8978c84..99b8c1d 100644
--- a/lib/RT/Report/Tickets/Entry.pm
+++ b/lib/RT/Report/Tickets/Entry.pm
@@ -56,19 +56,6 @@ use base qw/RT::Record/;
 # XXX TODO: how the heck do we acl a report?
 sub CurrentUserHasRight {1}
 
-sub ColumnInfo {
-    my $self = shift;
-    my $column = shift;
-
-    return $self->{'column_info'}{$column};
-}
-
-sub ColumnsList {
-    my $self = shift;
-    return keys %{ $self->{'column_info'} || {} };
-}
-
-
 =head2 LabelValue
 
 If you're pulling a value out of this collection and using it as a label,
@@ -83,16 +70,16 @@ sub LabelValue {
 
     my $raw = $self->RawValue( $name, @_ );
 
-    if ( my $code = $self->LabelCode( $name ) ) {
-        return $code->( $self, %{ $self->ColumnInfo( $name ) }, VALUE => $raw );
+    if ( my $code = $self->Report->LabelValueCode( $name ) ) {
+        return $code->( $self, %{ $self->Report->ColumnInfo( $name ) }, VALUE => $raw );
     }
 
     unless ( ref $raw ) {
         return $self->loc('(no value)') unless defined $raw && length $raw;
-        return $self->loc($raw) if $self->ColumnInfo( $name )->{'META'}{'Localize'};
+        return $self->loc($raw) if $self->Report->ColumnInfo( $name )->{'META'}{'Localize'};
         return $raw;
     } else {
-        my $loc = $self->ColumnInfo( $name )->{'META'}{'Localize'};
+        my $loc = $self->Report->ColumnInfo( $name )->{'META'}{'Localize'};
         my %res = %$raw;
         if ( $loc ) {
             $res{ $self->loc($_) } = delete $res{ $_ } foreach keys %res;
@@ -115,12 +102,12 @@ sub Query {
     my $self = shift;
 
     my @parts;
-    foreach my $column ( $self->ColumnsList ) {
-        my $info = $self->ColumnInfo( $column );
+    foreach my $column ( $self->Report->ColumnsList ) {
+        my $info = $self->Report->ColumnInfo( $column );
         next unless $info->{'TYPE'} eq 'grouping';
 
         my $custom = $info->{'META'}{'Query'};
-        if ( $custom and my $code = $self->FindImplementationCode( $custom ) ) {
+        if ( $custom and my $code = $self->Report->FindImplementationCode( $custom ) ) {
             push @parts, $code->( $self, COLUMN => $column, %$info );
         }
         else {
@@ -143,12 +130,8 @@ sub Query {
     return join ' AND ', map "($_)", grep defined && length, @parts;
 }
 
-sub LabelCode {
-    return RT::Report::Tickets->can('LabelValueCode')->(@_);
-}
-
-sub FindImplementationCode {
-    return RT::Report::Tickets->can('FindImplementationCode')->(@_);
+sub Report {
+    return $_[0]->{'report'};
 }
 
 RT::Base->_ImportOverlays();
diff --git a/share/html/Search/Chart b/share/html/Search/Chart
index a3457d6..9ed9c3f 100644
--- a/share/html/Search/Chart
+++ b/share/html/Search/Chart
@@ -175,7 +175,7 @@ if ($chart_class eq "GD::Graph::bars") {
         : $count > 10 ? 3
         : 5
     ;
-    if ( my $code = $report->First->LabelCode( $columns{'Functions'}[0] ) ) {
+    if ( my $code = $report->LabelValueCode( $columns{'Functions'}[0] ) ) {
         my %info = %{ $report->ColumnInfo( $columns{'Functions'}[0] ) };
         $args{'values_format'} = $args{'y_number_format'} = sub {
             return $code->($report, %info, VALUE => shift );

commit 86d7b4d2fb4c2d9e989ecd4fd5149fd0e6528774
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Fri May 17 17:42:42 2013 -0700

    Switch from an anonymous sub to a named method for formatting durations
    
    A named method gives us more flexibility from outside code if necessary,
    and is now correctly supported by the changes in the previous commit.

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index b8925d9..9d6e62b 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -227,24 +227,6 @@ foreach my $pair (qw(
 
 our %STATISTICS;
 
-my $duration_to_string_cb = sub {
-    my $self = shift;
-    my %args = @_;
-    my $v = $args{'VALUE'};
-    unless ( ref $v ) {
-        return $self->loc("(no value)") unless defined $v && length $v;
-        return RT::Date->new( $self->CurrentUser )->DurationAsString( $v );
-    }
-
-    my $date = RT::Date->new( $self->CurrentUser );
-    my %res = %$v;
-    foreach my $e ( values %res ) {
-        $e = $date->DurationAsString( $e ) if defined $e && length $e;
-        $e = $self->loc("(no value)") unless defined $e && length $e;
-    }
-    return \%res;
-};
-
 our %STATISTICS_META = (
     Count => {
         Function => sub {
@@ -272,7 +254,7 @@ our %STATISTICS_META = (
             my ($function, $field) = @_;
             return (FUNCTION => "$function(?)*60", FIELD => $field);
         },
-        Display => $duration_to_string_cb,
+        Display => 'DurationAsString',
     },
     TimeAll => {
         SubValues => sub { return ('Minimum', 'Average', 'Maximum', 'Summary') },
@@ -286,7 +268,7 @@ our %STATISTICS_META = (
                 Summary => { FUNCTION => "SUM(?)*60", FIELD => $field },
             );
         },
-        Display => $duration_to_string_cb,
+        Display => 'DurationAsString',
     },
     DateTimeInterval => {
         Function => sub {
@@ -300,7 +282,7 @@ our %STATISTICS_META = (
 
             return (FUNCTION => "$function($interval)");
         },
-        Display => $duration_to_string_cb,
+        Display => 'DurationAsString',
     },
     DateTimeIntervalAll => {
         SubValues => sub { return ('Minimum', 'Average', 'Maximum', 'Summary') },
@@ -320,7 +302,7 @@ our %STATISTICS_META = (
                 Summary => { FUNCTION => "SUM($interval)" },
             );
         },
-        Display => $duration_to_string_cb,
+        Display => 'DurationAsString',
     },
 );
 
@@ -765,6 +747,23 @@ sub GenerateWatcherFunction {
     return %args;
 }
 
+sub DurationAsString {
+    my $self = shift;
+    my %args = @_;
+    my $v = $args{'VALUE'};
+    unless ( ref $v ) {
+        return $self->loc("(no value)") unless defined $v && length $v;
+        return RT::Date->new( $self->CurrentUser )->DurationAsString( $v );
+    }
+
+    my $date = RT::Date->new( $self->CurrentUser );
+    my %res = %$v;
+    foreach my $e ( values %res ) {
+        $e = $date->DurationAsString( $e ) if defined $e && length $e;
+        $e = $self->loc("(no value)") unless defined $e && length $e;
+    }
+    return \%res;
+}
 
 sub LabelValueCode {
     my $self = shift;

commit 4fa2481ba84980b2715550951016f131257cdc0f
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Fri May 17 17:44:47 2013 -0700

    Rename "Summary" to "Total", since we call SUM(?) a total everywhere else

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 9d6e62b..b2836d1 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -257,7 +257,7 @@ our %STATISTICS_META = (
         Display => 'DurationAsString',
     },
     TimeAll => {
-        SubValues => sub { return ('Minimum', 'Average', 'Maximum', 'Summary') },
+        SubValues => sub { return ('Minimum', 'Average', 'Maximum', 'Total') },
         Function => sub {
             my $self = shift;
             my $field = shift;
@@ -265,7 +265,7 @@ our %STATISTICS_META = (
                 Minimum => { FUNCTION => "MIN(?)*60", FIELD => $field },
                 Average => { FUNCTION => "AVG(?)*60", FIELD => $field },
                 Maximum => { FUNCTION => "MAX(?)*60", FIELD => $field },
-                Summary => { FUNCTION => "SUM(?)*60", FIELD => $field },
+                Total   => { FUNCTION => "SUM(?)*60", FIELD => $field },
             );
         },
         Display => 'DurationAsString',
@@ -285,7 +285,7 @@ our %STATISTICS_META = (
         Display => 'DurationAsString',
     },
     DateTimeIntervalAll => {
-        SubValues => sub { return ('Minimum', 'Average', 'Maximum', 'Summary') },
+        SubValues => sub { return ('Minimum', 'Average', 'Maximum', 'Total') },
         Function => sub {
             my $self = shift;
             my ($from, $to) = @_;
@@ -299,7 +299,7 @@ our %STATISTICS_META = (
                 Minimum => { FUNCTION => "MIN($interval)" },
                 Average => { FUNCTION => "AVG($interval)" },
                 Maximum => { FUNCTION => "MAX($interval)" },
-                Summary => { FUNCTION => "SUM($interval)" },
+                Total   => { FUNCTION => "SUM($interval)" },
             );
         },
         Display => 'DurationAsString',
diff --git a/t/charts/compound-sql-function.t b/t/charts/compound-sql-function.t
index a9f4b08..2a8c3c3 100644
--- a/t/charts/compound-sql-function.t
+++ b/t/charts/compound-sql-function.t
@@ -48,7 +48,7 @@ use_ok 'RT::Report::Tickets';
                                { 'value' => 'Minimum', 'type' => 'head' },
                                { 'value' => 'Average', 'type' => 'head' },
                                { 'value' => 'Maximum', 'type' => 'head' },
-                               { 'value' => 'Summary', 'type' => 'head' }
+                               { 'value' => 'Total', 'type' => 'head' }
                              ]
                         }
                       ],

commit 738c076359536a180f55b9dda85a0d160184a53b
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Fri May 17 17:46:20 2013 -0700

    Use a clearer expression for "last index"
    
    For me, $#... improves the readability of an already hard to skim line.

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index b2836d1..0a427fb 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -468,7 +468,7 @@ sub SetupGroupings {
                 $e->{'NAME'} = $self->Column( FUNCTION => 'NULL' );
             }
             elsif ( $e->{'META'}{'SubValues'} ) {
-                my %tmp = $code->( $self, @{ $e->{INFO} }[2 .. scalar @{ $e->{INFO} } -1 ] );
+                my %tmp = $code->( $self, @{ $e->{INFO} }[2 .. $#{$e->{INFO}}] );
                 $e->{'NAME'} = 'postfunction'. $self->{'postfunctions'}++;
                 while ( my ($k, $v) = each %tmp ) {
                     $e->{'MAP'}{ $k }{'NAME'} = $self->Column( %$v );
@@ -477,7 +477,7 @@ sub SetupGroupings {
                 }
             }
             else {
-                my %tmp = $code->( $self, @{ $e->{INFO} }[2 .. scalar @{ $e->{INFO} } -1 ] );
+                my %tmp = $code->( $self, @{ $e->{INFO} }[2 .. $#{$e->{INFO}}] );
                 $e->{'NAME'} = $self->Column( %tmp );
                 @{ $e }{'FUNCTION', 'ALIAS', 'FIELD'} = @tmp{'FUNCTION', 'ALIAS', 'FIELD'};
             }

commit eff03c293e603ee6366ff051f7ea4d78ec8e398a
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Mon May 20 11:47:36 2013 -0700

    Allow searching by localized day of week and month name abbreviations
    
    Accepts the localized abbreviations that we display in charts and dates.
    
        Created.Month = 'Avr'   # April, in the French locale
    
    For more flexibility, it's possible we could be matching prefixes
    instead of the entire string, but not all abbreviations are necessarily
    just the first few characters.

diff --git a/lib/RT/Tickets.pm b/lib/RT/Tickets.pm
index 8f96284..e1b83b1 100644
--- a/lib/RT/Tickets.pm
+++ b/lib/RT/Tickets.pm
@@ -545,7 +545,14 @@ sub _DateLimit {
     if ( my $subkey = $rest{SUBKEY} ) {
         if ( $subkey eq 'DayOfWeek' && $op !~ /IS/i && $value =~ /[^0-9]/ ) {
             for ( my $i = 0; $i < @RT::Date::DAYS_OF_WEEK; $i++ ) {
-                next unless lc $RT::Date::DAYS_OF_WEEK[ $i ] eq lc $value;
+                # Use a case-insensitive regex for better matching across
+                # locales since we don't have fc() and lc() is worse.  Really
+                # we should be doing Unicode normalization too, but we don't do
+                # that elsewhere in RT.
+                # 
+                # XXX I18N: Replace the regex with fc() once we're guaranteed 5.16.
+                next unless lc $RT::Date::DAYS_OF_WEEK[ $i ] eq lc $value
+                         or $sb->CurrentUser->loc($RT::Date::DAYS_OF_WEEK[ $i ]) =~ /^\Q$value\E$/i;
 
                 $value = $i; last;
             }
@@ -554,7 +561,14 @@ sub _DateLimit {
         }
         elsif ( $subkey eq 'Month' && $op !~ /IS/i && $value =~ /[^0-9]/ ) {
             for ( my $i = 0; $i < @RT::Date::MONTHS; $i++ ) {
-                next unless lc $RT::Date::MONTHS[ $i ] eq lc $value;
+                # Use a case-insensitive regex for better matching across
+                # locales since we don't have fc() and lc() is worse.  Really
+                # we should be doing Unicode normalization too, but we don't do
+                # that elsewhere in RT.
+                # 
+                # XXX I18N: Replace the regex with fc() once we're guaranteed 5.16.
+                next unless lc $RT::Date::MONTHS[ $i ] eq lc $value
+                         or $sb->CurrentUser->loc($RT::Date::MONTHS[ $i ]) =~ /^\Q$value\E$/i;
 
                 $value = $i + 1; last;
             }

commit c0b94a16264a0e996475eb63090a3adfeafb8ae3
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Wed May 29 16:34:32 2013 -0700

    Limit User/Watcher subfields for grouping to public attributes
    
    At the same time, make a few more useful attributes public under the
    assumption that if you're putting it into RT, you want to be able to use
    it.  The public accessible check still lets _LocalAccessible overlays
    specify public => 0.  We start with a static set of fields rather than
    RT::User->ReadableAttributes since most of the user attributes are
    ~useless for grouping.

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 0a427fb..1fe7763 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -95,7 +95,7 @@ our %GROUPINGS_META = (
         Localize => 1,
     },
     User => {
-        SubFields => [qw(
+        SubFields => [grep RT::User->_Accessible($_, "public"), qw(
             Name RealName NickName
             EmailAddress
             Organization
@@ -104,7 +104,7 @@ our %GROUPINGS_META = (
         Function => 'GenerateUserFunction',
     },
     Watcher => {
-        SubFields => [qw(
+        SubFields => [grep RT::User->_Accessible($_, "public"), qw(
             Name RealName NickName
             EmailAddress
             Organization
diff --git a/lib/RT/User.pm b/lib/RT/User.pm
index 0355bc2..6cf2636 100644
--- a/lib/RT/User.pm
+++ b/lib/RT/User.pm
@@ -104,7 +104,9 @@ sub _OverlayAccessible {
           Gecos                 => { public => 1,  admin => 1 },
           PGPKey                => { public => 1,  admin => 1 },
           PrivateKey            => {               admin => 1 },
-
+          City                  => { public => 1 },
+          Country               => { public => 1 },
+          Timezone              => { public => 1 },
     }
 }
 

commit b2d0d67be60c09a1d29881892b9b3e3b6de85c53
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Wed May 29 17:04:31 2013 -0700

    Localize grouping and statistic labels
    
    Unrolling the loops to generate @STATISTICS is saner than statically
    generating and maintaining a bunch of comments like
    
        # loc("Summary of Created-Resolved")

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 1fe7763..266111f 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -57,28 +57,28 @@ use warnings;
 use Scalar::Util qw(weaken);
 
 our @GROUPINGS = (
-    Status => 'Enum',
+    Status => 'Enum',                   #loc_left_pair
 
-    Queue  => 'Queue',
+    Queue  => 'Queue',                  #loc_left_pair
 
-    Owner         => 'User',
-    Creator       => 'User',
-    LastUpdatedBy => 'User',
+    Owner         => 'User',            #loc_left_pair
+    Creator       => 'User',            #loc_left_pair
+    LastUpdatedBy => 'User',            #loc_left_pair
 
-    Requestor     => 'Watcher',
-    Cc            => 'Watcher',
-    AdminCc       => 'Watcher',
-    Watcher       => 'Watcher',
+    Requestor     => 'Watcher',         #loc_left_pair
+    Cc            => 'Watcher',         #loc_left_pair
+    AdminCc       => 'Watcher',         #loc_left_pair
+    Watcher       => 'Watcher',         #loc_left_pair
 
-    Created       => 'Date',
-    Starts        => 'Date',
-    Started       => 'Date',
-    Resolved      => 'Date',
-    Due           => 'Date',
-    Told          => 'Date',
-    LastUpdated   => 'Date',
+    Created       => 'Date',            #loc_left_pair
+    Starts        => 'Date',            #loc_left_pair
+    Started       => 'Date',            #loc_left_pair
+    Resolved      => 'Date',            #loc_left_pair
+    Due           => 'Date',            #loc_left_pair
+    Told          => 'Date',            #loc_left_pair
+    LastUpdated   => 'Date',            #loc_left_pair
 
-    CF            => 'CustomField',
+    CF            => 'CustomField',     #loc_left_pair
 );
 our %GROUPINGS;
 
@@ -191,8 +191,58 @@ our %GROUPINGS_META = (
     },
 );
 
+# loc'able strings below generated with:
+#   perl -MRT=-init -MRT::Report::Tickets -E 'say qq{\# loc("$_->[0]")} while $_ = splice @RT::Report::Tickets::STATISTICS, 0, 2'
+#
+# loc("Tickets")
+# loc("Summary of time worked")
+# loc("Total time worked")
+# loc("Average time worked")
+# loc("Minimum time worked")
+# loc("Maximum time worked")
+# loc("Summary of time estimated")
+# loc("Total time estimated")
+# loc("Average time estimated")
+# loc("Minimum time estimated")
+# loc("Maximum time estimated")
+# loc("Summary of time left")
+# loc("Total time left")
+# loc("Average time left")
+# loc("Minimum time left")
+# loc("Maximum time left")
+# loc("Summary of Created-Started")
+# loc("Total Created-Started")
+# loc("Average Created-Started")
+# loc("Minimum Created-Started")
+# loc("Maximum Created-Started")
+# loc("Summary of Created-Resolved")
+# loc("Total Created-Resolved")
+# loc("Average Created-Resolved")
+# loc("Minimum Created-Resolved")
+# loc("Maximum Created-Resolved")
+# loc("Summary of Created-LastUpdated")
+# loc("Total Created-LastUpdated")
+# loc("Average Created-LastUpdated")
+# loc("Minimum Created-LastUpdated")
+# loc("Maximum Created-LastUpdated")
+# loc("Summary of Starts-Started")
+# loc("Total Starts-Started")
+# loc("Average Starts-Started")
+# loc("Minimum Starts-Started")
+# loc("Maximum Starts-Started")
+# loc("Summary of Due-Resolved")
+# loc("Total Due-Resolved")
+# loc("Average Due-Resolved")
+# loc("Minimum Due-Resolved")
+# loc("Maximum Due-Resolved")
+# loc("Summary of Started-Resolved")
+# loc("Total Started-Resolved")
+# loc("Average Started-Resolved")
+# loc("Minimum Started-Resolved")
+# loc("Maximum Started-Resolved")
+
 our @STATISTICS = (
-    COUNT             => ['Tickets', 'Count', 'id'],
+    COUNT => ['Tickets', 'Count', 'id'],
 );
 
 foreach my $field (qw(TimeWorked TimeEstimated TimeLeft)) {

commit 68b4ac2ddf90442117aacc264e595389cb577272
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Wed May 29 17:27:37 2013 -0700

    Use the public API to grab the last TicketSQL query
    
    Instead of reaching into the object.

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 266111f..31d6183 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -691,7 +691,7 @@ sub CalculatePostFunction {
 
     my $column = $info->{'NAME'};
 
-    my $base_query = $self->{'_sql_query'};
+    my $base_query = $self->Query;
     foreach my $item ( @{ $self->{'items'} } ) {
         $item->{'values'}{$column} = $code->(
             $self,

commit e936ea928eb9cd89c7458389adeae5224b327ca7
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Wed May 29 17:54:09 2013 -0700

    Push group by validation down into RT::Report::Tickets
    
    It belongs in the API, not in the caller.

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 31d6183..6b22174 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -470,13 +470,20 @@ sub SetupGroupings {
 
     my @group_by = grep defined && length,
         ref( $args{'GroupBy'} )? @{ $args{'GroupBy'} } : ($args{'GroupBy'});
-    foreach my $e ( @group_by ) {
+    @group_by = ('Queue') unless @group_by;
+
+    foreach my $e ( splice @group_by ) {
+        unless ($self->IsValidGrouping( Query => $args{Query}, GroupBy => $e )) {
+            RT->Logger->error("'$e' is not a valid grouping for reports; skipping");
+            next;
+        }
         my ($key, $subkey) = split /\./, $e, 2;
         $e = { $self->_FieldToFunction( KEY => $key, SUBKEY => $subkey ) };
         $e->{'TYPE'} = 'grouping';
         $e->{'INFO'} = $GROUPINGS{ $key };
         $e->{'META'} = $GROUPINGS_META{ $e->{'INFO'} };
         $e->{'POSITION'} = $i++;
+        push @group_by, $e;
     }
     $self->GroupBy( map { {
         ALIAS    => $_->{'ALIAS'},
diff --git a/share/html/Search/Chart b/share/html/Search/Chart
index 9ed9c3f..5f9716f 100644
--- a/share/html/Search/Chart
+++ b/share/html/Search/Chart
@@ -48,7 +48,7 @@
 <%args>
 $Cache => undef
 $Query => "id > 0"
- at GroupBy => 'Queue'
+ at GroupBy => ()
 $ChartStyle => 'bars'
 @ChartFunction => 'COUNT'
 </%args>
@@ -65,13 +65,6 @@ if ( $Cache and my $data = delete $session{'charts_cache'}{ $Cache } ) {
     $report->Deserialize( $data->{'report'} );
     $session{'i'}++;
 } else {
-    foreach my $e ( splice @GroupBy ) {
-        next unless defined $e && length $e;
-        next unless $report->IsValidGrouping( Query => $Query, GroupBy => $e );
-        push @GroupBy, $e;
-    }
-    @GroupBy = ('Queue') unless @GroupBy;
-
     %columns = $report->SetupGroupings(
         Query => $Query,
         GroupBy => \@GroupBy,
diff --git a/share/html/Search/Elements/Chart b/share/html/Search/Elements/Chart
index b6c939c..44f0347 100644
--- a/share/html/Search/Elements/Chart
+++ b/share/html/Search/Elements/Chart
@@ -47,7 +47,7 @@
 %# END BPS TAGGED BLOCK }}}
 <%args>
 $Query => "id > 0"
- at GroupBy => 'Queue'
+ at GroupBy => ()
 $ChartStyle => 'bars'
 @ChartFunction => 'COUNT'
 </%args>
@@ -56,13 +56,6 @@ use RT::Report::Tickets;
 
 my $report = RT::Report::Tickets->new( $session{'CurrentUser'} );
 
-foreach my $e ( splice @GroupBy ) {
-    next unless defined $e && length $e;
-    next unless $report->IsValidGrouping( Query => $Query, GroupBy => $e );
-    push @GroupBy, $e;
-}
- at GroupBy = ('Queue') unless @GroupBy;
-
 my %columns = $report->SetupGroupings(
     Query => $Query,
     GroupBy => \@GroupBy,

commit c6bfe58fd4ca447901b4f0542bdbdba0e9fabf5d
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Fri May 31 16:44:10 2013 -0700

    Pie charts are 2D circles, not 3D ellipses
    
    3D pie charts make it impossible to visually compare areas.

diff --git a/share/html/Search/Chart b/share/html/Search/Chart
index 5f9716f..c011d61 100644
--- a/share/html/Search/Chart
+++ b/share/html/Search/Chart
@@ -51,6 +51,8 @@ $Query => "id > 0"
 @GroupBy => ()
 $ChartStyle => 'bars'
 @ChartFunction => 'COUNT'
+$Width  => 600
+$Height => ($ChartStyle eq 'pie' ? $Width : 400)
 </%args>
 <%init>
 use GD;
@@ -127,12 +129,14 @@ my %font_config = RT->Config->Get('ChartFont');
 my $font = $font_config{ $session{CurrentUser}->UserObj->Lang || '' }
     || $font_config{'others'};
 
+s/\D//g for $Width, $Height;
+
 # Pie charts don't like having no input, so we show a special image
 # that indicates an error message. Because this is used in an <img>
 # context, it can't be a simple error message. Without this check,
 # the chart will just be a non-loading image.
 unless ( $report->Count ) {
-    my $plot = GD::Image->new(600 => 400);
+    my $plot = GD::Image->new($Width => $Height);
     $plot->colorAllocate(255, 255, 255); # background
     my $black = $plot->colorAllocate(0, 0, 0);
 
@@ -147,8 +151,8 @@ unless ( $report->Count ) {
     $m->comp( 'SELF:Plot', plot => $plot, %ARGS );
 }
 
-my $chart = $chart_class->new( 600 => 400 );
-$chart->set( pie_height => 60 ) if $chart_class eq 'GD::Graph::pie';
+my $chart = $chart_class->new( $Width => $Height );
+$chart->set( '3d' => 0 ) if $chart_class eq 'GD::Graph::pie';
 $chart->set_title_font( $font, 16 ) if $chart->can('set_title_font');
 $chart->set_legend_font( $font, 16 ) if $chart->can('set_legend_font');
 $chart->set_x_label_font( $font, 14 ) if $chart->can('set_x_label_font');

commit fa5178a2a27365cdace20aed65ae66346cd7eea1
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Fri May 31 16:54:06 2013 -0700

    Default chart grouping to Status instead of Queue
    
    Queue may be visually crippling on instances with lots of queues.  A
    chart by status is less likely to be cluttered and/or broken.  While
    statuses may also be extensive on a system with lots of queues and lots
    of different lifecycles, a query on such an instance which returns
    tickets in all of the above is less likely.
    
    As the default chart is the one shown for any query when the page is
    first visited, it is important that it look and function properly.

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 6b22174..c9e556e 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -470,7 +470,7 @@ sub SetupGroupings {
 
     my @group_by = grep defined && length,
         ref( $args{'GroupBy'} )? @{ $args{'GroupBy'} } : ($args{'GroupBy'});
-    @group_by = ('Queue') unless @group_by;
+    @group_by = ('Status') unless @group_by;
 
     foreach my $e ( splice @group_by ) {
         unless ($self->IsValidGrouping( Query => $args{Query}, GroupBy => $e )) {
diff --git a/share/html/Search/Chart.html b/share/html/Search/Chart.html
index 2b8141e..587e8a5 100644
--- a/share/html/Search/Chart.html
+++ b/share/html/Search/Chart.html
@@ -46,7 +46,7 @@
 %#
 %# END BPS TAGGED BLOCK }}}
 <%args>
- at GroupBy => 'Queue'
+ at GroupBy => 'Status'
 $ChartStyle => 'bars'
 @ChartFunction => ('COUNT')
 $Description => undef

commit 77cb586346cd63262e0d198dee740dd8504f1a02
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Fri May 31 17:28:46 2013 -0700

    Handle plot errors with more aplomb
    
    For example, instead of die()ing and showing a broken image when the
    chart size is too small to plot, display an error image with the text:
    
        Error plotting chart: Horizontal size too small
    
    This is, at least, provides something to the user to complain about.  In
    the future, it'll be useful when the user can increase the chart size.

diff --git a/share/html/Search/Chart b/share/html/Search/Chart
index c011d61..5b773a9 100644
--- a/share/html/Search/Chart
+++ b/share/html/Search/Chart
@@ -58,6 +58,31 @@ $Height => ($ChartStyle eq 'pie' ? $Width : 400)
 use GD;
 use GD::Text;
 
+my %font_config = RT->Config->Get('ChartFont');
+my $font = $font_config{ $session{CurrentUser}->UserObj->Lang || '' }
+    || $font_config{'others'};
+
+s/\D//g for $Width, $Height;
+
+my $plot_error = sub {
+    my $text = shift;
+    my $plot = GD::Image->new($Width => $Height);
+    $plot->colorAllocate(255, 255, 255); # background
+    my $black = $plot->colorAllocate(0, 0, 0);
+
+    require GD::Text::Wrap;
+    my $error = GD::Text::Wrap->new($plot,
+        color       => $black,
+        text        => $text,
+        align       => "left",
+        preserve_nl => 1,
+    );
+    $error->set_font( $font, 16 );
+    $error->draw(0, 0);
+
+    $m->comp( 'SELF:Plot', plot => $plot, %ARGS );
+};
+
 use RT::Report::Tickets;
 my $report = RT::Report::Tickets->new( $session{'CurrentUser'} );
 
@@ -125,30 +150,12 @@ if ($ChartStyle eq 'pie') {
     $chart_class = "GD::Graph::bars";
 }
 
-my %font_config = RT->Config->Get('ChartFont');
-my $font = $font_config{ $session{CurrentUser}->UserObj->Lang || '' }
-    || $font_config{'others'};
-
-s/\D//g for $Width, $Height;
-
 # Pie charts don't like having no input, so we show a special image
 # that indicates an error message. Because this is used in an <img>
 # context, it can't be a simple error message. Without this check,
 # the chart will just be a non-loading image.
 unless ( $report->Count ) {
-    my $plot = GD::Image->new($Width => $Height);
-    $plot->colorAllocate(255, 255, 255); # background
-    my $black = $plot->colorAllocate(0, 0, 0);
-
-    require GD::Text::Wrap;
-    my $error = GD::Text::Wrap->new($plot,
-        color => $black,
-        text  => loc("No tickets found."),
-    );
-    $error->set_font( $font, 16 );
-    $error->draw(0, 0);
-
-    $m->comp( 'SELF:Plot', plot => $plot, %ARGS );
+    return $plot_error->(loc("No tickets found."));
 }
 
 my $chart = $chart_class->new( $Width => $Height );
@@ -215,8 +222,12 @@ $chart->{dclrs} = [
     };
 }
 
-my $plot = $chart->plot( \@data ) or die $chart->error;
-$m->comp( 'SELF:Plot', plot => $plot, %ARGS );
+if (my $plot = eval { $chart->plot( \@data ) }) {
+    $m->comp( 'SELF:Plot', plot => $plot, %ARGS );
+} else {
+    my $error = join "\n", grep defined && length, $chart->error, $@;
+    $plot_error->(loc("Error plotting chart: [_1]", $error));
+}
 </%init>
 
 <%METHOD Plot>

commit ec101790ee2f0f1f9f302d23a708b749653c3875
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Fri May 31 17:37:31 2013 -0700

    Customizable chart width and height

diff --git a/share/html/Search/Chart b/share/html/Search/Chart
index 5b773a9..0142e10 100644
--- a/share/html/Search/Chart
+++ b/share/html/Search/Chart
@@ -51,8 +51,8 @@ $Query => "id > 0"
 @GroupBy => ()
 $ChartStyle => 'bars'
 @ChartFunction => 'COUNT'
-$Width  => 600
-$Height => ($ChartStyle eq 'pie' ? $Width : 400)
+$Width  => undef
+$Height => undef
 </%args>
 <%init>
 use GD;
@@ -63,6 +63,8 @@ my $font = $font_config{ $session{CurrentUser}->UserObj->Lang || '' }
     || $font_config{'others'};
 
 s/\D//g for $Width, $Height;
+$Width  ||= 600;
+$Height ||= ($ChartStyle eq 'pie' ? $Width : 400);
 
 my $plot_error = sub {
     my $text = shift;
diff --git a/share/html/Search/Chart.html b/share/html/Search/Chart.html
index 587e8a5..9a31bde 100644
--- a/share/html/Search/Chart.html
+++ b/share/html/Search/Chart.html
@@ -50,6 +50,8 @@
 $ChartStyle => 'bars'
 @ChartFunction => ('COUNT')
 $Description => undef
+$Width => undef
+$Height => undef
 </%args>
 <%init>
 $ARGS{Query} ||= 'id > 0';
@@ -58,7 +60,7 @@ my $title = loc( "Grouped search results");
 
 my $saved_search = $m->comp( '/Widgets/SavedSearch:new',
     SearchType   => 'Chart',
-    SearchFields => [qw(Query GroupBy ChartStyle ChartFunction)] );
+    SearchFields => [qw(Query GroupBy ChartStyle ChartFunction Width Height)] );
 
 my @actions = $m->comp( '/Widgets/SavedSearch:process', args => \%ARGS, self => $saved_search );
 
@@ -136,7 +138,9 @@ my %query;
 </&>
 
 <&| /Widgets/TitleBox, title => loc('Picture') &>
-<% loc('Style') %> <& Elements/SelectChartType, Default => $ChartStyle, Name => 'ChartStyle' &>
+<label><% loc('Style') %> <& Elements/SelectChartType, Default => $ChartStyle, Name => 'ChartStyle' &></label>
+<label><% loc("Width") %> <input type="text" name="Width" value="<% $Width || "" %>" size=4></label>
+<label><% loc("Height") %> <input type="text" name="Height" value="<% $Height || "" %>" size=4></label>
 </&>
 
 <& /Elements/Submit, Label => loc('Update Chart'), Name => 'Update' &>

commit a1127c88653527f1f16f4705067b987e3598c62b
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Wed Jun 5 16:22:55 2013 +0400

    get better Y ticks for integer values
    
    If Y value is integer then try to make so that labels on Y
    axis are integer as well.
    
    Also, show one label-less tick between labeled.

diff --git a/share/html/Search/Chart b/share/html/Search/Chart
index 0142e10..3353b3b 100644
--- a/share/html/Search/Chart
+++ b/share/html/Search/Chart
@@ -189,6 +189,27 @@ if ($chart_class eq "GD::Graph::bars") {
     }
     $report->GotoFirstItem;
 
+    # normalize min/max values to graph boundaries
+    {
+        my $integer = 1;
+        $integer = 0 for grep $_ ne int $_, $min_value, $max_value;
+
+        $max_value *= $max_value > 0 ? 1.1 : 0.9
+            if $max_value;
+        $min_value *= $min_value > 0 ? 0.9 : 1.1
+            if $min_value;
+
+        if ($integer) {
+            $max_value = int($max_value + ($max_value > 0? 1 : 0) );
+            $min_value = int($min_value + ($min_value < 0? -1 : 0) );
+
+            my $span = abs($max_value - $min_value);
+            $max_value += 5 - ($span % 5);
+        }
+        $args{'y_label_skip'} = 2;
+        $args{'y_tick_number'} = 10;
+    }
+
     $chart->set(
         %args,
         x_label => join( ' - ', map $report->Label( $_ ), @{ $columns{'Groups'} } ),
@@ -200,8 +221,8 @@ if ($chart_class eq "GD::Graph::bars") {
 # use a top margin enough to display values over the top line if needed
         t_margin => 18,
 # the following line to make sure there's enough space for values to show
-        y_max_value => $max_value * 1.10,
-        y_min_value => $min_value * 0.9,
+        y_max_value => $max_value,
+        y_min_value => $min_value,
 # if there're too many bars or at least one key is too long, use vertical
         x_labels_vertical => ( $count * $max_key_length > 60 ) ? 1 : 0,
         bargroup_spacing => $args{'bar_spacing'}*5,

commit 5de477093121ab90f4720b040ce9e532095aa73c
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Sat Jun 8 01:17:58 2013 +0400

    try hard to fit in labels on X axis

diff --git a/share/html/Search/Chart b/share/html/Search/Chart
index 3353b3b..7de786c 100644
--- a/share/html/Search/Chart
+++ b/share/html/Search/Chart
@@ -108,8 +108,7 @@ my $max_value = 0;
 my $min_value;
 my $max_key_length = 0;
 while ( my $entry = $report->Next ) {
-    my $key = join ' - ', map $entry->LabelValue( $_ ), @{ $columns{'Groups'} };
-    push @{ $data[0] }, $key;
+    push @{ $data[0] }, [ map $entry->LabelValue( $_ ), @{ $columns{'Groups'} } ];
 
     my @values;
     foreach my $column ( @{ $columns{'Functions'} } ) {
@@ -132,16 +131,9 @@ while ( my $entry = $report->Next ) {
         $max_value = $v if $max_value < $v;
         $min_value = $v if !defined $min_value || $min_value > $v;
     }
-    $max_key_length = length $key if $max_key_length < length $key;
 }
 
 $ChartStyle = 'bars' if @data > 2;
-if ( $ChartStyle eq 'pie' ) {
-    my $i = 0;
-    while ( my $entry = $report->Next ) {
-        $data[0][$i++] .= ' - '. $entry->LabelValue( $columns{'Functions'}[0] );
-    }
-}
 
 my $chart_class;
 if ($ChartStyle eq 'pie') {
@@ -161,21 +153,11 @@ unless ( $report->Count ) {
 }
 
 my $chart = $chart_class->new( $Width => $Height );
-$chart->set( '3d' => 0 ) if $chart_class eq 'GD::Graph::pie';
-$chart->set_title_font( $font, 16 ) if $chart->can('set_title_font');
-$chart->set_legend_font( $font, 16 ) if $chart->can('set_legend_font');
-$chart->set_x_label_font( $font, 14 ) if $chart->can('set_x_label_font');
-$chart->set_y_label_font( $font, 14 ) if $chart->can('set_y_label_font');
-$chart->set_label_font( $font, 14 ) if $chart->can('set_label_font');
-$chart->set_x_axis_font( $font, 12 ) if $chart->can('set_x_axis_font');
-$chart->set_y_axis_font( $font, 12 ) if $chart->can('set_y_axis_font');
-$chart->set_values_font( $font, 12 ) if $chart->can('set_values_font');
-$chart->set_value_font( $font, 12 ) if $chart->can('set_value_font');
 
+my %chart_options;
 if ($chart_class eq "GD::Graph::bars") {
-    my %args;
     my $count = @{ $data[0] };
-    $args{'bar_spacing'} =
+    $chart_options{'bar_spacing'} =
         $count > 30 ? 1
         : $count > 20 ? 2
         : $count > 10 ? 3
@@ -183,7 +165,7 @@ if ($chart_class eq "GD::Graph::bars") {
     ;
     if ( my $code = $report->LabelValueCode( $columns{'Functions'}[0] ) ) {
         my %info = %{ $report->ColumnInfo( $columns{'Functions'}[0] ) };
-        $args{'values_format'} = $args{'y_number_format'} = sub {
+        $chart_options{'values_format'} = $chart_options{'y_number_format'} = sub {
             return $code->($report, %info, VALUE => shift );
         };
     }
@@ -206,12 +188,116 @@ if ($chart_class eq "GD::Graph::bars") {
             my $span = abs($max_value - $min_value);
             $max_value += 5 - ($span % 5);
         }
-        $args{'y_label_skip'} = 2;
-        $args{'y_tick_number'} = 10;
+        $chart_options{'y_label_skip'} = 2;
+        $chart_options{'y_tick_number'} = 10;
+    }
+
+    # try to fit in labels on X axis values, aka key
+    {
+        # we have several labels layouts:
+        # 1) horizontal, one line per label
+        # 2) horizontal, multi-line - doesn't work, GD::Chart bug
+        # 3) vertical, one line
+        # 4) vertical, multi-line
+
+        my $found_solution = 0;
+
+        my $x_space_for_label = $Width*0.8/($count+1.5);
+        my $y_space_for_label = $Height*0.4;
+        foreach my $font_size ( 12, 11, 10 ) {
+            my $font_handle = GD::Text::Align->new(
+                $chart->get('graph'), valign => 'top', 'halign' => 'center',
+            );
+            $font_handle->set_font($font, $font_size);
+
+            $font_handle->set_text('Q');
+            my $line_height = $font_handle->get('height');
+
+            $font_handle->set_text(join "\n", ('Q')x scalar @{ $data[0][0] });
+            my $keyset_height = $font_handle->get('height');
+
+            # if horizontal space doesn't allow us to fit one vertical line,
+            # then we need smaller font
+            next if $line_height > $x_space_for_label;
+
+            my %can = (
+                'horizontal, one line' => 1,
+                'vertical, one line' => 1,
+                'vertical, multi line'
+                    => @{$data[0][0]} > 1 && $keyset_height < $x_space_for_label,
+            );
+
+            foreach my $key ( @{ $data[0] } ) {
+                $font_handle->set_text( join ' - ', @$key );
+                my $width = $font_handle->get('width');
+                if ( $width > $x_space_for_label ) {
+                    $can{'horizontal, one line'} = 0;
+                }
+                if ( $width > $y_space_for_label ) {
+                    $can{'vertical, one line'} = 0;
+                }
+                if ( $can{'vertical, multi line'} ) {
+                    $font_handle->set_text( join "\n", @$key );
+                    my $width = $font_handle->get('width');
+                    if ( $width > $y_space_for_label ) {
+                        $can{'vertical, multi line'} = 0;
+                    }
+                }
+
+                last unless grep $_, values %can;
+            }
+            next unless grep $_, values %can;
+
+            $found_solution = 1;
+
+            $chart_options{'x_axis_font'} = [$font, $font_size];
+
+            if ( $can{'horizontal, one line'} ) {
+                $chart_options{'x_labels_vertical'} = 0;
+                $_ = join ' - ', @$_ foreach @{$data[0]};
+            }
+            elsif ( $can{'vertical, multi line'} ) {
+                $chart_options{'x_labels_vertical'} = 1;
+                $_ = join "\n", @$_ foreach @{$data[0]};
+            }
+            else {
+                $chart_options{'x_labels_vertical'} = 1;
+                $_ = join " - ", @$_ foreach @{$data[0]};
+            }
+            last;
+        }
+        unless ( $found_solution ) {
+            my $font_handle = GD::Text::Align->new(
+                $chart->get('graph'), valign => 'top', 'halign' => 'center',
+            );
+            $font_handle->set_font($font, 10);
+            $font_handle->set_text('Q');
+
+            my $line_height = $font_handle->get('height');
+            if ( $line_height > $x_space_for_label ) {
+                $Width *= $line_height/$x_space_for_label;
+                $Width = int( $Width+1 );
+            }
+
+            $_ = join " - ", @$_ foreach @{$data[0]};
+
+            my $max_text_width = 0;
+            foreach (@{$data[0]}) {
+                $font_handle->set_text($_);
+                my $width = $font_handle->get('width');
+                $max_text_width = $width if $width > $max_text_width;
+            }
+            if ( $max_text_width > $Height*0.4 ) {
+                $Height = int($max_text_width / 0.4 + 1);
+            }
+
+            $chart_options{'x_labels_vertical'} = 1;
+            $chart_options{'x_axis_font'} = [$font, 10];
+        }
     }
 
-    $chart->set(
-        %args,
+    %chart_options = (
+        %chart_options,
         x_label => join( ' - ', map $report->Label( $_ ), @{ $columns{'Groups'} } ),
         x_label_position => 0.6,
         y_label => $report->Label( $columns{'Functions'}[0] ),
@@ -224,10 +310,41 @@ if ($chart_class eq "GD::Graph::bars") {
         y_max_value => $max_value,
         y_min_value => $min_value,
 # if there're too many bars or at least one key is too long, use vertical
-        x_labels_vertical => ( $count * $max_key_length > 60 ) ? 1 : 0,
-        bargroup_spacing => $args{'bar_spacing'}*5,
+        bargroup_spacing => $chart_options{'bar_spacing'}*5,
     );
 }
+else {
+    my $i = 0;
+    while ( my $entry = $report->Next ) {
+        push @{ $data[0][$i++] }, $entry->LabelValue( $columns{'Functions'}[0] );
+    }
+    $_ = join ' - ', @$_ foreach @{$data[0]};
+}
+
+if ($chart->get('width') != $Width || $chart->get('height') != $Height ) {
+    $chart = $chart_class->new( $Width => $Height );
+}
+
+%chart_options = (
+    '3d'         => 0,
+    title_font   => [ $font, 16 ],
+    legend_font  => [ $font, 16 ],
+    x_label_font => [ $font, 14 ],
+    y_label_font => [ $font, 14 ],
+    label_font   => [ $font, 14 ],
+    y_axis_font  => [ $font, 12 ],
+    values_font  => [ $font, 12 ],
+    value_font   => [ $font, 12 ],
+    %chart_options,
+);
+
+foreach my $opt ( grep /_font$/, keys %chart_options ) {
+    my $v = delete $chart_options{$opt};
+    next unless my $can = $chart->can("set_$opt");
+
+    $can->($chart, @$v);
+}
+$chart->set(%chart_options) if keys %chart_options;
 
 # refine values' colors, with both Color::Scheme's help and my own tweak
 $chart->{dclrs} = [

commit a4436d0dc6d8d94caabb28aec05055e4c6768065
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Mon Jun 10 14:30:23 2013 -0700

    Rename "Count" label to "Calculate"
    
    Only one of the statistics is a count.

diff --git a/TODO.charts b/TODO.charts
index 66c289b..54eb62e 100644
--- a/TODO.charts
+++ b/TODO.charts
@@ -45,8 +45,6 @@ Look at Excel/OO for how it does multiple groupings/sortings?
 
 Option groups for groupings/count
 
-Rename "Count" label
-
 Chart labels can go haywire
 
 Fix max width problems
diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index c9e556e..04d84f1 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -194,7 +194,7 @@ our %GROUPINGS_META = (
 # loc'able strings below generated with:
 #   perl -MRT=-init -MRT::Report::Tickets -E 'say qq{\# loc("$_->[0]")} while $_ = splice @RT::Report::Tickets::STATISTICS, 0, 2'
 #
-# loc("Tickets")
+# loc("Ticket count")
 # loc("Summary of time worked")
 # loc("Total time worked")
 # loc("Average time worked")
@@ -242,7 +242,7 @@ our %GROUPINGS_META = (
 # loc("Maximum Started-Resolved")
 
 our @STATISTICS = (
-    COUNT => ['Tickets', 'Count', 'id'],
+    COUNT => ['Ticket count', 'Count', 'id'],
 );
 
 foreach my $field (qw(TimeWorked TimeEstimated TimeLeft)) {
diff --git a/share/html/Search/Chart.html b/share/html/Search/Chart.html
index 9a31bde..2163963 100644
--- a/share/html/Search/Chart.html
+++ b/share/html/Search/Chart.html
@@ -131,7 +131,7 @@ my %query;
     ShowEmpty => 1,
 &><br />
 
-<% loc('Count') %> <& Elements/SelectChartFunction, Default => $ChartFunction[0] &>
+<% loc('Calculate') %> <& Elements/SelectChartFunction, Default => $ChartFunction[0] &>
 <& Elements/SelectChartFunction, Default => $ChartFunction[1], ShowEmpty => 1 &>
 <& Elements/SelectChartFunction, Default => $ChartFunction[2], ShowEmpty => 1 &><br />
 

commit 865b37eaefffcfc656269291511ea42a54e8e6f4
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Mon Jun 10 14:52:59 2013 -0700

    Move the multi-op custom sort function into a coderef
    
    Cleaner code, less local().

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 04d84f1..bfac074 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -616,13 +616,6 @@ sub NewItem {
 # correct class.  However, since we're abusing a subclass, it's incorrect.
 sub _RoleGroupClass { "RT::Ticket" }
 
-{ our @SORT_OPS;
-sub __sort_function_we_need_named($$) {
-    for my $f ( @SORT_OPS ) {
-        my $r = $f->($_[0], $_[1]);
-        return $r if $r;
-    }
-}
 sub SortEntries {
     my $self = shift;
 
@@ -635,7 +628,13 @@ sub SortEntries {
         $self->ColumnsList;
     return unless @groups;
 
-    local @SORT_OPS;
+    my @SORT_OPS;
+    my $by_multiple = sub ($$) {
+        for my $f ( @SORT_OPS ) {
+            my $r = $f->($_[0], $_[1]);
+            return $r if $r;
+        }
+    };
     my @data = map [$_], @{ $self->{'items'} };
 
     for ( my $i = 0; $i < @groups; $i++ ) {
@@ -667,9 +666,9 @@ sub SortEntries {
     }
     $self->{'items'} = [
         map $_->[0],
-        sort __sort_function_we_need_named @data
+        sort $by_multiple @data
     ];
-} }
+}
 
 sub PostProcessRecords {
     my $self = shift;

commit a98515b4a9ede8645cbf399cc778166bd22d8df7
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Tue Jun 11 10:37:58 2013 -0700

    A named argument is more clear than inspecting arguments.length
    
    Cascaded selects used the presence of a third argument to indicate if
    the select is hierarchical (new style).  An additional boolean argument
    is much simpler.

diff --git a/share/html/Elements/EditCustomFieldSelect b/share/html/Elements/EditCustomFieldSelect
index 2992b6f..5297c68 100644
--- a/share/html/Elements/EditCustomFieldSelect
+++ b/share/html/Elements/EditCustomFieldSelect
@@ -73,7 +73,7 @@ jQuery(  function () {
             filter_cascade(
                 <% "$id-Values" |n,j%>,
                 basedon.value,
-                1
+                true
             );
             if (oldchange != null)
                 oldchange();
diff --git a/share/static/js/cascaded.js b/share/static/js/cascaded.js
index b47357e..56a913d 100644
--- a/share/static/js/cascaded.js
+++ b/share/static/js/cascaded.js
@@ -1,4 +1,4 @@
-function filter_cascade (id, val) {
+function filter_cascade (id, val, is_hierarchical) {
     var select = document.getElementById(id);
     var complete_select = document.getElementById(id + "-Complete" );
 
@@ -13,7 +13,7 @@ function filter_cascade (id, val) {
 
         var complete_children = complete_select.childNodes;
 
-        if ( val == '' && arguments.length == 3 ) {
+        if ( val == '' && is_hierarchical ) {
             // no category, and the category is from a hierchical cf;
             // leave this set of options empty
         } else if ( val == '' ) {
@@ -43,7 +43,7 @@ function filter_cascade (id, val) {
 // for back compatibility
         for (i in children) {
             if (!children[i].label) { continue };
-            if ( val == '' && arguments.length == 3 ) {
+            if ( val == '' && is_hierarchical ) {
                 hide(children[i]);
                 continue;
             }

commit a251473e8d190580dd7d2f5b82ac28d140f1e598
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Tue Jun 11 10:40:59 2013 -0700

    Rename filter_cascade() to filter_cascade_by_id()
    
    The former is now usable on elements without an id by passing element
    nodes instead.

diff --git a/share/html/Elements/EditCustomFieldSelect b/share/html/Elements/EditCustomFieldSelect
index 5297c68..e5df03c 100644
--- a/share/html/Elements/EditCustomFieldSelect
+++ b/share/html/Elements/EditCustomFieldSelect
@@ -55,7 +55,7 @@
 % if (!$HideCategory and @category and not $CustomField->BasedOnObj->id) {
   <script type="text/javascript" src="<%RT->Config->Get('WebPath')%>/static/js/cascaded.js"></script>
 %# XXX - Hide this select from w3m?
-  <select onchange="filter_cascade(<% "$id-Values" |n,j%>, this.value)" name="<% $id %>-Category" class="CF-<%$CustomField->id%>-Edit">
+  <select onchange="filter_cascade_by_id(<% "$id-Values" |n,j%>, this.value)" name="<% $id %>-Category" class="CF-<%$CustomField->id%>-Edit">
     <option value=""<% !$selected && qq[ selected="selected"] |n %>><&|/l&>-</&></option>
 %   foreach my $cat (@category) {
 %     my ($depth, $name) = @$cat;
@@ -70,7 +70,7 @@ jQuery(  function () {
     if (basedon != null) {
         var oldchange = basedon.onchange;
         basedon.onchange = function () {
-            filter_cascade(
+            filter_cascade_by_id(
                 <% "$id-Values" |n,j%>,
                 basedon.value,
                 true
diff --git a/share/static/js/cascaded.js b/share/static/js/cascaded.js
index 56a913d..c5cd023 100644
--- a/share/static/js/cascaded.js
+++ b/share/static/js/cascaded.js
@@ -1,7 +1,10 @@
-function filter_cascade (id, val, is_hierarchical) {
+function filter_cascade_by_id (id, val, is_hierarchical) {
     var select = document.getElementById(id);
     var complete_select = document.getElementById(id + "-Complete" );
+    return filter_cascade(select, complete_select, val, is_hierarchical);
+}
 
+function filter_cascade (select, complete_select, val, is_hierarchical) {
     if (!select) { return };
     var i;
     var children = select.childNodes;

commit 78a20e70a88429a5b6686a3d9c2bf26dc708b1c9
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Tue Jun 11 10:42:53 2013 -0700

    Avoid false positive matches on optgroup prefixes during <select> cascade
    
    Otherwise "LastUpdated" will show options for a "LastUpdatedBy"
    optgroup.
    
    The initial commit which added cascaded selects, 5d0cd2c, used substr()
    for unclear reasons.  It's possible someone is (ab)using this
    misfeature, but I believe it's a bug and should not be supported.

diff --git a/share/static/js/cascaded.js b/share/static/js/cascaded.js
index c5cd023..25b259a 100644
--- a/share/static/js/cascaded.js
+++ b/share/static/js/cascaded.js
@@ -33,7 +33,7 @@ function filter_cascade (select, complete_select, val, is_hierarchical) {
                 if (!complete_children[i].label ||
                       (complete_children[i].hasAttribute &&
                             !complete_children[i].hasAttribute('label') ) ||
-                        complete_children[i].label.substr(0, val.length) == val ) {
+                        complete_children[i].label === val ) {
                     if ( complete_children[i].cloneNode ) {
                         new_option = complete_children[i].cloneNode(true);
                         select.appendChild(new_option);

commit a4d38fb57d7e6350625b10cfe8131fb49ddd0fc7
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Tue Jun 11 11:01:10 2013 -0700

    Always include the cascaded <select> javascript
    
    There's no good reason not to always bundle the two filter_cascade
    functions.

diff --git a/lib/RT/Interface/Web.pm b/lib/RT/Interface/Web.pm
index bdbb58e..7a1713b 100644
--- a/lib/RT/Interface/Web.pm
+++ b/lib/RT/Interface/Web.pm
@@ -122,6 +122,7 @@ sub JSFiles {
       supersubs.js
       jquery.supposition.js
       history-folding.js
+      cascaded.js
       event-registration.js
       late.js
       /, RT->Config->Get('JSFiles');
diff --git a/share/html/Elements/EditCustomFieldSelect b/share/html/Elements/EditCustomFieldSelect
index e5df03c..cf10cc8 100644
--- a/share/html/Elements/EditCustomFieldSelect
+++ b/share/html/Elements/EditCustomFieldSelect
@@ -53,7 +53,6 @@
 % my $id = $NamePrefix . $CustomField->Id;
 % my $out = $m->scomp('SELF:options', %ARGS, SelectedRef => \$selected, CategoryRef => \@category);
 % if (!$HideCategory and @category and not $CustomField->BasedOnObj->id) {
-  <script type="text/javascript" src="<%RT->Config->Get('WebPath')%>/static/js/cascaded.js"></script>
 %# XXX - Hide this select from w3m?
   <select onchange="filter_cascade_by_id(<% "$id-Values" |n,j%>, this.value)" name="<% $id %>-Category" class="CF-<%$CustomField->id%>-Edit">
     <option value=""<% !$selected && qq[ selected="selected"] |n %>><&|/l&>-</&></option>
@@ -63,7 +62,6 @@
 %   }
     </select><br />
 % } elsif ($CustomField->BasedOnObj->id) {
-<script type="text/javascript" src="<%RT->Config->Get('WebPath')%>/static/js/cascaded.js"></script>
 <script type="text/javascript"><!--
 jQuery(  function () {
     var basedon = document.getElementById(<% $NamePrefix . $CustomField->BasedOnObj->id . "-Values" |n,j%>);

commit d68f64a76b20828ca63ce64186a34826b2f91f50
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Tue Jun 11 12:17:18 2013 -0700

    Cascaded selects for chart groupings

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index bfac074..491ae7a 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -167,7 +167,7 @@ our %GROUPINGS_META = (
             }
             $CustomFields->LimitToGlobal;
             while ( my $CustomField = $CustomFields->Next ) {
-                push @res, "Custom field '". $CustomField->Name ."'", "CF.{". $CustomField->id ."}";
+                push @res, ["Custom field", $CustomField->Name], "CF.{". $CustomField->id ."}";
             }
             return @res;
         },
@@ -366,10 +366,10 @@ sub Groupings {
     while ( my ($field, $type) = splice @tmp, 0, 2 ) {
         my $meta = $GROUPINGS_META{ $type } || {};
         unless ( $meta->{'SubFields'} ) {
-            push @fields, $field, $field;
+            push @fields, [$field, $field], $field;
         }
         elsif ( ref( $meta->{'SubFields'} ) eq 'ARRAY' ) {
-            push @fields, map { ("$field $_", "$field.$_") } @{ $meta->{'SubFields'} };
+            push @fields, map { ([$field, $_], "$field.$_") } @{ $meta->{'SubFields'} };
         }
         elsif ( my $code = $self->FindImplementationCode( $meta->{'SubFields'} ) ) {
             push @fields, $code->( $self, \%args );
diff --git a/share/html/Search/Chart.html b/share/html/Search/Chart.html
index 2163963..d3d4ed9 100644
--- a/share/html/Search/Chart.html
+++ b/share/html/Search/Chart.html
@@ -112,24 +112,33 @@ my %query;
 <input type="hidden" class="hidden" name="Query" value="<% $ARGS{Query} %>" />
 <input type="hidden" class="hidden" name="SavedChartSearchId" value="<% $saved_search->{SearchId} || 'new' %>" />
 
-<&| /Widgets/TitleBox, title => loc('Properties') &>
-<% loc('Group by') %> <& Elements/SelectGroupBy,
+<&| /Widgets/TitleBox, title => loc('Group by'), class => "chart-group-by" &>
+<fieldset><legend><% loc('Group tickets by') %></legend>
+<& Elements/SelectGroupBy,
     Name => 'GroupBy',
     Query => $ARGS{Query},
     Default => $GroupBy[0],
-&>
+    &>
+</fieldset>
+<fieldset><legend><% loc('and then') %></legend>
 <& Elements/SelectGroupBy,
     Name => 'GroupBy',
     Query => $ARGS{Query},
     Default => $GroupBy[1],
     ShowEmpty => 1,
-&>
+    &>
+</fieldset>
+<fieldset><legend><% loc('and then') %></legend>
 <& Elements/SelectGroupBy,
     Name => 'GroupBy',
     Query => $ARGS{Query},
     Default => $GroupBy[2],
     ShowEmpty => 1,
-&><br />
+    &>
+</fieldset>
+</&>
+
+<&| /Widgets/TitleBox, title => loc("Calculate") &>
 
 <% loc('Calculate') %> <& Elements/SelectChartFunction, Default => $ChartFunction[0] &>
 <& Elements/SelectChartFunction, Default => $ChartFunction[1], ShowEmpty => 1 &>
diff --git a/share/html/Search/Elements/SelectGroupBy b/share/html/Search/Elements/SelectGroupBy
index 532cadc..d79b03c 100644
--- a/share/html/Search/Elements/SelectGroupBy
+++ b/share/html/Search/Elements/SelectGroupBy
@@ -51,12 +51,27 @@ $Default => 'Status'
 $Query   => ''
 $ShowEmpty => 0
 </%args>
-<select name="<% $Name %>">
+<select name="<% $Name %>" class="cascade-by-optgroup">
 % if ( $ShowEmpty ) {
 <option value=""> </option>
 % }
-% while ( my ($text, $value) = splice @options, 0, 2 ) {
-<option value="<% $value %>" <% $value eq ($Default||'') ? 'selected="selected"' : '' |n %>><% $text %></option>
+<%perl>
+my $in_optgroup = "";
+while ( my ($label, $value) = splice @options, 0, 2 ) {
+    my ($optgroup, $text) = @$label;
+    if ($in_optgroup ne $optgroup) {
+        $m->out("</optgroup>\n") if $in_optgroup;
+
+        my $name = $m->interp->apply_escapes(loc($optgroup), 'h');
+        $m->out(qq[<optgroup label="$name">\n]);
+
+        $in_optgroup = $optgroup;
+    }
+</%perl>
+<option value="<% $value %>" <% $value eq ($Default||'') ? 'selected="selected"' : '' |n %>><% loc($text) %></option>
+% }
+% if ($in_optgroup) {
+  </optgroup>
 % }
 </select>
 <%init>
diff --git a/share/static/css/base/charts.css b/share/static/css/base/charts.css
new file mode 100644
index 0000000..525d9bd
--- /dev/null
+++ b/share/static/css/base/charts.css
@@ -0,0 +1,9 @@
+.chart-group-by fieldset {
+    border-width: 1px 0 0 0;
+    border-style: solid;
+    border-color: #aaa;
+}
+
+.chart-group-by fieldset legend {
+    padding: 0 1em;
+}
diff --git a/share/static/css/base/main.css b/share/static/css/base/main.css
index 25693f1..7f8f605 100644
--- a/share/static/css/base/main.css
+++ b/share/static/css/base/main.css
@@ -24,3 +24,4 @@
 @import "login.css";
 @import "history-folding.css";
 @import "history.css";
+ at import "charts.css";
diff --git a/share/static/js/event-registration.js b/share/static/js/event-registration.js
index 4a94f46..75f015d 100644
--- a/share/static/js/event-registration.js
+++ b/share/static/js/event-registration.js
@@ -40,3 +40,42 @@ function ReplaceUserReferences() {
     );
 }
 jQuery(ReplaceUserReferences);
+
+// Cascaded selects
+jQuery(function() {
+    jQuery("select.cascade-by-optgroup").each(function(){
+        var name = this.name;
+        if (!name) return;
+
+        // Generate elements for cascading based on the master <select> ...
+        var complete = jQuery(this)
+            .clone(true, true)
+            .attr("name", name + "-Complete")
+            .hide()
+            .insertAfter(this);
+
+        var groups = jQuery(this)
+            .clone(true, true)
+            .attr("name", name + "-Groups")
+            .find("option").remove().end()
+            .find("optgroup").replaceWith(function(){
+                return jQuery("<option>").val(this.label).text(this.label);
+            }).end()
+            .prepend( complete.find("option[value='']") )
+            .insertBefore(this);
+
+        // Synchronize the <select> we just generated
+        var selected = jQuery("option[selected]", this).parent().attr("label");
+        jQuery('option[value="' + selected + '"]', groups).attr("selected", "selected");
+
+        // Wire it all up
+        groups.change(function(){
+            var name     = this.name.replace(/-Groups$/, '');
+            var field    = jQuery(this);
+            var subfield = field.next("select[name=" + name + "]");
+            var complete = subfield.next("select[name=" + name + "-Complete]");
+            var value    = field.val();
+            filter_cascade( subfield[0], complete[0], value, true );
+        }).change();
+    });
+});

commit b184ecbcd520715597225061a6c51c704e32f233
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Tue Jun 11 15:02:18 2013 -0700

    Cascaded selects for chart functions
    
    The current option groups are suboptimal, but will do for now.

diff --git a/share/html/Search/Chart.html b/share/html/Search/Chart.html
index d3d4ed9..fc15117 100644
--- a/share/html/Search/Chart.html
+++ b/share/html/Search/Chart.html
@@ -138,11 +138,17 @@ my %query;
 </fieldset>
 </&>
 
-<&| /Widgets/TitleBox, title => loc("Calculate") &>
+<&| /Widgets/TitleBox, title => loc("Calculate"), class => "chart-calculate" &>
 
-<% loc('Calculate') %> <& Elements/SelectChartFunction, Default => $ChartFunction[0] &>
+<fieldset><legend><% loc('Calculate values of') %></legend>
+<& Elements/SelectChartFunction, Default => $ChartFunction[0] &>
+</fieldset>
+<fieldset><legend><% loc('and then') %></legend>
 <& Elements/SelectChartFunction, Default => $ChartFunction[1], ShowEmpty => 1 &>
-<& Elements/SelectChartFunction, Default => $ChartFunction[2], ShowEmpty => 1 &><br />
+</fieldset>
+<fieldset><legend><% loc('and then') %></legend>
+<& Elements/SelectChartFunction, Default => $ChartFunction[2], ShowEmpty => 1 &>
+</fieldset>
 
 </&>
 
diff --git a/share/html/Search/Elements/SelectChartFunction b/share/html/Search/Elements/SelectChartFunction
index 37e0b40..713ba7f 100644
--- a/share/html/Search/Elements/SelectChartFunction
+++ b/share/html/Search/Elements/SelectChartFunction
@@ -45,13 +45,28 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
-<select name="<% $Name %>">
+<select name="<% $Name %>" class="cascade-by-optgroup">
 % if ( $ShowEmpty ) {
 <option value=""> </option>
 % }
-% while ( my ($value, $display) = splice @functions, 0, 2 ) {
+<%perl>
+my $in_optgroup = "";
+while ( my ($value, $display) = splice @functions, 0, 2 ) {
+    my $optgroup = $value =~ /\((.+)\)$/ ? $1 : $display;
+    if ($in_optgroup ne $optgroup) {
+        $m->out("</optgroup>\n") if $in_optgroup;
+
+        my $name = $m->interp->apply_escapes(loc($optgroup), 'h');
+        $m->out(qq[<optgroup label="$name">\n]);
+
+        $in_optgroup = $optgroup;
+    }
+</%perl>
 <option value="<% $value %>"<% $value eq $Default ? qq[ selected="selected"] : '' |n %>><% loc( $display ) %></option>
 % }
+% if ($in_optgroup) {
+  </optgroup>
+% }
 </select>
 <%ARGS>
 $Name => 'ChartFunction'
diff --git a/share/static/css/base/charts.css b/share/static/css/base/charts.css
index 525d9bd..c651e1f 100644
--- a/share/static/css/base/charts.css
+++ b/share/static/css/base/charts.css
@@ -1,9 +1,11 @@
-.chart-group-by fieldset {
+.chart-group-by fieldset,
+.chart-calculate fieldset {
     border-width: 1px 0 0 0;
     border-style: solid;
     border-color: #aaa;
 }
 
-.chart-group-by fieldset legend {
+.chart-group-by fieldset legend,
+.chart-calculate fieldset legend {
     padding: 0 1em;
 }

commit 96da8956b3a01a5d431bf8b73dba7e5f5ce42771
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Tue Jun 11 15:37:06 2013 -0700

    Upgrade saved charts with old parameter values

diff --git a/etc/upgrade/4.1.17/content b/etc/upgrade/4.1.17/content
new file mode 100644
index 0000000..3454f78
--- /dev/null
+++ b/etc/upgrade/4.1.17/content
@@ -0,0 +1,26 @@
+use strict;
+use warnings;
+
+our @Initial = (sub {
+    my $searches = RT::Attributes->new(RT->SystemUser);
+    $searches->Limit( FIELD => 'Name', VALUE => 'SavedSearch' );
+    $searches->OrderBy( FIELD => 'id' );
+
+    while (my $search = $searches->Next) {
+        my $content = $search->Content;
+        next unless ref $content eq 'HASH';
+        next unless ($content->{SearchType} || '') eq 'Chart';
+
+        # Switch from PrimaryGroupBy to GroupBy name
+        # Switch from "CreatedMonthly" to "Created.Monthly"
+        $content->{GroupBy} ||= [delete $content->{PrimaryGroupBy}];
+        for (@{$content->{GroupBy}}) {
+            next if /\./;
+            s/(?<=[a-z])(?=[A-Z])/./;
+        }
+
+        my ($ok, $msg) = $search->SetContent($content);
+        RT->Logger->error("Unable to upgrade saved chart #@{[$search->id]}: $msg")
+            unless $ok;
+    }
+});

commit 1a30e4fe25543d0e331f74948d8b1cc58c40eeec
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Tue Jun 11 16:51:52 2013 -0700

    Note where the subkey is validated when searching DateField.SubKey

diff --git a/lib/RT/Tickets.pm b/lib/RT/Tickets.pm
index e1b83b1..caaa23d 100644
--- a/lib/RT/Tickets.pm
+++ b/lib/RT/Tickets.pm
@@ -584,6 +584,7 @@ sub _DateLimit {
                 if $to && lc $to ne 'utc';
         }
 
+        # $subkey is validated by DateTimeFunction
         my $function = $RT::Handle->DateTimeFunction(
             Type     => $subkey,
             Field    => $sb->NotSetDateToNullFunction,

commit 479935d3716ca9625d2bc242be737d771d81fc4b
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Tue Jun 11 16:52:25 2013 -0700

    Cleanup TODO.charts

diff --git a/TODO.charts b/TODO.charts
index 54eb62e..7741716 100644
--- a/TODO.charts
+++ b/TODO.charts
@@ -1,11 +1,7 @@
-move abuse protection code from callers to SetupGrouping
-    IsValidGrouping, etc.
+Example dashboard + saved charts in initialdata for slick demo out of the box
 
-protect Function in SetupGrouping from abuse
 
-upgrade script for saved charts
-* switch from space separator to dot
-* switch from PrimaryGroupBy to GroupBy name
+== Nice to have ==
 
 it'd be nice if full day and month names worked in 
 "Created.DayOfWeek = 'Thu'" searches.
@@ -34,21 +30,3 @@ for newely created tickets. It's either bug or just
 operation is long enough to span over two seconds. It
 would be nice to ignore difference up to 3 seconds or
 something like that.
-
-During rebase localization of "group by" was lost
-
-Check for abusable ->Column and ->GroupBy calls
-
- at SORT_OPTS and associated ugly function needs to be better
-
-Look at Excel/OO for how it does multiple groupings/sortings?
-
-Option groups for groupings/count
-
-Chart labels can go haywire
-
-Fix max width problems
-
-Configurable default grouping since Queue is outrageous on rt.cpan.org
-
-Integer y-axis ticks, rounded to nearest multiple of 5?

commit 26aff0330d61d037f01e7c4056155ea579183fbd
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Wed Jun 12 20:10:08 2013 +0400

    get rid of undef warning in QueryString element

diff --git a/share/html/Elements/QueryString b/share/html/Elements/QueryString
index 0be8e35..f28c202 100644
--- a/share/html/Elements/QueryString
+++ b/share/html/Elements/QueryString
@@ -54,6 +54,7 @@ for my $key (sort keys %ARGS) {
     if( UNIVERSAL::isa( $value, 'ARRAY' ) ) {
         push @params,
             map $key ."=". $m->interp->apply_escapes( $_, 'u' ),
+            map defined $_? $_ : '',
                 @$value;
     } else {
         push @params, $key ."=". $m->interp->apply_escapes($value, 'u');

commit 9002a0cd4d8d408cba524f9208f8743a130598ef
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Wed Jun 12 20:11:04 2013 +0400

    get rid of undef wanring when we sanitiza arguments

diff --git a/share/html/Search/Chart b/share/html/Search/Chart
index 7de786c..302ea48 100644
--- a/share/html/Search/Chart
+++ b/share/html/Search/Chart
@@ -62,7 +62,7 @@ my %font_config = RT->Config->Get('ChartFont');
 my $font = $font_config{ $session{CurrentUser}->UserObj->Lang || '' }
     || $font_config{'others'};
 
-s/\D//g for $Width, $Height;
+s/\D//g for grep defined, $Width, $Height;
 $Width  ||= 600;
 $Height ||= ($ChartStyle eq 'pie' ? $Width : 400);
 

commit f25f2d9c24f7d0426bfa5b1b078e955935975fe1
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Wed Jun 12 20:11:47 2013 +0400

    adjust charts test
    
    * structure of the table has been changed
    * "Tickets" was changed to "Ticket count"
    * default is group by status
    * catch warnings
    * mark one test as TODO, incorrect grouppings
      are skipped and code falls back to status, but UI
      doesn't indicate that chart is groupped by status

diff --git a/t/charts/basics.t b/t/charts/basics.t
index 0b5095d..7933498 100644
--- a/t/charts/basics.t
+++ b/t/charts/basics.t
@@ -33,7 +33,7 @@ use_ok 'RT::Report::Tickets';
         'thead' => [ {
                 'cells' => [
                     { 'value' => 'Status', 'type' => 'head' },
-                    { 'rowspan' => 1, 'value' => 'Tickets', 'type' => 'head' },
+                    { 'rowspan' => 1, 'value' => 'Ticket count', 'type' => 'head' },
                 ],
         } ],
        'tfoot' => [ {
diff --git a/t/web/charting.t b/t/web/charting.t
index 57551fe..8102dd2 100644
--- a/t/web/charting.t
+++ b/t/web/charting.t
@@ -5,7 +5,7 @@ BEGIN {
     require RT::Test;
 
     if (eval { require GD; 1 }) {
-        RT::Test->import(plan => 'no_plan');
+        RT::Test->import(tests => undef);
     }
     else {
         RT::Test->import(skip_all => 'GD required.');
@@ -35,8 +35,8 @@ ok( $m->login, "Logged in" );
 
 # Test that defaults work
 $m->get_ok( "/Search/Chart.html?Query=id>0" );
-$m->content_like(qr{<th[^>]*>Queue\s*</th>\s*<th[^>]*>Tickets\s*</th>}, "Grouped by queue");
-$m->content_like(qr{General</a>\s*</td>\s*<td[^>]*>\s*<a[^>]*>7</a>}, "Found results in table");
+$m->content_like(qr{<th[^>]*>Status\s*</th>\s*<th[^>]*>Ticket count\s*</th>}, "Grouped by status");
+$m->content_like(qr{new\s*</th>\s*<td[^>]*>\s*<a[^>]*>7</a>}, "Found results in table");
 $m->content_like(qr{<img src="/Search/Chart\?}, "Found image");
 
 $m->get_ok( "/Search/Chart?Query=id>0" );
@@ -46,8 +46,8 @@ ok( length($m->content), "Has content" );
 
 # Group by Queue
 $m->get_ok( "/Search/Chart.html?Query=id>0&GroupBy=Queue" );
-$m->content_like(qr{<th[^>]*>Queue\s*</th>\s*<th[^>]*>Tickets\s*</th>}, "Grouped by queue");
-$m->content_like(qr{General</a>\s*</td>\s*<td[^>]*>\s*<a[^>]*>7</a>}, "Found results in table");
+$m->content_like(qr{<th[^>]*>Queue\s*</th>\s*<th[^>]*>Ticket count\s*</th>}, "Grouped by queue");
+$m->content_like(qr{General\s*</th>\s*<td[^>]*>\s*<a[^>]*>7</a>}, "Found results in table");
 $m->content_like(qr{<img src="/Search/Chart\?}, "Found image");
 
 $m->get_ok( "/Search/Chart?Query=id>0&GroupBy=Queue" );
@@ -57,22 +57,30 @@ ok( length($m->content), "Has content" );
 
 # Group by Requestor email
 $m->get_ok( "/Search/Chart.html?Query=id>0&GroupBy=Requestor.EmailAddress" );
-$m->content_like(qr{<th[^>]*>Requestor\s+EmailAddress</th>\s*<th[^>]*>Tickets\s*</th>},
+$m->content_like(qr{<th[^>]*>Requestor\s+EmailAddress</th>\s*<th[^>]*>Ticket count\s*</th>},
                  "Grouped by requestor");
-$m->content_like(qr{root0\@localhost</a>\s*</td>\s*<td[^>]*>\s*<a[^>]*>3</a>}, "Found results in table");
+$m->content_like(qr{root0\@localhost\s*</th>\s*<td[^>]*>\s*<a[^>]*>3</a>}, "Found results in table");
 $m->content_like(qr{<img src="/Search/Chart\?}, "Found image");
 
-$m->get_ok( "/Search/Chart?Query=id>0&GroupBy=Requestor.Email" );
+$m->get_ok( "/Search/Chart?Query=id>0&GroupBy=Requestor.EmailAddress" );
 is( $m->content_type, "image/png" );
 ok( length($m->content), "Has content" );
 
-
 # Group by Requestor phone -- which is bogus, and falls back to queue
+
 $m->get_ok( "/Search/Chart.html?Query=id>0&GroupBy=Requestor.Phone" );
-$m->content_like(qr{General</a>\s*</td>\s*<td[^>]*>\s*<a[^>]*>7</a>},
+$m->warning_like( qr{'Requestor\.Phone' is not a valid grouping for reports} );
+
+TODO: {
+    local $TODO = "UI should show that it's group by status";
+    $m->content_like(qr{new\s*</th>\s*<td[^>]*>\s*<a[^>]*>7</a>},
                  "Found queue results in table, as a default");
+}
 $m->content_like(qr{<img src="/Search/Chart\?}, "Found image");
 
 $m->get_ok( "/Search/Chart?Query=id>0&GroupBy=Requestor.Phone" );
+$m->warning_like( qr{'Requestor\.Phone' is not a valid grouping for reports} );
 is( $m->content_type, "image/png" );
 ok( length($m->content), "Has content" );
+
+done_testing();

commit bd23ffb9d47d20a3c7ce54bd815347b4d9b8e3cb
Author: Christian Loos <cloos at netcologne.de>
Date:   Fri Oct 19 16:35:51 2012 +0200

    enlarge the space between bar and value

diff --git a/share/html/Search/Chart b/share/html/Search/Chart
index 302ea48..188d7ec 100644
--- a/share/html/Search/Chart
+++ b/share/html/Search/Chart
@@ -303,7 +303,7 @@ if ($chart_class eq "GD::Graph::bars") {
         y_label => $report->Label( $columns{'Functions'}[0] ),
         y_label_position => 0.6,
         show_values => 1,
-        values_space => -1,
+        values_space => 2,
 # use a top margin enough to display values over the top line if needed
         t_margin => 18,
 # the following line to make sure there's enough space for values to show

commit 885a64637c3e704b1961a562ebd1c44105ac767c
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Thu Jun 13 00:59:44 2013 +0400

    enhance $date->DurationAsString method
    
    * Show argument to show more than one unit, with Show => 2
      people will see more details, for example "4 hours 25 minutes"
      or "4 weeks 1 day".
    * Short argument to produce result with one character units.
    * use quant in localization

diff --git a/lib/RT/Date.pm b/lib/RT/Date.pm
index 9b21271..6c39e3a 100644
--- a/lib/RT/Date.pm
+++ b/lib/RT/Date.pm
@@ -334,50 +334,112 @@ sub DiffAsString {
 Takes a number of seconds. Returns a localized string describing
 that duration.
 
+Takes optional named arguments:
+
+=over 4
+
+=item * Show
+
+How many elements to show, how precise it should be. Default is 1,
+most vague variant.
+
+=item * Short
+
+Turn on short notation with one character units, for example
+"3M 2d 1m 10s".
+
+=back
+
 =cut
 
+# loc("[_1]s")
+# loc("[_1]m")
+# loc("[_1]h")
+# loc("[_1]d")
+# loc("[_1]W")
+# loc("[_1]M")
+# loc("[_1]Y")
+# loc("[quant,_1,second]")
+# loc("[quant,_1,minute]")
+# loc("[quant,_1,hour]")
+# loc("[quant,_1,day]")
+# loc("[quant,_1,week]")
+# loc("[quant,_1,month]")
+# loc("[quant,_1,year]")
+
 sub DurationAsString {
     my $self     = shift;
     my $duration = int shift;
+    my %args = ( Show => 1, Short => 0, @_ );
 
-    my ( $negative, $s, $time_unit );
+    my $negative;
     $negative = 1 if $duration < 0;
     $duration = abs $duration;
 
-    if ( $duration < $MINUTE ) {
-        $s         = $duration;
-        $time_unit = $self->loc("sec");
-    }
-    elsif ( $duration < ( 2 * $HOUR ) ) {
-        $s         = int( $duration / $MINUTE + 0.5 );
-        $time_unit = $self->loc("min");
-    }
-    elsif ( $duration < ( 2 * $DAY ) ) {
-        $s         = int( $duration / $HOUR + 0.5 );
-        $time_unit = $self->loc("hours");
-    }
-    elsif ( $duration < ( 2 * $WEEK ) ) {
-        $s         = int( $duration / $DAY + 0.5 );
-        $time_unit = $self->loc("days");
-    }
-    elsif ( $duration < ( 2 * $MONTH ) ) {
-        $s         = int( $duration / $WEEK + 0.5 );
-        $time_unit = $self->loc("weeks");
-    }
-    elsif ( $duration < $YEAR ) {
-        $s         = int( $duration / $MONTH + 0.5 );
-        $time_unit = $self->loc("months");
-    }
-    else {
-        $s         = int( $duration / $YEAR + 0.5 );
-        $time_unit = $self->loc("years");
+    my %units = (
+        s => 1,
+        m => $MINUTE,
+        h => $HOUR,
+        d => $DAY,
+        W => $WEEK,
+        M => $MONTH,
+        Y => $YEAR,
+    );
+    my %long_units = (
+        s => 'second',
+        m => 'minute',
+        h => 'hour',
+        d => 'day',
+        W => 'week',
+        M => 'month',
+        Y => 'year',
+    );
+
+    my @res;
+
+    my $coef = 2;
+    my $i = 0;
+    while ( $duration > 0 && ++$i <= $args{'Show'} ) {
+
+        my $unit;
+        if ( $duration < $MINUTE ) {
+            $unit = 's';
+        }
+        elsif ( $duration < ( $coef * $HOUR ) ) {
+            $unit = 'm';
+        }
+        elsif ( $duration < ( $coef * $DAY ) ) {
+            $unit = 'h';
+        }
+        elsif ( $duration < ( $coef * $WEEK ) ) {
+            $unit = 'd';
+        }
+        elsif ( $duration < ( $coef * $MONTH ) ) {
+            $unit = 'W';
+        }
+        elsif ( $duration < $YEAR ) {
+            $unit = 'M';
+        }
+        else {
+            $unit = 'Y';
+        }
+        my $value = int( $duration / $units{$unit}  + ($i < $args{'Show'}? 0 : 0.5) );
+        $duration -= int( $value * $units{$unit} );
+
+        if ( $args{'Short'} ) {
+            push @res, $self->loc("[_1]$unit", $value);
+        } else {
+            push @res, $self->loc("[quant,_1,$long_units{$unit}]", $value);
+        }
+
+        $coef = 1;
     }
 
     if ( $negative ) {
-        return $self->loc( "[_1] [_2] ago", $s, $time_unit );
+        return $self->loc( "[_1] ago", join ' ', @res );
     }
     else {
-        return $self->loc( "[_1] [_2]", $s, $time_unit );
+        return join ' ', @res;
     }
 }
 

commit 319e8ad2c9ff029a53e71a0ba4fe004afa1894c5
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Thu Jun 13 01:10:16 2013 +0400

    use short, but less vague time intervals in charts

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 491ae7a..5889f82 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -809,13 +809,16 @@ sub DurationAsString {
     my $v = $args{'VALUE'};
     unless ( ref $v ) {
         return $self->loc("(no value)") unless defined $v && length $v;
-        return RT::Date->new( $self->CurrentUser )->DurationAsString( $v );
+        return RT::Date->new( $self->CurrentUser )->DurationAsString(
+            $v, Show => 3, Short => 1
+        );
     }
 
     my $date = RT::Date->new( $self->CurrentUser );
     my %res = %$v;
     foreach my $e ( values %res ) {
-        $e = $date->DurationAsString( $e ) if defined $e && length $e;
+        $e = $date->DurationAsString( $e, Short => 1, Show => 3 )
+            if defined $e && length $e;
         $e = $self->loc("(no value)") unless defined $e && length $e;
     }
     return \%res;

commit c4154e468dac5f402a9603a6d3a5effcd909e8ea
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Thu Jun 13 01:34:10 2013 +0400

    take first available form in custom quant implementations
    
    [quant,_1,...] in loc strings that calls quant in RT::I18N::* files
    may have no translation or just less arguments than the implmentation
    actually needs. Let's return first defined form in case we don't
    have enough arguments.

diff --git a/lib/RT/I18N/cs.pm b/lib/RT/I18N/cs.pm
index faea9d7..be2d819 100644
--- a/lib/RT/I18N/cs.pm
+++ b/lib/RT/I18N/cs.pm
@@ -93,10 +93,11 @@ sub numerate {
   my $s = ($num == 1);
 
   return '' unless @forms;
-  return
+  return (
    $s ? $forms[0] :
    ( $num > 1 && $num < 5 ) ? $forms[1] :
-   $forms[2];
+   $forms[2]
+  ) || (grep defined, @forms)[0];
 }
 
 #--------------------------------------------------------------------------
diff --git a/lib/RT/I18N/ru.pm b/lib/RT/I18N/ru.pm
index a98636f..c10ac69 100644
--- a/lib/RT/I18N/ru.pm
+++ b/lib/RT/I18N/ru.pm
@@ -75,7 +75,7 @@ sub numerate {
     } else {
         $form = 2;
     }
-    return $forms[$form];
+    return $forms[$form] || (grep defined, @forms)[0];
 }
 
 RT::Base->_ImportOverlays();

commit 9170af4debc2cfb89d1aa64d0062e47e4d4f5835
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Sun Jun 16 18:31:01 2013 +0400

    make error picture(charts) less tall
    
    If you have an dashboard with e.g. 5 saved charts and 2 of them have no
    tickets the big "No tickets found" picture just takes too much space.
    
    Sadly GD doesn't allow to change height on existing objects, so
    we have to repeat whole process twice.

diff --git a/share/html/Search/Chart b/share/html/Search/Chart
index 188d7ec..aa6cb09 100644
--- a/share/html/Search/Chart
+++ b/share/html/Search/Chart
@@ -77,10 +77,26 @@ my $plot_error = sub {
         color       => $black,
         text        => $text,
         align       => "left",
+        width       => $Width - 20,
         preserve_nl => 1,
     );
     $error->set_font( $font, 16 );
-    $error->draw(0, 0);
+
+    my $text_height = ($error->get_bounds(0, 0))[3];
+
+    $plot = GD::Image->new($Width => $text_height+20);
+    $plot->colorAllocate(255, 255, 255); # background
+    $black = $plot->colorAllocate(0, 0, 0);
+
+    $error = GD::Text::Wrap->new($plot,
+        color       => $black,
+        text        => $text,
+        align       => "left",
+        width       => $Width - 20,
+        preserve_nl => 1,
+    );
+    $error->set_font( $font, 16 );
+    $error->draw(10, 10);
 
     $m->comp( 'SELF:Plot', plot => $plot, %ARGS );
 };

commit 5d1a7e632c1e2c68ef3a2a3a860601ce977ffbde
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Mon Jun 17 11:25:22 2013 -0700

    Refactor error plotting to reduce duplication
    
    It's too easy to change parameters much later and forget to update both
    plotting sequences.

diff --git a/share/html/Search/Chart b/share/html/Search/Chart
index aa6cb09..80f8671 100644
--- a/share/html/Search/Chart
+++ b/share/html/Search/Chart
@@ -68,36 +68,34 @@ $Height ||= ($ChartStyle eq 'pie' ? $Width : 400);
 
 my $plot_error = sub {
     my $text = shift;
-    my $plot = GD::Image->new($Width => $Height);
-    $plot->colorAllocate(255, 255, 255); # background
-    my $black = $plot->colorAllocate(0, 0, 0);
-
-    require GD::Text::Wrap;
-    my $error = GD::Text::Wrap->new($plot,
-        color       => $black,
-        text        => $text,
-        align       => "left",
-        width       => $Width - 20,
-        preserve_nl => 1,
-    );
-    $error->set_font( $font, 16 );
+    my ($plot, $error);
+
+    my $create_plot = sub {
+        my ($width, $height) = @_;
+
+        my $plot = GD::Image->new($width => $height);
+        $plot->colorAllocate(255, 255, 255); # background
+        my $black = $plot->colorAllocate(0, 0, 0);
+
+        require GD::Text::Wrap;
+        my $error = GD::Text::Wrap->new($plot,
+            color       => $black,
+            text        => $text,
+            align       => "left",
+            width       => $width - 20,
+            preserve_nl => 1,
+        );
+        $error->set_font( $font, 16 );
+        return ($plot, $error);
+    };
 
+    ($plot, $error) = $create_plot->($Width, $Height);
     my $text_height = ($error->get_bounds(0, 0))[3];
 
-    $plot = GD::Image->new($Width => $text_height+20);
-    $plot->colorAllocate(255, 255, 255); # background
-    $black = $plot->colorAllocate(0, 0, 0);
+    # GD requires us to replot it all with the new height
+    ($plot, $error) = $create_plot->($Width, $text_height + 20);
 
-    $error = GD::Text::Wrap->new($plot,
-        color       => $black,
-        text        => $text,
-        align       => "left",
-        width       => $Width - 20,
-        preserve_nl => 1,
-    );
-    $error->set_font( $font, 16 );
     $error->draw(10, 10);
-
     $m->comp( 'SELF:Plot', plot => $plot, %ARGS );
 };
 

commit 655573291cc7f07de71045576b2b9804e1dd7ea8
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Mon Jun 17 11:51:12 2013 -0700

    Disable the hidden select used to store the complete option hierarchy
    
    Disabling prevents the fields from being unnecessarily submitted.

diff --git a/share/static/js/event-registration.js b/share/static/js/event-registration.js
index 75f015d..98e1e27 100644
--- a/share/static/js/event-registration.js
+++ b/share/static/js/event-registration.js
@@ -51,6 +51,7 @@ jQuery(function() {
         var complete = jQuery(this)
             .clone(true, true)
             .attr("name", name + "-Complete")
+            .attr("disabled", "disabled")
             .hide()
             .insertAfter(this);
 

commit b9ba8c77ae18d036fca4e9c712d45796b91760b1
Author: Thomas Sibley <trs at bestpractical.com>
Date:   Mon Jun 17 12:20:58 2013 -0700

    Make policy tests happy

diff --git a/share/html/Search/Elements/ChartTable b/share/html/Search/Elements/ChartTable
index 790f0bf..7d8465d 100644
--- a/share/html/Search/Elements/ChartTable
+++ b/share/html/Search/Elements/ChartTable
@@ -1,3 +1,50 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2013 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
 <%ARGS>
 %Table => ()
 $Query => undef
diff --git a/share/html/Search/Elements/SelectChartFunction b/share/html/Search/Elements/SelectChartFunction
index 713ba7f..770ad63 100644
--- a/share/html/Search/Elements/SelectChartFunction
+++ b/share/html/Search/Elements/SelectChartFunction
@@ -2,7 +2,7 @@
 %#
 %# COPYRIGHT:
 %#
-%# This software is Copyright (c) 1996-2011 Best Practical Solutions, LLC
+%# This software is Copyright (c) 1996-2013 Best Practical Solutions, LLC
 %#                                          <sales at bestpractical.com>
 %#
 %# (Except where explicitly superseded by other copyright notices)
diff --git a/t/charts/basics.t b/t/charts/basics.t
index 7933498..523795a 100644
--- a/t/charts/basics.t
+++ b/t/charts/basics.t
@@ -1,5 +1,3 @@
-#!/usr/bin/perl -w
-
 use strict;
 use warnings;
 

commit f3b33e9bfb221ba8bd25eaaccdc769c7928a5969
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Wed Jun 26 16:39:54 2013 +0400

    try to fit values in charts above bars
    
    * use the same font for y axis as for x
    * use the same or smaller font for values as for x axis' labels
    * try to fit values labels horizontally or vertically
    * if failed then hide
    * use feature in newer GD::Graph to avoid hiding all labels

diff --git a/share/html/Search/Chart b/share/html/Search/Chart
index 80f8671..e308372 100644
--- a/share/html/Search/Chart
+++ b/share/html/Search/Chart
@@ -310,14 +310,91 @@ if ($chart_class eq "GD::Graph::bars") {
         }
     }
 
+    # use the same size for y axis labels
+    {
+        $chart_options{'y_axis_font'} = $chart_options{'x_axis_font'};
+    }
+
+    # try to fit in values above bars
+    {
+        my $found_solution = 0;
+
+        # 0.8 is guess, labels for ticks on Y axis can be wider
+        # 1.5 for paddings around bars that GD::Graph adds
+        my $x_space_for_label = $Width*0.8/($count*(@data - 1)+1.5);
+        foreach my $font_size ( grep $_ >= $chart_options{'x_axis_font'}[1], 12, 11, 10, 9 ) {
+            my $font_handle = GD::Text::Align->new(
+                $chart->get('graph'), valign => 'top', 'halign' => 'center',
+            );
+            $font_handle->set_font($font, $font_size);
+
+            $font_handle->set_text('Q');
+            my $line_height = $font_handle->get('height');
+
+            # if horizontal space doesn't allow us to fit one vertical line,
+            # then we need smaller font
+            next if $line_height > $x_space_for_label;
+
+            my %can = (
+                'horizontal, one line' => 1,
+                'vertical, one line' => 1,
+            );
+
+            my %seen;
+            foreach my $raw ( map {@$_} @data[1..(@data-1)] ) {
+                my $value = $raw;
+                $value = $chart_options{'values_format'}->($value)
+                    if $chart_options{'values_format'};
+                next if $seen{$value}++;
+
+                $font_handle->set_text( $value );
+                my $width = $font_handle->get('width');
+                if ( $width > $x_space_for_label ) {
+                    $can{'horizontal, one line'} = 0;
+                }
+                my $y_space_for_label = $Height * 0.6
+                    *( 1 - ($raw-$min_value)/($max_value-$min_value) );
+                if ( $width > $y_space_for_label ) {
+                    $can{'vertical, one line'} = 0;
+                }
+
+                last unless grep $_, values %can;
+            }
+            next unless grep $_, values %can;
+
+            $found_solution = 1;
+
+            $chart_options{'values_font'} = [ $font, $font_size ],
+            $chart_options{'show_values'} = 1;
+            $chart_options{'values_space'} = 2;
+
+            if ( $can{'horizontal, one line'} ) {
+                $chart_options{'values_vertical'} = 0;
+            }
+            else {
+                $chart_options{'values_vertical'} = 1;
+            }
+            last;
+        }
+        unless ( $found_solution ) {
+            $chart_options{'show_values'} = 0;
+
+            if ( do { local $@; eval { GD::Graph->VERSION("1.47") } } ) {
+                $chart_options{'values_font'} = [ $font, 9 ],
+                $chart_options{'show_values'} = 1;
+                $chart_options{'values_space'} = 2;
+                $chart_options{'values_vertical'} = 1;
+                $chart_options{'hide_overlapping_values'} = 1;
+            }
+        }
+    }
+
     %chart_options = (
         %chart_options,
         x_label => join( ' - ', map $report->Label( $_ ), @{ $columns{'Groups'} } ),
         x_label_position => 0.6,
         y_label => $report->Label( $columns{'Functions'}[0] ),
         y_label_position => 0.6,
-        show_values => 1,
-        values_space => 2,
 # use a top margin enough to display values over the top line if needed
         t_margin => 18,
 # the following line to make sure there's enough space for values to show
@@ -348,7 +425,6 @@ if ($chart->get('width') != $Width || $chart->get('height') != $Height ) {
     label_font   => [ $font, 14 ],
     y_axis_font  => [ $font, 12 ],
     values_font  => [ $font, 12 ],
-    value_font   => [ $font, 12 ],
     %chart_options,
 );
 

commit ed8b7781912f6d7db47c06b1797e132a634b160b
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Wed Jun 26 16:59:05 2013 +0400

    drop shebang from t/ file

diff --git a/t/charts/compound-sql-function.t b/t/charts/compound-sql-function.t
index 2a8c3c3..97408cf 100644
--- a/t/charts/compound-sql-function.t
+++ b/t/charts/compound-sql-function.t
@@ -1,4 +1,3 @@
-#!/usr/bin/perl -w
 
 use strict;
 use warnings;

commit 56669e8db4140c8114bea09df307139af9607f6e
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Wed Jun 26 17:01:05 2013 +0400

    return '0 seconds' for ->DurationAsString(0)
    
    This is backwards compatible value that was broken by recent changes.
    I don't know better value to suit most use cases, so stick to what
    we had before.

diff --git a/lib/RT/Date.pm b/lib/RT/Date.pm
index 6c39e3a..0dca217 100644
--- a/lib/RT/Date.pm
+++ b/lib/RT/Date.pm
@@ -372,6 +372,9 @@ sub DurationAsString {
     my $duration = int shift;
     my %args = ( Show => 1, Short => 0, @_ );
 
+    return $self->loc("0 seconds")
+        unless $duration;
+
     my $negative;
     $negative = 1 if $duration < 0;
     $duration = abs $duration;

commit 5740b64d329de83b1bf4b98b783190eaefde7a22
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Wed Jun 26 17:03:15 2013 +0400

    adjust test to recent changes in Date.pm

diff --git a/t/api/date.t b/t/api/date.t
index bce61bf..5b4a9ff 100644
--- a/t/api/date.t
+++ b/t/api/date.t
@@ -499,11 +499,11 @@ my $year = (localtime(time))[5] + 1900;
 { # DurationAsString
     my $date = RT::Date->new(RT->SystemUser);
 
-    is($date->DurationAsString(1), '1 sec', '1 sec');
-    is($date->DurationAsString(59), '59 sec', '59 sec');
-    is($date->DurationAsString(60), '1 min', '1 min');
-    is($date->DurationAsString(60*119), '119 min', '119 min');
-    is($date->DurationAsString(60*60*2-1), '120 min', '120 min');
+    is($date->DurationAsString(1), '1 second', '1 sec');
+    is($date->DurationAsString(59), '59 seconds', '59 sec');
+    is($date->DurationAsString(60), '1 minute', '1 min');
+    is($date->DurationAsString(60*119), '119 minutes', '119 min');
+    is($date->DurationAsString(60*60*2-1), '120 minutes', '120 min');
     is($date->DurationAsString(60*60*2), '2 hours', '2 hours');
     is($date->DurationAsString(60*60*48-1), '48 hours', '48 hours');
     is($date->DurationAsString(60*60*48), '2 days', '2 days');
@@ -512,9 +512,9 @@ my $year = (localtime(time))[5] + 1900;
     is($date->DurationAsString(60*60*24*7*8-1), '8 weeks', '8 weeks');
     is($date->DurationAsString(60*60*24*61), '2 months', '2 months');
     is($date->DurationAsString(60*60*24*365-1), '12 months', '12 months');
-    is($date->DurationAsString(60*60*24*366), '1 years', '1 years');
+    is($date->DurationAsString(60*60*24*366), '1 year', '1 year');
 
-    is($date->DurationAsString(-1), '1 sec ago', '1 sec ago');
+    is($date->DurationAsString(-1), '1 second ago', '1 sec ago');
 }
 
 { # DiffAsString
@@ -526,13 +526,13 @@ my $year = (localtime(time))[5] + 1900;
     $date->Unix(2);
     is($date->DiffAsString(-1), '', 'no diff, wrong input');
 
-    is($date->DiffAsString(3), '1 sec ago', 'diff: 1 sec ago');
-    is($date->DiffAsString(1), '1 sec', 'diff: 1 sec');
+    is($date->DiffAsString(3), '1 second ago', 'diff: 1 sec ago');
+    is($date->DiffAsString(1), '1 second', 'diff: 1 sec');
 
     my $ndate = RT::Date->new(RT->SystemUser);
     is($date->DiffAsString($ndate), '', 'no diff, wrong input');
     $ndate->Unix(3);
-    is($date->DiffAsString($ndate), '1 sec ago', 'diff: 1 sec ago');
+    is($date->DiffAsString($ndate), '1 second ago', 'diff: 1 sec ago');
 }
 
 { # Diff
@@ -547,7 +547,7 @@ my $year = (localtime(time))[5] + 1900;
     my $date = RT::Date->new(RT->SystemUser);
     $date->SetToNow;
     my $diff = $date->AgeAsString;
-    like($diff, qr/^(0 sec|[1-5] sec ago)$/, 'close enought');
+    like($diff, qr/^(0 seconds|(1 second|[2-5] seconds) ago)$/, 'close enought');
 }
 
 { # GetWeekday

commit 748ce640ae4d2e0874f4a0f90699dcbd10917a04
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Wed Jun 26 17:27:37 2013 +0400

    fixup c1ab6d8c93161511f13a599bc734f47b4cad6222

diff --git a/lib/RT/Date.pm b/lib/RT/Date.pm
index 0dca217..0918363 100644
--- a/lib/RT/Date.pm
+++ b/lib/RT/Date.pm
@@ -372,8 +372,9 @@ sub DurationAsString {
     my $duration = int shift;
     my %args = ( Show => 1, Short => 0, @_ );
 
-    return $self->loc("0 seconds")
-        unless $duration;
+    unless ( $duration ) {
+        return $args{Short}? $self->loc("0s") : $self->loc("0 seconds");
+    }
 
     my $negative;
     $negative = 1 if $duration < 0;

commit 6edfb565298311dbfeae54adf6ccaa69556f83f0
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Fri Jun 28 17:40:46 2013 +0400

    values and fetched in SB are lower cased
    
    In places where we directly access $sb->{'values'} or
    fetched use lc() as these hashes are with lowercased
    keys.
    
    Problem shows in situations when $sb->Column(...) returns value
    based on FIELD argument which can have mixed case. For example
    ALL(TimeWorked) statistics demontstrates the problem.

diff --git a/lib/RT/Report/Tickets.pm b/lib/RT/Report/Tickets.pm
index 5889f82..93b7f16 100644
--- a/lib/RT/Report/Tickets.pm
+++ b/lib/RT/Report/Tickets.pm
@@ -699,13 +699,13 @@ sub CalculatePostFunction {
 
     my $base_query = $self->Query;
     foreach my $item ( @{ $self->{'items'} } ) {
-        $item->{'values'}{$column} = $code->(
+        $item->{'values'}{ lc $column } = $code->(
             $self,
             Query => join(
                 ' AND ', map "($_)", grep defined && length, $base_query, $item->Query,
             ),
         );
-        $item->{'fetched'}{$column} = 1;
+        $item->{'fetched'}{ lc $column } = 1;
     }
 }
 
@@ -717,15 +717,15 @@ sub MapSubValues {
     my $map = $info->{'MAP'};
 
     foreach my $item ( @{ $self->{'items'} } ) {
-        my $dst = $item->{'values'}{ $to } = { };
+        my $dst = $item->{'values'}{ lc $to } = { };
         while (my ($k, $v) = each %{ $map } ) {
-            $dst->{ $k } = delete $item->{'values'}{ $v->{'NAME'} };
+            $dst->{ $k } = delete $item->{'values'}{ lc $v->{'NAME'} };
             utf8::decode( $dst->{ $k } )
                 if defined $dst->{ $k }
                and not utf8::is_utf8( $dst->{ $k } );
-            delete $item->{'fetched'}{ $v->{'NAME'} };
+            delete $item->{'fetched'}{ lc $v->{'NAME'} };
         }
-        $item->{'fetched'}{ $to } = 1;
+        $item->{'fetched'}{ lc $to } = 1;
     }
 }
 

commit ceb0055f40b4423aa0d57b4fa242e1a3be97a528
Author: Ruslan Zakirov <ruz at bestpractical.com>
Date:   Fri Jun 28 17:46:11 2013 +0400

    use hide_overlapping_values in any case
    
    Our calculations can be wrong, if GD new enough then we
    can correct mistakes. Hidden labels are better than out
    of bounds.

diff --git a/share/html/Search/Chart b/share/html/Search/Chart
index e308372..c785309 100644
--- a/share/html/Search/Chart
+++ b/share/html/Search/Chart
@@ -387,6 +387,7 @@ if ($chart_class eq "GD::Graph::bars") {
                 $chart_options{'hide_overlapping_values'} = 1;
             }
         }
+        $chart_options{'hide_overlapping_values'} = 1;
     }
 
     %chart_options = (

-----------------------------------------------------------------------


More information about the Rt-commit mailing list