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 +4 -4
- data/CHANGELOG.md +39 -2
- data/README.md +33 -6
- data/app/controllers/bible270/admin_controller.rb +36 -8
- data/app/mailers/bible270/notice_mailer.rb +28 -8
- data/app/models/bible270/comment.rb +13 -2
- data/app/views/bible270/admin/index.html.erb +63 -5
- data/app/views/bible270/shared/_styles.html.erb +14 -0
- data/db/migrate/20260101000022_change_all_comment_notices_default_to_true.rb +11 -0
- data/lib/bible270/migration_reconciler.rb +10 -0
- data/lib/bible270/reader_progress_report.rb +70 -0
- data/lib/bible270/version.rb +1 -1
- data/lib/bible270.rb +1 -0
- data/lib/tasks/bible270_notifications.rake +38 -0
- data/lib/tasks/bible270_readers.rake +17 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ddb40f5dac18c21c5661cf6b290eb55247b87666d01a1a4239c97cbde95fc251
|
|
4
|
+
data.tar.gz: ac97b21064ffe1658dd62c5cd0df71fb748b2db88829e2f7a71fbd799302a065
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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...
|
|
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.
|
|
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`:
|
|
262
|
-
|
|
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.
|
|
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.
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
70
|
-
@reachable =
|
|
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 =
|
|
127
|
-
|
|
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
|
|
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} #{
|
|
144
|
-
notice += " #{failures} #{
|
|
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:,
|
|
40
|
-
return message.perform_deliveries = false unless
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
|
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 @
|
|
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).
|
|
209
|
-
|
|
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
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
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
|
data/lib/bible270/version.rb
CHANGED
data/lib/bible270.rb
CHANGED
|
@@ -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.
|
|
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:
|