bible270 1.4.0 → 1.4.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: 8975632aa3e90165634356f25fc0c8cff2909e84b3177a58e192b16ee325b745
4
- data.tar.gz: 543aa029054018c26f87d2d1836302ab671de51b70caa7a531ed0011a405f5e6
3
+ metadata.gz: 8792007d09956f4b53cbe9842007baea209a5eb0010758ef35e7d6f1977928f0
4
+ data.tar.gz: 0c5d16299136af62a6e1466210e962ef6e8d48065d759d9b5b57450215471c31
5
5
  SHA512:
6
- metadata.gz: c76e854eb6449addef5b1deb2066d70cc6c51e8650817efa7c4fef8efa0d847d0b1e85c75d9faa91dde497495a01c980c674342148326f8c43f146a3321175d4
7
- data.tar.gz: 80761d784e504c167954827dbe3e3e8193e571e91eee67b8bd9aa12b7990b9845020f20399c3367f63b41d441107fa15748e6e4199d327ce2588d1976a6227ca
6
+ metadata.gz: 89cbba3ff6adfc107efacddb9b2d80903452488384983b488ecf733aa6e16d55451a0137626cda5a446591b2c28585201f49b612927a6ba80ad0b54a5889c377
7
+ data.tar.gz: d2d501c3f9935405bf0b850124f2329b3c5355ccfdf5d7403688329a65f1bfdc4a779fc3d65d957eba242363393e6a057519ef509b337986f6426bb2a3c6ffdc
data/CHANGELOG.md CHANGED
@@ -6,15 +6,50 @@ All notable changes to bible270. Format follows [Keep a Changelog](https://keepa
6
6
 
7
7
  ### Added
8
8
 
9
+ - let administrators disable the day-completion dove animation for individual readers
10
+
11
+ ### Changed
12
+
13
+ - simplify day completion to one continuous server-confirmed dove flight from the lower center, eliminating the stagger between a pre-submit animation and the Turbo or HTML response
14
+ - keep the dove bright in dark mode, grow it to an 80%-of-viewport fit, and finish with a quick fade instead of veering offscreen
15
+
16
+ ### Fixed
17
+
18
+ - show the dove only once for each persisted day-completion event, even when Turbo replays a response or retries a stale-session request
19
+
20
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.4.0...main)**
21
+
22
+ ## [1.4.0] - 2026-09-02
23
+
24
+ ### Added
25
+
9
26
  - mark a newly completed day with a brief, translucent dove animation that begins immediately on the final checkoff in JavaScript-capable browsers and remains available after Turbo or HTML-fallback responses; it respects reduced-motion preferences, replaces the final generic success message, and never blocks interaction
10
27
 
28
+ ### Changed
29
+
30
+ - refine the completion animation to 1.6 seconds with smoother easing, limit checkoff transitions to relevant visual properties, and make reduced-motion transitions effectively instantaneous
31
+
11
32
  ### Fixed
12
33
 
13
- - correct Day 27's New Testament reading to include Matthew 26:75
14
- - silently recover all Turbo interactions from stale sessions, retry once with a fresh CSRF token, and reload rather than submit with a known-stale token if refreshing fails
15
34
  - keep repeated or concurrent explicit checkoffs idempotent instead of raising a duplicate-validation Rails 422 error
16
35
 
17
- **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.3.0...main)**
36
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.3.2...v1.4.0)**
37
+
38
+ ## [1.3.2] - 2026-09-02
39
+
40
+ ### Fixed
41
+
42
+ - silently recover all Turbo interactions from stale sessions, retry once with a fresh CSRF token, and reload rather than submit with a known-stale token if refreshing fails
43
+
44
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.3.1...v1.3.2)**
45
+
46
+ ## [1.3.1] - 2026-09-01
47
+
48
+ ### Fixed
49
+
50
+ - correct Day 27's New Testament reading to include Matthew 26:75
51
+
52
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.3.0...v1.3.1)**
18
53
 
19
54
  ## [1.3.0] - 2026-08-29
20
55
 
@@ -596,6 +631,9 @@ This is the initial public release.
596
631
 
597
632
  * Email sign-in requires working Action Mailer delivery in the host application. Set `config.mailer_from`; delivery is inline by default, or set `email_sign_in_deliver_later` when a queue backend is available.
598
633
 
634
+ [1.4.0]: https://github.com/avonderluft/bible270/releases/tag/v1.4.0
635
+ [1.3.2]: https://github.com/avonderluft/bible270/releases/tag/v1.3.2
636
+ [1.3.1]: https://github.com/avonderluft/bible270/releases/tag/v1.3.1
599
637
  [1.3.0]: https://github.com/avonderluft/bible270/releases/tag/v1.3.0
600
638
  [1.2.0]: https://github.com/avonderluft/bible270/releases/tag/v1.2.0
601
639
  [1.1.7]: https://github.com/avonderluft/bible270/releases/tag/v1.1.7
@@ -13,19 +13,22 @@ module Bible270
13
13
 
14
14
  before_action :require_admin!
15
15
  before_action :load_reader,
16
- only: %i[show destroy update_start update_profile update_notifications remove_avatar complete_through
17
- toggle_day]
16
+ only: %i[show destroy update_start update_profile update_notifications update_completion_animation
17
+ remove_avatar complete_through toggle_day]
18
18
  before_action :load_comment, only: %i[hide_comment unhide_comment destroy_comment]
19
19
 
20
20
  # ---- enrolment --------------------------------------------------------
21
21
 
22
22
  def update_enrollment
23
- if params[:state] == 'closed'
23
+ case params[:state]
24
+ when 'closed'
24
25
  Setting.close_enrollment!
25
26
  redirect_to admin_path, notice: 'Closed to new readers. Existing readers can still sign in.'
26
- else
27
+ when 'open'
27
28
  Setting.open_enrollment!
28
29
  redirect_to admin_path, notice: 'Open to new readers.'
30
+ else
31
+ redirect_to admin_path, alert: 'Choose whether enrollment is open or closed. Nothing was changed.'
29
32
  end
30
33
  end
31
34
 
@@ -71,6 +74,7 @@ module Bible270
71
74
 
72
75
  def show
73
76
  @comment_notifications_available = comment_notifications_available_for?(@reader)
77
+ @completion_dove_preference_available = completion_dove_available_for(@reader)
74
78
  @days_completed = @reader.days_completed
75
79
  @comments = @reader.comments.order(created_at: :desc).limit(50)
76
80
  end
@@ -122,12 +126,23 @@ module Bible270
122
126
  recipients = Reader.where.not(email: [nil, '']).order(:id)
123
127
  return redirect_to(admin_path, alert: 'Nobody has an email address.') if recipients.empty?
124
128
 
129
+ recipient_count = recipients.count
125
130
  sent = deliver_broadcast(recipients, subject, body)
131
+ later = Bible270.config.registration_notice_deliver_later
132
+
133
+ if sent.zero?
134
+ action = later ? 'queued' : 'sent'
135
+ return redirect_to admin_path, alert: "The message could not be #{action} to any readers."
136
+ end
137
+
126
138
  Setting.write(LAST_BROADCAST_AT, Time.current.iso8601)
127
139
  Setting.write(LAST_BROADCAST_SUBJECT, subject)
128
140
 
