timely_reports 0.9

Sign up to get free protection for your applications and to get access to all the features.
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: []