bible270 1.5.1 → 1.6.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f8c170757c087fab1698cb43ef5251e28c4e7c4f08187cb33b0ac3962f41fc28
4
- data.tar.gz: 5e803ddf4f8d77b56c2c7c91859b7ea0f8222341162790f39fb2cc7abbc1f517
3
+ metadata.gz: ddb40f5dac18c21c5661cf6b290eb55247b87666d01a1a4239c97cbde95fc251
4
+ data.tar.gz: ac97b21064ffe1658dd62c5cd0df71fb748b2db88829e2f7a71fbd799302a065
5
5
  SHA512:
6
- metadata.gz: 3ff3dcde674bf0be28d1baec700a39d8db3a26a199c72fc467dd6864020d1608a1667e9feecb2e530706cf1a92b3a653cd7b904dd883cc4f495dc9027145c6c8
7
- data.tar.gz: 4e3566d9c90076d1a9c4c46ee99e9d2415320fcfeb8be0d2acc1a03ac9168278362a8cfe1dd93b108e4db312c292f3436a4061e24b5cb211ec217445a942a254
6
+ metadata.gz: 9543258320235373bd87deb1b39aed672c9a2fa1f753f5d39951f4b28db048a2b627e2a9cfbf30df4db5f506d4469e641b2b437394b21192ab2e859914deaa06
7
+ data.tar.gz: 54f1d379ba07ef8850f5e23f68d1d3b77e0ac29f01fb3e9b2e5b408e3c2b4c079d88049fb0e1b20f72a3f821d50d32b2a037541bd98accd0cdc41fc585cc16a7
data/CHANGELOG.md CHANGED
@@ -6,19 +6,48 @@ All notable changes to bible270. Format follows [Keep a Changelog](https://keepa
6
6
 
7
7
  ### Added
8
8
 
9
+ - let administrators sort the reader list by first name, last name, most completed days, or least completed days, and open the Rake task's progress report in a dismissable Reader Stats dialog that follows the current sort selection
10
+ - let admin broadcasts target either every reader with an email address or only readers recognized as administrators
11
+
12
+ ### Changed
13
+
14
+ - send each broad reflection/reply notification once with all eligible subscribers in BCC, while keeping direct reply and mention notices individually addressed
15
+
16
+ ### Fixed
17
+
18
+ - normalize boolean column defaults reported by Rails adapters so migration reconciliation and tests work consistently across Rails 7.1 through 8.1
19
+
20
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.5.1...main)**
21
+
22
+ ## [1.5.1] - 2026-09-11
23
+
24
+ ### Added
25
+
26
+ - add `bible270:notifications:enable_all` to opt every existing reader into emails for each new reflection and reply, with environment-aware counts and guidance when the selected database has no readers
27
+ - add `bible270:readers:list` to print aligned Name, Days Read, and schedule Status columns, ordered by completed days
28
+
29
+ ### Changed
30
+
31
+ - default new readers to emails for every new reflection and reply; migration `20260101000022` changes only the default and preserves existing reader preferences
32
+
33
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.5.0...v1.5.1)**
34
+
35
+ ## [1.5.0] - 2026-09-07
36
+
37
+ ### Added
38
+
9
39
  - add an on-demand Markdown formatting drawer and separate server-rendered Preview control for reflections and replies, with safe rendering, formatted notification emails, mention and browser-draft compatibility, and literal display of existing plain-text reflections; upgrading hosts must copy and run the new `body_format` comment migration
10
- - add `bible270:notifications:enable_all` to opt every existing reader into emails for each new reflection and reply
11
- - add `bible270:readers:list` to print an aligned, progress-sorted reader report with schedule status
12
40
 
13
41
  ### Changed
14
42
 
15
- - default new readers to emails for every new reflection and reply; existing reader preferences remain unchanged by the migration
43
+ - update RuboCop and SimpleCov development dependencies and replace deprecated SimpleCov configuration methods
44
+ - use the current Coveralls-backed Shields.io coverage badge endpoint for the `main` branch
16
45
 
17
46
  ### Fixed
18
47
 