129
- redirect_to admin_path,
130
- notice: "Sent to #{sent} #{'reader'.pluralize(sent)}."
141
+ action = later ? 'Queued for' : 'Sent to'
142
+ failures = recipient_count - sent
143
+ notice = "#{action} #{sent} #{'reader'.pluralize(sent)}."
144
+ notice += " #{failures} #{'reader'.pluralize(failures)} could not be #{later ? 'queued' : 'sent'}." if failures.positive?
145
+ redirect_to admin_path, notice: notice
131
146
  end
132
147
 
133
148
  def update_bible_version
@@ -169,9 +184,14 @@ module Bible270
169
184
  redirect_to admin_reader_path(@reader),
170
185
  alert: "Couldn't read #{params[:start_date].inspect} as a date."
171
186
  end
172
- elsif params[:day].present? && @reader.restart_on!(day: params[:day])
173
- redirect_to admin_reader_path(@reader),
174
- notice: "#{@reader.display_name} is now on day #{params[:day]}."
187
+ elsif params[:day].present?
188
+ day = strict_integer(params[:day], 1..Plan::DAYS)
189
+ if day && @reader.restart_on!(day: day)
190
+ redirect_to admin_reader_path(@reader),
191
+ notice: "#{@reader.display_name} is now on day #{day}."
192
+ else
193
+ redirect_to admin_reader_path(@reader), alert: 'Give a start date or a day between 1 and 270.'
194
+ end
175
195
  else
176
196
  redirect_to admin_reader_path(@reader), alert: 'Give a start date or a day between 1 and 270.'
177
197
  end
@@ -181,12 +201,15 @@ module Bible270
181
201
  # themselves on their own profile.
182
202
  def update_profile
183
203
  problems = []
184
- if (params[:first_name].present? || params[:last_name].present?) && !@reader.update_names(params[:first_name],
185
- params[:last_name])
186
- problems << 'both a first and last name'
187
- end
188
- if params[:avatar].present? && !@reader.attach_avatar(params[:avatar])
189
- problems << (@reader.errors[:avatar].first || 'a valid image')
204
+ @reader.transaction do
205
+ if (params[:first_name].present? || params[:last_name].present?) && !@reader.update_names(params[:first_name],
206
+ params[:last_name])
207
+ problems << 'both a first and last name'
208
+ end
209
+ if problems.empty? && params[:avatar].present? && !@reader.attach_avatar(params[:avatar])
210
+ problems << (@reader.errors[:avatar].first || 'a valid image')
211
+ end
212
+ raise ActiveRecord::Rollback if problems.any?
190
213
  end
191
214
 
192
215
  if problems.empty?
@@ -214,28 +237,40 @@ module Bible270
214
237
  redirect_to admin_reader_path(@reader), notice: 'Reflection email setting updated.'
215
238
  end
216
239
 
240
+ def update_completion_animation
241
+ unless completion_dove_available_for(@reader)
242
+ alert = 'Run the pending Bible270 database migration before changing the completion animation.'
243
+ return redirect_to(admin_reader_path(@reader), alert: alert)
244
+ end
245
+
246
+ @reader.update!(completion_dove_disabled: params[:completion_dove_disabled] == '1')
247
+ redirect_to admin_reader_path(@reader), notice: 'Completion animation setting updated.'
248
+ end
249
+
217
250
  def remove_avatar
218
251
  @reader.remove_avatar!
219
252
  redirect_to admin_reader_path(@reader), notice: "Removed #{@reader.display_name}'s picture."
220
253
  end
221
254
 
222
255
  def complete_through
223
- day = params[:day].to_i
224
- if @reader.mark_through!(day)
225
- redirect_to admin_reader_path(@reader),
226
- notice: day.zero? ? 'All check-offs cleared.' : "Marked days 1-#{day} complete."
227
- else
228
- redirect_to admin_reader_path(@reader), alert: 'Give a day between 0 and 270.'
256
+ day = strict_integer(params[:day], 0..Plan::DAYS)
257
+ unless day
258
+ return redirect_to admin_reader_path(@reader), alert: 'Give a day between 0 and 270.'
229
259
  end
260
+
261
+ @reader.mark_through!(day)
262
+ redirect_to admin_reader_path(@reader),
263
+ notice: day.zero? ? 'All check-offs cleared.' : "Marked days 1-#{day} complete."
230
264
  end
231
265
 
232
266
  def toggle_day
233
- day = params[:day].to_i
234
- if @reader.toggle_day!(day)
235
- redirect_to admin_reader_path(@reader, anchor: "day-#{day}")
236
- else
237
- redirect_to admin_reader_path(@reader), alert: 'That day is outside the plan.'
267
+ day = strict_integer(params[:day], 1..Plan::DAYS)
268
+ unless day
269
+ return redirect_to admin_reader_path(@reader), alert: 'That day is outside the plan.'
238
270
  end
271
+
272
+ @reader.toggle_day!(day)
273
+ redirect_to admin_reader_path(@reader, anchor: "day-#{day}")
239
274
  end
240
275
 
241
276
  # Failures are counted rather than raised: one bad address must not stop the
@@ -274,6 +309,12 @@ module Bible270
274
309
  true
275
310
  end
276
311
 
312
+ def completion_dove_available_for(reader)
313
+ available = Reader.completion_dove_column?
314
+ reader.reload if available && !reader.has_attribute?('completion_dove_disabled')
315
+ available
316
+ end
317
+
277
318
  def require_admin!
278
319
  return if Bible270.config.admin_configured? && Bible270.config.admin?(current_reader)
279
320
 
@@ -282,16 +323,25 @@ module Bible270
282
323
 
283
324
  def load_comment
284
325
  @comment = Comment.find_by(id: params[:id])
285
- redirect_to(admin_comments_path, alert: 'Reflection not found.') if @comment.nil?
326
+ return if @comment
327
+
328
+ redirect_to admin_comments_path, alert: 'Reflection not found.'
286
329
  end
287
330
 
288
331
  def redirect_back_to_comments(notice)
289
- redirect_to(params[:return_to].presence || admin_comments_path, notice: notice)
332
+ redirect_to(url_from(params[:return_to]) || admin_comments_path, notice: notice)
290
333
  end
291
334
 
292
335
  def load_reader
293
336
  @reader = Reader.find_by(id: params[:id])
294
- redirect_to(admin_path, alert: 'Reader not found.') if @reader.nil?
337
+ return if @reader
338
+
339
+ redirect_to admin_path, alert: 'Reader not found.'
340
+ end
341
+
342
+ def strict_integer(value, range)
343
+ integer = Integer(value.to_s, 10, exception: false)
344
+ integer if range.cover?(integer)
295
345
  end
296
346
  end
297
347
  end
@@ -18,25 +18,27 @@ module Bible270
18
18
  requested_state = params[:checked] if params.key?(:checked)
19
19
  head :bad_request and return if params.key?(:checked) && !%w[0 1].include?(requested_state)
20
20
 
21
- marked_read, checkoff_changed = apply_checkoff(requested_state)
22
-
23
- @reader = current_reader.reload.reload_progress
24
- @day_just_completed = marked_read && checkoff_changed && @reader.day_complete?(@day)
21
+ # Serialize this reader's transitions so two different final portions
22
+ # submitted concurrently cannot both announce the same day completion.
23
+ marked_read = nil
24
+ current_reader.with_lock do
25
+ marked_read, @completion_event_id = apply_checkoff(requested_state)
26
+ @reader = current_reader.reload.reload_progress
27
+ @day_just_completed = marked_read && @completion_event_id && @reader.day_complete?(@day)
28
+ end
25
29
  @reader_tracks = @reader.read_tracks_for(@day)
