timely_reports 0.9
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +15 -0
- data/MIT-LICENSE +20 -0
- data/README.md +6 -0
- data/Rakefile +3 -0
- data/lib/timely/cell.rb +82 -0
- data/lib/timely/column.rb +79 -0
- data/lib/timely/configuration_error.rb +1 -0
- data/lib/timely/formats/excel.rb +86 -0
- data/lib/timely/formats/hash.rb +18 -0
- data/lib/timely/formats/json.rb +9 -0
- data/lib/timely/formatter.rb +14 -0
- data/lib/timely/report.rb +203 -0
- data/lib/timely/row.rb +123 -0
- data/lib/timely/rows/average.rb +15 -0
- data/lib/timely/rows/average_days_between.rb +23 -0
- data/lib/timely/rows/average_hours_between.rb +23 -0
- data/lib/timely/rows/average_week_hours_between.rb +49 -0
- data/lib/timely/rows/count.rb +9 -0
- data/lib/timely/rows/present.rb +14 -0
- data/lib/timely/rows/standard_deviation.rb +19 -0
- data/lib/timely/rows/sum.rb +12 -0
- data/lib/timely/rows/total_count.rb +13 -0
- data/lib/timely/rows/total_sum.rb +15 -0
- data/lib/timely.rb +111 -0
- metadata +163 -0
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
data/Rakefile
ADDED
data/lib/timely/cell.rb
ADDED
@@ -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,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,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,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: []
|