19
48
  - keep `json` below 3.0 while supported Rails releases still pass positional parse options, preventing session-cookie decoding from crashing every request under `json` 3.0
20
49
 
21
- **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.4.2...main)**
50
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.4.2...v1.5.0)**
22
51
 
23
52
  ## [1.4.2] - 2026-09-03
24
53
 
@@ -667,7 +696,9 @@ This is the initial public release.
667
696
 
668
697
  * 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.
669
698
 
670
- [Unreleased]: https://github.com/avonderluft/bible270/compare/v1.4.2...HEAD
699
+ [Unreleased]: https://github.com/avonderluft/bible270/compare/v1.5.1...HEAD
700
+ [1.5.1]: https://github.com/avonderluft/bible270/releases/tag/v1.5.1
701
+ [1.5.0]: https://github.com/avonderluft/bible270/releases/tag/v1.5.0
671
702
  [1.4.2]: https://github.com/avonderluft/bible270/releases/tag/v1.4.2
672
703
  [1.4.1]: https://github.com/avonderluft/bible270/releases/tag/v1.4.1
673
704
  [1.4.0]: https://github.com/avonderluft/bible270/releases/tag/v1.4.0
data/README.md CHANGED
@@ -258,8 +258,10 @@ end
258
258
 
259
259
  ## Admin panel
260
260
 
261
- At `<mount>/admin`: remove readers, adjust their completions, move them to a given day. Unreachable
262
- every route 404s rather than 403s unless you say who may use it:
261
+ At `<mount>/admin`: sort readers by first name, last name, or completed days; open **Reader Stats** for
262
+ the same progress report as `bible270:readers:list`, ordered by the current admin sort selection; remove
263
+ readers; adjust their completions; and move them to a given day. The panel is unreachable — every route
264
+ 404s rather than 403s — unless you say who may use it:
263
265
 
264
266
  ```ruby
265
267
  config.admin_emails = %w[andrew@example.org]
@@ -267,7 +269,9 @@ config.admin_emails = %w[andrew@example.org]
267
269
  config.admin_resolver = ->(reader) { reader.email.to_s.end_with?('@example.org') }
268
270
  ```
269
271
 
270
- Admins get an extra "Admin" link in the header. From a reader's page you can:
272
+ Admins get an extra "Admin" link in the header. The broadcast form can send an individually addressed
273
+ message either to every reader with an email address or only to readers recognized as administrators.
274
+ From a reader's page you can:
271
275
 
272
276
  - **move them to a day** — "put them on day 42 as of today" back-dates the start date; check-offs are
273
277
  keyed to day numbers, so nothing in their history moves;
@@ -293,8 +297,9 @@ them. Upgrading hosts must copy and run migration `20260101000021`, which record
293
297
  plain text or Markdown; until then, the composer safely falls back to plain text.
294
298
 
295
299
  When `config.mention_notifications` is enabled, each reader can request email for every new reflection
296
- and reply, only replies to their reflections and mentions of them, or none. Administrators can change
297
- the same preference from the reader's Admin page. Migration
300
+ and reply, only replies to their reflections and mentions of them, or none. Broad notifications are sent
301
+ once with eligible subscribers in BCC; direct reply and mention notices remain individually addressed.
302
+ Administrators can change the same preference from the reader's Admin page. Migration
298
303
  `20260101000018` adds the broad opt-in without changing any existing reader's choice, while migration
299
304
  `20260101000022` makes that broad option the default for readers created afterward. Migration
300
305
  `20260101000017` stores each reader's last visit to the Reflections page. After upgrading, install and
@@ -10,6 +10,13 @@ module Bible270
10
10
  class AdminController < ApplicationController
11
11
  LAST_BROADCAST_AT = 'last_broadcast_at'
12
12
  LAST_BROADCAST_SUBJECT = 'last_broadcast_subject'
13
+ BROADCAST_AUDIENCES = %w[everyone admins].freeze
14
+ READER_SORT_OPTIONS = {
15
+ 'first_name' => 'First Name',
16
+ 'last_name' => 'Last Name',
17
+ 'most_completed' => 'Most days completed',
18
+ 'least_completed' => 'Least days completed'
19
+ }.freeze
13
20
 