26
30
  @readings = Plan.readings_for(@day)
27
31
 
28
- required = Plan.total_parts(@day)
29
- completer_ids = Checkoff.where(day: @day)
30
- .group(:reader_id)
31
- .having('COUNT(*) >= ?', required)
32
- .pluck(:reader_id)
33
- @completers = Reader.where(id: completer_ids).order(:display_name)
32
+ @completers = Reader.completers_for(@day)
34
33
 
35
34
  respond_to do |format|
36
35
  format.turbo_stream
37
36
  format.html do
38
37
  if @day_just_completed
39
- redirect_to day_path(@day), flash: { b270_day_just_completed: @day }
38
+ redirect_to day_path(@day), flash: {
39
+ b270_day_just_completed: @day,
40
+ b270_completion_event_id: @completion_event_id
41
+ }
40
42
  else
41
43
  message = marked_read ? 'Reading marked read.' : 'Reading marked unread.'
42
44
  redirect_to day_path(@day), flash: { b270_interaction_status: message }
@@ -51,9 +53,10 @@ module Bible270
51
53
  existing = find_checkoff
52
54
  if requested_state == '1'
53
55
  current_reader.ensure_started!
54
- [true, existing.nil? && create_checkoff]
56
+ [true, existing.nil? ? create_checkoff : nil]
55
57
  elsif requested_state == '0' || existing
56
- [false, existing ? existing.destroy.present? : false]
58
+ existing&.destroy
59
+ [false, nil]
57
60
  else
58
61
  current_reader.ensure_started!
59
62
  [true, create_checkoff]
@@ -66,15 +69,16 @@ module Bible270
66
69
  end
67
70
  end
68
71
 
72
+ # The created id is stable if Turbo replays this response, while a later
73
+ # re-completion creates a new id and is correctly treated as a new event.
69
74
  def create_checkoff
70
- Checkoff.create!(reader: current_reader, day: @day, track: @track, part: @part)
71
- true
75
+ Checkoff.create!(reader: current_reader, day: @day, track: @track, part: @part).id
72
76
  rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
73
77
  # A repeated or concurrent desired-state request is already successful. Do
74
78
  # not hide any other validation failure under the same recovery.
75
79
  raise unless find_checkoff
76
80
 
77
- false
81
+ nil
78
82
  end
79
83
  end
80
84
  end
@@ -21,16 +21,9 @@ module Bible270
21
21
  @new_comment = Comment.new(day: @day, parent_id: reply_parent&.id)
22
22
  @reader_tracks = current_reader ? current_reader.read_tracks_for(@day) : []
23
23
  @day_just_completed = flash[:b270_day_just_completed].to_i == @day
24
+ @completion_event_id = flash[:b270_completion_event_id] if @day_just_completed
24
25
 
25
- # Every box on the day, not every track: an Old Testament reading of three
26
- # chapters is three rows, so counting tracks would call someone finished
27
- # who had only read the Old Testament.
28
- required = Plan.total_parts(@day)
29
- completer_ids = Checkoff.where(day: @day)
30
- .group(:reader_id)
31
- .having('COUNT(*) >= ?', required)
32
- .pluck(:reader_id)
33
- @completers = Reader.where(id: completer_ids).order(:display_name)
26
+ @completers = Reader.completers_for(@day)
34
27
  end
35
28
 
36
29
  private
@@ -33,6 +33,7 @@ module Bible270
33
33
  REFLECTIONS_SEEN_COLUMN = 'reflections_seen_at'
34
34
  COMMENT_NOTIFICATION_LEVELS = %w[all personal none].freeze
35
35
  COMMENT_NOTIFICATION_COLUMNS = %w[notify_on_mention notify_on_all_comments].freeze
36
+ COMPLETION_DOVE_COLUMN = 'completion_dove_disabled'
36
37
  MENTION_SUGGESTION_LIMIT = 5
37
38
 
38
39
  validates :display_name, presence: true
@@ -64,6 +65,18 @@ module Bible270
64
65
  end
65
66
  end
66
67
 
68
+ # A day is completed when its final required checkoff is created, so MAX(created_at)
69
+ # is the completion moment. Keep reader_id as a deterministic tie-breaker.
70
+ def self.completers_for(day)
71
+ reader_ids = Checkoff.where(day: day)
72
+ .group(:reader_id)
73
+ .having('COUNT(*) >= ?', Plan.total_parts(day))
74
+ .order(Checkoff.arel_table[:created_at].maximum.asc, :reader_id)
75
+ .pluck(:reader_id)
76
+ readers_by_id = where(id: reader_ids).index_by(&:id)
77
+ reader_ids.filter_map { |reader_id| readers_by_id[reader_id] }
78
+ end
79
+
67
80
  def self.reflections_seen_column?
68
81
  column_names.include?(REFLECTIONS_SEEN_COLUMN)
69
82
  rescue ActiveRecord::StatementInvalid
@@ -289,6 +302,22 @@ module Bible270
289
302
  where(notify_on_all_comments: true).where.not(email: [nil, ''])
290
303
  end
291
304
 
305
+ def self.completion_dove_column?
306
+ return true if column_names.include?(COMPLETION_DOVE_COLUMN)
307
+
308
+ reset_column_information
309
+ column_names.include?(COMPLETION_DOVE_COLUMN)
310
+ rescue ActiveRecord::StatementInvalid
311
+ false
312
+ end
313
+
314
+ # Keep day completion usable while an upgraded host is waiting to run the
315
+ # preference migration. Historically the dove was enabled, so a missing
316
+ # column preserves that behavior.
317
+ def completion_dove_disabled?
318
+ has_attribute?(COMPLETION_DOVE_COLUMN) && self[COMPLETION_DOVE_COLUMN] == true
319
+ end
320
+
292
321
  # Small, deterministic suggestions for the reflection composer. Full-handle
293
322
  # collisions are omitted because the server must never guess which reader a
294
323
  # mention intended.
@@ -70,6 +70,29 @@
70
70
  <% end %>
71
71
  </div>
72
72
 
73
+ <div class="b270-panel">
74
+ <h2 class="b270-subhead">Completion animation</h2>
75
+ <% unless @completion_dove_preference_available %>
76
+ <p class="b270-flash alert" style="margin:0">
77
+ Run the pending Bible270 database migration before changing the completion animation.
78
+ </p>
79
+ <% else %>
80
+ <%= form_with url: admin_reader_completion_animation_path(@reader), method: :patch,
81
+ class: "b270-emailform" do %>
82
+ <div class="b270-field">
83
+ <label class="b270-reminder-optin">
84
+ <%= check_box_tag :completion_dove_disabled, "1", @reader.completion_dove_disabled? %>
85
+ <span>Turn off the dove animation for this reader</span>
86
+ </label>
87
+ </div>
88
+ <p class="b270-hint" style="margin:0">
89
+ Day completion and progress tracking still work normally when the animation is off.
90
+ </p>
91
+ <%= submit_tag 'Save', class: 'b270-btn' %>
92
+ <% end %>
93
+ <% end %>
94
+ </div>
95
+
73
96
  <div class="b270-panel">
