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 +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: []
|