14
21
  before_action :require_admin!
15
22
  before_action :load_reader,
@@ -55,7 +62,11 @@ module Bible270
55
62
  def index
56
63
  @enrollment_closed = Setting.enrollment_closed?
57
64
  @enrollment_closed_at = Setting.enrollment_closed_at
58
- @readers = Reader.all.to_a.sort_by(&:sort_name)
65
+ @days_completed = Reader.completed_days_by_id
66
+ @reader_sort_options = READER_SORT_OPTIONS
67
+ @reader_sort = READER_SORT_OPTIONS.key?(params[:sort]) ? params[:sort] : 'first_name'
68
+ @reader_report = ReaderProgressReport.new(completed_days: @days_completed, sort: @reader_sort)
69
+ @readers = @reader_report.sorted_readers
59
70
  @run_start_date = Setting.run_start_date
60
71
  @configured_start_date = Bible270.config.start_date
61
72
  @run_start_date_overridden = Setting.run_start_date_overridden?
@@ -66,8 +77,9 @@ module Bible270
66
77
  @shared_calendar_readers = @readers.size
67
78
  @personal_calendar_readers = 0
68
79
  end
69
- @days_completed = Reader.completed_days_by_id
70
- @reachable = Reader.where.not(email: [nil, '']).count
80
+ reachable_readers = @readers.select { |reader| reader.email.present? }
81
+ @reachable = reachable_readers.size
82
+ @reachable_admins = reachable_readers.count { |reader| Bible270.config.admin?(reader) }
71
83
  @last_broadcast_at = parsed_time(Setting.read(LAST_BROADCAST_AT))
72
84
  @last_broadcast_subject = Setting.read(LAST_BROADCAST_SUBJECT)
73
85
  end
@@ -118,13 +130,21 @@ module Bible270
118
130
  def broadcast
119
131
  subject = params[:subject].to_s.strip
120
132
  body = params[:body].to_s.strip
133
+ audience = params[:audience].presence || 'everyone'
121
134
 
135
+ unless BROADCAST_AUDIENCES.include?(audience)
136
+ return redirect_to admin_path, alert: 'Choose who should receive the message. Nothing was sent.'
137
+ end
122
138
  if subject.empty? || body.empty?
123
139
  return redirect_to(admin_path, alert: 'A message needs both a subject and something to say.')
124
140
  end
125
141
 
126
- recipients = Reader.where.not(email: [nil, '']).order(:id)
127
- return redirect_to(admin_path, alert: 'Nobody has an email address.') if recipients.empty?
142
+ recipients = broadcast_recipients(audience)
143
+ noun = audience == 'admins' ? 'admin' : 'reader'
144
+ if recipients.empty?
145
+ alert = audience == 'admins' ? 'No admins have an email address.' : 'Nobody has an email address.'
146
+ return redirect_to admin_path, alert: alert
147
+ end
128
148
 
129
149
  recipient_count = recipients.count
130
150
  sent = deliver_broadcast(recipients, subject, body)
@@ -132,7 +152,7 @@ module Bible270
132
152
 
133
153
  if sent.zero?
134
154
  action = later ? 'queued' : 'sent'
135
- return redirect_to admin_path, alert: "The message could not be #{action} to any readers."
155
+ return redirect_to admin_path, alert: "The message could not be #{action} to any #{noun.pluralize}."
136
156
  end
137
157
 
138
158
  Setting.write(LAST_BROADCAST_AT, Time.current.iso8601)
@@ -140,8 +160,8 @@ module Bible270
140
160
 
141
161
  action = later ? 'Queued for' : 'Sent to'
142
162
  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?
163
+ notice = "#{action} #{sent} #{noun.pluralize(sent)}."
164
+ notice += " #{failures} #{noun.pluralize(failures)} could not be #{later ? 'queued' : 'sent'}." if failures.positive?
145
165
  redirect_to admin_path, notice: notice
146
166
  end
147
167
 
@@ -285,6 +305,14 @@ module Bible270
285
305
  nil
286
306
  end
287
307
 
