[Rt-commit] rt branch, 4.2/charts, created. rt-4.1.8-485-g81df450

Ruslan Zakirov ruz at bestpractical.com
Wed Jun 12 17:39:34 EDT 2013


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

- Log -----------------------------------------------------------------
commit cf96eedae276e2d0f146398b3e6bc7f2ac414a11
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 afd79e2..93a7e40 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 ecff954d4e7329c9cb593019d02d293ffd65677a
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 93a7e40..1e6918d 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 b06c570ead1b7980b0d67a4c04bb543dadde87dd
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 1e6918d..6d1593e 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 7f6c09378b117b78927c17ccd8e8b50b8fdeab21
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 df0a4c5..55d3232 100644
--- a/lib/RT/Tickets.pm
+++ b/lib/RT/Tickets.pm
@@ -530,7 +530,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 =~ /^(=|>|<|>=|<=)$/;
@@ -539,6 +539,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 );
 
@@ -559,14 +582,14 @@ sub _DateLimit {
             FIELD    => $meta->[1],
             OPERATOR => ">=",
             VALUE    => $daystart,
-            @rest,
+            %rest,
         );
 
         $sb->Limit(
             FIELD    => $meta->[1],
             OPERATOR => "<",
             VALUE    => $dayend,
-            @rest,
+            %rest,
             ENTRYAGGREGATOR => 'AND',
         );
 
@@ -578,7 +601,7 @@ sub _DateLimit {
             FIELD    => $meta->[1],
             OPERATOR => $op,
             VALUE    => $date->ISO,
-            @rest,
+            %rest,
         );
     }
 }

commit 6042181e0d62b7ff5295e99f64b6680c3a8822c6
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 46cf96c..e8ff019 100644
--- a/lib/RT/SearchBuilder.pm
+++ b/lib/RT/SearchBuilder.pm
@@ -760,10 +760,7 @@ sub Limit {
         $ARGS{'VALUE'} = 'NULL';
     }
 