74
97
  <h2 class="b270-subhead">Where they are in the plan</h2>
75
98
  <%= form_with url: admin_reader_start_path(@reader), method: :patch, class: "b270-startform" do %>
@@ -5,7 +5,7 @@
5
5
 
6
6
  <%= turbo_stream.replace "day_progress_#{@day}" do %>
7
7
  <%= render "bible270/days/day_progress", day: @day, reader: @reader,
8
- just_completed: @day_just_completed %>
8
+ just_completed: @day_just_completed, completion_event_id: @completion_event_id %>
9
9
  <% end %>
10
10
 
11
11
  <%= turbo_stream.replace "completers_#{@day}" do %>
@@ -2,9 +2,8 @@
2
2
  <div class="b270-completers" id="completers_<%= day %>">
3
3
  <% if completers.any? %>
4
4
  <span>Finished this day:</span>
5
- <% completers.first(12).each do |reader| %>
6
- <%= link_to b270_avatar(reader), reader_path(reader), title: reader.display_name %>
5
+ <% completers.each do |reader| %>
6
+ <%= link_to b270_avatar(reader, size: 56), reader_path(reader), title: reader.display_name %>
7
7
  <% end %>
8
- <% if completers.size > 12 %><span><%= completers.size - 12 %> more</span><% end %>
9
8
  <% end %>
10
9
  </div>
@@ -1,4 +1,4 @@
1
- <%# locals: (day:, reader:, just_completed: false) %>
1
+ <%# locals: (day:, reader:, just_completed: false, completion_event_id: nil) %>
2
2
  <%
3
3
  previous_day = day > 1 ? day - 1 : nil
4
4
  next_day = day < Bible270::Plan::DAYS ? day + 1 : nil
@@ -33,7 +33,9 @@
33
33
 
34
34
 
35
35
  <% if complete %>
36
- <%= render "bible270/shared/day_completion_dove", day: day if just_completed %>
36
+ <% if just_completed && completion_event_id && !reader&.completion_dove_disabled? %>
37
+ <%= render "bible270/shared/day_completion_dove", day: day, completion_event_id: completion_event_id %>
38
+ <% end %>
37
39
  <div class="b270-day-complete" role="status" aria-live="polite"
38
40
  <%= tag.attributes(data: completion_data) %>>
39
41
  <div>
@@ -2,7 +2,7 @@
2
2
 
3
3
  <div class="b270-day-page">
4
4
  <%= render "bible270/days/day_progress", day: @day, reader: current_reader,
5
- just_completed: @day_just_completed %>
5
+ just_completed: @day_just_completed, completion_event_id: @completion_event_id %>
6
6
 
7
7
  <%= render "bible270/shared/interaction_status" %>
8
8
 
@@ -16,8 +16,6 @@
16
16
  <% end %>
17
17
 
18
18
  <div class="b270-day-community">
19
- <%= render "bible270/days/completers", day: @day, completers: @completers %>
20
-
21
19
  <h2 class="b270-section-h">Reflections</h2>
22
20
  <div id="comments">
23
21
  <%= render partial: "bible270/comments/comment", collection: @comments, as: :comment %>
@@ -30,8 +28,9 @@
30
28
  <%= render "bible270/shared/comment_drafts" %>
31
29
  <%= render "bible270/shared/mention_typeahead" %>
32
30
  <% end %>
31
+
32
+ <%= render "bible270/days/completers", day: @day, completers: @completers %>
33
33
  </div>
34
34
  </div>
35
35
 
36
36
  <%= render "bible270/shared/interaction_ui" %>
37
- <%= render "bible270/shared/day_completion_effect", day: @day %>
@@ -1,17 +1,19 @@
1
- <%# locals: (day:) %>
1
+ <%# locals: (day:, completion_event_id:) %>
2
2
  <svg class="b270-completion-dove" data-b270-completion-dove data-b270-day="<%= day %>"
3
+ data-b270-completion-event="<%= completion_event_id %>" data-turbo-temporary
3
4
  viewBox="0 0 260 200" aria-hidden="true" focusable="false">
4
5
  <defs>
5
6
  <radialGradient id="b270-dove-light" cx="50%" cy="43%" r="68%">
6
- <stop offset="0%" stop-color="#fff" />
7
- <stop offset="58%" stop-color="#fffef8" />
8
- <stop offset="100%" stop-color="#fff9e2" />
7
+ <stop class="b270-dove-light-center" offset="0%" stop-color="#fff" />
8
+ <stop class="b270-dove-light-middle" offset="58%" stop-color="#fffef8" />
9
+ <stop class="b270-dove-light-edge" offset="100%" stop-color="#fff9e2" />
9
10
  </radialGradient>
10
11
  </defs>
11
12
  <g class="b270-dove-silhouette">
12
13
  <path d="M122 94C103 82 87 69 72 53L39 17l11 28-23-23 15 32-23-20 19 30-24-16 22 26-25-12 27 23-24-7 30 19-22-3 31 17c20 11 42 16 59 8 5-9 8-18 10-25Z" />
13
14
  <path d="M138 94c19-12 35-25 50-41l33-36-11 28 23-23-15 32 23-20-19 30 24-16-22 26 25-12-27 23 24-7-30 19 22-3-31 17c-20 11-42 16-59 8-5-9-8-18-10-25Z" />
14
15
  <path d="M148 140c6 7 16 14 24 19 2 2 1 5-2 6-5 2-10-1-14-5 4 8 7 17 4 22-2 3-5 1-7-2l-6-11c3 10 2 19-3 24-2 3-5 1-6-3l-4-15c0 11-3 21-8 24-3 2-5-1-6-5l-1-19c-3 10-6 17-11 20-3 2-5-1-5-5l2-19c-4 8-9 13-14 14-3 1-5-2-4-6l6-18c-5 5-11 7-16 4-3-1-3-4 0-7 7-7 17-13 26-19Z" />
15
- <path d="M108 116c5-10 9-17 14-17-1-11-2-23 4-29 5-4 12-3 15 1 2 3 2 6 1 9 3 .2 6 .8 8 1.5-2.5 1.3-5.5 2.1-8 2.5-1 5-3 10-5 15 6 3 11 9 15 17-1 15-6 28-16 39-3 4-7 7-10 9-4-2-8-5-11-9-9-11-14-25-11-37 1-1 2-2 4-2Z" />
16
+ <path class="b270-dove-beak" d="M140 80C144 80 148 80.8 151 82 152 82.4 152 83.2 151 83.8 148 85.1 144 85.8 140 85 138.5 83.5 138.5 81.2 140 80Z" />
17
+ <path d="M108 116c5-10 9-17 14-17-1-11-2-23 4-29 5-4 12-3 15 1 2 3 2 6 1 9 1 1 1 3 0 4-1 5-3 10-5 15 6 3 11 9 15 17-1 15-6 28-16 39-3 4-7 7-10 9-4-2-8-5-11-9-9-11-14-25-11-37 1-1 2-2 4-2Z" />
16
18
  </g>
17
19
  </svg>
@@ -18,6 +18,53 @@
18
18
  region.dataset.state = state;
19
19
  }
20
20
 