308
+ def broadcast_recipients(audience)
309
+ recipients = Reader.where.not(email: [nil, '']).order(:id)
310
+ return recipients if audience == 'everyone'
311
+
312
+ admin_ids = recipients.select { |reader| Bible270.config.admin?(reader) }.map(&:id)
313
+ recipients.where(id: admin_ids)
314
+ end
315
+
288
316
  def deliver_broadcast(recipients, subject, body)
289
317
  later = Bible270.config.registration_notice_deliver_later
290
318
  sent = 0
@@ -36,16 +36,19 @@ module Bible270
36
36
  deliver_comment_notice("#{@app_name}: #{@author.display_name} replied on day #{@comment.day}")
37
37
  end
38
38
 
39
- def comment_posted(comment_id:, reader_id:)
40
- return message.perform_deliveries = false unless prepare_comment_notice(comment_id, reader_id)
41
- return message.perform_deliveries = false unless @reader.wants_all_comment_notifications?
42
- return message.perform_deliveries = false if @comment.reader_id == @reader.id
39
+ def comment_posted(comment_id:, reader_ids:)
40
+ return message.perform_deliveries = false unless prepare_comment(comment_id)
41
+
42
+ addresses = all_comment_addresses(reader_ids)
43
+ return message.perform_deliveries = false if addresses.empty?
43
44
 
44
45
  kind = @comment.reply? ? 'reply' : 'reflection'
45
46
  @heading = "#{@author.display_name} posted a #{kind}"
46
47
  @introduction = "#{@author.display_name} posted a #{kind} on day #{@comment.day}."
47
48
  @reason = 'You are getting this because you asked to be emailed about every new reflection and reply.'
48
- deliver_comment_notice("#{@app_name}: #{@author.display_name} posted a #{kind} on day #{@comment.day}")
49
+ mail bcc: addresses,
50
+ subject: "#{@app_name}: #{@author.display_name} posted a #{kind} on day #{@comment.day}",
51
+ template_name: 'comment_notice'
49
52
  end
50
53
 
51
54
  def daily_reminder(reader_id:, day:, on: Bible270.today)
@@ -108,13 +111,21 @@ module Bible270
108
111
  end
109
112
 
110
113
  def prepare_comment_notice(comment_id, reader_id)
111
- return false unless Bible270.config.mention_notifications
114
+ return false unless prepare_comment(comment_id)
112
115
 
113
- @comment = Comment.find_by(id: comment_id)
114
116
  @reader = Reader.find_by(id: reader_id)
115
- return false if @comment.nil? || @reader.nil? || @reader.email.blank?
117
+ return false if @reader.nil? || @reader.email.blank?
116
118
  return false unless @reader.wants_comment_notifications?
117
119
 
120
+ true
121
+ end
122
+
123
+ def prepare_comment(comment_id)
124
+ return false unless Bible270.config.mention_notifications
125
+
126
+ @comment = Comment.find_by(id: comment_id)
127
+ return false if @comment.nil?
128
+
118
129
  @author = @comment.reader
119
130
  @app_name = Bible270.config.app_name
120
131
  @day_url = day_url_for(@comment.day)
@@ -122,6 +133,15 @@ module Bible270
122
133
  true
123
134
  end
124
135
 
136
+ def all_comment_addresses(reader_ids)
137
+ Reader.all_comment_notification_recipients
138
+ .where(id: Array(reader_ids))
139
+ .where.not(id: @comment.reader_id)
140
+ .pluck(:email)
141
+ .map(&:strip)
142
+ .uniq(&:downcase)
143
+ end
144
+
125
145
  def deliver_comment_notice(subject)
126
146
  mail to: @reader.email, subject: subject, template_name: 'comment_notice'
127
147
  end
@@ -205,9 +205,20 @@ module Bible270
205
205
  end
206
206
 
207
207
  def notify_all_comment_readers(except: [])