-    if ($ARGS{FUNCTION}) {
-        ($ARGS{ALIAS}, $ARGS{FIELD}) = split /\./, delete $ARGS{FUNCTION}, 2;
-        $self->SUPER::Limit(%ARGS);
-    } elsif ($ARGS{FIELD} =~ /\W/
+    if ($ARGS{FIELD} =~ /\W/
           or $ARGS{OPERATOR} !~ /^(=|<|>|!=|<>|<=|>=
                                   |(NOT\s*)?LIKE
                                   |(NOT\s*)?(STARTS|ENDS)WITH

commit 19d203fba09c2e9b1e9b76f6a0b536297eba6154
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 6d1593e..c6248ef 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 e8ff019..adf5306 100644
--- a/lib/RT/SearchBuilder.pm
+++ b/lib/RT/SearchBuilder.pm
@@ -856,6 +856,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 55d3232..4c0d172 100644
--- a/lib/RT/Tickets.pm
+++ b/lib/RT/Tickets.pm
@@ -549,7 +549,7 @@ sub _DateLimit {
         }
         my $function = $RT::Handle->DateTimeFunction(
             Type     => $subkey,
-            Field    => '?',
+            Field    => $sb->NotSetDateToNullFunction,
             Timezone => $tz,
         );
 
@@ -598,6 +598,7 @@ sub _DateLimit {
     }
     else {
         $sb->Limit(
+            FUNCTION => $sb->NotSetDateToNullFunction,
             FIELD    => $meta->[1],
             OPERATOR => $op,
             VALUE    => $date->ISO,

commit 66d7a959d9910132fbcc97574e5b41c0b83b916a
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 c6248ef..017ae14 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 dd669791ce2ac84a8794a200adeb3a4b5f65a8b1
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 4c0d172..a63207b 100644
--- a/lib/RT/Tickets.pm
+++ b/lib/RT/Tickets.pm
@@ -540,6 +540,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
@@ -547,6 +566,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 46ed55a5c111d7f0d6c8da6826cbfed2dea01000
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 017ae14..9678c02 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 0b9c66655f3d6370cf96f28211737a972ecb4a0f
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 9678c02..c55cb5e 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 effaed6def5b516fc53907973b8064bfb656becc
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 0b9a18e5dee846eb3671cd34b87fca9a0438e441
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 a13233375d80293bae11cc73dfec0e8d10761287
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 bd79f9d3ab1aa309a2ca17b61b22742e30ce88d1
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 c55cb5e..3e828ed 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 a51a2bf911621ffa46663ab27d0f6956c4ebfa2c
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 3e828ed..91747ca 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( Type => $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( Type => $type );
+        $self->{"_sql_report_watcher_users_alias_$type"} = $u_alias;
+    }
+    @args{qw(ALIAS FIELD)} = ($u_alias, $column);
+
+    return %args;
+}
+
 RT::Base->_ImportOverlays();
 
 1;

commit 3e1f98a7f909eb18d7eddbdfa0bd95e433bf48d8
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 91747ca..8715808 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 d75835ce2bf8fc3355e942be25553d177b3e492e
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 8715808..7789ccb 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 6440c09b4bf8cc160eba826b2673d6f0a88dd102
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 7789ccb..11afc3e 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 fbf0933eef32ea4adc3db0698dfb110af03af949
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 11afc3e..0a4abe0 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 90df9b132bb4cb26f8e14e6161a755f6d082580f
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 3a6d2ade0a8ad094c32c8c8703fc1f2a65ce697d
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 0a4abe0..d81c7c0 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 272dd40e1a9a8e02851fa832ac504fc4e237d370
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 d81c7c0..72322f1 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 97667555b25cb03bacb025091d693f69d6e70898
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 72322f1..b4caa86 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 4567a490f1f06af1521679ea77fc5e0ab94f3d94
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 b4caa86..5c54859 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 031f757b952db38f578c1a9ede869b31e0664af6
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 6df76a3ff27ebf50bc21ec3e9763d85b0b2d8dd4
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 5c54859..26a8830 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 8d8f53ac468ff971c72c176e2fd6b1e6410ed8af
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 26a8830..5c390e2 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 9f5797d2135872ebfb8ed93aa55da6a04acfcb9c
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 5c390e2..9f6280a 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 9a3d7eb07904ea5ac755906bf8b5fb826b420869
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 9f6280a..b4f1e1d 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 9f5fa2a266eb2c418efad1423251be2fa29ca536
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 d46c90f26759363d748aa1f0de0048061dcafc87
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 b4f1e1d..16fc4f7 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 70d0dbccc4436be9114e7d3769d301ab71657b6d
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 bbe2c50e4cbd4aa46f5780a749b6287ecff7f178
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 16fc4f7..e1e4b98 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 7667a2f0714b3f914705ea82929f4e0e6e4104bf
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 6a15492a5acd4aae1f16085b84816ed2a99c35e5
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 cd8176639a052090fcee498a031f6237a3c0a45e
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 e1e4b98..76bff39 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 3a9ced072f1611bc9d3a8b92ed6d1739e684deae
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 e9ffd6cc11ed3699ea6e647cae8f8bb5ff1016b2
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 dc7d459dc8d3bb5f1c5db5436601bf30f0670497
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 2bc847ee374ca3b2c8126240a6503cd4664eb571
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 293dc18c25384b3b519c5442200330c476764c9a
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 d804f2947e8f60e9b855eba10275cbc3a63c1bc7
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 bfcda70d967f424e36785de758070a2de9e847bf
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 76bff39..9014799 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 9ac57da61160eafdd06e9f86298c5ea77695ca77
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 9014799..8b55d9e 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 c12dc0438d112d8b9aeb4b4b9a54e85e2093a4c8
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 5dc33d9ead25e52e51ddfcf58907dd7b467c6c52
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 8b55d9e..4296d9e 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 3e2d430014745a83813619c2a470efad1bae336b
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 1183d5de3f171a9b99a4460bc29dddde66610044
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 4296d9e..26830cf 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 c969d14f5b4eeeec2d2489ed08417082428541af
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 26830cf..fc47418 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 bbf1dceaf8e4acc3794e696e5f7f286cd54cd972
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 bb89411d92be74c77a6544c7d8388e9d32f01fcb
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 632377435cc953f2015ef7584ed4ba15bc54aa39
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 819dac632ee754531ffab1886bb1b82ed129db7c
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 582166e41c6fd13860c9ec8a5a7bd77e39d65274
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 fc47418..e35938c 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 1e5eb543c571e0ff6e5aa1d7ee3f783a9c0eb09e
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 35b87e7476d5418e8d0098d7cf2ce1e59792bb45
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 e35938c..b842710 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 5344d42e97780c32e53cbef58c0f03b65b559951
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 b842710..d9a21b5 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 af705566b46058f2bbb7f5588daf8f6984cd517b
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 d9a21b5..b026a21 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 5ef0cfcf41d8f50a30af29b3215a5bce03a4b159
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 b026a21..a887a30 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 d5ada8cd5b20a97ffda531e45207dc4cdc43ae11
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 df89a38..9ab6a57 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 54769599bed5805efb04c6acff7b578129a8057b
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 a887a30..fdbbea3 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 6f08b8eba16ae9123b20617e165f6d1761445d7c
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 6c32ba5f319e9fbc389f7d647f47dece12cfca7b
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 fdbbea3..258af03 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 9430771385c185ba54892be53dc1f37ef8271539
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 258af03..57c1d48 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 f30caae4656a5eb3d11556162e843a68f3ad7d9b
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 6d025c434a7420e5c87f79252547a75cee1a96e9
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 57c1d48..8343538 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 12dc4e4b37152bf62b60f10a5c06d132d208acaf
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 8343538..8291746 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 e77c3bbe76e3e9efef7b40ccfec357dd244ad032
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 8291746..e1e5360 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 b1060ddb8c76ad8cc110e0d6d4167b40fc95cc9e
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 e1e5360..1d60073 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 2a4228fa6b001494344f0aa31891b27cca44a6eb
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 1d60073..c06d4a9 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 496c1bf0ccca19c45fdc87e43a9051dbbdf9daeb
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 c5cd3b6747c2d367d6db6bfb05bbe7f4c96fa360
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 c06d4a9..0469ad2 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 96e4579fdaedf7ab48a5a528103654a3fc0db699
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 0469ad2..636bd87 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 fb4bfa0fb139946c5a070d437b7388f9a4892d59
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 94a2d0fefc8f609cfb23286a3ea5a8c944bb01c9
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 636bd87..4422b44 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 1e591910beffdb2c3901d87c99978642f0ae9f76
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 caf81557ee665ea4e53ee87c9fed4319ded13e33
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 4422b44..4a33d50 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 2f6ca1455c142979668536f888746fe0dc4d8fec
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 4a33d50..5580b14 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 de413d7a684414978f119c8219d3eafee9469a7b
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 e2ff74a4979a60ee056b4c7c0bdb8282ab72f03f
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 523b7ac14e7aa3c6a63c26359fe76658b895357e
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 5580b14..0f41668 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 2b11bde35a2a64a23b3972ed97beaa368c5ec6c9
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 0f41668..c237531 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 f2a4fc92c4adf30ce32d1fb43cca41d08ba370e0
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 c237531..7429052 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 7837c1daedba42a262afdca4105716889561e60e
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 7429052..45f7e0d 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 5de2fe26d4fa8666b58cde4b48335d1b14ff8bfb
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 a63207b..519b268 100644
--- a/lib/RT/Tickets.pm
+++ b/lib/RT/Tickets.pm
@@ -542,7 +542,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;
             }
@@ -551,7 +558,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 4526b3b13a077c27a346c4bde504d6c5e86b2aac
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 45f7e0d..33d5b1e 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 fda95f1..b8fbcc3 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 64b629a0a738e30f3fd1034c1e7c408ba4428d4c
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 33d5b1e..afd8d89 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 095d94da0eec496e1afef3c5a389ee878516d873
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 afd8d89..1003fc6 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 c5ca861e5b2aa8609c2cc99a66947ae1f8b3f9cb
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 1003fc6..80e6fd4 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 fb7447927fd67727d4835653ed284fcc736194aa
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 152c5b452923747db5d0fb261f0d45f8f316f2b3
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 80e6fd4..7ecf5fd 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 ec0fc2287c9508cb57d2f1168fb1805e2664eead
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 3a83138ec708aad6a4a7c05f0f06b8843f14c84c
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 3c019c10d813410be2d5e2eb14658caa06eaecae
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 ae870a1ae08edd715682d478c7b2f0f72c7f0efb
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 61bf9aac5d519ef5175ffcfce7de9f94dd140b9b
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 7ecf5fd..4d5850b 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 c539332e5719bfec7f0986c75e81009fc1a185bc
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 4d5850b..ab9c369 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 e9cc8b3e39f93e8499917d6f037208d5155602c4
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 147567560fd34ae4a51dd1f74736feb50d98f776
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 51231562a7e1fb18fe85bb0729bf9deb1ce78f83
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 d9886cecc6d239e6c2c0adeafea2cc989c2b81c6
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 ac6f9a5..57a0329 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 ff328d9aa28049d4eeefe4bd0ea02f7adbc1d1ab
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 ab9c369..afa45da 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 ba513d92e3a8703e0b1e7b7bc3f56d3adc626b50
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 846b5ed4c8ab90e336ba7d34e8d9e0ad1ae1c72e
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 99fabe6400bca4641788bd4c2a21e09e54286ef8
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 519b268..789244a 100644
--- a/lib/RT/Tickets.pm
+++ b/lib/RT/Tickets.pm
@@ -581,6 +581,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 479280da4a20713ca1f256f7de26472f42723bbd
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 d3a680a1930931ce6aa740711979ebf61ef1f726
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 30e79e8581638184b75e7f4245d353e466a4be3a
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 5d02d7c26a7e20b971ee90d88033d20af6461c16
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 fb5caf40fd61f9fd373408e2f33ca5701e77a78a
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 77fa6525da4009e9bbaa2b29dc0c4c4c60af35b0
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 5779b03..de80bd1 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 576ab51b91a23f0b544a191a125b64bc1d9b6b9a
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 afa45da..6de24e2 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 81df450202bc4ce9cc916c1c09672ce6515e8a41
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();

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


More information about the Rt-commit mailing list