21
+ const shownCompletionDoves = new Set();
22
+
23
+ function claimCompletionDove(dove) {
24
+ const eventId = dove.dataset.b270CompletionEvent;
25
+ if (!eventId) return false;
26
+
27
+ const key = `b270-completion-dove:${eventId}`;
28
+ if (shownCompletionDoves.has(key)) return false;
29
+
30
+ try {
31
+ if (window.sessionStorage.getItem(key) === "shown") return false;
32
+ window.sessionStorage.setItem(key, "shown");
33
+ } catch (_error) {
34
+ // Some privacy modes disable session storage. The in-memory set still
35
+ // prevents duplicate Turbo rendering within the current page.
36
+ }
37
+ shownCompletionDoves.add(key);
38
+ return true;
39
+ }
40
+
41
+ function initializeCompletionDove(dove) {
42
+ if (dove.dataset.b270DoveInitialized === "true") return;
43
+ dove.dataset.b270DoveInitialized = "true";
44
+
45
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
46
+ dove.remove();
47
+ return;
48
+ }
49
+
50
+ if (!claimCompletionDove(dove)) {
51
+ dove.remove();
52
+ return;
53
+ }
54
+
55
+ // Keep an active flight outside the Turbo-replaced progress panel so a
56
+ // replayed response cannot restart or cut short the same completion event.
57
+ if (dove.parentElement !== document.body) document.body.appendChild(dove);
58
+ requestAnimationFrame(() => {
59
+ if (dove.isConnected) dove.classList.add("b270-completion-dove-active");
60
+ });
61
+ }
62
+
63
+ function initializeCompletionDoves(root = document) {
64
+ if (root.matches && root.matches("[data-b270-completion-dove]")) initializeCompletionDove(root);
65
+ root.querySelectorAll?.("[data-b270-completion-dove]").forEach(initializeCompletionDove);
66
+ }
67
+
21
68
  function resetForm(form) {
22
69
  form.removeAttribute("aria-busy");
23
70
  form.dataset.b270Submitting = "false";
@@ -29,8 +76,18 @@
29
76
 
30
77
  function reset() {
31
78
  document.querySelectorAll("form[data-b270-submit='true']").forEach(resetForm);
79
+ initializeCompletionDoves();
32
80
  }
33
81
 
82
+ const completionDoveObserver = new MutationObserver((mutations) => {
83
+ mutations.forEach((mutation) => {
84
+ mutation.addedNodes.forEach((node) => {
85
+ if (node.nodeType === Node.ELEMENT_NODE) initializeCompletionDoves(node);
86
+ });
87
+ });
88
+ });
89
+ completionDoveObserver.observe(document.documentElement, { childList: true, subtree: true });
90
+
34
91
  const SESSION_REFRESH_AFTER = 5 * 60 * 1000;
35
92
  let sessionRefreshedAt = Date.now();
36
93
  let sessionRefresh = null;
@@ -201,6 +258,13 @@
201
258
  }
202
259
  });
203
260
  document.addEventListener("turbo:load", reset);
261
+ document.addEventListener("turbo:before-cache", () => {
262
+ document.querySelectorAll("[data-b270-completion-dove]").forEach((dove) => dove.remove());
263
+ });
264
+ document.addEventListener("animationend", (event) => {
265
+ if (event.animationName === "b270-dove-flight" &&
266
+ event.target.matches("[data-b270-completion-dove]")) event.target.remove();
267
+ });
204
268
 
205
269
  window.Bible270InteractionUI = { reset };
206
270
  reset();
@@ -147,17 +147,21 @@
147
147
  .b270-day-complete strong{font-family:"Spectral",serif;font-size:16px}
148
148
  .b270-day-complete span{font-size:12px;color:var(--muted)}
149
149
  .b270 .b270-day-complete .b270-btn{flex:none;margin:0 0 0 auto}