208
- Reader.all_comment_notification_recipients.where.not(id: except).find_each do |recipient|
209
- deliver_comment_notice(recipient, :comment_posted)
208
+ recipient_ids = Reader.all_comment_notification_recipients.where.not(id: except).pluck(:id)
209
+ return if recipient_ids.empty?
210
+
211
+ notice = NoticeMailer.comment_posted(comment_id: id, reader_ids: recipient_ids)
212
+ if Bible270.config.registration_notice_deliver_later
213
+ notice.deliver_later
214
+ else
215
+ notice.deliver_now
210
216
  end
217
+ rescue StandardError => e
218
+ Rails.logger.error(
219
+ "[bible270] could not send comment_posted notice for comment #{id} " \
220
+ "to #{recipient_ids&.size || 0} readers: #{e.class}: #{e.message}"
221
+ )
211
222
  end
212
223
 
213
224
  def reply_notification_reader
@@ -63,6 +63,18 @@
63
63
  </div>
64
64
 
65
65
  <% if @readers.any? %>
66
+ <div class="b270-admin-reader-tools">
67
+ <%= form_with url: admin_path, method: :get, class: "b270-admin-reader-sort" do %>
68
+ <label>
69
+ <span>Sort readers by</span>
70
+ <%= select_tag :sort, options_for_select(@reader_sort_options.map { |value, label| [label, value] }, @reader_sort) %>
71
+ </label>
72
+ <%= submit_tag "Sort", class: "b270-btn" %>
73
+ <% end %>
74
+ <button type="button" class="b270-btn b270-reader-stats-open" aria-haspopup="dialog"
75
+ aria-controls="b270-reader-stats-dialog" data-b270-reader-stats-open hidden>Reader Stats</button>
76
+ </div>
77
+
66
78
  <div class="b270-list">
67
79
  <% @readers.each do |reader| %>
68
80
  <div class="b270-li">
@@ -84,27 +96,73 @@
84
96
  </div>
85
97
  <% end %>
86
98
  </div>
99
+
100
+ <dialog id="b270-reader-stats-dialog" class="b270-reader-stats-dialog"
101
+ aria-labelledby="b270-reader-stats-title" data-b270-reader-stats-dialog>
102
+ <div class="b270-reader-stats-content">
103
+ <div class="b270-reader-stats-head">
104
+ <h2 id="b270-reader-stats-title" class="b270-subhead">Reader Stats</h2>
105
+ <form method="dialog"><button type="submit" class="b270-linkbtn">Close</button></form>
106
+ </div>
107
+ <pre><%= @reader_report.to_table %></pre>
108
+ </div>
109
+ </dialog>
110
+
111
+ <script nonce="<%= content_security_policy_nonce %>">
112
+ (() => {
113
+ function scan() {
114
+ document.querySelectorAll("[data-b270-reader-stats-open]").forEach((button) => {
115
+ const dialog = document.getElementById(button.getAttribute("aria-controls"));
116
+ if (dialog && typeof dialog.showModal === "function") button.hidden = false;
117
+ });
118
+ }
119
+
120
+ if (window.Bible270ReaderStats) {
121
+ scan();
122
+ return;
123
+ }
124
+
125
+ document.addEventListener("click", (event) => {
126
+ const target = event.target;
127
+ const openButton = target.closest && target.closest("[data-b270-reader-stats-open]");
128
+ if (openButton) {
129
+ const dialog = document.getElementById(openButton.getAttribute("aria-controls"));
130
+ if (dialog && typeof dialog.showModal === "function") dialog.showModal();
131
+ return;
132
+ }
133
+
134
+ if (target.matches && target.matches("[data-b270-reader-stats-dialog]")) target.close();
135
+ });
136
+ document.addEventListener("turbo:load", scan);
137
+ window.Bible270ReaderStats = { scan };
138
+ scan();
139
+ })();
140
+ </script>
87
141
  <% else %>
88
142
  <p style="color:var(--faint)">No readers yet.</p>
89
143
  <% end %>
90
144
 
91
145
  <div class="b270-panel">
92
- <h2 class="b270-subhead">Write to everyone</h2>
146
+ <h2 class="b270-subhead">Write a message</h2>
93
147
 
94
148
  <% if @reachable.zero? %>
95
149
  <p class="b270-hint" style="margin-top:0">No reader has an email address yet.</p>
