rails_onboarding 0.8.0 → 0.8.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1a343a9b6bbf7c81c4fc94b106a78cede4b1b240256dbb4941a4dc872d14c796
4
- data.tar.gz: a93c58d4ef30dfe92b61d3062a3c8815cbb68b5ebc3e1aae970b5f1e497cb942
3
+ metadata.gz: 6a33d58aa73f7c851196a6ed683c0a907eabc60addda69cc841206ef7765201e
4
+ data.tar.gz: 28b37e7d218f6503e256e35292eb71c48a2074bb18f89be4581e417a98365cc0
5
5
  SHA512:
6
- metadata.gz: f59b87619ce223cb9efe7e4f4c6b3445e133b1d926a21c3e387dc1d6772d6e88eb7b03fe0f195e13879aa9a8bf837530d1824e22aaf59d70de0e11f8b8644311
7
- data.tar.gz: 013a28a891c159d8867de01b83998ac5ce3bf91ab1e4c592c96f7b089df95129883f85902395b9d379603217913f841ef2b3f9b515b6ae031432c42f24a2bb19
6
+ metadata.gz: b1e1fa8588b97a8402ae6f68c0c968e88a0e8229fff82f9f1e79249bfec60b067f465369800241f56bcf63543663d979dcc5dbb14eefabd7982af921695edcaa
7
+ data.tar.gz: eb6c36a63a60df9e93e83713022ffa175d6579fba9cd9b73246f1a82f8c1cc7fa165729b8d5f852564d494e246e7e0b099fcd3a6478bf184cb5a7d1e36ec125a
@@ -149,8 +149,8 @@ export default class extends Controller {
149
149
  </div>
150
150
  `
151
151
 
152
- // Position tooltip (top/left are viewport-relative and computed per
153
- // call, so they stay inline; everything else lives in .step-tooltip)
152
+ // Position tooltip (top/left are computed per call, so they stay inline;
153
+ // everything else lives in .step-tooltip)
154
154
  document.body.appendChild(tooltip)
155
155
  this.positionTooltip(tooltip, targetElement)
156
156
 
@@ -179,8 +179,12 @@ export default class extends Controller {
179
179
  top = rect.top - tooltipRect.height - 8
180
180
  }
181
181
 
182
- tooltip.style.top = `${top}px`
183
- tooltip.style.left = `${left}px`
182
+ // The maths above is all in viewport space (getBoundingClientRect,
183
+ // window.innerWidth/Height), but .step-tooltip is `position: absolute`
184
+ // on document.body, so add the scroll offset when applying. See the same
185
+ // conversion in tooltip_controller.js#positionTooltip.
186
+ tooltip.style.top = `${top + window.scrollY}px`
187
+ tooltip.style.left = `${left + window.scrollX}px`
184
188
  }
185
189
 
186
190
  // Advance to next step (for testing/demo purposes)
@@ -784,9 +784,16 @@ export default class extends Controller {
784
784
 
785
785
  let { position, top, left, arrowPosition } = bestPosition
786
786
 
787
- // Apply position
788
- this.tooltip.style.top = `${top}px`
789
- this.tooltip.style.left = `${left}px`
787
+ // Apply position, converting viewport coordinates to document ones.
788
+ // Everything above - getBoundingClientRect, the edge clamping in
789
+ // calculatePosition, the collision checks in scorePosition - works in
790
+ // viewport space, but .onboarding-tooltip is `position: absolute` on
791
+ // document.body, so top/left are resolved against the document. Without
792
+ // the scroll offset the tooltip lands a full scroll position away from
793
+ // its trigger. Absolute (not fixed) is deliberate: it keeps the tooltip
794
+ // glued to the trigger if the page scrolls while it's open.
795
+ this.tooltip.style.top = `${top + viewport.scrollY}px`
796
+ this.tooltip.style.left = `${left + viewport.scrollX}px`
790
797
 
791
798
  // Style arrow based on final position
792
799
  if (arrow) {
@@ -46,18 +46,30 @@ module RailsOnboarding
46
46
  @analytics_error = e.message
47
47
  end
48
48
 
49
+ # Milestones have no table of their own. They are *defined* in the host's
50
+ # initializer (RailsOnboarding.configuration.milestones) and *awarded*
51
+ # onto the user record: `milestones_achieved` holds a serialized array of
52
+ # {"key", "achieved_at"} hashes, with `milestone_points` and
53
+ # `last_milestone_at` beside it.
54
+ #
55
+ # This panel used to query a RailsOnboarding::Milestone model and a
56
+ # rails_onboarding_milestone_achievements join table. Neither has ever
57
+ # existed in the engine, so the `defined?` guard was always false and the
58
+ # whole section returned early - silently, while real achievements piled
59
+ # up on the users table. Guard on the two things actually required
60
+ # instead: milestones being switched on, and a user model that speaks
61
+ # Onboardable.
49
62
  def load_milestone_data
50
- return unless defined?(RailsOnboarding::Milestone)
63
+ return unless RailsOnboarding.configuration.enable_milestones
64
+ return unless user_class.method_defined?(:achieved_milestone_entries)
51
65
 
52
- @total_milestones = RailsOnboarding::Milestone.count
66
+ @total_milestones = RailsOnboarding.configuration.milestones.size
53
67
 
54
- # Use Arel to build safe SQL
55
- users_table = user_class.arel_table
56
- @milestone_achievements = user_class
57
- .joins("LEFT JOIN rails_onboarding_milestone_achievements ON rails_onboarding_milestone_achievements.user_id = #{users_table.name}.id")
58
- .group(users_table[:id])
59
- .count
60
- @top_milestones = top_achieved_milestones
68
+ stats = milestone_achievement_stats
69
+ @milestones_awarded = stats[:awarded]
70
+ @milestone_points_awarded = stats[:points]
71
+ @users_with_milestones = stats[:users]
72
+ @top_milestones = stats[:top]
61
73
  rescue StandardError => e
62
74
  logger.error "Error loading milestone data: #{e.message}"
63
75
  @milestone_error = e.message
@@ -154,16 +166,78 @@ module RailsOnboarding
154
166
  trend
155
167
  end
156
168
 
157
- def top_achieved_milestones
158
- return [] unless defined?(RailsOnboarding::Milestone)
169
+ # Roll every user's achievements up into the numbers the panel shows.
170
+ #
171
+ # `milestones_achieved` is a serialized text column, so this cannot be a
172
+ # GROUP BY - the rows have to be loaded and counted in Ruby. Two things
173
+ # keep that honest: only users who hold at least one achievement are
174
+ # loaded, and only the three columns needed are selected. It is still
175
+ # O(users-with-milestones), which is fine into the tens of thousands and
176
+ # is the price of milestones not having a table. An install that outgrows
177
+ # it should denormalise achievements into their own table rather than
178
+ # paginate this.
179
+ #
180
+ # Achievements are counted within the dashboard's selected date range,
181
+ # like every other time-based figure here. Undated achievements are
182
+ # counted regardless: a legacy string entry on a record with no
183
+ # last_milestone_at is still a real award, and dropping it would repeat
184
+ # in miniature exactly the bug this method replaces.
185
+ def milestone_achievement_stats
186
+ counts = Hash.new(0)
187
+ awarded = 0
188
+ points = 0
189
+ users = 0
190
+
191
+ achievement_holders.find_each do |user|
192
+ keys = user.achieved_milestone_entries.filter_map do |key, achieved_at|
193
+ key if achieved_at.nil? || achieved_at >= @start_date
194
+ end
195
+ next if keys.empty?
196
+
197
+ users += 1
198
+ awarded += keys.size
199
+ keys.each do |key|
200
+ counts[key] += 1
201
+ points += RailsOnboarding.configuration.milestone_by_key(key)&.dig(:points).to_i
202
+ end
203
+ end
204
+
205
+ { awarded: awarded, points: points, users: users, top: top_milestones_from(counts) }
206
+ end
207
+
208
+ # Users holding at least one achievement. The empty cases are the column's
209
+ # default (NULL) and a serialized empty array, which is what
210
+ # reset_onboarding! leaves behind.
211
+ #
212
+ # The empty-string checks go through raw SQL on purpose: `milestones_achieved`
213
+ # is a serialized attribute, so `where.not(milestones_achieved: "[]")` would
214
+ # hand "[]" to the JSON coder and compare against the string '"[]"' instead.
215
+ # A bare nil is safe - the serialized type passes it straight through.
216
+ def achievement_holders
217
+ column = "#{user_class.quoted_table_name}.milestones_achieved"
159
218
 
160
- RailsOnboarding::Milestone
161
- .joins("LEFT JOIN rails_onboarding_milestone_achievements ON rails_onboarding_milestone_achievements.milestone_id = rails_onboarding_milestones.id")
162
- .group("rails_onboarding_milestones.id", "rails_onboarding_milestones.name", "rails_onboarding_milestones.title")
163
- .order("COUNT(rails_onboarding_milestone_achievements.id) DESC")
164
- .limit(5)
165
- .pluck("rails_onboarding_milestones.name", "rails_onboarding_milestones.title", "COUNT(rails_onboarding_milestone_achievements.id)")
166
- .map { |name, title, count| { name: name, title: title, count: count } }
219
+ user_class
220
+ .where.not(milestones_achieved: nil)
221
+ .where("#{column} NOT IN (?, ?)", "", "[]")
222
+ .select(:id, :milestones_achieved, :last_milestone_at)
223
+ end
224
+
225
+ # The five most-awarded milestones, resolved against the configuration for
226
+ # their display copy. A key with no matching configuration entry is one the
227
+ # host has since renamed or removed; show it under its raw key rather than
228
+ # dropping it, so the leftover data is visible and can be cleaned up.
229
+ def top_milestones_from(counts)
230
+ counts.sort_by { |key, count| [ -count, key ] }.first(5).map do |key, count|
231
+ milestone = RailsOnboarding.configuration.milestone_by_key(key)
232
+
233
+ {
234
+ name: key,
235
+ title: milestone&.dig(:title) || key.to_s.humanize,
236
+ icon: milestone&.dig(:icon),
237
+ configured: !milestone.nil?,
238
+ count: count
239
+ }
240
+ end
167
241
  end
168
242
 
169
243
  def date_range_start(range)
@@ -307,6 +307,30 @@ module RailsOnboarding
307
307
  milestones.map { |m| m.is_a?(Hash) ? m["key"] : m.to_s }
308
308
  end
309
309
 
310
+ # Every achievement as a [key, achieved_at] pair, in the order it was
311
+ # awarded.
312
+ #
313
+ # #achieved_milestones gives the keys and #milestone_achieved_at dates one
314
+ # key at a time; anything that needs both at once (the admin dashboard
315
+ # counting achievements per period, for one) would otherwise re-scan the
316
+ # array once per key and re-implement the two stored formats for itself.
317
+ #
318
+ # +achieved_at+ is nil only for a legacy string entry on a record with no
319
+ # last_milestone_at to fall back on - the achievement is real, its date
320
+ # simply was never stored. Callers filtering by date should decide
321
+ # deliberately what to do with those rather than dropping them by accident.
322
+ #
323
+ # @return [Array<Array(String, Time, nil)>]
324
+ def achieved_milestone_entries
325
+ (milestones_achieved || []).map do |entry|
326
+ if entry.is_a?(Hash)
327
+ [ entry["key"].to_s, parse_achieved_at(entry["achieved_at"]) ]
328
+ else
329
+ [ entry.to_s, last_milestone_at ]
330
+ end
331
+ end
332
+ end
333
+
310
334
  def milestone_achieved?(milestone_key)
311
335
  achieved_milestones.include?(milestone_key.to_s)
312
336
  end
@@ -654,6 +678,16 @@ module RailsOnboarding
654
678
 
655
679
  private
656
680
 
681
+ # A stored achieved_at, or last_milestone_at when it is missing or
682
+ # unparseable - the same fallback #milestone_achieved_at applies.
683
+ def parse_achieved_at(value)
684
+ return last_milestone_at if value.blank?
685
+
686
+ Time.parse(value.to_s)
687
+ rescue StandardError
688
+ last_milestone_at
689
+ end
690
+
657
691
  # Persists +attributes+ (or just re-saves already-assigned attributes if
658
692
  # none given), then runs the tracking block only after that succeeds.
659
693
  # Analytics tracking (and, transitively, any milestone-achievement it
@@ -126,19 +126,43 @@
126
126
  <!-- Bottom Row -->
127
127
  <div class="admin-bottom-grid">
128
128
  <!-- Milestone Stats -->
129
- <% if defined?(RailsOnboarding::Milestone) %>
129
+ <%# @total_milestones is assigned only when milestones are enabled and the
130
+ user model speaks Onboardable, so it doubles as the render guard. %>
131
+ <% if @total_milestones %>
130
132
  <div class="admin-card">
131
133
  <div class="admin-card-header">
132
- <h3 class="admin-card-title">Top Milestones</h3>
134
+ <h3 class="admin-card-title">Milestones</h3>
135
+ <span class="admin-card-action"><%= pluralize(@total_milestones, "configured") %></span>
133
136
  </div>
134
137
  <div class="admin-card-body">
135
- <% if @top_milestones && @top_milestones.any? %>
138
+ <div class="admin-stats-grid admin-stats-compact">
139
+ <div class="admin-stat-card">
140
+ <div class="admin-stat-content">
141
+ <div class="admin-stat-label">Awarded</div>
142
+ <div class="admin-stat-value"><%= number_with_delimiter(@milestones_awarded) %></div>
143
+ <div class="admin-stat-change">to <%= pluralize(@users_with_milestones, "user") %></div>
144
+ </div>
145
+ </div>
146
+ <div class="admin-stat-card">
147
+ <div class="admin-stat-content">
148
+ <div class="admin-stat-label">Points</div>
149
+ <div class="admin-stat-value"><%= number_with_delimiter(@milestone_points_awarded) %></div>
150
+ <div class="admin-stat-change">in selected period</div>
151
+ </div>
152
+ </div>
153
+ </div>
154
+
155
+ <% if @top_milestones&.any? %>
136
156
  <div class="admin-list">
137
157
  <% @top_milestones.each do |milestone| %>
138
158
  <div class="admin-list-item">
139
159
  <div class="admin-list-content">
140
- <div class="admin-list-title"><%= milestone[:title] %></div>
141
- <div class="admin-list-meta"><%= milestone[:name] %></div>
160
+ <div class="admin-list-title">
161
+ <% if milestone[:icon].present? %><span aria-hidden="true"><%= milestone[:icon] %></span> <% end %><%= milestone[:title] %>
162
+ </div>
163
+ <div class="admin-list-meta">
164
+ <%= milestone[:name] %><% unless milestone[:configured] %> — no longer configured<% end %>
165
+ </div>
142
166
  </div>
143
167
  <div class="admin-list-value">
144
168
  <span class="admin-badge"><%= number_with_delimiter(milestone[:count]) %> achieved</span>
@@ -147,7 +171,7 @@
147
171
  <% end %>
148
172
  </div>
149
173
  <% else %>
150
- <p class="admin-empty-state">No milestone data available</p>
174
+ <p class="admin-empty-state">No milestones achieved in this period</p>
151
175
  <% end %>
152
176
  </div>
153
177
  </div>
@@ -1,3 +1,3 @@
1
1
  module RailsOnboarding
2
- VERSION = "0.8.0"
2
+ VERSION = "0.8.2"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_onboarding
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.8.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Lewis
@@ -282,7 +282,7 @@ licenses:
282
282
  metadata:
283
283
  allowed_push_host: https://rubygems.org
284
284
  homepage_uri: https://github.com/bunnahabhain/rails_onboarding
285
- source_code_uri: https://github.com/bunnahabhain/rails_onboarding/tree/v0.8.0
285
+ source_code_uri: https://github.com/bunnahabhain/rails_onboarding/tree/v0.8.2
286
286
  changelog_uri: https://github.com/bunnahabhain/rails_onboarding/blob/master/docs/CHANGELOG.md
287
287
  rdoc_options: []
288
288
  require_paths: