bible270 1.5.0 → 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: 7867bb4de49fe177447a5f7b1cfa9fa031e1926cdb8c4c73f2071eb8a7620c82
4
- data.tar.gz: '0499e0931abdd3890f07945f7fc82f6de8dc5da1a156fbb8147e1b5b2be79596'
3
+ metadata.gz: ddb40f5dac18c21c5661cf6b290eb55247b87666d01a1a4239c97cbde95fc251
4
+ data.tar.gz: ac97b21064ffe1658dd62c5cd0df71fb748b2db88829e2f7a71fbd799302a065
5
5
  SHA512:
6
- metadata.gz: 422848afb4e96f1a94cbfc26078f7c343afdb519e431c25aa6f2de10be0578d51fa65aa6d04683490b792685d5888474b8f711f0e1839ccd6ccaeb45c52c26d0
7
- data.tar.gz: 81cd1f5df1028fa4064bc77ec5ee20866406d21c1ae5d4d992e8137bde59e5c6d6c505c7fc92b85111c931b7450da60b125692cb0bacbe63652e63012f9257da
6
+ metadata.gz: 9543258320235373bd87deb1b39aed672c9a2fa1f753f5d39951f4b28db048a2b627e2a9cfbf30df4db5f506d4469e641b2b437394b21192ab2e859914deaa06
7
+ data.tar.gz: 54f1d379ba07ef8850f5e23f68d1d3b77e0ac29f01fb3e9b2e5b408e3c2b4c079d88049fb0e1b20f72a3f821d50d32b2a037541bd98accd0cdc41fc585cc16a7
data/CHANGELOG.md CHANGED
@@ -6,13 +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
40
 
41
+ ### Changed
42
+
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
45
+
11
46
  ### Fixed
12
47
 
13
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
14
49
 
15
- **[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)**
16
51
 
17
52
  ## [1.4.2] - 2026-09-03
18
53
 
@@ -661,7 +696,9 @@ This is the initial public release.
661
696
 
662
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.
663
698
 
664
- [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
665
702
  [1.4.2]: https://github.com/avonderluft/bible270/releases/tag/v1.4.2
666
703
  [1.4.1]: https://github.com/avonderluft/bible270/releases/tag/v1.4.1
667
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,9 +297,11 @@ 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
298
- `20260101000018` adds the broad opt-in without changing any existing reader's choice. 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
303
+ `20260101000018` adds the broad opt-in without changing any existing reader's choice, while migration
304
+ `20260101000022` makes that broad option the default for readers created afterward. Migration
299
305
  `20260101000017` stores each reader's last visit to the Reflections page. After upgrading, install and
300
306
  run the current migrations:
301
307
 
@@ -304,6 +310,27 @@ bin/rails bible270:install:migrations
304
310
  bin/rails db:migrate
305
311
  ```
306
312
 
313
+ To change every existing reader to **Every new reflection and reply**, run:
314
+
315
+ ```bash
316
+ bin/rails bible270:notifications:enable_all
317
+ ```
318
+
319
+ With Docker Compose:
320
+
321
+ ```bash
322
+ docker compose exec -e RAILS_ENV=production rails bin/rails bible270:notifications:enable_all
323
+ ```
324
+
325
+ To print an aligned progress report ordered by completed days, run:
326
+
327
+ ```bash
328
+ docker compose exec -e RAILS_ENV=production rails bin/rails bible270:readers:list
329
+ ```
330
+
331
+ The report compares completed days with the calendar day implied by each reader's effective start date
332
+ and shows `on track`, days ahead or behind, or `undated` when no calendar applies.
333
+
307
334
  Break points can also live in your app rather than the gem, and be changed without a restart:
308
335
 
309
336
  ```ruby
@@ -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)}
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ChangeAllCommentNoticesDefaultToTrue < ActiveRecord::Migration[7.0]
4
+ def up
5
+ change_column_default :bible270_readers, :notify_on_all_comments, from: false, to: true
6
+ end
7
+
8
+ def down
9
+ change_column_default :bible270_readers, :notify_on_all_comments, from: true, to: false
10
+ end
11
+ end
@@ -31,6 +31,7 @@ module Bible270
31
31
  'ChangePassageSourceDefaultToBibleCom' => :check_passage_source_default,
32
32
  'AddCompletionDoveDisabledToBible270Readers' => :check_completion_dove_disabled,
33
33
  'AddBodyFormatToBible270Comments' => :check_comment_body_format,
34
+ 'ChangeAllCommentNoticesDefaultToTrue' => :check_all_comment_notices_default,
34
35
  'CreateActiveStorageTables' => :check_active_storage
35
36
  }.freeze
36
37
 
@@ -221,6 +222,15 @@ module Bible270
221
222
  missing
222
223
  end
223
224
 
225
+ def check_all_comment_notices_default
226
+ missing = table_missing(:bible270_readers, columns: %i[notify_on_all_comments])
227
+ return missing if missing.any?
228
+
229
+ column = connection.columns(:bible270_readers).find { |candidate| candidate.name == 'notify_on_all_comments' }
230
+ default = ActiveModel::Type::Boolean.new.cast(column&.default)
231
+ default == true ? [] : ['column bible270_readers.notify_on_all_comments default true']
232
+ end
233
+
224
234
  # The Bible270 installer can install Active Storage for reader avatars. A
225
235
  # copied database can therefore have the same timestamp mismatch for Rails'