96
150
  <% else %>
97
151
  <p class="b270-hint" style="margin-top:0">
98
- Goes to <strong><%= @reachable %></strong>
99
- <%= @reachable == 1 ? 'reader' : 'readers' %> individually, not as one message with
100
- everyone in bcc so each is greeted by name.
152
+ <strong><%= pluralize(@reachable, 'reader') %></strong> can receive email,
153
+ including <strong><%= pluralize(@reachable_admins, 'admin') %></strong>.
154
+ Messages are sent individually so each recipient can be greeted by name.
101
155
  <% if @last_broadcast_at.present? %>
102
156
  <br>Last sent <%= b270_datetime(@last_broadcast_at) %><% if @last_broadcast_subject.present? %>: <em><%= @last_broadcast_subject %></em><% end %>.
103
157
  <% end %>
104
158
  </p>
105
159
 
106
160
  <%= form_with url: admin_broadcast_path, method: :post, class: "b270-form",
107
- data: { turbo_confirm: "Send this to #{@reachable} #{@reachable == 1 ? 'reader' : 'readers'}?" } do %>
161
+ data: { turbo_confirm: "Send this message to the selected recipients?" } do %>
162
+ <div class="b270-broadcast-audience">
163
+ <label for="b270-broadcast-audience" class="b270-sr-only">Recipients</label>
164
+ <%= select_tag :audience, options_for_select([['Write to everyone', 'everyone'], ['Write to all admins', 'admins']], 'everyone'), id: 'b270-broadcast-audience', class: 'b270-select' %>
165
+ </div>
108
166
  <%= text_field_tag :subject, params[:subject], placeholder: "Subject", class: "b270-input" %>
109
167
  <%= text_area_tag :body, params[:body], placeholder: "What would you like to say?", rows: 6 %>
110
168
  <div class="row">
