class TrackRecordReport::Report

A Report object queries the database for a set of numbers and compiles them into a set of Sections, Rows and Cells which can be queried via a accessors and enumerators provided herein.

Constants

FREQUENCY

Configure the handlers and human-readable labels for the ways in which reports get broken up, in terms of frequency. View code which presents a choice of report frequency should obtain the labels for the list with the ‘label’ method. Use the ‘#column_title’ method for a column “title”, shown alongside or above column headings. Use the ‘#column_heading’ method for per-column headings.

These must stay in the same order!

If you add new entries, you must add them at the end of the list.

Saved reports specify the reporting frequency by reference to this constant and an index into its array of entries. If you produce a new TrackRecord version that changes the meaning of any array index rather than merely adding new entries, you will have to include a migration that maps old indices to new for existing saved report records.

IMPORTANT! Database dependence

Executive summary: Reports are fast on PostgreSQL and slow on anything else, unless you take steps to make sure they run quickly.

To run report sums in the database quickly, with minimum queries, the database is asked to group its calculations. When we can group using something that matches the columns of a report, we can get the database to in effect calculate the entire body of the report in one query. So for example, a monthly report would group by year and month number; any given date produces a unique year and month.

To do this, the SQL ‘EXTRACT’ function is used by default.

With the standard report generator implementation, TrackRecord reports rely on being able to extract:

  • YEAR (must match Ruby Date#year, i.e. numeric, four digit standard calendar year)

  • QUARTER (must match Ruby Date#quarter, i.e. numeric 1-4, simple 3 full month splits)

  • MONTH (must match Ruby Date#month, i.e. numeric 1-12)

  • WEEK - see below - so also ISOYEAR (PostgreSQL v8.3+ specific)

    • Ruby mirrors ISOYEAR with Date#cwyear. PostgreSQL v8.3 and later also

    provide ISODOW (day-of-week), equivalent to Ruby’s Date#cwday, but that is not needed by TrackRecord.

Weekly reports are thorny, because that requires grouping by some idea of a unique numbered week. The commercial week number is the obvious choice. MySQL and PostgreSQL provide a WEEK parameter for this in EXTRACT, but Oracle does not offer this and can’t be used without changing the TrackRecord report generator’s FREQUENCY constant for weekly reports, or disabling weekly reports entirely.

In any case, even that is insufficient. The 31st December 2012 marks the start of commercial week number 1 for the commercial year 2013; but the date itself is in calendar year 2012. January 1st 2006 is actually the last day of commercial week 52 in the commercial year 2005, despite the date being in calendar year 2006. If we were to just use a standard calendar year, we’d get into trouble - 2012 would appear to contain week 1 twice, leading to numbers from entirely different time periods being combined in the report calculations.

Thus <strong>weekly reports will only function if you use PostgreSQL version 8.3 or later</strong> unless you modify the report code. Without modifications, Oracle and MySQL should work (but are not tested). Note that SQLite will <strong>not work at all</strong> as it has no support for EXTRACT at all - you need to use ‘strftime’ instead, and this provides easily enough flexibility to support weekly reports too:

To modify FREQUENCY for a different database:

  1. Change “config/initializers/can_database_do_fast_reports.rb” so that the SLOW_DATABASE_ALTERNATIVE constant value is “false” for your database. Without this, you won’t be able to test your modifications to FREQUENCY as a “safe mode” fallback is active.

  2. Change the “:grouping” key to the equivalent of the EXTRACT already present, noting that it shoudld always be an array, even if there’s only one group. Ensure that whatever you group by will lead to one group for every single unique column for the intended report, e.g. uniquely identifies months across many years, or weeks across awkward end-of-year boundaries.

  3. Change the “:date_to_key” proc so that Ruby generates an array that matches the grouping keys returned by the database when it runs the query that calculates the report data. To find out that bit of the puzzle, search in “lib/track_record_report.rb” (i.e. this file) for the string “*date_based_key”. You’ll see a line of code that assigns this from a variable. Underneath that line, add in: “raise date_based_key.inspect” and generate a report of the column duration you are modifying, while in development mode. The raised exception will show in your browser the form of the value that the date-to-key proc must return.

Also look at the code below the assignment of FREQUENCY to see how the slow “safe mode” is done. You could always create those values inside FREQUENCY by hand for report types you can’t generate in the database.

Attributes

active_task_ids[R]
cacheable_end_indicator[R]

“Cacheable” range values. If a report’s start or end date is fixed, returns the date. If the start or end date are start/end-of-all-time, one or both returns :all. If the report is being generated for a relative month or week, returns “last_”, “this_” or “two_” with a suffix of “month” or “week”, as a symbol, for both start and end.

cacheable_start_indicator[R]

“Cacheable” range values. If a report’s start or end date is fixed, returns the date. If the start or end date are start/end-of-all-time, one or both returns :all. If the report is being generated for a relative month or week, returns “last_”, “this_” or “two_” with a suffix of “month” or “week”, as a symbol, for both start and end.

customer_sort_field[RW]

Sort fields for customers, projects and tasks; grouping options.

filtered_tasks[RW]
filtered_users[RW]
frequency[R]

A row from the FREQUENCY constant and the current index into that array.

frequency_data[R]

A row from the FREQUENCY constant and the current index into that array.

inactive_task_ids[R]
project_sort_field[RW]

Sort fields for customers, projects and tasks; grouping options.

range[RW]

Complete date range for the whole report; array of user IDs used for per-user breakdowns; array of task IDs the report will represent.

range_end[R]

Range data for the ‘new’ view form. Custom attribute writer methods are used to call “rationalise_dates” whenever a range value is altered.

range_month_end[R]
range_month_start[R]
range_one_month[R]
range_one_week[R]
range_start[R]

Range data for the ‘new’ view form. Custom attribute writer methods are used to call “rationalise_dates” whenever a range value is altered.

range_week_end[R]
range_week_start[R]
reportable_user_ids[R]
task_filter[RW]

Handle all (“all”), only billable (“billable”) or only non-billable (“non_billable”) tasks?

task_grouping[RW]
task_ids[R]
task_sort_field[RW]

Sort fields for customers, projects and tasks; grouping options.

tasks[R]

Read-only array of actual user and task objects based on the IDs. Not all users or tasks may be included, depending on security settings.

throttled[R]

A report’s total date span may be restricted to avoid generating giant reports which would swamp a server (for example, a daily report over hundreds or thousands of days would be a bad idea). If not done, this holds ‘nil’, else the Date giving the original start date (the limited, actual start date used is in the “range” property).

Views/generators can use it to include warnings that the start date was limited, should they so wish.

title[RW]

Optional report title.

total_actual_remaining[R]

Total duration of all tasks in all rows; number of hours remaining (may be negative for overrun) after all hours worked in tasks with non-zero duration. If ‘nil’, all tasks had zero duration. The ‘actual’ value only accounts for committed hours, while the ‘potential’ value includes both committed and not-committed hours (thus, subject to change).

A report that hides zero total rows will not include task durations on those rows. A report that shows them will include task durations on those rows.

total_duration[R]

Total duration of all tasks in all rows; number of hours remaining (may be negative for overrun) after all hours worked in tasks with non-zero duration. If ‘nil’, all tasks had zero duration. The ‘actual’ value only accounts for committed hours, while the ‘potential’ value includes both committed and not-committed hours (thus, subject to change).

A report that hides zero total rows will not include task durations on those rows. A report that shows them will include task durations on those rows.

total_potential_remaining[R]

Total duration of all tasks in all rows; number of hours remaining (may be negative for overrun) after all hours worked in tasks with non-zero duration. If ‘nil’, all tasks had zero duration. The ‘actual’ value only accounts for committed hours, while the ‘potential’ value includes both committed and not-committed hours (thus, subject to change).

A report that hides zero total rows will not include task durations on those rows. A report that shows them will include task durations on those rows.

users[R]

Read-only array of actual user and task objects based on the IDs. Not all users or tasks may be included, depending on security settings.

Public Class Methods

label( frequency ) click to toggle source

Class method equivalent of “label” above. Returns the label for the given frequency, which must be a valid index into the array defined by the FREQUENCY constant. See also “labels” below.

# File lib/track_record_report.rb, line 901
def self.label( frequency )
  return Report::FREQUENCY[ frequency ][ :label ]
end
labels() click to toggle source

Class method which returns an array of labels for various report frequencies. The index into the array indicates the frequency index.

# File lib/track_record_report.rb, line 908
def self.labels
  return Report::FREQUENCY.map { | f | f[ :label ] }
end
new( current_user, params = nil ) click to toggle source

Create a new Report. In the first parameter, pass the current TrackRecord user. In the next parameter pass nothing to use default values for a ‘new report’ view form, or pass “params[ :report ]” (or similar) to create using a params hash from a ‘new report’ form submission.

# File lib/track_record_report.rb, line 496
def initialize( current_user, params = nil )

  super()

  @current_user          = current_user

  @range_start           = nil
  @range_end             = nil
  @range_week_start      = nil
  @range_week_end        = nil
  @range_month_start     = nil
  @range_month_end       = nil
  @frequency             = 0

  @customer_sort_field   = 'title'
  @project_sort_field    = 'title'
  @task_sort_field       = Task::DEFAULT_SORT_COLUMN
  @task_grouping         = :default
  @task_filter           = 'all'

  @include_totals        = true
  @include_committed     = false
  @include_not_committed = false
  @exclude_zero_rows     = false
  @exclude_zero_cols     = false # Totals only - ignores zero com/non-com columns in CSV exports for total/com/non-com column groups with non-zero totals

  @rows                  = {}
  @column_totals         = {}
  @column_ranges         = []
  @column_keys           = []

  @tasks                 = Task.scoped
  @filtered_tasks        = Task.scoped
  @task_ids              = []
  @active_task_ids       = []
  @inactive_task_ids     = []

  @users                 = []
  @filtered_users        = []
  @reportable_user_ids   = []

  unless ( params.nil? )

    # Adapted from ActiveRecord::Base "attributes=", Rails 2.1.0
    # on 29-Jun-2008.

    attributes = params.dup
    attributes.stringify_keys!
    attributes.each do | key, value |
      if ( key.include?( '(' ) )
        raise( "Multi-parameter attributes are not supported." )
      else
        begin
          send( key + "=", value )
        rescue
          # Ignore errors
        end
      end
    end
  end
end

Public Instance Methods

active_task_ids=( ids ) click to toggle source

Build the ‘tasks’ array if ‘#active_task_ids’ is updated externally. The result will be the sum of existing inactive and updated active task IDs.

# File lib/track_record_report.rb, line 595
def active_task_ids=( ids )
  @provided_active_task_ids = map_raw_ids( ids )
  assign_actual_tasks_from_provided_ids()
end
active_tasks() click to toggle source

Virtual accessor for the active task list, which just filters the master task list and returns the result (less RAM than keeping dual lists and not speed-critical as not used that often in practice).

# File lib/track_record_report.rb, line 562
def active_tasks
  @tasks.where( :active => true )
end
column_count() click to toggle source

Only useful for compiled reports - see “compile”.

Returns the number of columns the report generated, taking into account zero total columns and the “hide zero columns” flag.

# File lib/track_record_report.rb, line 813
def column_count
  @relevant_column_keys.count
end
column_heading( range, plain_text = false ) click to toggle source

Helper method which returns a user-displayable column heading appropriate for the report type. Pass a column range (see e.g. “#each_column_range”). Optionally pass “true” to replace “<br />” with a space (if present) in the heading, for a plain text alternative.

# File lib/track_record_report.rb, line 925
def column_heading( range, plain_text = false )
  heading = send( @frequency_data[ :column ], range )
  plain_text ? heading.gsub( "<br />", " " ) : heading
end
column_title() click to toggle source

Helper method which returns a user-displayable column title to be shown once, next to or near per-column headings (see “#column_heading”), appropriate for the report type.

# File lib/track_record_report.rb, line 916
def column_title
  return @frequency_data[ :title ]
end
column_total( date_based_key ) click to toggle source

Return the total value as a Calculator subclass instance for the column identified by the given date-based key.

Will return ‘nil’ if no such non-zero total column exists (yet). Only really useful if the report has been compiled by calling “compile”.

# File lib/track_record_report.rb, line 672
def column_total( date_based_key )
  @column_totals[ date_based_key ]
end
column_total!( date_based_key ) click to toggle source

Return the total value as a Cell instance for the column identified by the given date-based key. A Cell class is used so that per-user column totals can be maintained. Think of each column total as a cell in an extra total-based row of the report.

Will create an empty zero-hour Cell subclass instance in passing if necessary. Usually only useful during the process of report compilation (see “compile”) but may have specialist external uses too.

# File lib/track_record_report.rb, line 686
def column_total!( date_based_key )
  @column_totals[ date_based_key ] ||= Cell.new
end
compile() click to toggle source

Compile the report. Returns ‘self’ for convenience.

# File lib/track_record_report.rb, line 869
def compile
  apply_filters()
  return if ( @filtered_tasks.count.zero? ) # Nothing to do...

  rationalise_dates()
  set_columns()
  sort_and_group()
  initialize_sections( @filtered_tasks, Section ) # TrackRecordSections::SectionsMixin

  calculate()

  self
end
display_range() click to toggle source

Helper method which returns a user-displayable range describing the total date range for this report.

# File lib/track_record_report.rb, line 893
def display_range
  return heading_total( @range )
end
each_cell_for( row ) { |cell| ... } click to toggle source

Only useful for compiled reports - see “compile”.

Iterate over the cells in a given Row or Row subclass. Calls a caller-supplied block, passing the cell instance. Will pass ‘nil’ for cells with a zero hour total (it is much faster to check for ‘nil’ many times than instantiate a useless cell object with zero values for its hours).

A ‘nil’ input parameter value is allowed. The caller block will be invoked with ‘nil’ for each cell that would have been on the row if it existed.

# File lib/track_record_report.rb, line 729
def each_cell_for( row ) # :yields: cell

  # We use zero column total values to indicate that an associated entry
  # in the column ranges should be omitted.

  if ( row.nil? )
    @relevant_column_keys.each { yield( nil ) }
  else
    @relevant_column_keys.each do | date_based_key |
      yield( row.cell( date_based_key ) )
    end
  end
end
each_cell_for_user_on_row( user, row ) { |cell_for_user| ... } click to toggle source

Only useful for compiled reports - see “compile”.

Results of calling here are undefined unless the report parameters tell it to include per-user details during compilation.

For a given user and row, return cells for that row giving the column based totals for just that specific user. Zero columns are skipped if “hide zero columns” is enabled.

This is similar to just doing “#each_cell_for( row )” and calling the cell’s “user_total” method manually for whatever your current User of interest happens to be, but calling here takes care of that for you and deals with ‘nil’ cleanly in passing.

The given User instance must be valid. The given Row instance may be ‘nil’. If so, either the block is called with ‘nil’ for each column if “hide zero rows” is disabled, else it isn’t called at all.

# File lib/track_record_report.rb, line 796
def each_cell_for_user_on_row( user, row ) # :yields: cell_for_user
  if ( row.nil? )
    @relevant_column_keys.each { yield( nil ) } unless ( @exclude_zero_rows )
  else
    user_id_str = user.id.to_s

    @relevant_column_keys.each do | date_based_key |
      yield( row.cell( date_based_key ).try( :user_total, user_id_str ) )
    end
  end
end
each_column_range() { |column_range| ... } click to toggle source

Only useful for compiled reports - see “compile”.

Iterate over all columns, calling a caller-supplied block with a Range object describing the Date range for each one, inclusive of column start/end. Always calls with a valid Range, never ‘nil’.

# File lib/track_record_report.rb, line 836
def each_column_range # :yields: column_range

  # We use zero column total values to indicate that an associated entry
  # in the column ranges should be omitted.

  @column_keys.each_with_index do | date_based_key, linear_column_index |
    next if ( @exclude_zero_cols and not @column_totals.has_key?( date_based_key ) )
    yield @column_ranges[ linear_column_index ]
  end
end
each_column_total() { |column_total| ... } click to toggle source

Only useful for compiled reports - see “compile”.

Iterate over all column totals, calling the caller-supplied block with a Calculator subclass describing the total for that column. If the total is zero and ‘hide zero columns’ is disabled, your block will be called with ‘nil’ for that column.

# File lib/track_record_report.rb, line 824
def each_column_total # :yields: column_total
  @relevant_column_keys.each do | date_based_key |
    yield( @column_totals[ date_based_key ] )
  end
end
each_row() { |row, task| ... } click to toggle source

Only useful for compiled reports - see “compile”.

Iterate over all defined rows, yielding a caller supplied block passing it the row and task in task list order. If “hide zero rows” is set, only defined rows and tasks with non-zero totals will be sent. Otherwise, you will be called with a row value of ‘nil’ and the task for which the row total was zero (it is much faster to check for ‘nil’ many times than instantiate a useless row object with zero values for its hours).

# File lib/track_record_report.rb, line 710
def each_row # :yields: row, task
  @filtered_tasks.each do | task |
    row = @rows[ task.id.to_s ]
    yield( row, task ) unless ( @exclude_zero_rows and row.nil? )
  end
end
each_user() { |user| ... } click to toggle source

Only useful for compiled reports - see “compile”.

Iterate over the list of users passed in the constructor, calling a caller-supplied block with the User instances in the order they were originally given. If “hide zero columns” is set, then only users with a non-zero overall total will be included.

# File lib/track_record_report.rb, line 863
def each_user # :yields: user
  @filtered_users.each { | user | yield( user ) }
end
each_user_on_row( row ) { |user, user_total_for_row| ... } click to toggle source

Only useful for compiled reports - see “compile”.

Results of calling here are undefined unless the report parameters tell it to include per-user details during compilation.

For each user associated with a row, call the caller’s block with the User instance and the Calculator subclass giving the user’s total on that row. Will call with “nil” for each user in the row is “nil”, unless “hide zero rows” is enabled, in which case if given nil it will not call the block at all.

The block is only called for a non-nil row if a user has a non-zero total, or if “hide zero rows” is disabled, in which case the user total instance may be ‘nil’ (but the User is always valid).

See also “#each_cell_for_user_on_row”.

# File lib/track_record_report.rb, line 760
def each_user_on_row( row ) # :yields: user, user_total_for_row

  # Not 'filtered_users' - those are aimed at the columns in reports
  # where each *column* represents a user, so hide-zero-columns will
  # result in hidden users. Here, we're looking at the per-user
  # breakdown for a single task on a row. If hide-zero-cols is set
  # but hide-zero-rows is not, we don't want to hide users here.

  if ( row.nil? )
    @users.each { | user | yield( user, nil ) } unless ( @exclude_zero_rows )
  else
    @users.each do | user |
      user_total_for_row = row.user_total( user.id.to_s )
      yield( user, user_total_for_row ) unless ( @exclude_zero_rows and user_total_for_row.nil? )
    end
  end
end
frequency=( freq ) click to toggle source

Set the ‘#frequency_data’ field when ‘frequency’ is updated externally.

# File lib/track_record_report.rb, line 629
def frequency=( freq )
  @frequency      = freq.to_i
  @frequency_data = FREQUENCY[ @frequency ].dup
end
has_rows?() click to toggle source

Only useful for compiled reports - see “compile”.

Returns ‘true’ if the report has any non-zero hours counted for any of its rows, else ‘false’ (all tasks counted to zero hours within the other report constraints/parameters).

# File lib/track_record_report.rb, line 696
def has_rows?
  not @rows.empty?
end
inactive_task_ids=( ids ) click to toggle source

Build the ‘tasks’ array if ‘#inactive_task_ids’ is updated externally. The result will be the sum of existing active and updated inactive task IDs.

# File lib/track_record_report.rb, line 603
def inactive_task_ids=( ids )
  @provided_inactive_task_ids = map_raw_ids( ids )
  assign_actual_tasks_from_provided_ids()
end
inactive_tasks() click to toggle source

As above, but for inactive tasks.

# File lib/track_record_report.rb, line 568
def inactive_tasks
  @tasks.where( :active => false )
end
label() click to toggle source

Helper method which returns a user-displayable label describing this report type. There’s a class method equivalent below.

# File lib/track_record_report.rb, line 886
def label
  return @frequency_data[ :label ]
end
partial_column?( range ) click to toggle source

Does the column at the given index only contain partial results, because it is the first or last column in the overall range and that range starts or ends somewhere in the middle? Returns ‘true’ if so, else ‘false’.

# File lib/track_record_report.rb, line 934
def partial_column?( range )
  if ( range == @column_ranges.first )
    @column_first_partial
  elsif ( range == @column_ranges.last )
    @column_last_partial
  else
    false
  end
end
range_end=( value ) click to toggle source
# File lib/track_record_report.rb, line 638
def range_end=( value );         @range_end         = value; rationalise_dates(); end
range_month_end=( value ) click to toggle source
# File lib/track_record_report.rb, line 643
def range_month_end=( value );   @range_month_end   = value; rationalise_dates(); end
range_month_start=( value ) click to toggle source
# File lib/track_record_report.rb, line 642
def range_month_start=( value ); @range_month_start = value; rationalise_dates(); end
range_one_month=( value ) click to toggle source
# File lib/track_record_report.rb, line 644
def range_one_month=( value );   @range_one_month   = value; rationalise_dates(); end
range_one_week=( value ) click to toggle source
# File lib/track_record_report.rb, line 641
def range_one_week=( value );    @range_one_week    = value; rationalise_dates(); end
range_start=( value ) click to toggle source

Rationalise overall date ranges whenever a related field is updated externally.

# File lib/track_record_report.rb, line 637
def range_start=( value );       @range_start       = value; rationalise_dates(); end
range_week_end=( value ) click to toggle source
# File lib/track_record_report.rb, line 640
def range_week_end=( value );    @range_week_end    = value; rationalise_dates(); end
range_week_start=( value ) click to toggle source
# File lib/track_record_report.rb, line 639
def range_week_start=( value );  @range_week_start  = value; rationalise_dates(); end
reportable_user_ids=( ids ) click to toggle source

Build the ‘user’ array if ‘#reportable_user_ids’ is updated externally.

# File lib/track_record_report.rb, line 610
def reportable_user_ids=( ids )
  ids ||= []
  ids = ids.values if ( ids.is_a?( Hash ) or ids.is_a?( HashWithIndifferentAccess ) )
  @reportable_user_ids = ids.map { | str | str.to_i }

  # Security - if the current user is restricted they might try and hack
  # the form to view other user details.

  if ( @current_user.restricted? )
    @reportable_user_ids = [ @current_user.id ] unless( @reportable_user_ids.empty? )
  end

  # Turn the list of (now numeric) user IDs into user objects.

  @users = User.where( :id => @reportable_user_ids )
end
row( task_id_str ) click to toggle source

Return the row defined for the given task ID, specified as a string.

Will return ‘nil’ if no such row exists. Only really useful if the report has been compiled by calling “compile”.

# File lib/track_record_report.rb, line 651
def row( task_id_str )
  @rows[ task_id_str ]
end
row!( task_id_str ) click to toggle source

Return the row defined for the given task ID, specified as a string.

Will create an empty Row instance in passing if necessary. Usually only useful during the process of report compilation (see “compile”) but may have specialist external uses too.

# File lib/track_record_report.rb, line 661
def row!( task_id_str )
  @rows[ task_id_str ] ||= Row.new
end
task_ids=( ids ) click to toggle source

Build the ‘tasks’ array if ‘#task_ids’ is updated externally.

# File lib/track_record_report.rb, line 587
def task_ids=( ids )
  @provided_task_ids = map_raw_ids( ids )
  assign_actual_tasks_from_provided_ids()
end
tasks=( array ) click to toggle source

Set a task array directly (will always be filtered according to security settings for the current user).

# File lib/track_record_report.rb, line 575
def tasks=( array )
  unless array.nil? || array.count.zero?
    @tasks = Task.where( :id => array )
  else
    @tasks = Task.scoped
  end

  update_internal_task_lists()
end
user_count() click to toggle source

Only useful for compiled reports - see “compile”.

Returns the number of users the report considered, taking into account zero total users and the “hide zero columns” flag.

# File lib/track_record_report.rb, line 852
def user_count
  @filtered_users.count
end