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 +4 -4
- data/CHANGELOG.md +36 -5
- data/README.md +10 -5
- 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/lib/bible270/migration_reconciler.rb +2 -1
- data/lib/bible270/reader_progress_report.rb +26 -8
- data/lib/bible270/version.rb +1 -1
- metadata +1 -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,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
|
-
-
|
|
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...
|
|
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.
|
|
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`:
|
|
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,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.
|
|
297
|
-
|
|
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
|
-
@
|
|
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)}
|
|
@@ -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
|
-
|
|
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 ||=
|
|
16
|
-
|
|
17
|
-
|
|
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
|
data/lib/bible270/version.rb
CHANGED