@@ -277,6 +277,7 @@
277
277
  .b270-input{width:100%;padding:9px 11px;border:1px solid var(--line);border-radius:9px;background:#fff;font:inherit;font-size:14px;margin-bottom:8px}
278
278
  .b270-form textarea{width:100%;font:inherit;font-size:15px;border:1px solid var(--line);border-radius:10px;padding:11px 13px;background:#fff;resize:vertical;min-height:76px}
279
279
  .b270-form textarea[data-b270-autosize=false]{min-height:114px}
280
+ .b270-broadcast-audience{margin:8px 0}
280
281
  .b270-editor-utilities[hidden]{display:none}
281
282
  .b270-editor-utilities{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:0 2px 5px}
282
283
  .b270 .b270-editor-utility{padding:2px 0;border:0;background:transparent;color:var(--muted);font:inherit;font-size:12px;cursor:pointer}
@@ -313,6 +314,19 @@
313
314
  .b270-btn:hover{background:#3a352b}
314
315
 
315
316
  /* community */
317
+ .b270-admin-reader-tools{display:flex;align-items:flex-end;justify-content:flex-end;gap:8px;margin:0 0 10px;flex-wrap:wrap}
318
+ .b270-admin-reader-sort{display:flex;align-items:flex-end;gap:8px}
319
+ .b270-admin-reader-sort label{display:grid;gap:3px;font-size:11.5px;color:var(--muted)}
320
+ .b270-admin-reader-sort select{min-width:170px;padding:7px 25px 7px 9px;border:1px solid var(--line);border-radius:8px;background:#fff;color:var(--ink);font:inherit;font-size:12.5px}
321
+ .b270 .b270-admin-reader-sort .b270-btn,.b270 .b270-reader-stats-open{margin:0;padding:8px 13px;font-size:12.5px}
322
+ .b270-reader-stats-dialog{width:min(680px,calc(100vw - 28px));max-width:none;padding:0;border:1px solid var(--line);border-radius:14px;background:var(--card);color:var(--ink);box-shadow:0 18px 60px rgba(35,31,24,.35)}
323
+ .b270-reader-stats-dialog::backdrop{background:rgba(35,31,24,.55)}
324
+ .b270-reader-stats-content{padding:20px}
325
+ .b270-reader-stats-head{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:12px}
326
+ .b270-reader-stats-head .b270-subhead{margin:0}
327
+ .b270-reader-stats-head form{margin:0}
328
+ .b270-reader-stats-head .b270-linkbtn{margin:0;color:var(--muted)}
329
+ .b270-reader-stats-dialog pre{max-height:65vh;overflow:auto;margin:0;padding:12px;border:1px solid var(--line-soft);border-radius:9px;background:#fff;color:var(--ink);font:12.5px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
316
330
  .b270-list{background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);overflow:hidden}
317
331
  .b270-li{display:flex;align-items:center;gap:14px;padding:14px 18px}
318
332
  .b270-li+.b270-li{border-top:1px solid var(--line-soft)}
@@ -227,7 +227,8 @@ module Bible270
227
227
  return missing if missing.any?
228
228
 
229
229
  column = connection.columns(:bible270_readers).find { |candidate| candidate.name == 'notify_on_all_comments' }
230
- column&.default == true ? [] : ['column bible270_readers.notify_on_all_comments default true']
230
+ default = ActiveModel::Type::Boolean.new.cast(column&.default)
231
+ default == true ? [] : ['column bible270_readers.notify_on_all_comments default true']
231
232
  end
232
233
 
233
234
  # The Bible270 installer can install Active Storage for reader avatars. A
@@ -4,20 +4,24 @@ module Bible270
4
4
  # Builds the plain-text reader progress table used by administrative tasks.
5
5
  class ReaderProgressReport
6
6
  HEADERS = ['Name', 'Days Read', 'Status'].freeze
7
+ SORTS = %w[first_name last_name most_completed least_completed].freeze
8
+ DEFAULT_SORT = 'most_completed'
7
9
  Row = Struct.new(:name, :days_read, :status, keyword_init: true)
8
10
 
9
- def initialize(readers: Reader.all, completed_days: Reader.completed_days_by_id)
11
+ def initialize(readers: Reader.all, completed_days: Reader.completed_days_by_id, sort: DEFAULT_SORT)
10
12
  @readers = readers
11
13
  @completed_days = completed_days
14
+ @sort = SORTS.include?(sort.to_s) ? sort.to_s : DEFAULT_SORT
15
+ end
16
+
17
+ def sorted_readers
18
+ @sorted_readers ||= readers.to_a.sort_by { |reader| reader_sort_key(reader) }
12
19
  end
13
20
 
14
21
  def rows
15
- @rows ||= begin
16
- report_rows = readers.map do |reader|
17
- days_read = completed_days.fetch(reader.id, 0)
18
- Row.new(name: reader.display_name, days_read: days_read, status: status(reader, days_read))
19
- end
20
- report_rows.sort_by { |row| [-row.days_read, row.name.downcase, row.name] }
22
+ @rows ||= sorted_readers.map do |reader|
23
+ days_read = completed_days.fetch(reader.id, 0)
24
+ Row.new(name: reader.display_name, days_read: days_read, status: status(reader, days_read))
21
25
  end
22
26
  end
23
27
 
@@ -36,7 +40,21 @@ module Bible270
36
40
 
37
41
  private
38
42
 
39
- attr_reader :readers, :completed_days
43
+ attr_reader :readers, :completed_days, :sort
44
+
45
+ def reader_sort_key(reader)
46
+ case sort
47
+ when 'first_name'
48
+ [reader.sort_name, reader.id]
49
+ when 'last_name'
50
+ last_name = reader.last_name.presence || reader.display_name.to_s.split.last
51
+ [last_name.to_s.downcase, reader.sort_name, reader.id]
52
+ when 'least_completed'
53
+ [completed_days[reader.id].to_i, reader.sort_name, reader.id]
54
+ else
55
+ [-completed_days[reader.id].to_i, reader.sort_name, reader.id]
56
+ end
57
+ end
40
58
 
41
59
  def status(reader, days_read)
42
60
  calendar_day = reader.calendar_day
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bible270
4
- VERSION = '1.5.1'
4
+ VERSION = '1.6.0'
5
5
  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.5.1
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew vonderLuft