150
- .b270-completion-dove{position:fixed;z-index:1000;top:var(--b270-dove-origin-y,72vh);left:var(--b270-dove-origin-x,50vw);
151
- width:min(75vw,97.5vh);height:auto;overflow:visible;pointer-events:none;opacity:0;
150
+ .b270-completion-dove{position:fixed;z-index:1000;top:72vh;left:50vw;
151
+ width:min(80vw,104vh);height:auto;overflow:visible;pointer-events:none;opacity:0;
152
+ color-scheme:only light;forced-color-adjust:none;
152
153
  filter:drop-shadow(0 0 8px rgba(255,255,255,1)) drop-shadow(0 0 24px rgba(255,252,220,1)) drop-shadow(0 0 52px rgba(225,191,92,.82));
153
- will-change:top,left,transform,opacity;animation:b270-dove-flight 1.6s cubic-bezier(.18,.72,.28,1) both}
154
- .b270-dove-silhouette{fill:url(#b270-dove-light);stroke:none}
154
+ will-change:top,left,transform,opacity}
155
+ .b270-completion-dove-active{animation:b270-dove-flight 1.7s cubic-bezier(.18,.72,.28,1) both}
156
+ .b270-dove-silhouette{fill:url(#b270-dove-light)!important;stroke:none}
157
+ .b270-dove-light-center{stop-color:#fff!important}
158
+ .b270-dove-light-middle{stop-color:#fffef8!important}
159
+ .b270-dove-light-edge{stop-color:#fff9e2!important}
155
160
  @keyframes b270-dove-flight{
156
- 0%{top:var(--b270-dove-origin-y,72vh);left:var(--b270-dove-origin-x,50vw);opacity:.22;visibility:visible;transform:translate(-50%,-50%) scale(.02) rotate(-8deg)}
157
- 8%{top:calc(var(--b270-dove-origin-y,72vh) - 10px);left:var(--b270-dove-origin-x,50vw);opacity:.78;transform:translate(-50%,-50%) scale(.08) rotate(-5deg)}
158
- 64%{top:38vh;left:52vw;opacity:.92;transform:translate(-50%,-50%) scale(1) rotate(4deg)}
159
- 99%{visibility:visible}
160
- 100%{top:-25vh;left:115vw;opacity:0;visibility:hidden;transform:translate(-50%,-50%) scale(1.55) rotate(12deg)}
161
+ 0%{top:72vh;left:50vw;opacity:.22;visibility:visible;transform:translate(-50%,-50%) scale(.02) rotate(-8deg)}
162
+ 8%{top:calc(72vh - 10px);left:50vw;opacity:.78;transform:translate(-50%,-50%) scale(.08) rotate(-5deg)}
163
+ 33%{top:50vh;left:50vw;opacity:1;visibility:visible;transform:translate(-50%,-50%) scale(1) rotate(4deg)}
164
+ 100%{top:50vh;left:50vw;opacity:0;visibility:hidden;transform:translate(-50%,-50%) scale(1) rotate(4deg)}
161
165
  }
162
166
 
163
167
  /* readings */
data/config/routes.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Rails routes are intentionally kept in one cohesive DSL block.
3
4
  Bible270::Engine.routes.draw do
4
5
  root to: 'days#index'
5
6
 
@@ -22,6 +23,8 @@ Bible270::Engine.routes.draw do
22
23
  delete 'admin/readers/:id', to: 'admin#destroy'
23
24
  patch 'admin/readers/:id/profile', to: 'admin#update_profile', as: :admin_reader_profile
24
25
  patch 'admin/readers/:id/notifications', to: 'admin#update_notifications', as: :admin_reader_notifications
26
+ patch 'admin/readers/:id/completion-animation', to: 'admin#update_completion_animation',
27
+ as: :admin_reader_completion_animation
25
28
  delete 'admin/readers/:id/avatar', to: 'admin#remove_avatar', as: :admin_reader_avatar
26
29
  patch 'admin/readers/:id/version', to: 'admin#update_bible_version', as: :admin_reader_version
27
30
  patch 'admin/readers/:id/start', to: 'admin#update_start', as: :admin_reader_start
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddCompletionDoveDisabledToBible270Readers < ActiveRecord::Migration[7.0]
4
+ def change
5
+ add_column :bible270_readers, :completion_dove_disabled, :boolean, null: false, default: false
6
+ end
7
+ end
@@ -29,6 +29,7 @@ module Bible270
29
29
  'AddReflectionsSeenAtToBible270Readers' => :check_reflections_seen_at,
30
30
  'AddAllCommentNoticesToBible270Readers' => :check_all_comment_notices,
31
31
  'ChangePassageSourceDefaultToBibleCom' => :check_passage_source_default,
32
+ 'AddCompletionDoveDisabledToBible270Readers' => :check_completion_dove_disabled,
32
33
  'CreateActiveStorageTables' => :check_active_storage
33
34
  }.freeze
34
35
 
@@ -204,6 +205,10 @@ module Bible270
204
205
  column&.default == 'bible_com' ? [] : ['column bible270_readers.passage_source default bible_com']
205
206
  end
206
207
 
208
+ def check_completion_dove_disabled
209
+ table_missing(:bible270_readers, columns: %i[completion_dove_disabled])
210
+ end
211
+
207
212
  # The Bible270 installer can install Active Storage for reader avatars. A
208
213
  # copied database can therefore have the same timestamp mismatch for Rails'
209
214
  # generated Active Storage migration as it has for the engine migrations.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bible270
4
- VERSION = '1.4.0'
4
+ VERSION = '1.4.2'
5
5
  end
@@ -18,10 +18,12 @@ module Bible270
18
18
  # --mailer-from=no-reply@example.org
19
19
  #
20
20
  class InstallGenerator < Rails::Generators::Base
21
- MOUNT_LINE = 'mount Bible270::Engine, at: Bible270.config.mount_at'
22
- DEFAULT_MOUNT = '/daily-bread'
23
- DEFAULT_PROVIDERS = 'github'
24
- KNOWN_PROVIDERS = {
21
+ MOUNT_LINE = 'mount Bible270::Engine, at: Bible270.config.mount_at'
22
+ MOUNT_LINE_PATTERN = %r{^\s*mount\s*\(?\s*Bible270::Engine\b}
23
+ PROVIDER_IDENTIFIER = %r{\A[a-z][a-z0-9_]*\z}
24
+ DEFAULT_MOUNT = '/daily-bread'
25
+ DEFAULT_PROVIDERS = 'github'
26
+ KNOWN_PROVIDERS = {
25
27
  'github' => 'omniauth-github',
26
28
  'google_oauth2' => 'omniauth-google-oauth2',
27
29
  'facebook' => 'omniauth-facebook',
@@ -97,18 +99,23 @@ module Bible270
97
99
  def add_strategy_gems
98
100
  return if @providers.empty?
99
101
 
100
- missing = @providers.filter_map { |p| KNOWN_PROVIDERS[p] }.reject { |g| gemfile_has?(g) }
101
- unknown = @providers.reject { |p| KNOWN_PROVIDERS.key?(p) }
102
+ missing = @providers.filter_map { |provider| KNOWN_PROVIDERS[provider] }
103
+ .reject { |gem_name| gemfile_has?(gem_name) }
104
+ @unknown_providers = @providers.reject { |provider| KNOWN_PROVIDERS.key?(provider) }
105
+ @strategy_gems_added = missing.any?
102
106
 
103
107
  missing.each { |name| gem name }
104
108
 
105
- return if unknown.empty?
109
+ return if @unknown_providers.empty?
106
110
 
107
- say " Unrecognised provider(s): #{unknown.join(', ')}. Add their strategy gems yourself.", :yellow
111
+ identifiers = @unknown_providers.map { |provider| provider.to_sym.inspect }.join(', ')
112
+ say " Unrecognised provider(s): #{@unknown_providers.join(', ')}.", :yellow
113
+ say " Add an OmniAuth strategy gem that registers #{identifiers} to the Gemfile, then run bundle install.",
114
+ :yellow
108
115
  end
109
116
 
110
117
  def install_bundle
111
- return if @providers.empty?
118
+ return unless @strategy_gems_added
112
119
 
113
120
  @bundled = decide(:bundle, 'Run bundle install now (needed before the app can boot)?', true)
114
121
  run 'bundle install' if @bundled
@@ -121,7 +128,16 @@ module Bible270
121
128
  # rather than assuming a fresh app has it.
122
129
 
123
130
  def ensure_active_storage
124
- return if skip_rails_commands?
131
+ if skip_rails_commands?
132
+ @active_storage = if !active_storage_enabled?
133
+ :unavailable
134
+ elsif active_storage_installed?
135
+ :present
136
+ else
137
+ :pending
138
+ end
139
+ return
140
+ end
125
141
 
126
142
  unless active_storage_enabled?
127
143
  @active_storage = :unavailable
@@ -153,11 +169,15 @@ module Bible270
153
169
  # Migrations run before the initializers are written, so a problem in the
154
170
  # generated config can never block the database work.
155
171
  def copy_migrations
156
- return if skip_rails_commands?
172
+ if skip_rails_commands?
173
+ @migrations_copied = false
174
+ return
175
+ end
157
176
 
158
177
  say ''
159
178
  say 'Copying migrations into db/migrate', :yellow
160
179
  rails_command 'bible270:install:migrations'
180
+ @migrations_copied = true
161
181
  end
162
182
 
163
183
  def run_migrations
@@ -191,7 +211,11 @@ module Bible270
191
211
 
192
212
  def add_route
193
213
  if routes_mounted?
194
- say " config/routes.rb already mounts Bible270::Engine — left as is.#{cms_order_hint}", :yellow
214
+ if move_mount_above_catch_all
215
+ say ' config/routes.rb already mounted Bible270::Engine; moved it above the catch-all route.', :yellow
216
+ else
217
+ say " config/routes.rb already mounts Bible270::Engine — left as is.#{cms_order_hint}", :yellow
218
+ end
195
219
  return
196
220
  end
197
221
 
@@ -202,7 +226,9 @@ module Bible270
202
226
  # across files, an unusual layout, a missing file). Verify rather than
203
227
  # assume, since a silent miss leaves the plan unreachable.
204
228
  if routes_mounted?
205
- say " Mounted in config/routes.rb.#{cms_order_hint}", :green
229
+ moved = move_mount_above_catch_all
230
+ detail = moved ? ' Mounted above the catch-all route.' : cms_order_hint
231
+ say " Mounted in config/routes.rb.#{detail}", :green
206
232
  else
207
233
  @route_failed = true
208
234
  say ' Could not edit config/routes.rb automatically.', :red
@@ -220,7 +246,7 @@ module Bible270
220
246
  say " Plan mounted at #{@mount_at}"
221
247
  say " Sign-in #{auth_summary}"
222
248
  say " Initializers #{initializer_summary}"
223
- say " Migrations #{@migrated ? 'copied and run' : 'copied (not yet run)'}"
249
+ say " Migrations #{migration_summary}"
224
250
  say " Picture uploads #{active_storage_summary}"
225
251
  say ''
226
252
 
@@ -241,16 +267,13 @@ module Bible270
241
267
  # bin/rails call fail ("Could not find ... in locally installed gems"), so
242
268
  # don't even try.
243
269
  def skip_rails_commands?
244
- return false unless @providers.any? && @bundled == false
270
+ return false unless @strategy_gems_added && @bundled == false
245
271
  return true if @warned_unbundled
246
272
 
247
273
  @warned_unbundled = true
248
274
  say ''
249
275
  say ' Gemfile changed but not bundled, so bin/rails cannot run yet.', :yellow
250
- say ' Skipping the migration steps run these once you have bundled:', :yellow
251
- say ' bundle install'
252
- say ' bin/rails bible270:install:migrations'
253
- say ' bin/rails db:migrate'
276
+ say ' Skipping Active Storage and migration commands until bundle install succeeds.', :yellow
254
277
  true
255
278
  end
256
279
 
@@ -320,10 +343,10 @@ module Bible270
320
343
 
321
344
  def ask_mailer_from
322
345
  default = "no-reply@#{app_domain}"
323
- address = prompt('From: address for sign-in emails?', default)
346
+ address = options[:mailer_from] || prompt('From: address for sign-in emails?', default)
324
347
 
325
348
  until valid_email?(address)
326
- say " '#{address}' doesn't look like an email address.", :red
349
+ say " #{address.inspect} doesn't look like an email address.", :red
327
350
  address = prompt('From: address for sign-in emails?', default)
328
351
  end
329
352
  address
@@ -352,7 +375,15 @@ module Bible270
352
375
  end
353
376
 
354
377
  def parse_providers(value)
355
- value.to_s.split(',').map { |p| p.strip.downcase.tr('-', '_') }.reject(&:empty?).uniq
378
+ providers = value.to_s.split(',').map { |provider| provider.strip.downcase.tr('-', '_') }
379
+ .reject(&:empty?).uniq
380
+ invalid = providers.grep_v(PROVIDER_IDENTIFIER)
381
+ if invalid.any?
382
+ say " Ignoring unsafe provider identifier(s): #{invalid.join(', ')}.", :red
383
+ say ' Provider names must start with a letter and contain only lowercase letters, numbers, and underscores.',
384
+ :red
385
+ end
386
+ providers - invalid
356
387
  end
357
388
 
358
389
  def app_domain
@@ -363,7 +394,11 @@ module Bible270
363
394
  end
364
395
 
365
396
  def providers_literal
366
- @providers.map { |p| ":#{p}" }.join(', ')
397
+ @providers.map { |provider| provider.to_sym.inspect }.join(', ')
398
+ end
399
+
400
+ def ruby_literal(value)
401
+ value.to_s.dump
367
402
  end
368
403
 
369
404
  def auth_path_prefix
@@ -382,15 +417,40 @@ module Bible270
382
417
  path = File.join(destination_root, 'config', 'routes.rb')
383
418
  return '' unless File.exist?(path)
384
419
 
385
- body = File.read(path)
386
- return '' unless body.match?(%r{comfy_route|match\s+['"]\*|get\s+['"]\*})
420
+ lines = File.readlines(path)
421
+ mount_index = lines.index { |line| line.match?(MOUNT_LINE_PATTERN) }
422
+ catch_all_index = catch_all_route_index(lines)
423
+ return '' unless catch_all_index && (!mount_index || mount_index > catch_all_index)
387
424
 
388
- ' Check the mount line sits ABOVE any catch-all route.'
425
+ ' Move the mount line ABOVE the catch-all route.'
426
+ end
427
+
428
+ def move_mount_above_catch_all
429
+ path = File.join(destination_root, 'config', 'routes.rb')
430
+ lines = File.readlines(path)
431
+ mount_index = lines.index { |line| line.match?(MOUNT_LINE_PATTERN) }
432
+ catch_all_index = catch_all_route_index(lines)
433
+ return false unless mount_index && catch_all_index && mount_index > catch_all_index
434
+
435
+ mount_line = lines.delete_at(mount_index)
436
+ lines.insert(catch_all_index, mount_line)
437
+ File.write(path, lines.join)
438
+ true
439
+ rescue StandardError
440
+ false
441
+ end
442
+
443
+ def catch_all_route_index(lines)
444
+ lines.index do |line|
445
+ code = line.sub(%r{#.*\z}, '')
446
+ code.match?(%r{\bcomfy_route\s+:cms\b.*\bpath:\s*['"]/['"]}) ||
447
+ code.match?(%r{\b(?:get|match)\s*\(?\s*['"]\*})
448
+ end
389
449
  end
390
450
 
391
451
  def routes_mounted?
392
452
  path = File.join(destination_root, 'config', 'routes.rb')
393
- File.exist?(path) && File.read(path).include?('Bible270::Engine')
453
+ File.exist?(path) && File.foreach(path).any? { |line| line.match?(MOUNT_LINE_PATTERN) }
394
454
  end
395
455
 
396
456
  def report_auth_choice
@@ -434,17 +494,30 @@ module Bible270
434
494
  body.include?('path_prefix') && !body.include?('Bible270.config.auth_path_prefix')
435
495
  end
436
496
 
497
+ def migration_summary
498
+ if @migrations_copied
499
+ @migrated ? 'copied and run' : 'copied (not yet run)'
500
+ else
501
+ 'not copied (not run)'
502
+ end
503
+ end
504
+
437
505
  def active_storage_summary
438
506
  case @active_storage
439
507
  when :installed then 'Active Storage installed'
440
508
  when :present then 'Active Storage already present'
441
509
  when :declined then 'off — Active Storage not installed'
442
510
  when :unavailable then 'off — Active Storage not enabled in this app'
511
+ when :pending then 'not installed — waiting for bundle install'
443
512
  else 'unchanged'
444
513
  end
445
514
  end
446
515
 
447
516
  def outstanding_steps
517
+ route_and_initializer_steps + installation_steps + provider_setup_steps + mailer_steps
518
+ end
519
+
520
+ def route_and_initializer_steps
448
521
  steps = []
449
522
  steps << "Add to config/routes.rb: #{MOUNT_LINE}" if @route_failed
450
523
  if @providers.any? && !initializer_present?('omniauth.rb')
@@ -453,22 +526,36 @@ module Bible270
453
526
  if stale_omniauth_prefix?
454
527
  steps << "config/initializers/omniauth.rb sets its own path_prefix — make it 'Bible270.config.auth_path_prefix'"
455
528
  end
456
- steps << 'bundle install' if @providers.any? && !@bundled
457
- steps << 'bin/rails bible270:install:migrations' if skip_rails_commands?
529
+ steps
530
+ end
531
+
532
+ def installation_steps
533
+ steps = []
534
+ steps << 'bundle install' if @strategy_gems_added && !@bundled
535
+ steps << unknown_provider_gem_step if @unknown_providers&.any?
536
+ steps << 'bin/rails active_storage:install' if @active_storage == :pending
537
+ steps << 'bin/rails bible270:install:migrations' unless @migrations_copied
458
538
  steps << 'bin/rails db:migrate' unless @migrated
459
539
  if @active_storage == :declined
460
540
  steps << 'bin/rails active_storage:install && bin/rails db:migrate — for picture uploads'
461
541
  end
542
+ steps
543
+ end
462
544
 
463
- if @providers.any?
464
- steps << 'Set each provider\'s client id/secret in credentials or ENV'
465
- @providers.each { |p| steps << "Register callback URL: https://YOUR-HOST#{auth_path_prefix}/#{p}/callback" }
466
- end
545
+ def unknown_provider_gem_step
546
+ providers = @unknown_providers.map { |provider| provider.to_sym.inspect }.join(', ')
547
+ "Add an OmniAuth strategy gem registering #{providers} to Gemfile, then run bundle install"
548
+ end
467
549
 
468
- if @email
469
- steps << 'Confirm Action Mailer can deliver mail in this environment'
470
- end
471
- steps
550
+ def provider_setup_steps
551
+ return [] if @providers.empty?
552
+
553
+ ['Set each provider\'s client id/secret in credentials or ENV'] +
554
+ @providers.map { |provider| "Register callback URL: https://YOUR-HOST#{auth_path_prefix}/#{provider}/callback" }
555
+ end
556
+
557
+ def mailer_steps
558
+ @email ? ['Confirm Action Mailer can deliver mail in this environment'] : []
472
559
  end
473
560
 
474
561
  # ---- file bodies ------------------------------------------------------
@@ -489,7 +576,7 @@ module Bible270
489
576
  # Change this one value to move the plan. config/routes.rb and
490
577
  # config/initializers/omniauth.rb both read it, so nothing else needs editing
491
578
  # (but do update the callback URLs registered with each OAuth provider).
492
- config.mount_at = '#{@mount_at}'
579
+ config.mount_at = #{ruby_literal(@mount_at)}
493
580
 
494
581
  config.app_name = 'Daily Bread'
495
582
  config.tagline = 'A 270-day journey through Scripture'
@@ -532,7 +619,7 @@ module Bible270
532
619
  # Passwordless email sign-in: a one-time link, no password, no third-party
533
620
  # account. This is what lets anyone take part. Needs Action Mailer delivery.
534
621
  config.email_sign_in = true
535
- config.mailer_from = '#{@mailer_from}'
622
+ config.mailer_from = #{ruby_literal(@mailer_from)}
536
623
  config.email_sign_in_ask_name = true
537
624
  # config.email_sign_in_ttl = 20 * 60 # link lifetime, seconds
538
625
  # config.email_sign_in_max_per_window = 5 # per address, per window
@@ -607,11 +694,12 @@ module Bible270
607
694
  end
608
695
 
609
696
  def provider_line(provider)
697
+ provider_symbol = provider.to_sym.inspect
610
698
  env = provider.upcase
611
699
  <<~RUBY.chomp
612
- provider :#{provider},
613
- Rails.application.credentials.dig(:#{provider}, :client_id) || ENV.fetch('#{env}_CLIENT_ID', nil),
614
- Rails.application.credentials.dig(:#{provider}, :client_secret) || ENV.fetch('#{env}_CLIENT_SECRET', nil)
700
+ provider #{provider_symbol},
701
+ Rails.application.credentials.dig(#{provider_symbol}, :client_id) || ENV.fetch(#{ruby_literal("#{env}_CLIENT_ID")}, nil),
702
+ Rails.application.credentials.dig(#{provider_symbol}, :client_secret) || ENV.fetch(#{ruby_literal("#{env}_CLIENT_SECRET")}, nil)
615
703
  RUBY
616
704
  end
617
705
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bible270
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew vonderLuft
@@ -139,7 +139,6 @@ files:
139
139
  - app/views/bible270/shared/_comment_drafts.html.erb
140
140
  - app/views/bible270/shared/_comment_notification_choices.html.erb
141
141
  - app/views/bible270/shared/_day_completion_dove.html.erb
142
- - app/views/bible270/shared/_day_completion_effect.html.erb
143
142
  - app/views/bible270/shared/_day_index.html.erb
144
143
  - app/views/bible270/shared/_footer.html.erb
145
144
  - app/views/bible270/shared/_grid_legend.html.erb
@@ -176,6 +175,7 @@ files:
176
175
  - db/migrate/20260101000017_add_reflections_seen_at_to_bible270_readers.rb
177
176
  - db/migrate/20260101000018_add_all_comment_notices_to_bible270_readers.rb
178
177
  - db/migrate/20260101000019_change_passage_source_default_to_bible_com.rb
178
+ - db/migrate/20260101000020_add_completion_dove_disabled_to_bible270_readers.rb
179
179
  - lib/bible270.rb
180
180
  - lib/bible270/avatars.rb
181
181
  - lib/bible270/configuration.rb
@@ -1,67 +0,0 @@
1
- <%# locals: (day:) %>
2
- <template data-b270-completion-dove-template data-b270-day="<%= day %>">
3
- <%= render "bible270/shared/day_completion_dove", day: day %>
4
- </template>
5
- <script nonce="<%= content_security_policy_nonce %>">
6
- (() => {
7
- if (window.Bible270DayCompletionEffect) return;
8
-
9
- function willCompleteDay(form) {
10
- const desiredState = form.querySelector("input[name='checked']");
11
- if (!desiredState || desiredState.value !== "1") return false;
12
-
13
- const unfinished = Array.from(
14
- document.querySelectorAll("form[data-b270-checkoff='true'] input[name='checked'][value='1']")
15
- );
16
- return unfinished.length === 1 && unfinished[0].form === form;
17
- }
18
-
19
- function startDove(day) {
20
- if (document.querySelector("[data-b270-completion-dove][data-b270-immediate]")) return;
21
-
22
- const template = document.querySelector("[data-b270-completion-dove-template]");
23
- if (!template || template.dataset.b270Day !== day) return;
24
-
25
- const dove = template.content.querySelector("[data-b270-completion-dove]").cloneNode(true);
26
- dove.dataset.b270Immediate = "true";
27
- document.body.append(dove);
28
- }
29
-
30
- function removeServerDuplicate() {
31
- const immediate = document.querySelector("[data-b270-completion-dove][data-b270-immediate]");
32
- if (!immediate) return;
33
-
34
- document.querySelectorAll("[data-b270-completion-dove]").forEach((dove) => {
35
- if (dove !== immediate) dove.remove();
36
- });
37
- }
38
-
39
- document.addEventListener("submit", (event) => {
40
- const form = event.target.closest("form[data-b270-checkoff='true']");
41
- const check = form && form.querySelector(".b270-check");
42
- if (!check) return;
43
-
44
- const bounds = check.getBoundingClientRect();
45
- document.documentElement.style.setProperty(
46
- "--b270-dove-origin-x",
47
- `${bounds.left + bounds.width / 2}px`
48
- );
49
- document.documentElement.style.setProperty(
50
- "--b270-dove-origin-y",
51
- `${bounds.top + bounds.height / 2}px`
52
- );
53
-
54
- if (willCompleteDay(form)) startDove(form.action.match(/\/day\/(\d+)/)?.[1] || "");
55
- });
56
-
57
- document.addEventListener("animationend", (event) => {
58
- if (event.target.matches("[data-b270-completion-dove]")) event.target.remove();
59
- });
60
-
61
- new MutationObserver(removeServerDuplicate).observe(document.documentElement, {
62
- childList: true,
63
- subtree: true
64
- });
65
- window.Bible270DayCompletionEffect = true;
66
- })();
67
- </script>