timely_reports 0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,15 @@
1
+ ---
2
+ !binary "U0hBMQ==":
3
+ metadata.gz: !binary |-
4
+ ZjBmYjFjM2I0NDIzZWYxODQyMzczOGY2MjdhYWEyNmUwN2QwOTliZQ==
5
+ data.tar.gz: !binary |-
6
+ YzdjNDFhZWYzNjk4OTFkN2QwNWRiNjI3OGExNTcwNmE4NDM2MWM1Mg==
7
+ !binary "U0hBNTEy":
8
+ metadata.gz: !binary |-
9
+ OGRmOTE5NTUxMDMxNGY3NThhODlmOTdiZWI3YzBhZmY4MDc0YzE4M2FlYTdk
10
+ MzUyOWQ4Zjc1ZjIwOTJmYTA1NTViZjkxMWI3MGZhN2ExNjMxOTc4YmU4N2M5
11
+ ODFhMjNkYWM0MDQzMDk3MGEwODRjOWZjOGQ4Yjk2MTA1MGEwYTY=
12
+ data.tar.gz: !binary |-
13
+ YmI1ZGQ3ZmJhMTQ5NTdjNzk2MDI2YjA3YzNkMDhhYWNhMjVjYmQ0OTA3NDdl
14
+ ZGFlYTEyYWMzYmJkOGFlNTM2YWQ1YWE3ZDU0ODk5NzgwNmZhYTA0YWZmMTJm
15
+ OGM3ZTU2Njc0MDU1ZWYyYThiYzVkYzJmZTBhMjQzOGNmYjk4ZWE=
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2013 Nick Ragaz
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,6 @@
1
+ Timely
2
+ ============
3
+
4
+ Create time-based reports about your database records. e.g. # of records per day, month or year.
5
+
6
+ Requires Rails ~> 3 and Ruby 1.9.2.
data/Rakefile ADDED
@@ -0,0 +1,3 @@
1
+ require "bundler/gem_tasks"
2
+
3
+ task :default => :test
@@ -0,0 +1,82 @@
1
+ # encoding: UTF-8
2
+
3
+ module Timely
4
+ class Cell
5
+ attr_accessor :report, :row, :column
6
+
7
+ def initialize(report, column, row)
8
+ self.report = report
9
+ self.column = column
10
+ self.row = row
11
+ end
12
+
13
+ def to_s
14
+ "#<#{self.class.name} row: \"#{row.title}\", starts_at: #{column.starts_at}, ends_at: #{column.ends_at}>"
15
+ end
16
+
17
+ def column_key
18
+ column.to_i
19
+ end
20
+
21
+ def column_title
22
+ column.title
23
+ end
24
+
25
+ def row_title
26
+ row.title
27
+ end
28
+
29
+ def value
30
+ @value ||= cacheable? ? value_with_caching : value_without_caching
31
+ end
32
+
33
+ def cacheable?
34
+ row.cacheable? && column.cacheable?
35
+ end
36
+
37
+ def cache_key
38
+ @cache_key ||= [report.cache_key, row.cache_key, column.cache_key].join(cache_sep)
39
+ end
40
+
41
+ private
42
+
43
+ def value_without_caching
44
+ row.value(column.starts_at, column.ends_at)
45
+ end
46
+
47
+ def value_with_caching
48
+ Timely.redis ? value_from_redis : value_from_rails_cache
49
+ end
50
+
51
+ def value_from_rails_cache
52
+ Rails.cache.fetch(cache_key) { value_without_caching }
53
+ end
54
+
55
+ # retrieve a cached value from a redis hash.
56
+ #
57
+ # hashes are accessed using the report title and row title. values within
58
+ # the hash are keyed using the column's start/end timestamps
59
+ def value_from_redis
60
+ if val = Timely.redis.hget(redis_hash_key, redis_value_key)
61
+ val = val.include?(".") ? val.to_f : val.to_i
62
+ else
63
+ val = value_without_caching
64
+ Timely.redis.hset(redis_hash_key, redis_value_key, val)
65
+ end
66
+
67
+ val
68
+ end
69
+
70
+ def redis_hash_key
71
+ @redis_hash_key ||= [report.cache_key, row.cache_key].join(cache_sep)
72
+ end
73
+
74
+ def redis_value_key
75
+ @redis_value_key ||= column.cache_key
76
+ end
77
+
78
+ def cache_sep
79
+ Timely.cache_separator
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,79 @@
1
+ # encoding: UTF-8
2
+
3
+ class Timely::Column
4
+ attr_accessor :period, :starts_at, :options
5
+
6
+ def initialize(period, starts_at, options={})
7
+ self.period = period
8
+ self.starts_at = starts_at
9
+ self.options = options
10
+ end
11
+
12
+ def inspect
13
+ "#<#{self.class.name} period: #{period}, starts_at: #{starts_at}, ends_at: #{ends_at}>"
14
+ end
15
+
16
+ # calculate the end time
17
+ def ends_at
18
+ @ends_at ||= begin
19
+ args = period == :quarter ? { months: 3 } : { periods => 1 }
20
+ starts_at.advance args
21
+ end
22
+ end
23
+
24
+ # calculate the time between the start and the end. useful as the x value
25
+ # for bar and line graphs
26
+ def midpoint
27
+ @midpoint ||= Time.at((starts_at.to_i + ends_at.to_i) / 2)
28
+ end
29
+
30
+ def title
31
+ format_time_for_human
32
+ end
33
+
34
+ def to_s
35
+ format_time_for_group
36
+ end
37
+
38
+ def to_i
39
+ starts_at.to_i
40
+ end
41
+
42
+ def cache_key
43
+ [starts_at.to_i, ends_at.to_i].join(Timely.cache_separator)
44
+ end
45
+
46
+ # only cache values when the period is over
47
+ def cacheable?
48
+ ends_at < Time.zone.now
49
+ end
50
+
51
+ private
52
+
53
+ # :month -> :months, etc.
54
+ def periods
55
+ "#{period}s".to_sym
56
+ end
57
+
58
+ def format_time_for_group
59
+ case period
60
+ when :year
61
+ starts_at.strftime("%Y")
62
+ when :quarter
63
+ quarter_number = ((starts_at.month - 1) / 3) + 1
64
+ "#{starts_at.year}#{quarter_number}"
65
+ when :month
66
+ starts_at.strftime("%Y%m")
67
+ when :week
68
+ starts_at.strftime("%Y%U")
69
+ when :day
70
+ starts_at.strftime("%Y%m%d")
71
+ when :hour
72
+ starts_at.strftime("%Y%m%d%H")
73
+ end
74
+ end
75
+
76
+ def format_time_for_human
77
+ starts_at.strftime Timely.date_formats[period]
78
+ end
79
+ end
@@ -0,0 +1 @@
1
+ class Timely::ConfigurationError < Exception ; end
@@ -0,0 +1,86 @@
1
+ # encoding: UTF-8
2
+
3
+ require 'spreadsheet'
4
+
5
+ class Timely::Formats::Excel < Timely::Formatter
6
+ CELL_FORMATS = {
7
+ title: Spreadsheet::Format.new(bold: true, size: 14),
8
+ headings: Spreadsheet::Format.new(bold: true, size: 10, align: 'right'),
9
+ values: Spreadsheet::Format.new(align: 'right')
10
+ }
11
+
12
+ attr_reader :row_idx, :workbook, :worksheet, :path
13
+
14
+ def initialize(report, options={})
15
+ super
16
+
17
+ @path = options[:path] || Tempfile.new('timely-excel').path
18
+ @row_idx = 0
19
+ @workbook = Spreadsheet::Workbook.new
20
+ @worksheet = create_worksheet
21
+ end
22
+
23
+ def output
24
+ write_title
25
+ write_columns
26
+ write_rows
27
+ write_generated_at
28
+ save_workbook
29
+
30
+ path
31
+ end
32
+
33
+ private
34
+
35
+ def row_idx
36
+ @row_idx
37
+ end
38
+
39
+ def increment_row_idx(by=1)
40
+ @row_idx += by
41
+ end
42
+
43
+ def create_worksheet
44
+ worksheet = workbook.create_worksheet
45
+ worksheet.name = 'Report'
46
+ worksheet.column(0).width = 24
47
+ worksheet
48
+ end
49
+
50
+ def write_title(worksheet)
51
+ worksheet.row(row_idx).default_format = CELL_FORMATS[:titles]
52
+ worksheet.row(row_idx).push report.title
53
+ increment_row_idx
54
+
55
+ worksheet.row(row_idx).push "#{report.starts_at.to_s(:long)} - #{report.ends_at.to_s(:long)} (by #{report.period})"
56
+ increment_row_idx 2 # add a blank line
57
+ end
58
+
59
+ def write_columns(worksheet)
60
+ worksheet.row(row_idx).default_format = CELL_FORMATS[:headings]
61
+ worksheet.row(row_idx).push ""
62
+ worksheet.row(row_idx).concat report.columns.map(&:title)
63
+
64
+ increment_row_idx
65
+ end
66
+
67
+ def write_rows(worksheet, row_idx)
68
+ raw = report.raw
69
+ raw.each do |row, cells|
70
+ worksheet.row(row_idx).push row.title.dup
71
+ worksheet.row(row_idx).concat cells.map(&:value)
72
+ increment_row_idx
73
+ end
74
+ end
75
+
76
+ def write_generated_at
77
+ text = "Generated at #{Time.zone.now.to_formatted_s(:rfc822)}"
78
+ worksheet.row(row_idx).push text
79
+
80
+ increment_row_idx
81
+ end
82
+
83
+ def save_workbook
84
+ workbook.write path
85
+ end
86
+ end
@@ -0,0 +1,18 @@
1
+ # encoding: UTF-8
2
+
3
+ class Timely::Formats::Hash < Timely::Formatter
4
+ # Turn a report into a simple hash keyed off of the row titles
5
+ def output
6
+ {}.tap do |hash|
7
+ raw = report.raw
8
+
9
+ # headings
10
+ hash[""] = report.columns.map(&:title)
11
+
12
+ # data
13
+ raw.each do |row, cells|
14
+ hash[row.title] = cells.map(&:value)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,9 @@
1
+ # encoding: UTF-8
2
+
3
+ require 'json'
4
+
5
+ class Timely::Formats::Json < Timely::Formatter
6
+ def output
7
+ JSON.dump report.to_hash
8
+ end
9
+ end
@@ -0,0 +1,14 @@
1
+ # encoding: UTF-8
2
+
3
+ class Timely::Formatter
4
+ attr_accessor :report, :options
5
+
6
+ def initialize(report, options={})
7
+ self.report = report
8
+ self.options = options
9
+ end
10
+
11
+ def to_s
12
+ "#<#{self.class.name} report: \"#{report.title}\">"
13
+ end
14
+ end
@@ -0,0 +1,203 @@
1
+ # encoding: UTF-8
2
+
3
+ class Timely::Report
4
+ ## Class Definition ##
5
+
6
+ class_attribute :_row_args, :_row_scopes, instance_writer: false
7
+
8
+ ## Default Row Settings ##
9
+
10
+ class_attribute :default_key
11
+ self.default_key = :created_at
12
+
13
+ class_attribute :default_klass
14
+ self.default_klass = :total_count
15
+
16
+ class << self
17
+ # Define the report's rows in a subclass:
18
+ #
19
+ # row "Stuff", :created_at, :count do
20
+ # objects.where type: 'Stuff'
21
+ # end
22
+ #
23
+ # When the report is generated, the block is evaluated
24
+ # in the context of the report, so you can use helper methods
25
+ # in the report to filter your scopes. For example, you may
26
+ # want to have a `user` attribute on the report and scope each
27
+ # row's data to that user's associations.
28
+ def row(title, key=default_key, klass=default_klass, options={}, &scope)
29
+ self._row_args ||= []
30
+ self._row_scopes ||= []
31
+
32
+ klass = symbol_to_row_class klass if klass.is_a?(Symbol)
33
+
34
+ self._row_args << [klass, [title, key, options]]
35
+ self._row_scopes << scope
36
+ end
37
+
38
+ private
39
+
40
+ # :count -> Timely::Rows::Count
41
+ def symbol_to_row_class(sym)
42
+ klass = sym.to_s.camelcase
43
+ Timely::Rows.const_get(klass)
44
+ rescue
45
+ raise Timely::ConfigurationError, "No row class defined for #{klass}"
46
+ end
47
+ end
48
+
49
+ ## Instance Definition ##
50
+
51
+ attr_accessor :title, :period, :length, :starts_at, :options
52
+
53
+ # This can be overridden to set defaults by calling super(default args)
54
+ def initialize(options={})
55
+ options = options.symbolize_keys
56
+ options.reverse_merge! period: :month
57
+
58
+ self.period = options.delete(:period)
59
+ self.length = options.delete(:length) || default_length
60
+ self.starts_at = options.delete(:starts_at) || default_starts_at
61
+ self.ends_at = options.delete(:ends_at) if options.has_key?(:ends_at)
62
+ self.options = options
63
+ end
64
+
65
+ def title
66
+ @title.is_a?(Symbol) ? I18n.t("timely.reports.#{@title}") : @title
67
+ end
68
+
69
+ def to_s
70
+ "#<#{self.class.name} title: \"#{title}\", period: #{period}, starts_at: #{starts_at}, length: #{length}>"
71
+ end
72
+
73
+ # ensure that period is a valid symbol and not dangerous
74
+ def period=(val)
75
+ if Timely.periods.include?(val.to_s)
76
+ @period = val.to_sym
77
+ else
78
+ raise Timely::ConfigurationError, "period must be in the list: #{Timely.periods.join(", ")} (provided #{val})"
79
+ end
80
+ end
81
+
82
+ # ensure that length is an integer
83
+ def length=(val)
84
+ @length = val.to_i
85
+ end
86
+
87
+ # round the given time to the beginning of the period in which the
88
+ # provided time falls
89
+ def starts_at=(val)
90
+ raise Timely::ConfigurationError, "period must be set before setting starts_at" unless period
91
+
92
+ if val == :hour
93
+ @starts_at = val.change(min: 0, sec: 0)
94
+ else
95
+ @starts_at = val.send("beginning_of_#{period}")
96
+ end
97
+ end
98
+
99
+ # recalculate the length so that the report includes the given date
100
+ def ends_at=(val)
101
+ raise Timely::ConfigurationError, "starts_at must be set before setting ends_at" unless starts_at
102
+
103
+ duration_in_seconds = val - starts_at
104
+ period_duration = period == :quarter ? 3.months : 1.send(period)
105
+
106
+ self.length = (duration_in_seconds.to_f / period_duration).ceil
107
+ end
108
+
109
+ # calculate the end time
110
+ def ends_at
111
+ @ends_at ||= begin
112
+ if period == :quarter
113
+ starts_at.advance months: length * 3
114
+ else
115
+ starts_at.advance periods => length
116
+ end
117
+ end
118
+ end
119
+
120
+ # return an array of row objects after evaluating each row's scope in the
121
+ # context of self
122
+ def rows
123
+ @rows ||= _row_args.map.with_index do |row_args, i|
124
+ klass, args = row_args
125
+
126
+ args = args.dup
127
+ options = args.extract_options!
128
+ proc = _row_scopes[i]
129
+ scope = self.instance_eval(&proc)
130
+
131
+ klass.new(*args, scope, options)
132
+ end
133
+ end
134
+
135
+ # return an array of column objects representing each time segment
136
+ def columns
137
+ @columns ||= (0..(length-1)).map do |inc|
138
+ args = period == :quarter ? { months: inc*3 } : { periods => inc }
139
+ Timely::Column.new period, starts_at.advance(args)
140
+ end
141
+ end
142
+
143
+ def cells(row)
144
+ (@cells ||= {})[row.title] ||= columns.map do |col|
145
+ Timely::Cell.new(self, col, row)
146
+ end
147
+ end
148
+
149
+ # return a hash where each row is a key pointing to an array of cells
150
+ def raw
151
+ @cache ||= Hash[ rows.map { |row| [row, cells(row)] } ]
152
+ end
153
+
154
+ # pass in a custom object that responds to `output`
155
+ def to_format(formatter_klass, options={})
156
+ formatter_klass.new(self, options).output
157
+ end
158
+
159
+ # override the cache key to include information about any objects that
160
+ # affect the scopes passed to each row, e.g. a user
161
+ def cache_key
162
+ title.parameterize
163
+ end
164
+
165
+ private
166
+
167
+ # handle `to_#{name}` methods by looking up a formatter class defined as
168
+ # Timely::Formats::#{name.camelcase}
169
+ def method_missing(method, *args, &block)
170
+ if method.to_s =~ /\Ato_(.+)\z/
171
+ to_missing_format $1, args[0]
172
+ else
173
+ super
174
+ end
175
+ end
176
+
177
+ # find the formatter class under Timely::Formats
178
+ def to_missing_format(formatter_name, options={})
179
+ formatter_klass = Timely::Formats.const_get(formatter_name.camelcase)
180
+ to_format formatter_klass, options
181
+ end
182
+
183
+ # :month -> :months, etc.
184
+ def periods
185
+ "#{period}s".to_sym
186
+ end
187
+
188
+ def default_length
189
+ Timely.default_lengths[ period ]
190
+ end
191
+
192
+ def default_starts_at
193
+ if period == :quarter
194
+ (length * 3 - 3).months.ago.send("beginning_of_#{period}")
195
+ elsif period == :hour
196
+ Time.zone.now.change(mins: 0).advance(hours: -(length-1))
197
+ elsif period == :day
198
+ Time.zone.now.change(hours: 0).advance(days: -(length-1))
199
+ else
200
+ (length - 1).send("#{period}s").ago.send("beginning_of_#{period}")
201
+ end
202
+ end
203
+ end
data/lib/timely/row.rb ADDED
@@ -0,0 +1,123 @@
1
+ # encoding: UTF-8
2
+
3
+ class Timely::Row
4
+ ## Defaults ##
5
+
6
+ class_attribute :default_options, instance_writer: false
7
+ self.default_options = {}
8
+
9
+ ## Instance Definition ##
10
+
11
+ attr_accessor :title, :key, :scope, :options
12
+
13
+ # Title: string or symbol for I18n lookup
14
+ # Key: timestamp column name
15
+ # Scope: (internal) Generated by the report
16
+ # Options:
17
+ # cacheable: if true, cache past values in Redis
18
+ # transform: proc to apply to each value (e.g. for rounding), or pass
19
+ # `:round` to apply default rounding (two-decimal precision) or `:to_i`
20
+ # to round to 0 decimals
21
+ def initialize(title, key, scope, options={})
22
+ options.reverse_merge! default_options.reverse_merge(cacheable: true)
23
+
24
+ self.title = title
25
+ self.key = key
26
+ self.scope = scope
27
+ self.options = options
28
+ end
29
+
30
+ def to_s
31
+ "#<#{self.class.name} title: \"#{title}\", key: #{key}>"
32
+ end
33
+
34
+ def title
35
+ @title.is_a?(Symbol) ? I18n.t("timely.rows.#{@title}") : @title
36
+ end
37
+
38
+ def cache_key
39
+ title.parameterize
40
+ end
41
+
42
+ def cacheable?
43
+ !!options[:cacheable]
44
+ end
45
+
46
+ ## Values ##
47
+
48
+ # override value or raw_value in subclasses
49
+ def value(starts_at, ends_at)
50
+ transform raw_value_from date_range_scope(starts_at, ends_at)
51
+ end
52
+
53
+ def total(ends_at)
54
+ transform raw_value_from date_range_scope(nil, ends_at)
55
+ end
56
+
57
+ private
58
+
59
+ # override in subclasses to perform a custom calculation
60
+ def raw_value_from(scope)
61
+ scope.count
62
+ end
63
+
64
+ private
65
+
66
+ ## Helpers ##
67
+
68
+ # helper function for rounding non-integer values for nicer output
69
+ def round(val)
70
+ val ? val.to_f.round(Timely.default_precision) : 0
71
+ end
72
+
73
+ # transform the given value using either one of the default
74
+ # transformations (:round or :to_i) or a custom proc
75
+ def transform(value)
76
+ transform = options[:transform]
77
+
78
+ if transform == :round
79
+ round value
80
+ elsif transform == :to_i
81
+ value.round
82
+ elsif transform
83
+ transform.call(value)
84
+ else
85
+ value
86
+ end
87
+ end
88
+
89
+ ## Scopes ##
90
+
91
+ # only return records between the reporting dates
92
+ def date_range_scope(starts_at, ends_at)
93
+ starts = starts_at && key_is_date? ? starts_at.to_date : starts_at
94
+ ends = ends_at && key_is_date? ? ends_at.to_date : ends_at
95
+
96
+ if starts && ends
97
+ scope.where("#{key_sql} >= ? AND #{key_sql} < ?", starts, ends)
98
+ elsif starts
99
+ scope.where("#{key_sql} >= ?", starts)
100
+ elsif ends
101
+ scope.where("#{key_sql} < ?", ends)
102
+ else
103
+ scope
104
+ end
105
+ end
106
+
107
+ ## Column Names ##
108
+
109
+ # if the column name does not specify the table, add the table name
110
+ def disambiguate_column_name(col)
111
+ col = col.to_s
112
+ col.include?(".") ? col : "#{scope.table_name}.#{col}"
113
+ end
114
+
115
+ def key_sql
116
+ @key_sql ||= disambiguate_column_name key
117
+ end
118
+
119
+ # check if a date column's name ends in _on (vs. _at)
120
+ def key_is_date?
121
+ @key_is_date ||= (key =~ /_on\z/)
122
+ end
123
+ end
@@ -0,0 +1,15 @@
1
+ # encoding: UTF-8
2
+
3
+ class Timely::Rows::Average < Timely::Row
4
+ self.default_options = { transform: :round }
5
+
6
+ private
7
+
8
+ def raw_value_from(scope)
9
+ scope.average column_sql
10
+ end
11
+
12
+ def column_sql
13
+ @column_sql ||= disambiguate_column_name options[:column]
14
+ end
15
+ end
@@ -0,0 +1,23 @@
1
+ # encoding: UTF-8
2
+
3
+ class Timely::Rows::AverageDaysBetween < Timely::Row
4
+ self.default_options = { transform: :round }
5
+
6
+ private
7
+
8
+ def raw_value_from(scope)
9
+ scope.average query
10
+ end
11
+
12
+ def query
13
+ "DATEDIFF(#{to}, #{from})"
14
+ end
15
+
16
+ def from
17
+ @from ||= disambiguate_column_name column[:from]
18
+ end
19
+
20
+ def to
21
+ @to ||= disambiguate_column_name column[:to]
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ # encoding: UTF-8
2
+
3
+ class Timely::Rows::AverageHoursBetween < Timely::Row
4
+ self.default_options = { transform: :round }
5
+
6
+ private
7
+
8
+ def raw_value_from(scope)
9
+ scope.average query
10
+ end
11
+
12
+ def query
13
+ "TIMESTAMPDIFF(MINUTE, #{from}, #{to}) / 60"
14
+ end
15
+
16
+ def from
17
+ @from ||= disambiguate_column_name column[:from]
18
+ end
19
+
20
+ def to
21
+ @to ||= disambiguate_column_name column[:to]
22
+ end
23
+ end
@@ -0,0 +1,49 @@
1
+ # encoding: UTF-8
2
+
3
+ class Timely::Rows::AverageWeekHoursBetween < Timely::Row
4
+ self.default_options = { transform: :round }
5
+
6
+ private
7
+
8
+ def raw_value_from(scope)
9
+ scope.average query
10
+ end
11
+
12
+ # This is equivalent to the following, with substitutions:
13
+ #
14
+ # @old := objects.created_at,
15
+ # @new := objects.completed_at,
16
+ # @oldWeek := WEEK(@old, 2),
17
+ # @newWeek := WEEK(@new, 2),
18
+ # @weekends := @newWeek - @oldWeek,
19
+ # @weekendSecs := @weekends * 172800,
20
+ # @oldWeekSaturday := STR_TO_DATE( CONCAT(DATE_FORMAT(@old, '%X%V'), ' Saturday'), '%X%V %W %h-%i-%s'),
21
+ # @extraFirstWeekSecs := TIME_TO_SEC( TIMEDIFF(@old, @oldWeekSaturday) ),
22
+ # @extraFirstWeekSecs := (@extraFirstWeekSecs > 0) * @extraFirstWeekSecs,
23
+ # @newWeekSaturday := STR_TO_DATE( CONCAT(DATE_FORMAT(@new, '%X%V'), ' Saturday'), '%X%V %W %h-%i-%s'),
24
+ # @extraLastWeekSecs := TIME_TO_SEC( TIMEDIFF(@new, @newWeekSaturday) ),
25
+ # @extraLastWeekSecs := (@extraLastWeekSecs > 0) * @extraLastWeekSecs,
26
+ # @totalSecs := TIME_TO_SEC( TIMEDIFF(@new, @old) ),
27
+ # @totalHours := @totalSecs / 3600 as total_hours,
28
+ # @avg := (@totalSecs - @weekendSecs + @extraFirstWeekSecs + @extraLastWeekSecs) / 3600 as weekday_hours
29
+ #
30
+ # The algorithm is:
31
+ #
32
+ # total time between dates - total time on weekends between dates
33
+ #
34
+ # the total weekend time is adjusted to remove any time before or after
35
+ # the dates themselves (e.g. if the first date is *on* a weekend, then
36
+ # the hours between the weekend beginning and the first date should not
37
+ # be counted)
38
+ def query
39
+ "((TIME_TO_SEC( TIMEDIFF(#{to}, #{from}) ) - ( ( WEEK(#{to}, 2) - ( WEEK(#{from}, 2) - ( ( YEAR(#{to}) - YEAR(#{from}) ) * 52 ) ) ) * 172800 )) + (( TIME_TO_SEC( TIMEDIFF(#{from}, STR_TO_DATE( CONCAT( DATE_FORMAT(#{from}, '%X%V'), ' Saturday'), '%X%V %W %h-%i-%s' ))) > 0) * TIME_TO_SEC( TIMEDIFF(#{from}, STR_TO_DATE( CONCAT(DATE_FORMAT(#{from}, '%X%V'), ' Saturday'), '%X%V %W %h-%i-%s')) )) + ((TIME_TO_SEC( TIMEDIFF(#{to}, STR_TO_DATE( CONCAT(DATE_FORMAT(#{to}, '%X%V'), ' Saturday'), '%X%V %W %h-%i-%s')) ) > 0) * TIME_TO_SEC( TIMEDIFF(#{to}, STR_TO_DATE( CONCAT(DATE_FORMAT(#{to}, '%X%V'), ' Saturday'), '%X%V %W %h-%i-%s')) )) ) / 3600"
40
+ end
41
+
42
+ def from
43
+ @from ||= disambiguate_column_name column[:from]
44
+ end
45
+
46
+ def to
47
+ @to ||= disambiguate_column_name column[:to]
48
+ end
49
+ end
@@ -0,0 +1,9 @@
1
+ # encoding: UTF-8
2
+
3
+ class Timely::Rows::Count < Timely::Row
4
+ private
5
+
6
+ def raw_value_from(scope)
7
+ scope.count
8
+ end
9
+ end
@@ -0,0 +1,14 @@
1
+ # encoding: UTF-8
2
+
3
+ class Timely::Rows::Present < Timely::Row
4
+ private
5
+
6
+ def raw_value_from(scope)
7
+ scope.where(*conditions).count
8
+ end
9
+
10
+ def conditions
11
+ column_sql = disambiguate_column_name options[:column]
12
+ ["#{column_sql} IS NOT NULL AND #{column_sql} != ?", ""]
13
+ end
14
+ end
@@ -0,0 +1,19 @@
1
+ # encoding: UTF-8
2
+
3
+ class Timely::Rows::StandardDeviation < Timely::Row
4
+ self.default_options = { transform: :round }
5
+
6
+ private
7
+
8
+ def raw_value_from(scope)
9
+ scope.select(query).first.sd_val
10
+ end
11
+
12
+ def query
13
+ "STDDEV(#{column}) as sd_val"
14
+ end
15
+
16
+ def column
17
+ @column ||= disambiguate_column_name options[:column]
18
+ end
19
+ end
@@ -0,0 +1,12 @@
1
+ # encoding: UTF-8
2
+
3
+ class Timely::Rows::Sum < Timely::Row
4
+ self.default_options = { transform: :to_i }
5
+
6
+ private
7
+
8
+ def raw_value_from(scope)
9
+ column_sql = disambiguate_column_name options[:column]
10
+ scope.sum column_sql
11
+ end
12
+ end
@@ -0,0 +1,13 @@
1
+ # encoding: UTF-8
2
+
3
+ class Timely::Rows::TotalCount < Timely::Row
4
+ def value(starts_at, ends_at)
5
+ total ends_at
6
+ end
7
+
8
+ private
9
+
10
+ def raw_value_from(scope)
11
+ scope.count
12
+ end
13
+ end
@@ -0,0 +1,15 @@
1
+ # encoding: UTF-8
2
+
3
+ class Timely::Rows::TotalSum < Timely::Row
4
+ self.default_options = { transform: :to_i }
5
+
6
+ def value(starts_at, ends_at)
7
+ total ends_at
8
+ end
9
+
10
+ private
11
+
12
+ def raw_value_from(scope)
13
+ scope.sum disambiguate_column_name(options[:column])
14
+ end
15
+ end
data/lib/timely.rb ADDED
@@ -0,0 +1,111 @@
1
+ require 'active_support/dependencies'
2
+
3
+ module Timely
4
+ autoload :Cell, 'timely/cell'
5
+ autoload :Column, 'timely/column'
6
+ autoload :ConfigurationError, 'timely/configuration_error'
7
+ autoload :Formatter, 'timely/formatter'
8
+ autoload :Report, 'timely/report'
9
+ autoload :Row, 'timely/row'
10
+
11
+ module Formats
12
+ autoload :Excel, 'timely/formats/excel'
13
+ autoload :Hash, 'timely/formats/hash'
14
+ autoload :Json, 'timely/formats/json'
15
+ end
16
+
17
+ module Rows
18
+ autoload :Average, 'timely/rows/average'
19
+ autoload :AverageDaysBetween, 'timely/rows/average_days_between'
20
+ autoload :AverageHoursBetween, 'timely/rows/average_hours_between'
21
+ autoload :AverageWeekHoursBetween, 'timely/rows/average_week_hours_between'
22
+ autoload :Count, 'timely/rows/count'
23
+ autoload :Present, 'timely/rows/present'
24
+ autoload :StandardDeviation, 'timely/rows/standard_deviation'
25
+ autoload :Sum, 'timely/rows/sum'
26
+ autoload :TotalCount, 'timely/rows/total_count'
27
+ autoload :TotalSum, 'timely/rows/total_sum'
28
+ end
29
+
30
+ PERIODS = %w( year quarter month week day hour )
31
+
32
+ # Customize the rounding precision
33
+ mattr_accessor :default_precision
34
+ @@default_precision = 2
35
+
36
+ # Customize how column headings are formatted
37
+ mattr_accessor :date_formats
38
+ @@date_formats = {
39
+ year: "%Y",
40
+ quarter: "%b %Y",
41
+ month: "%b %Y",
42
+ week: "%-1d %b",
43
+ day: "%-1d %b",
44
+ hour: "%-1I %p (%m/%-1d)"
45
+ }
46
+
47
+ # Customize the # of periods shown by default
48
+ mattr_accessor :default_lengths
49
+ @@default_lengths = {
50
+ year: 3,
51
+ quarter: 6,
52
+ month: 6,
53
+ week: 5,
54
+ day: 7,
55
+ hour: 8
56
+ }
57
+
58
+ # Provide a Redis connection for caching. If this is not configured,
59
+ # the default Rails' cache will be used instead.
60
+ mattr_accessor :redis
61
+ @@redis = nil
62
+
63
+ # Define the separator for turning cache keys into strings
64
+ mattr_accessor :cache_separator
65
+ @@cache_separator = ":"
66
+
67
+ # Access the configuration in an initializer like:
68
+ #
69
+ # Timely.setup do |config|
70
+ # config.redis = ...
71
+ # end
72
+ def self.setup
73
+ yield self
74
+ end
75
+
76
+ def self.periods
77
+ PERIODS
78
+ end
79
+
80
+ def self.redis=(server)
81
+ case server
82
+ when String
83
+ if server =~ /redis\:\/\//
84
+ redis = Redis.connect(:url => server, :thread_safe => true)
85
+ else
86
+ server, namespace = server.split('/', 2)
87
+ host, port, db = server.split(':')
88
+ redis = Redis.new(:host => host, :port => port,
89
+ :thread_safe => true, :db => db)
90
+ end
91
+ namespace ||= :resque
92
+
93
+ @redis = Redis::Namespace.new(namespace, :redis => redis)
94
+ when Redis::Namespace
95
+ @redis = server
96
+ else
97
+ @redis = Redis::Namespace.new(:resque, :redis => server)
98
+ end
99
+ end
100
+
101
+ # Returns the current Redis connection. If none has been created, will
102
+ # create a new one.
103
+ def self.redis
104
+ return @redis if @redis
105
+ self.redis = Redis.respond_to?(:connect) ? Redis.connect : "localhost:6379"
106
+ self.redis
107
+ end
108
+
109
+ class Engine < Rails::Engine
110
+ end
111
+ end
metadata ADDED
@@ -0,0 +1,163 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: timely_reports
3
+ version: !ruby/object:Gem::Version
4
+ version: '0.9'
5
+ platform: ruby
6
+ authors:
7
+ - Nick Ragaz
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2013-03-26 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ~>
17
+ - !ruby/object:Gem::Version
18
+ version: 3.2.0
19
+ version_requirements: !ruby/object:Gem::Requirement
20
+ requirements:
21
+ - - ~>
22
+ - !ruby/object:Gem::Version
23
+ version: 3.2.0
24
+ type: :runtime
25
+ prerelease: false
26
+ name: activesupport
27
+ - !ruby/object:Gem::Dependency
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ~>
31
+ - !ruby/object:Gem::Version
32
+ version: 3.2.0
33
+ version_requirements: !ruby/object:Gem::Requirement
34
+ requirements:
35
+ - - ~>
36
+ - !ruby/object:Gem::Version
37
+ version: 3.2.0
38
+ type: :runtime
39
+ prerelease: false
40
+ name: activerecord
41
+ - !ruby/object:Gem::Dependency
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ! '>='
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ version_requirements: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - ! '>='
50
+ - !ruby/object:Gem::Version
51
+ version: '0'
52
+ type: :runtime
53
+ prerelease: false
54
+ name: ruby-ole
55
+ - !ruby/object:Gem::Dependency
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ! '>='
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ version_requirements: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ! '>='
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ type: :runtime
67
+ prerelease: false
68
+ name: spreadsheet
69
+ - !ruby/object:Gem::Dependency
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ~>
73
+ - !ruby/object:Gem::Version
74
+ version: '1.3'
75
+ version_requirements: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ~>
78
+ - !ruby/object:Gem::Version
79
+ version: '1.3'
80
+ type: :development
81
+ prerelease: false
82
+ name: bundler
83
+ - !ruby/object:Gem::Dependency
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ! '>='
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ version_requirements: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ! '>='
92
+ - !ruby/object:Gem::Version
93
+ version: '0'
94
+ type: :development
95
+ prerelease: false
96
+ name: rake
97
+ - !ruby/object:Gem::Dependency
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ! '>='
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ version_requirements: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - ! '>='
106
+ - !ruby/object:Gem::Version
107
+ version: '0'
108
+ type: :development
109
+ prerelease: false
110
+ name: sqlite3
111
+ description: Create reports about periods of time in your database.
112
+ email: nick.ragaz@gmail.com
113
+ executables: []
114
+ extensions: []
115
+ extra_rdoc_files: []
116
+ files:
117
+ - lib/timely/cell.rb
118
+ - lib/timely/column.rb
119
+ - lib/timely/configuration_error.rb
120
+ - lib/timely/formats/excel.rb
121
+ - lib/timely/formats/hash.rb
122
+ - lib/timely/formats/json.rb
123
+ - lib/timely/formatter.rb
124
+ - lib/timely/report.rb
125
+ - lib/timely/row.rb
126
+ - lib/timely/rows/average.rb
127
+ - lib/timely/rows/average_days_between.rb
128
+ - lib/timely/rows/average_hours_between.rb
129
+ - lib/timely/rows/average_week_hours_between.rb
130
+ - lib/timely/rows/count.rb
131
+ - lib/timely/rows/present.rb
132
+ - lib/timely/rows/standard_deviation.rb
133
+ - lib/timely/rows/sum.rb
134
+ - lib/timely/rows/total_count.rb
135
+ - lib/timely/rows/total_sum.rb
136
+ - lib/timely.rb
137
+ - MIT-LICENSE
138
+ - Rakefile
139
+ - README.md
140
+ homepage: http://github.com/nragaz/timely
141
+ licenses: []
142
+ metadata: {}
143
+ post_install_message:
144
+ rdoc_options: []
145
+ require_paths:
146
+ - lib
147
+ required_ruby_version: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - ! '>='
150
+ - !ruby/object:Gem::Version
151
+ version: '0'
152
+ required_rubygems_version: !ruby/object:Gem::Requirement
153
+ requirements:
154
+ - - ! '>='
155
+ - !ruby/object:Gem::Version
156
+ version: '0'
157
+ requirements: []
158
+ rubyforge_project:
159
+ rubygems_version: 2.0.3
160
+ signing_key:
161
+ specification_version: 4
162
+ summary: Create reports about periods of time in your database.
163
+ test_files: []