ab-split 1.0.0

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.
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