ab-split 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (93) hide show
  1. checksums.yaml +7 -0
  2. data/.codeclimate.yml +30 -0
  3. data/.csslintrc +2 -0
  4. data/.eslintignore +1 -0
  5. data/.eslintrc +213 -0
  6. data/.github/FUNDING.yml +1 -0
  7. data/.github/ISSUE_TEMPLATE/bug_report.md +24 -0
  8. data/.rspec +1 -0
  9. data/.rubocop.yml +7 -0
  10. data/.rubocop_todo.yml +679 -0
  11. data/.travis.yml +60 -0
  12. data/Appraisals +19 -0
  13. data/CHANGELOG.md +696 -0
  14. data/CODE_OF_CONDUCT.md +74 -0
  15. data/CONTRIBUTING.md +62 -0
  16. data/Gemfile +7 -0
  17. data/LICENSE +22 -0
  18. data/README.md +955 -0
  19. data/Rakefile +9 -0
  20. data/ab-split.gemspec +44 -0
  21. data/gemfiles/4.2.gemfile +9 -0
  22. data/gemfiles/5.0.gemfile +9 -0
  23. data/gemfiles/5.1.gemfile +9 -0
  24. data/gemfiles/5.2.gemfile +9 -0
  25. data/gemfiles/6.0.gemfile +9 -0
  26. data/lib/split.rb +76 -0
  27. data/lib/split/algorithms/block_randomization.rb +23 -0
  28. data/lib/split/algorithms/weighted_sample.rb +18 -0
  29. data/lib/split/algorithms/whiplash.rb +38 -0
  30. data/lib/split/alternative.rb +191 -0
  31. data/lib/split/combined_experiments_helper.rb +37 -0
  32. data/lib/split/configuration.rb +255 -0
  33. data/lib/split/dashboard.rb +74 -0
  34. data/lib/split/dashboard/helpers.rb +45 -0
  35. data/lib/split/dashboard/pagination_helpers.rb +86 -0
  36. data/lib/split/dashboard/paginator.rb +16 -0
  37. data/lib/split/dashboard/public/dashboard-filtering.js +43 -0
  38. data/lib/split/dashboard/public/dashboard.js +24 -0
  39. data/lib/split/dashboard/public/jquery-1.11.1.min.js +4 -0
  40. data/lib/split/dashboard/public/reset.css +48 -0
  41. data/lib/split/dashboard/public/style.css +328 -0
  42. data/lib/split/dashboard/views/_controls.erb +18 -0
  43. data/lib/split/dashboard/views/_experiment.erb +155 -0
  44. data/lib/split/dashboard/views/_experiment_with_goal_header.erb +8 -0
  45. data/lib/split/dashboard/views/index.erb +26 -0
  46. data/lib/split/dashboard/views/layout.erb +27 -0
  47. data/lib/split/encapsulated_helper.rb +42 -0
  48. data/lib/split/engine.rb +15 -0
  49. data/lib/split/exceptions.rb +6 -0
  50. data/lib/split/experiment.rb +486 -0
  51. data/lib/split/experiment_catalog.rb +51 -0
  52. data/lib/split/extensions/string.rb +16 -0
  53. data/lib/split/goals_collection.rb +45 -0
  54. data/lib/split/helper.rb +165 -0
  55. data/lib/split/metric.rb +101 -0
  56. data/lib/split/persistence.rb +28 -0
  57. data/lib/split/persistence/cookie_adapter.rb +94 -0
  58. data/lib/split/persistence/dual_adapter.rb +85 -0
  59. data/lib/split/persistence/redis_adapter.rb +57 -0
  60. data/lib/split/persistence/session_adapter.rb +29 -0
  61. data/lib/split/redis_interface.rb +50 -0
  62. data/lib/split/trial.rb +117 -0
  63. data/lib/split/user.rb +69 -0
  64. data/lib/split/version.rb +7 -0
  65. data/lib/split/zscore.rb +57 -0
  66. data/spec/algorithms/block_randomization_spec.rb +32 -0
  67. data/spec/algorithms/weighted_sample_spec.rb +19 -0
  68. data/spec/algorithms/whiplash_spec.rb +24 -0
  69. data/spec/alternative_spec.rb +320 -0
  70. data/spec/combined_experiments_helper_spec.rb +57 -0
  71. data/spec/configuration_spec.rb +258 -0
  72. data/spec/dashboard/pagination_helpers_spec.rb +200 -0
  73. data/spec/dashboard/paginator_spec.rb +37 -0
  74. data/spec/dashboard_helpers_spec.rb +42 -0
  75. data/spec/dashboard_spec.rb +210 -0
  76. data/spec/encapsulated_helper_spec.rb +52 -0
  77. data/spec/experiment_catalog_spec.rb +53 -0
  78. data/spec/experiment_spec.rb +533 -0
  79. data/spec/goals_collection_spec.rb +80 -0
  80. data/spec/helper_spec.rb +1111 -0
  81. data/spec/metric_spec.rb +31 -0
  82. data/spec/persistence/cookie_adapter_spec.rb +106 -0
  83. data/spec/persistence/dual_adapter_spec.rb +194 -0
  84. data/spec/persistence/redis_adapter_spec.rb +90 -0
  85. data/spec/persistence/session_adapter_spec.rb +32 -0
  86. data/spec/persistence_spec.rb +34 -0
  87. data/spec/redis_interface_spec.rb +111 -0
  88. data/spec/spec_helper.rb +52 -0
  89. data/spec/split_spec.rb +43 -0
  90. data/spec/support/cookies_mock.rb +20 -0
  91. data/spec/trial_spec.rb +299 -0
  92. data/spec/user_spec.rb +87 -0
  93. metadata +322 -0
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+ module Split
3
+ module CombinedExperimentsHelper
4
+ def ab_combined_test(metric_descriptor, control = nil, *alternatives)
5
+ return nil unless experiment = find_combined_experiment(metric_descriptor)
6
+ raise(Split::InvalidExperimentsFormatError, "Unable to find experiment #{metric_descriptor} in configuration") if experiment[:combined_experiments].nil?
7
+
8
+ alternative = nil
9
+ weighted_alternatives = nil
10
+ experiment[:combined_experiments].each do |combined_experiment|
11
+ if alternative.nil?
12
+ if control
13
+ alternative = ab_test(combined_experiment, control, alternatives)
14
+ else
15
+ normalized_alternatives = Split::Configuration.new.normalize_alternatives(experiment[:alternatives])
16
+ alternative = ab_test(combined_experiment, normalized_alternatives[0], *normalized_alternatives[1])
17
+ end
18
+ else
19
+ weighted_alternatives ||= experiment[:alternatives].each_with_object({}) do |alt, memo|
20
+ alt = Alternative.new(alt, experiment[:name]).name
21
+ memo[alt] = (alt == alternative ? 1 : 0)
22
+ end
23
+
24
+ ab_test(combined_experiment, [weighted_alternatives])
25
+ end
26
+ end
27
+ alternative
28
+ end
29
+
30
+ def find_combined_experiment(metric_descriptor)
31
+ raise(Split::InvalidExperimentsFormatError, 'Invalid descriptor class (String or Symbol required)') unless metric_descriptor.class == String || metric_descriptor.class == Symbol
32
+ raise(Split::InvalidExperimentsFormatError, 'Enable configuration') unless Split.configuration.enabled
33
+ raise(Split::InvalidExperimentsFormatError, 'Enable `allow_multiple_experiments`') unless Split.configuration.allow_multiple_experiments
34
+ Split::configuration.experiments[metric_descriptor.to_sym]
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+ module Split
3
+ class Configuration
4
+ attr_accessor :ignore_ip_addresses
5
+ attr_accessor :ignore_filter
6
+ attr_accessor :db_failover
7
+ attr_accessor :db_failover_on_db_error
8
+ attr_accessor :db_failover_allow_parameter_override
9
+ attr_accessor :allow_multiple_experiments
10
+ attr_accessor :enabled
11
+ attr_accessor :persistence
12
+ attr_accessor :persistence_cookie_length
13
+ attr_accessor :algorithm
14
+ attr_accessor :store_override
15
+ attr_accessor :start_manually
16
+ attr_accessor :reset_manually
17
+ attr_accessor :on_trial
18
+ attr_accessor :on_trial_choose
19
+ attr_accessor :on_trial_complete
20
+ attr_accessor :on_experiment_reset
21
+ attr_accessor :on_experiment_delete
22
+ attr_accessor :on_before_experiment_reset
23
+ attr_accessor :on_before_experiment_delete
24
+ attr_accessor :include_rails_helper
25
+ attr_accessor :beta_probability_simulations
26
+ attr_accessor :winning_alternative_recalculation_interval
27
+ attr_accessor :redis
28
+ attr_accessor :dashboard_pagination_default_per_page
29
+
30
+ attr_reader :experiments
31
+
32
+ attr_writer :bots
33
+ attr_writer :robot_regex
34
+
35
+ def bots
36
+ @bots ||= {
37
+ # Indexers
38
+ 'AdsBot-Google' => 'Google Adwords',
39
+ 'Baidu' => 'Chinese search engine',
40
+ 'Baiduspider' => 'Chinese search engine',
41
+ 'bingbot' => 'Microsoft bing bot',
42
+ 'Butterfly' => 'Topsy Labs',
43
+ 'Gigabot' => 'Gigabot spider',
44
+ 'Googlebot' => 'Google spider',
45
+ 'MJ12bot' => 'Majestic-12 spider',
46
+ 'msnbot' => 'Microsoft bot',
47
+ 'rogerbot' => 'SeoMoz spider',
48
+ 'PaperLiBot' => 'PaperLi is another content curation service',
49
+ 'Slurp' => 'Yahoo spider',
50
+ 'Sogou' => 'Chinese search engine',
51
+ 'spider' => 'generic web spider',
52
+ 'UnwindFetchor' => 'Gnip crawler',
53
+ 'WordPress' => 'WordPress spider',
54
+ 'YandexAccessibilityBot' => 'Yandex accessibility spider',
55
+ 'YandexBot' => 'Yandex spider',
56
+ 'YandexMobileBot' => 'Yandex mobile spider',
57
+ 'ZIBB' => 'ZIBB spider',
58
+
59
+ # HTTP libraries
60
+ 'Apache-HttpClient' => 'Java http library',
61
+ 'AppEngine-Google' => 'Google App Engine',
62
+ 'curl' => 'curl unix CLI http client',
63
+ 'ColdFusion' => 'ColdFusion http library',
64
+ 'EventMachine HttpClient' => 'Ruby http library',
65
+ 'Go http package' => 'Go http library',
66
+ 'Go-http-client' => 'Go http library',
67
+ 'Java' => 'Generic Java http library',
68
+ 'libwww-perl' => 'Perl client-server library loved by script kids',
69
+ 'lwp-trivial' => 'Another Perl library loved by script kids',
70
+ 'Python-urllib' => 'Python http library',
71
+ 'PycURL' => 'Python http library',
72
+ 'Test Certificate Info' => 'C http library?',
73
+ 'Typhoeus' => 'Ruby http library',
74
+ 'Wget' => 'wget unix CLI http client',
75
+
76
+ # URL expanders / previewers
77
+ 'awe.sm' => 'Awe.sm URL expander',
78
+ 'bitlybot' => 'bit.ly bot',
79
+ 'bot@linkfluence.net' => 'Linkfluence bot',
80
+ 'facebookexternalhit' => 'facebook bot',
81
+ 'Facebot' => 'Facebook crawler',
82
+ 'Feedfetcher-Google' => 'Google Feedfetcher',
83
+ 'https://developers.google.com/+/web/snippet' => 'Google+ Snippet Fetcher',
84
+ 'LinkedInBot' => 'LinkedIn bot',
85
+ 'LongURL' => 'URL expander service',
86
+ 'NING' => 'NING - Yet Another Twitter Swarmer',
87
+ 'Pinterest' => 'Pinterest Bot',
88
+ 'redditbot' => 'Reddit Bot',
89
+ 'ShortLinkTranslate' => 'Link shortener',
90
+ 'Slackbot' => 'Slackbot link expander',
91
+ 'TweetmemeBot' => 'TweetMeMe Crawler',
92
+ 'Twitterbot' => 'Twitter URL expander',
93
+ 'UnwindFetch' => 'Gnip URL expander',
94
+ 'vkShare' => 'VKontake Sharer',
95
+
96
+ # Uptime monitoring
97
+ 'check_http' => 'Nagios monitor',
98
+ 'GoogleStackdriverMonitoring' => 'Google Cloud monitor',
99
+ 'NewRelicPinger' => 'NewRelic monitor',
100
+ 'Panopta' => 'Monitoring service',
101
+ 'Pingdom' => 'Pingdom monitoring',
102
+ 'SiteUptime' => 'Site monitoring services',
103
+ 'UptimeRobot' => 'Monitoring service',
104
+
105
+ # ???
106
+ 'DigitalPersona Fingerprint Software' => 'HP Fingerprint scanner',
107
+ 'ShowyouBot' => 'Showyou iOS app spider',
108
+ 'ZyBorg' => 'Zyborg? Hmmm....',
109
+ 'ELB-HealthChecker' => 'ELB Health Check'
110
+ }
111
+ end
112
+
113
+ def experiments= experiments
114
+ raise InvalidExperimentsFormatError.new('Experiments must be a Hash') unless experiments.respond_to?(:keys)
115
+ @experiments = experiments
116
+ end
117
+
118
+ def disabled?
119
+ !enabled
120
+ end
121
+
122
+ def experiment_for(name)
123
+ if normalized_experiments
124
+ # TODO symbols
125
+ normalized_experiments[name.to_sym]
126
+ end
127
+ end
128
+
129
+ def metrics
130
+ return @metrics if defined?(@metrics)
131
+ @metrics = {}
132
+ if self.experiments
133
+ self.experiments.each do |key, value|
134
+ metrics = value_for(value, :metric) rescue nil
135
+ Array(metrics).each do |metric_name|
136
+ if metric_name
137
+ @metrics[metric_name.to_sym] ||= []
138
+ @metrics[metric_name.to_sym] << Split::Experiment.new(key)
139
+ end
140
+ end
141
+ end
142
+ end
143
+ @metrics
144
+ end
145
+
146
+ def normalized_experiments
147
+ return nil if @experiments.nil?
148
+
149
+ experiment_config = {}
150
+ @experiments.keys.each do |name|
151
+ experiment_config[name.to_sym] = {}
152
+ end
153
+
154
+ @experiments.each do |experiment_name, settings|
155
+ alternatives = if (alts = value_for(settings, :alternatives))
156
+ normalize_alternatives(alts)
157
+ end
158
+
159
+ experiment_data = {
160
+ alternatives: alternatives,
161
+ goals: value_for(settings, :goals),
162
+ metadata: value_for(settings, :metadata),
163
+ algorithm: value_for(settings, :algorithm),
164
+ resettable: value_for(settings, :resettable)
165
+ }
166
+
167
+ experiment_data.each do |name, value|
168
+ experiment_config[experiment_name.to_sym][name] = value if value != nil
169
+ end
170
+ end
171
+
172
+ experiment_config
173
+ end
174
+
175
+ def normalize_alternatives(alternatives)
176
+ given_probability, num_with_probability = alternatives.inject([0,0]) do |a,v|
177
+ p, n = a
178
+ if percent = value_for(v, :percent)
179
+ [p + percent, n + 1]
180
+ else
181
+ a
182
+ end
183
+ end
184
+
185
+ num_without_probability = alternatives.length - num_with_probability
186
+ unassigned_probability = ((100.0 - given_probability) / num_without_probability / 100.0)
187
+
188
+ if num_with_probability.nonzero?
189
+ alternatives = alternatives.map do |v|
190
+ if (name = value_for(v, :name)) && (percent = value_for(v, :percent))
191
+ { name => percent / 100.0 }
192
+ elsif name = value_for(v, :name)
193
+ { name => unassigned_probability }
194
+ else
195
+ { v => unassigned_probability }
196
+ end
197
+ end
198
+
199
+ [alternatives.shift, alternatives]
200
+ else
201
+ alternatives = alternatives.dup
202
+ [alternatives.shift, alternatives]
203
+ end
204
+ end
205
+
206
+ def robot_regex
207
+ @robot_regex ||= /\b(?:#{escaped_bots.join('|')})\b|\A\W*\z/i
208
+ end
209
+
210
+ def initialize
211
+ @ignore_ip_addresses = []
212
+ @ignore_filter = proc{ |request| is_robot? || is_ignored_ip_address? }
213
+ @db_failover = false
214
+ @db_failover_on_db_error = proc{|error|} # e.g. use Rails logger here
215
+ @on_experiment_reset = proc{|experiment|}
216
+ @on_experiment_delete = proc{|experiment|}
217
+ @on_before_experiment_reset = proc{|experiment|}
218
+ @on_before_experiment_delete = proc{|experiment|}
219
+ @db_failover_allow_parameter_override = false
220
+ @allow_multiple_experiments = false
221
+ @enabled = true
222
+ @experiments = {}
223
+ @persistence = Split::Persistence::SessionAdapter
224
+ @persistence_cookie_length = 31536000 # One year from now
225
+ @algorithm = Split::Algorithms::WeightedSample
226
+ @include_rails_helper = true
227
+ @beta_probability_simulations = 10000
228
+ @winning_alternative_recalculation_interval = 60 * 60 * 24 # 1 day
229
+ @redis = ENV.fetch(ENV.fetch('REDIS_PROVIDER', 'REDIS_URL'), 'redis://localhost:6379')
230
+ @dashboard_pagination_default_per_page = 10
231
+ end
232
+
233
+ def redis_url=(value)
234
+ warn '[DEPRECATED] `redis_url=` is deprecated in favor of `redis=`'
235
+ self.redis = value
236
+ end
237
+
238
+ def redis_url
239
+ warn '[DEPRECATED] `redis_url` is deprecated in favor of `redis`'
240
+ self.redis
241
+ end
242
+
243
+ private
244
+
245
+ def value_for(hash, key)
246
+ if hash.kind_of?(Hash)
247
+ hash.has_key?(key.to_s) ? hash[key.to_s] : hash[key.to_sym]
248
+ end
249
+ end
250
+
251
+ def escaped_bots
252
+ bots.map { |key, _| Regexp.escape(key) }
253
+ end
254
+ end
255
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+ require 'sinatra/base'
3
+ require 'split'
4
+ require 'bigdecimal'
5
+ require 'split/dashboard/helpers'
6
+ require 'split/dashboard/pagination_helpers'
7
+
8
+ module Split
9
+ class Dashboard < Sinatra::Base
10
+ dir = File.dirname(File.expand_path(__FILE__))
11
+
12
+ set :views, "#{dir}/dashboard/views"
13
+ set :public_folder, "#{dir}/dashboard/public"
14
+ set :static, true
15
+ set :method_override, true
16
+
17
+ helpers Split::DashboardHelpers
18
+ helpers Split::DashboardPaginationHelpers
19
+
20
+ get '/' do
21
+ # Display experiments without a winner at the top of the dashboard
22
+ @experiments = Split::ExperimentCatalog.all_active_first
23
+
24
+ @metrics = Split::Metric.all
25
+
26
+ # Display Rails Environment mode (or Rack version if not using Rails)
27
+ if Object.const_defined?('Rails')
28
+ @current_env = Rails.env.titlecase
29
+ else
30
+ @current_env = "Rack: #{Rack.version}"
31
+ end
32
+ erb :index
33
+ end
34
+
35
+ post '/force_alternative' do
36
+ experiment = Split::ExperimentCatalog.find(params[:experiment])
37
+ alternative = Split::Alternative.new(params[:alternative], experiment.name)
38
+ alternative.increment_participation
39
+ Split::User.new(self)[experiment.key] = alternative.name
40
+ redirect url('/')
41
+ end
42
+
43
+ post '/experiment' do
44
+ @experiment = Split::ExperimentCatalog.find(params[:experiment])
45
+ @alternative = Split::Alternative.new(params[:alternative], params[:experiment])
46
+ @experiment.winner = @alternative.name
47
+ redirect url('/')
48
+ end
49
+
50
+ post '/start' do
51
+ @experiment = Split::ExperimentCatalog.find(params[:experiment])
52
+ @experiment.start
53
+ redirect url('/')
54
+ end
55
+
56
+ post '/reset' do
57
+ @experiment = Split::ExperimentCatalog.find(params[:experiment])
58
+ @experiment.reset
59
+ redirect url('/')
60
+ end
61
+
62
+ post '/reopen' do
63
+ @experiment = Split::ExperimentCatalog.find(params[:experiment])
64
+ @experiment.reset_winner
65
+ redirect url('/')
66
+ end
67
+
68
+ delete '/experiment' do
69
+ @experiment = Split::ExperimentCatalog.find(params[:experiment])
70
+ @experiment.delete
71
+ redirect url('/')
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+ module Split
3
+ module DashboardHelpers
4
+ def h(text)
5
+ Rack::Utils.escape_html(text)
6
+ end
7
+
8
+ def url(*path_parts)
9
+ [ path_prefix, path_parts ].join("/").squeeze('/')
10
+ end
11
+
12
+ def path_prefix
13
+ request.env['SCRIPT_NAME']
14
+ end
15
+
16
+ def number_to_percentage(number, precision = 2)
17
+ round(number * 100)
18
+ end
19
+
20
+ def round(number, precision = 2)
21
+ begin
22
+ BigDecimal(number.to_s)
23
+ rescue ArgumentError
24
+ BigDecimal(0)
25
+ end.round(precision).to_f
26
+ end
27
+
28
+ def confidence_level(z_score)
29
+ return z_score if z_score.is_a? String
30
+
31
+ z = round(z_score.to_s.to_f, 3).abs
32
+
33
+ if z >= 2.58
34
+ '99% confidence'
35
+ elsif z >= 1.96
36
+ '95% confidence'
37
+ elsif z >= 1.65
38
+ '90% confidence'
39
+ else
40
+ 'Insufficient confidence'
41
+ end
42
+
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+ require 'split/dashboard/paginator'
3
+
4
+ module Split
5
+ module DashboardPaginationHelpers
6
+ def pagination_per
7
+ default_per_page = Split.configuration.dashboard_pagination_default_per_page
8
+ @pagination_per ||= (params[:per] || default_per_page).to_i
9
+ end
10
+
11
+ def page_number
12
+ @page_number ||= (params[:page] || 1).to_i
13
+ end
14
+
15
+ def paginated(collection)
16
+ Split::DashboardPaginator.new(collection, page_number, pagination_per).paginate
17
+ end
18
+
19
+ def pagination(collection)
20
+ html = []
21
+ html << first_page_tag if show_first_page_tag?
22
+ html << ellipsis_tag if show_first_ellipsis_tag?
23
+ html << prev_page_tag if show_prev_page_tag?
24
+ html << current_page_tag
25
+ html << next_page_tag if show_next_page_tag?(collection)
26
+ html << ellipsis_tag if show_last_ellipsis_tag?(collection)
27
+ html << last_page_tag(collection) if show_last_page_tag?(collection)
28
+ html.join
29
+ end
30
+
31
+ private
32
+
33
+ def show_first_page_tag?
34
+ page_number > 2
35
+ end
36
+
37
+ def first_page_tag
38
+ %Q(<a href="#{url.chop}?page=1&per=#{pagination_per}">1</a>)
39
+ end
40
+
41
+ def show_first_ellipsis_tag?
42
+ page_number >= 4
43
+ end
44
+
45
+ def ellipsis_tag
46
+ '<span>...</span>'
47
+ end
48
+
49
+ def show_prev_page_tag?
50
+ page_number > 1
51
+ end
52
+
53
+ def prev_page_tag
54
+ %Q(<a href="#{url.chop}?page=#{page_number - 1}&per=#{pagination_per}">#{page_number - 1}</a>)
55
+ end
56
+
57
+ def current_page_tag
58
+ "<span><b>#{page_number}</b></span>"
59
+ end
60
+
61
+ def show_next_page_tag?(collection)
62
+ (page_number * pagination_per) < collection.count
63
+ end
64
+
65
+ def next_page_tag
66
+ %Q(<a href="#{url.chop}?page=#{page_number + 1}&per=#{pagination_per}">#{page_number + 1}</a>)
67
+ end
68
+
69
+ def show_last_ellipsis_tag?(collection)
70
+ (total_pages(collection) - page_number) >= 3
71
+ end
72
+
73
+ def total_pages(collection)
74
+ collection.count / pagination_per + ((collection.count % pagination_per).zero? ? 0 : 1)
75
+ end
76
+
77
+ def show_last_page_tag?(collection)
78
+ page_number < (total_pages(collection) - 1)
79
+ end
80
+
81
+ def last_page_tag(collection)
82
+ total = total_pages(collection)
83
+ %Q(<a href="#{url.chop}?page=#{total}&per=#{pagination_per}">#{total}</a>)
84
+ end
85
+ end
86
+ end