226
236
  # generated Active Storage migration as it has for the engine migrations.
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bible270
4
+ # Builds the plain-text reader progress table used by administrative tasks.
5
+ class ReaderProgressReport
6
+ HEADERS = ['Name', 'Days Read', 'Status'].freeze
7
+ SORTS = %w[first_name last_name most_completed least_completed].freeze
8
+ DEFAULT_SORT = 'most_completed'
9
+ Row = Struct.new(:name, :days_read, :status, keyword_init: true)
10
+
11
+ def initialize(readers: Reader.all, completed_days: Reader.completed_days_by_id, sort: DEFAULT_SORT)
12
+ @readers = readers
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) }
19
+ end
20
+
21
+ def rows
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))
25
+ end
26
+ end
27
+
28
+ def empty? = rows.empty?
29
+
30
+ def to_table
31
+ values = rows.map { |row| [row.name, row.days_read.to_s, row.status] }
32
+ widths = HEADERS.each_index.map do |index|
33
+ ([HEADERS[index].length] + values.map { |value| value[index].length }).max
34
+ end
35
+ format = "%-#{widths[0]}s %#{widths[1]}s %-#{widths[2]}s"
36
+ lines = [format % HEADERS, format % widths.map { |width| '-' * width }].map(&:rstrip)
37
+ lines.concat(values.map { |value| (format % value).rstrip })
38
+ lines.join("\n")
39
+ end
40
+
41
+ private
42
+
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
58
+
59
+ def status(reader, days_read)
60
+ calendar_day = reader.calendar_day
61
+ return 'undated' unless calendar_day
62
+
63
+ difference = days_read - calendar_day
64
+ return 'on track' if difference.zero?
65
+
66
+ amount = difference.abs
67
+ "#{amount} #{amount == 1 ? 'day' : 'days'} #{difference.positive? ? 'ahead' : 'behind'}"
68
+ end
69
+ end
70
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bible270
4
- VERSION = '1.5.0'
4
+ VERSION = '1.6.0'
5
5
  end
data/lib/bible270.rb CHANGED
@@ -13,6 +13,7 @@ require 'bible270/translations'
13
13
  require 'bible270/favicon'
14
14
  require 'bible270/configuration'
15
15
  require 'bible270/daily_reminders'
16
+ require 'bible270/reader_progress_report'
16
17
  require 'bible270/engine' if defined?(Rails::Engine)
17
18
 
18
19
  module Bible270
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :bible270 do
4
+ namespace :notifications do
5
+ desc 'Email every existing reader about each new reflection and reply'
6
+ task enable_all: :environment do
7
+ unless Bible270::Reader.comment_notification_columns?
8
+ abort 'Reflection email columns are missing. Run bin/rails bible270:install:migrations and bin/rails db:migrate.'
9
+ end
10
+
11
+ total = Bible270::Reader.count
12
+ puts "Found #{total} Bible270 #{'reader'.pluralize(total)} in the #{Rails.env} environment."
13
+ if total.zero?
14
+ puts 'No reader settings were changed.'
15
+ puts 'Rerun with RAILS_ENV=production if that is where your readers are stored.' unless Rails.env.production?
16
+ next
17
+ end
18
+
19
+ readers = Bible270::Reader.where(notify_on_mention: false)
20
+ .or(Bible270::Reader.where(notify_on_all_comments: false))
21
+ updated = 0
22
+
23
+ Bible270::Reader.transaction do
24
+ readers.find_each do |reader|
25
+ reader.update_comment_notification_level!('all')
26
+ updated += 1
27
+ end
28
+ end
29
+
30
+ if updated.zero?
31
+ puts "All #{total} #{'reader'.pluralize(total)} already receive every new reflection and reply."
32
+ else
33
+ puts "Updated #{updated} #{'reader'.pluralize(updated)}. " \
34
+ "All #{total} #{'reader'.pluralize(total)} now receive every new reflection and reply."
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bible270/reader_progress_report'
4
+
5
+ namespace :bible270 do
6
+ namespace :readers do
7
+ desc 'List readers by completed days with their schedule status'
8
+ task list: :environment do
9
+ report = Bible270::ReaderProgressReport.new
10
+ if report.empty?
11
+ puts "No Bible270 readers found in the #{Rails.env} environment."
12
+ else
13
+ puts report.to_table
14
+ end
15
+ end
16
+ end
17
+ 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.0
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew vonderLuft
@@ -214,6 +214,7 @@ files:
214
214
  - db/migrate/20260101000019_change_passage_source_default_to_bible_com.rb
215
215
  - db/migrate/20260101000020_add_completion_dove_disabled_to_bible270_readers.rb
216
216
  - db/migrate/20260101000021_add_body_format_to_bible270_comments.rb
217
+ - db/migrate/20260101000022_change_all_comment_notices_default_to_true.rb
217
218
  - lib/bible270.rb
218
219
  - lib/bible270/avatars.rb
219
220
  - lib/bible270/comment_formatter.rb
@@ -226,6 +227,7 @@ files:
226
227
  - lib/bible270/migration_reconciler.rb
227
228
  - lib/bible270/names.rb
228
229
  - lib/bible270/plan.rb
230
+ - lib/bible270/reader_progress_report.rb
229
231
  - lib/bible270/translations.rb
230
232
  - lib/bible270/versification.rb
231
233
  - lib/bible270/version.rb
@@ -233,6 +235,8 @@ files:
233
235
  - lib/generators/bible270/install/templates/bible270.rb.tt
234
236
  - lib/generators/bible270/install/templates/omniauth.rb.tt
235
237
  - lib/tasks/bible270_migrations.rake
238
+ - lib/tasks/bible270_notifications.rake
239
+ - lib/tasks/bible270_readers.rake
236
240
  - lib/tasks/bible270_reminders.rake
237
241
  homepage: https://github.com/avonderluft/bible270
238
242
  licenses: