bible270 0.14.1 → 0.15.1
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 +28 -1
- data/README.md +2 -2
- data/app/controllers/bible270/admin_controller.rb +1 -1
- data/app/controllers/bible270/application_controller.rb +40 -0
- data/app/controllers/bible270/checkoffs_controller.rb +8 -3
- data/app/controllers/bible270/days_controller.rb +6 -3
- data/app/controllers/bible270/readers_controller.rb +10 -1
- data/app/controllers/bible270/sessions_controller.rb +10 -0
- data/app/models/bible270/checkoff.rb +12 -1
- data/app/models/bible270/reader.rb +85 -15
- data/app/views/bible270/admin/show.html.erb +1 -26
- data/app/views/bible270/days/_reading.html.erb +51 -11
- data/app/views/bible270/days/index.html.erb +2 -39
- data/app/views/bible270/readers/progress.html.erb +33 -0
- data/app/views/bible270/readers/show.html.erb +6 -23
- data/app/views/bible270/shared/_header.html.erb +1 -1
- data/app/views/bible270/shared/_progress.html.erb +55 -0
- data/app/views/bible270/shared/_reflections.html.erb +44 -0
- data/app/views/bible270/shared/_styles.html.erb +20 -1
- data/app/views/bible270/shared/_track_bars.html.erb +21 -0
- data/config/routes.rb +5 -0
- data/db/migrate/20260101000009_add_part_to_bible270_checkoffs.rb +59 -0
- data/db/migrate/20260101000010_add_remember_token_to_bible270_readers.rb +11 -0
- data/lib/bible270/configuration.rb +14 -2
- data/lib/bible270/plan.rb +36 -0
- data/lib/bible270/versification.rb +11 -1
- data/lib/bible270/version.rb +1 -1
- data/lib/generators/bible270/install/templates/bible270.rb.tt +2 -1
- metadata +21 -2
- data/lib/bible270/bible270.rb +0 -14
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b0d1b963495f4293f7e9c2a2bfdd6e6bf22c70d632ee6c233f7a977c2e46e124
|
|
4
|
+
data.tar.gz: 5ef68e1d13d4e475a674bea5301ba2eed3e7054b1acb29a8cabce68700bf3870
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 55d02aad22edc399886cd67823b186414f1f989e5b672213bfa201445b10feeefdc94350be403689a3d57fa68f7acdcb42258a01f162d5fd71fc1514e292c2ea
|
|
7
|
+
data.tar.gz: f6fe740f3208ed91c46e129133cdf533095a121ed76b74c2dc303f5aa5af440dcd14a3b10d7b55a40ac1577387898bc146459de8ee1dea7c928cbbfe61199f81
|
data/CHANGELOG.md
CHANGED
|
@@ -6,8 +6,35 @@ All notable changes to bible270. Format follows [Keep a Changelog](https://keepa
|
|
|
6
6
|
|
|
7
7
|
See the changes since the last release:
|
|
8
8
|
|
|
9
|
-
**[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.
|
|
9
|
+
**[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.15.0...main)**
|
|
10
10
|
|
|
11
|
+
## [0.15.0] - 2026-08-02
|
|
12
|
+
|
|
13
|
+
**[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.14.1...v0.15.0)**
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- per-chapter checkoffs for reading with multiple chapters
|
|
18
|
+
- github actions for Rails CI, and Coverage reporting
|
|
19
|
+
- add dummy app in /test to test Rails functions with gem
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- mobile session requiring sign-in every time: use persistent sessions using 'remember-me' cookie
|
|
24
|
+
|
|
25
|
+
## [0.14.1] - 2026-07-30
|
|
26
|
+
|
|
27
|
+
**[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.14.0...v0.14.1)**
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- update Admin panel display verbage on plan run status
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- don't display 'Today' badge unless it really is
|
|
36
|
+
- display 'Start' not 'Continue' if reader is on day 1
|
|
37
|
+
- adjust badge vertical alignment in styles
|
|
11
38
|
|
|
12
39
|
## [0.14.0] - 2026-07-30
|
|
13
40
|
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Bible 270
|
|
2
2
|
|
|
3
|
-
[](https://rubygems.org/gems/bible270) [](https://rubygems.org/gems/bible270) [](https://github.com/avonderluft/bible270/releases)
|
|
3
|
+
[](https://rubygems.org/gems/bible270) [](https://coveralls.io/github/avonderluft/bible270?branch=main) [](https://rubygems.org/gems/bible270) [](https://github.com/avonderluft/bible270/releases)
|
|
4
4
|
|
|
5
5
|
A mountable **Rails engine** that drops a 270-day (9 month) interactive Bible reading plan into any Rails app, built with [ComfortableMediaSurfer](https://github.com/shakacode/comfortable-media-surfer) CMS in mind. Readers tick off each day's readings, post reflections, and see how everyone else is getting on, much like the social plans on Bible.com.
|
|
6
6
|
|
|
@@ -506,4 +506,4 @@ A few things that'll make review quick:
|
|
|
506
506
|
|
|
507
507
|
## License
|
|
508
508
|
|
|
509
|
-
[MIT-LICENSE](./MIT-LICENSE).
|
|
509
|
+
[MIT-LICENSE](./MIT-LICENSE).
|
|
@@ -31,7 +31,7 @@ module Bible270
|
|
|
31
31
|
@readers = Reader.all.to_a.sort_by(&:sort_name)
|
|
32
32
|
counts = Checkoff.group(:reader_id, :day).count
|
|
33
33
|
@days_completed = Hash.new(0)
|
|
34
|
-
counts.each { |(rid, day), n| @days_completed[rid] += 1 if n >= Plan.
|
|
34
|
+
counts.each { |(rid, day), n| @days_completed[rid] += 1 if n >= Plan.total_parts(day) }
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
def show
|
|
@@ -22,6 +22,8 @@ module Bible270
|
|
|
22
22
|
|
|
23
23
|
helper_method :current_reader, :signed_in?, :b270_config, :enrollment_closed?
|
|
24
24
|
|
|
25
|
+
REMEMBER_COOKIE = :bible270_remember
|
|
26
|
+
|
|
25
27
|
private
|
|
26
28
|
|
|
27
29
|
def bible270_layout
|
|
@@ -44,9 +46,47 @@ module Bible270
|
|
|
44
46
|
resolver.call(self)
|
|
45
47
|
elsif (id = session[:bible270_reader_id])
|
|
46
48
|
Reader.find_by(id: id)
|
|
49
|
+
else
|
|
50
|
+
reader_from_remember_cookie
|
|
47
51
|
end
|
|
48
52
|
end
|
|
49
53
|
|
|
54
|
+
# A session cookie dies when the browser restarts, which on a phone can be
|
|
55
|
+
# daily. This restores the session from a long-lived signed cookie carrying
|
|
56
|
+
# the reader id and a rotating token, so the reader stays signed in.
|
|
57
|
+
def reader_from_remember_cookie
|
|
58
|
+
return nil unless Bible270.config.remember_signed_in?
|
|
59
|
+
|
|
60
|
+
payload = cookies.encrypted[REMEMBER_COOKIE]
|
|
61
|
+
return nil if payload.blank?
|
|
62
|
+
|
|
63
|
+
id, token = payload.to_s.split(':', 2)
|
|
64
|
+
reader = Reader.from_remember_cookie(id, token)
|
|
65
|
+
return forget_reader! if reader.nil?
|
|
66
|
+
|
|
67
|
+
# Re-establish the session so the rest of the request behaves as normal.
|
|
68
|
+
session[:bible270_reader_id] = reader.id
|
|
69
|
+
reader
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Encrypted, so the payload can be neither read nor forged by the client.
|
|
73
|
+
def remember_reader!(reader)
|
|
74
|
+
return unless Bible270.config.remember_signed_in?
|
|
75
|
+
|
|
76
|
+
cookies.encrypted[REMEMBER_COOKIE] = {
|
|
77
|
+
value: "#{reader.id}:#{reader.remember_token!}",
|
|
78
|
+
expires: Bible270.config.remember_for.to_i.seconds.from_now,
|
|
79
|
+
httponly: true,
|
|
80
|
+
same_site: :lax,
|
|
81
|
+
secure: request.ssl?
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def forget_reader!
|
|
86
|
+
cookies.delete(REMEMBER_COOKIE)
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
50
90
|
def signed_in?
|
|
51
91
|
current_reader.present?
|
|
52
92
|
end
|
|
@@ -7,15 +7,20 @@ module Bible270
|
|
|
7
7
|
|
|
8
8
|
@day = params[:day].to_i
|
|
9
9
|
@track = params[:track].to_s
|
|
10
|
+
# Part 0 is the first chapter of the reading. Requests without one predate
|
|
11
|
+
# per-chapter check-offs and mean the first part.
|
|
12
|
+
@part = params[:part].to_i
|
|
10
13
|
|
|
11
|
-
head :bad_request and return unless Plan.valid_day?(@day) &&
|
|
14
|
+
head :bad_request and return unless Plan.valid_day?(@day) &&
|
|
15
|
+
Plan.present_tracks(@day).include?(@track) &&
|
|
16
|
+
Plan.valid_part?(@day, @track, @part)
|
|
12
17
|
|
|
13
|
-
existing = current_reader.checkoffs.find_by(day: @day, track: @track)
|
|
18
|
+
existing = current_reader.checkoffs.find_by(day: @day, track: @track, part: @part)
|
|
14
19
|
if existing
|
|
15
20
|
existing.destroy
|
|
16
21
|
else
|
|
17
22
|
current_reader.ensure_started!
|
|
18
|
-
current_reader.checkoffs.create(day: @day, track: @track)
|
|
23
|
+
current_reader.checkoffs.create(day: @day, track: @track, part: @part)
|
|
19
24
|
end
|
|
20
25
|
|
|
21
26
|
@reader = current_reader.reload
|
|
@@ -9,7 +9,7 @@ module Bible270
|
|
|
9
9
|
@readers = Reader.order(updated_at: :desc).to_a
|
|
10
10
|
counts = Checkoff.group(:reader_id, :day).count
|
|
11
11
|
@days_completed = Hash.new(0)
|
|
12
|
-
counts.each { |(rid, day), n| @days_completed[rid] += 1 if n >= Plan.
|
|
12
|
+
counts.each { |(rid, day), n| @days_completed[rid] += 1 if n >= Plan.total_parts(day) }
|
|
13
13
|
@readers = @readers.sort_by { |r| -@days_completed[r.id] }
|
|
14
14
|
|
|
15
15
|
@start_day = current_reader&.current_day || 1
|
|
@@ -28,10 +28,13 @@ module Bible270
|
|
|
28
28
|
@new_comment = Comment.new(day: @day)
|
|
29
29
|
@reader_tracks = current_reader ? current_reader.read_tracks_for(@day) : []
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
# Every box on the day, not every track: an Old Testament reading of three
|
|
32
|
+
# chapters is three rows, so counting tracks would call someone finished
|
|
33
|
+
# who had only read the Old Testament.
|
|
34
|
+
required = Plan.total_parts(@day)
|
|
32
35
|
completer_ids = Checkoff.where(day: @day)
|
|
33
36
|
.group(:reader_id)
|
|
34
|
-
.having('COUNT(*) >= ?',
|
|
37
|
+
.having('COUNT(*) >= ?', required)
|
|
35
38
|
.pluck(:reader_id)
|
|
36
39
|
@completers = Reader.where(id: completer_ids).order(:display_name)
|
|
37
40
|
|
|
@@ -6,10 +6,19 @@ module Bible270
|
|
|
6
6
|
@readers = Reader.all.to_a
|
|
7
7
|
counts = Checkoff.group(:reader_id, :day).count
|
|
8
8
|
@days_completed = Hash.new(0)
|
|
9
|
-
counts.each { |(rid, day), n| @days_completed[rid] += 1 if n >= Plan.
|
|
9
|
+
counts.each { |(rid, day), n| @days_completed[rid] += 1 if n >= Plan.total_parts(day) }
|
|
10
10
|
@readers.sort_by! { |r| [-@days_completed[r.id], r.display_name.to_s] }
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
+
# The reader's own progress. Deliberately open to visitors: the panel shows an
|
|
14
|
+
# invitation to sign in rather than an error, and every value it needs comes
|
|
15
|
+
# from the reader it is given.
|
|
16
|
+
def progress
|
|
17
|
+
@reader = current_reader
|
|
18
|
+
# An empty relation rather than nil, so the view needs no safe navigation.
|
|
19
|
+
@recent_comments = @reader ? @reader.comments.recent.limit(5) : Comment.none
|
|
20
|
+
end
|
|
21
|
+
|
|
13
22
|
def show
|
|
14
23
|
@reader = Reader.find_by(id: params[:id])
|
|
15
24
|
redirect_to(community_path, alert: 'Reader not found.') and return unless @reader
|
|
@@ -33,12 +33,17 @@ module Bible270
|
|
|
33
33
|
# Rotate the session id to avoid session fixation, then sign in.
|
|
34
34
|
reset_session
|
|
35
35
|
session[:bible270_reader_id] = reader.id
|
|
36
|
+
remember_reader!(reader)
|
|
36
37
|
@current_reader = reader
|
|
37
38
|
|
|
38
39
|
redirect_to destination, notice: "Welcome #{reader.first_name}."
|
|
39
40
|
end
|
|
40
41
|
|
|
41
42
|
def destroy
|
|
43
|
+
# Clear the long-lived cookie too, or the next request would sign them
|
|
44
|
+
# straight back in. The stored token is left alone so their other devices
|
|
45
|
+
# stay signed in; Reader#forget! is what revokes those.
|
|
46
|
+
forget_reader!
|
|
42
47
|
reset_session
|
|
43
48
|
@current_reader = nil
|
|
44
49
|
redirect_to(after_sign_out_path, notice: 'Signed out.')
|
|
@@ -109,6 +114,7 @@ module Bible270
|
|
|
109
114
|
|
|
110
115
|
reset_session
|
|
111
116
|
session[:bible270_reader_id] = reader.id
|
|
117
|
+
remember_reader!(reader)
|
|
112
118
|
@current_reader = reader
|
|
113
119
|
|
|
114
120
|
if name_needed?(reader)
|
|
@@ -174,6 +180,10 @@ module Bible270
|
|
|
174
180
|
return nil if value.include?('//') # protocol-relative URL
|
|
175
181
|
return nil if value.include?('\\') # browsers may normalise \ to /
|
|
176
182
|
return nil if value.include?('/auth/')
|
|
183
|
+
# Never return someone to sign-in or sign-out after signing in: harmless,
|
|
184
|
+
# but it reads as though the sign-in failed. Matched by path so it holds
|
|
185
|
+
# wherever the engine is mounted.
|
|
186
|
+
return nil if value.match?(%r{/sign_(in|out)(/|\z|\?)})
|
|
177
187
|
|
|
178
188
|
value
|
|
179
189
|
end
|
|
@@ -9,8 +9,10 @@ module Bible270
|
|
|
9
9
|
|
|
10
10
|
validates :day, inclusion: { in: 1..Plan::DAYS }
|
|
11
11
|
validates :track, inclusion: { in: Plan::TRACKS.keys }
|
|
12
|
-
validates :track, uniqueness: { scope: %i[reader_id day] }
|
|
12
|
+
validates :track, uniqueness: { scope: %i[reader_id day part] }
|
|
13
|
+
validates :part, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
|
13
14
|
validate :track_present_on_day
|
|
15
|
+
validate :part_within_reading
|
|
14
16
|
|
|
15
17
|
private
|
|
16
18
|
|
|
@@ -20,5 +22,14 @@ module Bible270
|
|
|
20
22
|
|
|
21
23
|
errors.add(:track, "is not part of day #{day}")
|
|
22
24
|
end
|
|
25
|
+
|
|
26
|
+
# Part 0 is the first chapter of the day's reading; an Old Testament reading
|
|
27
|
+
# of three chapters has parts 0, 1 and 2.
|
|
28
|
+
def part_within_reading
|
|
29
|
+
return if day.nil? || track.nil? || part.nil?
|
|
30
|
+
return if Plan.valid_part?(day, track, part)
|
|
31
|
+
|
|
32
|
+
errors.add(:part, "is not part of the #{track} reading on day #{day}")
|
|
33
|
+
end
|
|
23
34
|
end
|
|
24
35
|
end
|
|
@@ -140,6 +140,36 @@ module Bible270
|
|
|
140
140
|
[last_name, first_name].map { |n| n.to_s.strip.downcase }.join(' ').strip.presence || display_name.to_s.downcase
|
|
141
141
|
end
|
|
142
142
|
|
|
143
|
+
# ---- staying signed in -------------------------------------------------
|
|
144
|
+
|
|
145
|
+
# Generated on first use rather than at sign-up, so readers who never stay
|
|
146
|
+
# signed in never carry one.
|
|
147
|
+
def remember_token!
|
|
148
|
+
return remember_token if remember_token.present?
|
|
149
|
+
|
|
150
|
+
token = SecureRandom.urlsafe_base64(32)
|
|
151
|
+
update_column(:remember_token, token)
|
|
152
|
+
token
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Rotating the token invalidates every device at once.
|
|
156
|
+
def forget!
|
|
157
|
+
update_column(:remember_token, nil)
|
|
158
|
+
true
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def self.from_remember_cookie(reader_id, token)
|
|
162
|
+
return nil if reader_id.blank? || token.blank?
|
|
163
|
+
|
|
164
|
+
reader = find_by(id: reader_id)
|
|
165
|
+
return nil if reader.nil? || reader.remember_token.blank?
|
|
166
|
+
|
|
167
|
+
# Constant-time comparison: the token is a credential.
|
|
168
|
+
return nil unless ActiveSupport::SecurityUtils.secure_compare(reader.remember_token, token.to_s)
|
|
169
|
+
|
|
170
|
+
reader
|
|
171
|
+
end
|
|
172
|
+
|
|
143
173
|
def avatar_uploaded?
|
|
144
174
|
self.class.avatar_uploads? && avatar.attached?
|
|
145
175
|
end
|
|
@@ -183,28 +213,44 @@ module Bible270
|
|
|
183
213
|
done = checked_count(day)
|
|
184
214
|
return :none if done.zero?
|
|
185
215
|
|
|
186
|
-
done >= Plan.
|
|
216
|
+
done >= Plan.total_parts(day) ? :complete : :partial
|
|
187
217
|
end
|
|
188
218
|
|
|
189
219
|
def read_tracks_for(day)
|
|
190
|
-
checkoffs.where(day: day).pluck(:track)
|
|
220
|
+
checkoffs.where(day: day).pluck(:track).uniq
|
|
191
221
|
end
|
|
192
222
|
|
|
193
|
-
|
|
194
|
-
|
|
223
|
+
# Which chapters of a track the reader has ticked on this day.
|
|
224
|
+
def read_parts_for(day, track)
|
|
225
|
+
checkoffs.where(day: day, track: track.to_s).pluck(:part)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def read?(day, track, part = nil)
|
|
229
|
+
return read_parts_for(day, track).include?(part) if part
|
|
230
|
+
|
|
231
|
+
# No part given: the track counts as read only when every chapter is.
|
|
232
|
+
read_parts_for(day, track).size >= Plan.part_count(day, track)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def track_partially_read?(day, track)
|
|
236
|
+
done = read_parts_for(day, track).size
|
|
237
|
+
done.positive? && done < Plan.part_count(day, track)
|
|
195
238
|
end
|
|
196
239
|
|
|
197
240
|
def day_complete?(day)
|
|
198
241
|
n = checked_counts[day].to_i
|
|
199
|
-
n.positive? && n >= Plan.
|
|
242
|
+
n.positive? && n >= Plan.total_parts(day)
|
|
200
243
|
end
|
|
201
244
|
|
|
202
245
|
def days_completed
|
|
203
|
-
checked_counts.count { |day, n| n >= Plan.
|
|
246
|
+
checked_counts.count { |day, n| n >= Plan.total_parts(day) }
|
|
204
247
|
end
|
|
205
248
|
|
|
249
|
+
# Days on which this track is *finished*. Counting rows would count chapters
|
|
250
|
+
# now that an Old Testament reading has one per chapter.
|
|
206
251
|
def days_read_in(track)
|
|
207
|
-
checkoffs.where(track: track.to_s).count
|
|
252
|
+
checkoffs.where(track: track.to_s).group(:day).count
|
|
253
|
+
.count { |day, done| done >= Plan.part_count(day, track) }
|
|
208
254
|
end
|
|
209
255
|
|
|
210
256
|
def completion_percent
|
|
@@ -240,13 +286,37 @@ module Bible270
|
|
|
240
286
|
def mark_day_complete!(day)
|
|
241
287
|
return false unless Plan.valid_day?(day)
|
|
242
288
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
289
|
+
# Deliberately not find_or_create_by!: since Rails 8.1 that creates first
|
|
290
|
+
# and rescues RecordNotUnique, but Checkoff validates uniqueness, so a
|
|
291
|
+
# duplicate raises RecordInvalid before the database is reached and is
|
|
292
|
+
# never rescued. Reading what exists first is also one query per day rather
|
|
293
|
+
# than one per chapter.
|
|
294
|
+
missing = missing_parts_on(day)
|
|
295
|
+
insert_checkoffs(day, missing) if missing.any?
|
|
296
|
+
|
|
246
297
|
reload_progress
|
|
247
298
|
true
|
|
248
299
|
end
|
|
249
300
|
|
|
301
|
+
# [[track, part], ...] not yet ticked on this day.
|
|
302
|
+
def missing_parts_on(day)
|
|
303
|
+
wanted = Plan.present_tracks(day).flat_map do |track|
|
|
304
|
+
Array.new(Plan.part_count(day, track)) { |part| [track, part] }
|
|
305
|
+
end
|
|
306
|
+
have = checkoffs.where(day: day).pluck(:track, :part)
|
|
307
|
+
|
|
308
|
+
wanted - have
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def insert_checkoffs(day, pairs)
|
|
312
|
+
now = Time.current
|
|
313
|
+
Checkoff.insert_all(
|
|
314
|
+
pairs.map do |track, part|
|
|
315
|
+
{ reader_id: id, day: day, track: track, part: part, created_at: now, updated_at: now }
|
|
316
|
+
end
|
|
317
|
+
)
|
|
318
|
+
end
|
|
319
|
+
|
|
250
320
|
def clear_day!(day)
|
|
251
321
|
return false unless Plan.valid_day?(day)
|
|
252
322
|
|
|
@@ -261,13 +331,12 @@ module Bible270
|
|
|
261
331
|
|
|
262
332
|
# Mark everything up to and including `day` complete, and clear anything after.
|
|
263
333
|
def mark_through!(day)
|
|
264
|
-
|
|
334
|
+
day = day.to_i
|
|
335
|
+
return false unless day.between?(0, Plan::DAYS)
|
|
265
336
|
|
|
266
337
|
transaction do
|
|
267
|
-
checkoffs.where(day: (day
|
|
268
|
-
(1..day
|
|
269
|
-
Plan.present_tracks(d).each { |t| checkoffs.find_or_create_by!(day: d, track: t) }
|
|
270
|
-
end
|
|
338
|
+
checkoffs.where(day: (day + 1)..).delete_all
|
|
339
|
+
(1..day).each { |d| mark_day_complete!(d) }
|
|
271
340
|
end
|
|
272
341
|
reload_progress
|
|
273
342
|
true
|
|
@@ -300,6 +369,7 @@ module Bible270
|
|
|
300
369
|
# Private, but declared this way rather than with a `private` section: methods
|
|
301
370
|
# defined below here are called from controllers and views.
|
|
302
371
|
private :notify_of_registration
|
|
372
|
+
private :missing_parts_on, :insert_checkoffs
|
|
303
373
|
|
|
304
374
|
def reload_progress
|
|
305
375
|
@checked_counts = nil
|
|
@@ -88,32 +88,7 @@
|
|
|
88
88
|
</div>
|
|
89
89
|
|
|
90
90
|
<div class="b270-panel">
|
|
91
|
-
|
|
92
|
-
<% if @comments.any? %>
|
|
93
|
-
<% @comments.each do |comment| %>
|
|
94
|
-
<div class="b270-comment">
|
|
95
|
-
<div style="flex:1">
|
|
96
|
-
<div class="b270-cmeta">
|
|
97
|
-
<%= link_to "day #{comment.day}", day_path(comment.day) %>
|
|
98
|
-
· <%= comment.created_at.strftime('%b %-d, %Y') %>
|
|
99
|
-
<% if comment.hidden? %><span class="b270-badge">Hidden</span><% end %>
|
|
100
|
-
</div>
|
|
101
|
-
<p class="b270-cbody"><%= comment.body %></p>
|
|
102
|
-
<div style="display:flex;gap:10px;margin-top:6px">
|
|
103
|
-
<% if comment.hidden? %>
|
|
104
|
-
<%= button_to 'Restore', admin_unhide_comment_path(comment), method: :patch,
|
|
105
|
-
params: { return_to: request.fullpath }, class: 'b270-linkbtn' %>
|
|
106
|
-
<% else %>
|
|
107
|
-
<%= button_to 'Hide', admin_hide_comment_path(comment), method: :patch,
|
|
108
|
-
params: { return_to: request.fullpath }, class: 'b270-linkbtn' %>
|
|
109
|
-
<% end %>
|
|
110
|
-
</div>
|
|
111
|
-
</div>
|
|
112
|
-
</div>
|
|
113
|
-
<% end %>
|
|
114
|
-
<% else %>
|
|
115
|
-
<p class="b270-hint" style="margin-top:0">None yet.</p>
|
|
116
|
-
<% end %>
|
|
91
|
+
<%= render "bible270/shared/reflections", comments: @comments, moderate: true %>
|
|
117
92
|
</div>
|
|
118
93
|
|
|
119
94
|
<div class="b270-panel">
|
|
@@ -1,18 +1,58 @@
|
|
|
1
|
-
<%
|
|
1
|
+
<%
|
|
2
|
+
t = b270_track(track)
|
|
3
|
+
ref = readings[track]
|
|
4
|
+
parts = Bible270::Plan.parts_for(day, track)
|
|
5
|
+
read_parts = signed_in? ? current_reader.read_parts_for(day, track) : []
|
|
6
|
+
done = ref.present? && read_parts.size >= parts.size
|
|
7
|
+
%>
|
|
2
8
|
<% if ref %>
|
|
3
|
-
<div class="b270-reading <%= track %> <%= '
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
9
|
+
<div class="b270-reading <%= track %> <%= 'multi' if parts.size > 1 %> <%= 'done' if done %>"
|
|
10
|
+
id="reading_<%= day %>_<%= track %>">
|
|
11
|
+
<%# One box per chapter. A reading of a single chapter keeps the original
|
|
12
|
+
single-box layout; only multi-chapter Old Testament readings split. The
|
|
13
|
+
multi layout is a block, not a flex row, so each chapter's box sits in the
|
|
14
|
+
same column as the single box on the other tracks. %>
|
|
15
|
+
<% if parts.size > 1 %>
|
|
16
|
+
<div class="b270-rbody">
|
|
17
|
+
<div class="b270-cat"><%= t[:label] %></div>
|
|
18
|
+
<ul class="b270-parts">
|
|
19
|
+
<% parts.each_with_index do |part_ref, index| %>
|
|
20
|
+
<% part_done = read_parts.include?(index) %>
|
|
21
|
+
<li class="b270-part <%= 'done' if part_done %>">
|
|
22
|
+
<% if signed_in? %>
|
|
23
|
+
<%= button_to toggle_checkoff_part_path(day, track, index), class: "b270-toggle",
|
|
24
|
+
form: { data: { turbo_stream: true } },
|
|
25
|
+
aria: { label: (part_done ? "Mark #{part_ref} unread" : "Mark #{part_ref} read") } do %>
|
|
26
|
+
<span class="b270-check"><svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg></span>
|
|
27
|
+
<% end %>
|
|
28
|
+
<% else %>
|
|
29
|
+
<span class="b270-check"><svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg></span>
|
|
30
|
+
<% end %>
|
|
31
|
+
<span class="b270-ref">
|
|
32
|
+
<%= link_to part_ref, b270_passage_url(part_ref), target: "_blank", rel: "noopener" %>
|
|
33
|
+
</span>
|
|
34
|
+
</li>
|
|
35
|
+
<% end %>
|
|
36
|
+
</ul>
|
|
37
|
+
<div class="b270-parts-meta">
|
|
38
|
+
<%= read_parts.size %> of <%= parts.size %> chapters <%= b270_version_tag %>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
<% else %>
|
|
42
|
+
<% if signed_in? %>
|
|
43
|
+
<%= button_to toggle_checkoff_part_path(day, track, 0), class: "b270-toggle",
|
|
44
|
+
form: { data: { turbo_stream: true } },
|
|
45
|
+
aria: { label: (done ? "Mark #{t[:label]} unread" : "Mark #{t[:label]} read") } do %>
|
|
46
|
+
<span class="b270-check"><svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg></span>
|
|
47
|
+
<% end %>
|
|
48
|
+
<% else %>
|
|
7
49
|
<span class="b270-check"><svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg></span>
|
|
8
50
|
<% end %>
|
|
9
|
-
|
|
10
|
-
|
|
51
|
+
<div class="b270-rbody">
|
|
52
|
+
<div class="b270-cat"><%= t[:label] %></div>
|
|
53
|
+
<div class="b270-ref"><%= link_to ref, b270_passage_url(ref), target: "_blank", rel: "noopener" %> <%= b270_version_tag %></div>
|
|
54
|
+
</div>
|
|
11
55
|
<% end %>
|
|
12
|
-
<div class="b270-rbody">
|
|
13
|
-
<div class="b270-cat"><%= t[:label] %></div>
|
|
14
|
-
<div class="b270-ref"><%= link_to ref, b270_passage_url(ref), target: "_blank", rel: "noopener" %> <%= b270_version_tag %></div>
|
|
15
|
-
</div>
|
|
16
56
|
</div>
|
|
17
57
|
<% else %>
|
|
18
58
|
<div class="b270-reading <%= track %> rest" id="reading_<%= day %>_<%= track %>">
|
|
@@ -3,47 +3,11 @@
|
|
|
3
3
|
<h1><%= Bible270.config.app_name %> <em>— read God's whole story</em></h1>
|
|
4
4
|
<p class="lede">Daily passages from the Old Testament, New Testament, and Psalms or Proverbs — portions sized to take about the same time to read. Check off what you read; your place is saved for you, and you can see how your friends are progressing as well.</p>
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
<% if signed_in? %>
|
|
8
|
-
<div class="b270-bignum">
|
|
9
|
-
<span class="n"><%= current_reader.days_completed %></span>
|
|
10
|
-
<span class="of">of <%= Bible270::Plan::DAYS %> days complete</span>
|
|
11
|
-
<span class="pct"><%= current_reader.completion_percent %>%</span>
|
|
12
|
-
</div>
|
|
13
|
-
<div class="b270-tracks">
|
|
14
|
-
<% [["ot", current_reader.days_read_in("ot"), Bible270::Plan::DAYS],
|
|
15
|
-
["nt", current_reader.days_read_in("nt"), Bible270::Plan.nt_content_days],
|
|
16
|
-
["pp", current_reader.days_read_in("pp"), Bible270::Plan::DAYS]].each do |track, read, total| %>
|
|
17
|
-
<% t = b270_track(track) %>
|
|
18
|
-
<div class="b270-tm">
|
|
19
|
-
<span class="lab"><span class="b270-rib" style="background:<%= t[:color] %>"></span><%= t[:label] %></span>
|
|
20
|
-
<span class="b270-bar"><i style="width:<%= total.zero? ? 0 : (read.to_f/total*100).round %>%;background:<%= t[:color] %>"></i></span>
|
|
21
|
-
<span class="val"><%= read %>/<%= total %></span>
|
|
22
|
-
</div>
|
|
23
|
-
<% end %>
|
|
24
|
-
</div>
|
|
25
|
-
<p style="margin:16px 0 0">
|
|
26
|
-
<% @day_verb = @start_day == 1 ? "Start" : "Continue" %>
|
|
27
|
-
<%= link_to "#{@day_verb} reading — Day #{@start_day} →", day_path(@start_day), class: "b270-btn" %>
|
|
28
|
-
<% if @today_day && @today_day != @start_day %>
|
|
29
|
-
<%= link_to "Go to today (Day #{@today_day})", day_path(@today_day), style: "margin-left:12px" %>
|
|
30
|
-
<% end %>
|
|
31
|
-
</p>
|
|
32
|
-
<%= render "bible270/days/start_date", reader: current_reader %>
|
|
33
|
-
<% else %>
|
|
34
|
-
<p class="b270-signedout"><%= link_to "Sign in", sign_in_path %> to track your progress and join
|
|
35
|
-
the conversation. You can browse the whole plan and read others' reflections without an account.</p>
|
|
36
|
-
<% if @community_start_date %>
|
|
37
|
-
<p class="b270-startline">This community plan runs from <strong><%= @community_start_date.strftime("%B %-d, %Y") %></strong> to
|
|
38
|
-
<strong><%= Bible270::Plan.end_date_for(@community_start_date).strftime("%B %-d, %Y") %></strong>.</p>
|
|
39
|
-
<% end %>
|
|
40
|
-
<p style="margin:14px 0 0"><%= link_to "Start at Day 1 →", day_path(1), class: "b270-btn" %></p>
|
|
41
|
-
<% end %>
|
|
42
|
-
</div>
|
|
6
|
+
<%= render "bible270/shared/progress" %>
|
|
43
7
|
|
|
44
8
|
<% if @readers.any? %>
|
|
45
9
|
<% rankings= @readers.count < 5 ? @readers.count : 5 %>
|
|
46
|
-
<h2 class="b270-section-h">Reader Leaders
|
|
10
|
+
<h2 class="b270-section-h">Reader Leaders</h2>
|
|
47
11
|
<div class="b270-list">
|
|
48
12
|
<% @readers.first(rankings).each_with_index do |r, i| %>
|
|
49
13
|
<div class="b270-li">
|
|
@@ -60,4 +24,3 @@
|
|
|
60
24
|
</div>
|
|
61
25
|
<p style="margin:12px 0 0"><%= link_to "See everyone →", community_path %></p>
|
|
62
26
|
<% end %>
|
|
63
|
-
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<% content_for :title, "Your progress — #{Bible270.config.app_name}" %>
|
|
2
|
+
<% content_for :hide_day_index, true %><%# the panel is followed by its own grid below %>
|
|
3
|
+
|
|
4
|
+
<p class="b270-eyebrow">Your progress</p>
|
|
5
|
+
<h1><%= @reader ? @reader.display_name : "Your progress" %></h1>
|
|
6
|
+
|
|
7
|
+
<%= render "bible270/shared/progress" %>
|
|
8
|
+
|
|
9
|
+
<div class="b270-panel">
|
|
10
|
+
<%= render "bible270/shared/reflections", comments: @recent_comments,
|
|
11
|
+
title: "Your recent reflections",
|
|
12
|
+
empty_text: "You have not written any reflections yet." %>
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
<% if @reader %>
|
|
16
|
+
<div class="b270-panel">
|
|
17
|
+
<h2 class="b270-subhead">Every day at a glance</h2>
|
|
18
|
+
<div class="b270-grid">
|
|
19
|
+
<% today = @reader.today_day %>
|
|
20
|
+
<% (1..Bible270::Plan::DAYS).each do |d| %>
|
|
21
|
+
<%= link_to d, day_path(d),
|
|
22
|
+
class: "b270-cell #{b270_day_status_class(@reader, d)} #{'now' if d == today}" %>
|
|
23
|
+
<% end %>
|
|
24
|
+
</div>
|
|
25
|
+
<p class="b270-hint">Filled squares are days you have finished; a lighter square means you have
|
|
26
|
+
read some of that day but not all of it.</p>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<p style="margin-top:18px">
|
|
30
|
+
<%= link_to "How others are doing →", community_path %>
|
|
31
|
+
· <%= link_to "Your profile", profile_path %>
|
|
32
|
+
</p>
|
|
33
|
+
<% end %>
|
|
@@ -12,18 +12,12 @@
|
|
|
12
12
|
</div>
|
|
13
13
|
|
|
14
14
|
<div class="b270-panel" style="margin-top:18px">
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
<span class="lab"><span class="b270-rib" style="background:<%= t[:color] %>"></span><%= t[:label] %></span>
|
|
22
|
-
<span class="b270-bar"><i style="width:<%= total.zero? ? 0 : (read.to_f/total*100).round %>%;background:<%= t[:color] %>"></i></span>
|
|
23
|
-
<span class="val"><%= read %>/<%= total %></span>
|
|
24
|
-
</div>
|
|
25
|
-
<% end %>
|
|
26
|
-
</div>
|
|
15
|
+
<%= render "bible270/shared/track_bars", reader: @reader %>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<div class="b270-panel">
|
|
19
|
+
<%= render "bible270/shared/reflections", comments: @recent_comments,
|
|
20
|
+
title: "Their reflections", empty_text: "No reflections yet." %>
|
|
27
21
|
</div>
|
|
28
22
|
|
|
29
23
|
<div class="b270-grid">
|
|
@@ -32,14 +26,3 @@
|
|
|
32
26
|
<% end %>
|
|
33
27
|
</div>
|
|
34
28
|
|
|
35
|
-
<% if @recent_comments.any? %>
|
|
36
|
-
<h2 class="b270-section-h">Recent reflections</h2>
|
|
37
|
-
<% @recent_comments.each do |c| %>
|
|
38
|
-
<div class="b270-comment">
|
|
39
|
-
<div style="flex:1">
|
|
40
|
-
<div class="b270-cmeta"><%= link_to "Day #{c.day}", day_path(c.day), style: "text-decoration:none;color:var(--gold)" %> · <%= c.created_at.strftime("%b %-d, %Y") %></div>
|
|
41
|
-
<p class="b270-cbody"><%= c.body %></p>
|
|
42
|
-
</div>
|
|
43
|
-
</div>
|
|
44
|
-
<% end %>
|
|
45
|
-
<% end %>
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<%= link_to "People", community_path %>
|
|
9
9
|
<% if signed_in? %>
|
|
10
10
|
<% if Bible270.config.admin?(current_reader) %><%= link_to "Admin", admin_path %><% end %>
|
|
11
|
-
<%= link_to "Progress",
|
|
11
|
+
<%= link_to "Progress", progress_path %>
|
|
12
12
|
<%= link_to "Profile", profile_path %>
|
|
13
13
|
<%= button_to "Sign out", sign_out_path, method: :delete, class: "b270-linkbtn",
|
|
14
14
|
form: { style: "display:inline" } %>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<%# locals: (reader: nil, start_day: nil, today_day: nil, community_start_date: :unset) %>
|
|
2
|
+
<%#
|
|
3
|
+
The progress panel.
|
|
4
|
+
|
|
5
|
+
Every value is derived here rather than read from controller instance
|
|
6
|
+
variables, so it can be rendered from any page. @start_day, @today_day and
|
|
7
|
+
@community_start_date were set only by DaysController#index; anywhere else they
|
|
8
|
+
would silently have been nil.
|
|
9
|
+
|
|
10
|
+
Render it with no locals to describe the signed-in reader, or pass
|
|
11
|
+
reader: @reader to describe someone else.
|
|
12
|
+
|
|
13
|
+
community_start_date defaults to :unset rather than nil, so that passing nil
|
|
14
|
+
explicitly can mean "do not mention a community date".
|
|
15
|
+
|
|
16
|
+
NB: no ERB tags in this comment. The first close tag would close it, and everything
|
|
17
|
+
after would become live template code — which is exactly how this partial once
|
|
18
|
+
came to render itself.
|
|
19
|
+
%>
|
|
20
|
+
<%
|
|
21
|
+
panel_reader = reader || current_reader
|
|
22
|
+
panel_start_day = start_day || panel_reader&.current_day || 1
|
|
23
|
+
panel_today_day = today_day || panel_reader&.today_day
|
|
24
|
+
panel_community_date = community_start_date == :unset ? Bible270.config.start_date : community_start_date
|
|
25
|
+
day_verb = panel_start_day == 1 ? 'Start' : 'Continue'
|
|
26
|
+
%>
|
|
27
|
+
<div class="b270-panel">
|
|
28
|
+
<% if panel_reader %>
|
|
29
|
+
<div class="b270-bignum">
|
|
30
|
+
<span class="n"><%= panel_reader.days_completed %></span>
|
|
31
|
+
<span class="of">of <%= Bible270::Plan::DAYS %> days complete</span>
|
|
32
|
+
<span class="pct"><%= panel_reader.completion_percent %>%</span>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<%= render "bible270/shared/track_bars", reader: panel_reader %>
|
|
36
|
+
|
|
37
|
+
<p style="margin:16px 0 0">
|
|
38
|
+
<%= link_to "#{day_verb} reading — Day #{panel_start_day} →", day_path(panel_start_day), class: "b270-btn" %>
|
|
39
|
+
<% if panel_today_day && panel_today_day != panel_start_day %>
|
|
40
|
+
<%= link_to "Go to today (Day #{panel_today_day})", day_path(panel_today_day), style: "margin-left:12px" %>
|
|
41
|
+
<% end %>
|
|
42
|
+
</p>
|
|
43
|
+
|
|
44
|
+
<%= render "bible270/days/start_date", reader: panel_reader %>
|
|
45
|
+
<% else %>
|
|
46
|
+
<p class="b270-signedout"><%= link_to "Sign in", sign_in_path %> to track your progress and join
|
|
47
|
+
the conversation. You can browse the whole plan and read others' reflections without an account.</p>
|
|
48
|
+
<% if panel_community_date %>
|
|
49
|
+
<p class="b270-startline">This community plan runs from
|
|
50
|
+
<strong><%= panel_community_date.strftime("%B %-d, %Y") %></strong> to
|
|
51
|
+
<strong><%= Bible270::Plan.end_date_for(panel_community_date).strftime("%B %-d, %Y") %></strong>.</p>
|
|
52
|
+
<% end %>
|
|
53
|
+
<p style="margin:14px 0 0"><%= link_to "Start at Day 1 →", day_path(1), class: "b270-btn" %></p>
|
|
54
|
+
<% end %>
|
|
55
|
+
</div>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<%# locals: (comments:, title: "Their reflections", moderate: false, empty_text: "None yet.") %>
|
|
2
|
+
<%#
|
|
3
|
+
A reader's reflections. Three views showed near-identical markup: the admin
|
|
4
|
+
page, a reader's public page, and the progress page, differing only in whether
|
|
5
|
+
hide/restore controls appeared.
|
|
6
|
+
|
|
7
|
+
moderate: true adds those controls, and shows a Hidden badge. They are only
|
|
8
|
+
meaningful for an admin, so the caller decides — the partial does not check
|
|
9
|
+
permissions itself.
|
|
10
|
+
|
|
11
|
+
No ERB tags in this comment: the first close-tag would end it and the rest
|
|
12
|
+
would become live template code.
|
|
13
|
+
%>
|
|
14
|
+
<h2 class="b270-subhead"><%= title %></h2>
|
|
15
|
+
|
|
16
|
+
<% if comments.any? %>
|
|
17
|
+
<% comments.each do |comment| %>
|
|
18
|
+
<div class="b270-comment">
|
|
19
|
+
<div style="flex:1">
|
|
20
|
+
<div class="b270-cmeta">
|
|
21
|
+
<%= link_to "Day #{comment.day}", day_path(comment.day) %>
|
|
22
|
+
· <%= comment.created_at.strftime("%b %-d, %Y") %>
|
|
23
|
+
<% if moderate && comment.hidden? %><span class="b270-badge">Hidden</span><% end %>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<p class="b270-cbody"><%= comment.body %></p>
|
|
27
|
+
|
|
28
|
+
<% if moderate %>
|
|
29
|
+
<div style="display:flex;gap:10px;margin-top:6px">
|
|
30
|
+
<% if comment.hidden? %>
|
|
31
|
+
<%= button_to "Restore", admin_unhide_comment_path(comment), method: :patch,
|
|
32
|
+
params: { return_to: request.fullpath }, class: "b270-linkbtn" %>
|
|
33
|
+
<% else %>
|
|
34
|
+
<%= button_to "Hide", admin_hide_comment_path(comment), method: :patch,
|
|
35
|
+
params: { return_to: request.fullpath }, class: "b270-linkbtn" %>
|
|
36
|
+
<% end %>
|
|
37
|
+
</div>
|
|
38
|
+
<% end %>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
<% end %>
|
|
42
|
+
<% else %>
|
|
43
|
+
<p class="b270-hint" style="margin-top:0"><%= empty_text %></p>
|
|
44
|
+
<% end %>
|
|
@@ -108,6 +108,25 @@
|
|
|
108
108
|
.b270-rbody{flex:1;min-width:0}
|
|
109
109
|
.b270-cat{font-size:11px;letter-spacing:.13em;text-transform:uppercase;color:var(--faint);font-weight:600}
|
|
110
110
|
.b270-ref{font-family:"Spectral",serif;font-size:19px;font-weight:500;margin-top:1px}
|
|
111
|
+
/* per-chapter check-offs: a smaller box per chapter, listed under the track */
|
|
112
|
+
/* A multi-chapter reading is laid out as a block rather than a flex row, so
|
|
113
|
+
each chapter's box sits in the same column as the single box on the other
|
|
114
|
+
tracks. The label is indented by that column's width (26px) plus the row
|
|
115
|
+
gap (15px) to stay level with the other tracks' labels. */
|
|
116
|
+
.b270-reading.multi{display:block}
|
|
117
|
+
.b270-reading.multi .b270-cat{margin-left:41px}
|
|
118
|
+
.b270-reading.multi .b270-parts-meta{margin-left:41px}
|
|
119
|
+
.b270-parts{list-style:none;margin:6px 0 0;padding:0;display:flex;flex-direction:column;gap:8px}
|
|
120
|
+
.b270-part{display:flex;align-items:center;gap:15px}
|
|
121
|
+
.b270-part .b270-ref{font-size:19px}
|
|
122
|
+
/* A ticked chapter looks exactly like a ticked single reading: the box fills
|
|
123
|
+
in the track's colour and the tick appears. No strikethrough — the other
|
|
124
|
+
tracks don't strike their references either. */
|
|
125
|
+
.b270-reading.ot .b270-part.done .b270-check{background:var(--teal);border-color:var(--teal)}
|
|
126
|
+
.b270-reading.nt .b270-part.done .b270-check{background:var(--oxblood);border-color:var(--oxblood)}
|
|
127
|
+
.b270-reading.pp .b270-part.done .b270-check{background:var(--gold);border-color:var(--gold)}
|
|
128
|
+
.b270-part.done .b270-check svg{opacity:1}
|
|
129
|
+
.b270-parts-meta{margin-top:10px;font-size:12.5px;color:var(--faint)}
|
|
111
130
|
.b270-version{font-size:11.5px;color:var(--faint);font-weight:400;letter-spacing:.02em;white-space:nowrap}
|
|
112
131
|
.b270-select{font:inherit;font-size:14px;padding:10px 30px 10px 12px;border:1px solid var(--line);border-radius:9px;background:#fff;color:var(--ink);width:100%;max-width:100%;text-overflow:ellipsis}
|
|
113
132
|
.b270-ref a{text-decoration:none;border-bottom:1px solid transparent}
|
|
@@ -156,5 +175,5 @@
|
|
|
156
175
|
.b270-footer p{margin:0;line-height:1.5}
|
|
157
176
|
details.b270-index summary{cursor:pointer;font-family:"Spectral",serif;font-size:18px;font-weight:600;list-style:none;margin-top:30px}
|
|
158
177
|
details.b270-index summary::-webkit-details-marker{display:none}
|
|
159
|
-
@media (max-width:480px){ .b270-tm{grid-template-columns:104px 1fr auto} .b270-reading{padding:14px 15px;gap:12px} }
|
|
178
|
+
@media (max-width:480px){ .b270-tm{grid-template-columns:104px 1fr auto} .b270-reading{padding:14px 15px;gap:12px} .b270-reading.multi .b270-cat,.b270-reading.multi .b270-parts-meta{margin-left:38px} .b270-part{gap:12px} }
|
|
160
179
|
</style>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<%# locals: (reader:) %>
|
|
2
|
+
<%#
|
|
3
|
+
The three progress bars. This markup was duplicated in days/index and
|
|
4
|
+
readers/show, differing only in which reader it described.
|
|
5
|
+
|
|
6
|
+
The New Testament total is nt_content_days rather than DAYS, because a plan
|
|
7
|
+
with rest days has fewer NT readings than days.
|
|
8
|
+
%>
|
|
9
|
+
<div class="b270-tracks">
|
|
10
|
+
<% [["ot", Bible270::Plan::DAYS],
|
|
11
|
+
["nt", Bible270::Plan.nt_content_days],
|
|
12
|
+
["pp", Bible270::Plan::DAYS]].each do |track, total| %>
|
|
13
|
+
<% t = b270_track(track) %>
|
|
14
|
+
<% read = reader.days_read_in(track) %>
|
|
15
|
+
<div class="b270-tm">
|
|
16
|
+
<span class="lab"><span class="b270-rib" style="background:<%= t[:color] %>"></span><%= t[:label] %></span>
|
|
17
|
+
<span class="b270-bar"><i style="width:<%= total.zero? ? 0 : (read.to_f / total * 100).round %>%;background:<%= t[:color] %>"></i></span>
|
|
18
|
+
<span class="val"><%= read %>/<%= total %></span>
|
|
19
|
+
</div>
|
|
20
|
+
<% end %>
|
|
21
|
+
</div>
|
data/config/routes.rb
CHANGED
|
@@ -5,6 +5,9 @@ Bible270::Engine.routes.draw do
|
|
|
5
5
|
|
|
6
6
|
get 'day/:day', to: 'days#show', as: :day, constraints: { day: %r{\d+} }
|
|
7
7
|
|
|
8
|
+
# :part is the chapter within the reading; omitted means the first.
|
|
9
|
+
post 'day/:day/toggle/:track/:part', to: 'checkoffs#toggle', as: :toggle_checkoff_part,
|
|
10
|
+
constraints: { day: %r{\d+}, part: %r{\d+} }
|
|
8
11
|
post 'day/:day/toggle/:track', to: 'checkoffs#toggle', as: :toggle_checkoff,
|
|
9
12
|
constraints: { day: %r{\d+} }
|
|
10
13
|
post 'day/:day/comments', to: 'comments#create', as: :day_comments,
|
|
@@ -31,6 +34,8 @@ Bible270::Engine.routes.draw do
|
|
|
31
34
|
patch 'profile', to: 'profiles#update'
|
|
32
35
|
delete 'profile/avatar', to: 'profiles#remove_avatar', as: :profile_avatar
|
|
33
36
|
|
|
37
|
+
get 'progress', to: 'readers#progress', as: :progress
|
|
38
|
+
|
|
34
39
|
get 'community', to: 'readers#index', as: :community
|
|
35
40
|
get 'readers/:id', to: 'readers#show', as: :reader
|
|
36
41
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddPartToBible270Checkoffs < ActiveRecord::Migration[7.0]
|
|
4
|
+
# An Old Testament reading is usually several whole chapters, and readers get
|
|
5
|
+
# through them one at a time, so each chapter becomes a separately tickable
|
|
6
|
+
# part. Part 0 is the first chapter of that day's reading.
|
|
7
|
+
def up
|
|
8
|
+
add_column :bible270_checkoffs, :part, :integer, null: false, default: 0
|
|
9
|
+
|
|
10
|
+
old_index = 'idx_b270_checkoffs_unique'
|
|
11
|
+
remove_index :bible270_checkoffs, name: old_index if index_name_exists?(:bible270_checkoffs, old_index)
|
|
12
|
+
add_index :bible270_checkoffs, %i[reader_id day track part], unique: true,
|
|
13
|
+
name: 'idx_b270_checkoffs_part_unique'
|
|
14
|
+
|
|
15
|
+
expand_existing_checkoffs
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def down
|
|
19
|
+
# Collapse back: a track counts as read if any of its chapters were.
|
|
20
|
+
execute <<~SQL.squish
|
|
21
|
+
DELETE FROM bible270_checkoffs
|
|
22
|
+
WHERE id NOT IN (
|
|
23
|
+
SELECT MIN(id) FROM bible270_checkoffs GROUP BY reader_id, day, track
|
|
24
|
+
)
|
|
25
|
+
SQL
|
|
26
|
+
|
|
27
|
+
remove_index :bible270_checkoffs, name: 'idx_b270_checkoffs_part_unique'
|
|
28
|
+
add_index :bible270_checkoffs, %i[reader_id day track], unique: true,
|
|
29
|
+
name: 'idx_b270_checkoffs_unique'
|
|
30
|
+
remove_column :bible270_checkoffs, :part
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# Before this migration one row meant "the whole reading is done". Leaving
|
|
36
|
+
# those rows at part 0 would silently demote a finished multi-chapter day to
|
|
37
|
+
# one chapter of it, so each is expanded into a row per chapter.
|
|
38
|
+
def expand_existing_checkoffs
|
|
39
|
+
rows = select_all(<<~SQL.squish).to_a
|
|
40
|
+
SELECT id, reader_id, day, track FROM bible270_checkoffs WHERE part = 0
|
|
41
|
+
SQL
|
|
42
|
+
return if rows.empty?
|
|
43
|
+
|
|
44
|
+
now = Time.current
|
|
45
|
+
additions = rows.flat_map do |row|
|
|
46
|
+
count = Bible270::Plan.part_count(row['day'].to_i, row['track'])
|
|
47
|
+
next [] if count <= 1
|
|
48
|
+
|
|
49
|
+
(1...count).map do |part|
|
|
50
|
+
{ reader_id: row['reader_id'], day: row['day'], track: row['track'],
|
|
51
|
+
part: part, created_at: now, updated_at: now }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
return if additions.empty?
|
|
55
|
+
|
|
56
|
+
say "expanding #{rows.size} check-offs into #{rows.size + additions.size} chapter parts", true
|
|
57
|
+
Bible270::Checkoff.insert_all(additions)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddRememberTokenToBible270Readers < ActiveRecord::Migration[7.0]
|
|
4
|
+
# Lets a reader stay signed in across browser restarts. The cookie carries this
|
|
5
|
+
# token alongside the reader id, so rotating it signs that reader out
|
|
6
|
+
# everywhere — which a bare id in a cookie could not offer.
|
|
7
|
+
def change
|
|
8
|
+
add_column :bible270_readers, :remember_token, :string
|
|
9
|
+
add_index :bible270_readers, :remember_token, name: 'idx_b270_readers_remember'
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -292,7 +292,7 @@ module Bible270
|
|
|
292
292
|
# from, so without this the notice names the reader but cannot link to them.
|
|
293
293
|
attr_accessor :mailer_host
|
|
294
294
|
|
|
295
|
-
attr_accessor :after_sign_out_path, :email_sign_in_max_per_window, :passage_url_builder, :tagline, :footer_html, :footer, :favicon, :avatar_max_bytes, :chapter_breaks, :admin_emails, :admin_resolver, :bible_version
|
|
295
|
+
attr_accessor :after_sign_out_path, :email_sign_in_max_per_window, :passage_url_builder, :tagline, :footer_html, :footer, :favicon, :avatar_max_bytes, :chapter_breaks, :admin_emails, :admin_resolver, :bible_version, :require_sign_in_to_participate
|
|
296
296
|
|
|
297
297
|
# Public labels.
|
|
298
298
|
attr_accessor :app_name
|
|
@@ -311,7 +311,18 @@ module Bible270
|
|
|
311
311
|
attr_accessor :allow_reader_start_date
|
|
312
312
|
|
|
313
313
|
# Only signed-in readers may check off and comment (viewing is always open).
|
|
314
|
-
|
|
314
|
+
# How long a reader stays signed in on a device, in seconds. The plan runs
|
|
315
|
+
# 270 days and most people read it on a phone, where a browser-session cookie
|
|
316
|
+
# is discarded within days — so this defaults to a year. Set nil or false to
|
|
317
|
+
# keep sign-in to the browser session only.
|
|
318
|
+
#
|
|
319
|
+
# Expressed in seconds rather than 1.year because this file has to load
|
|
320
|
+
# without ActiveSupport.
|
|
321
|
+
attr_accessor :remember_for
|
|
322
|
+
|
|
323
|
+
def remember_signed_in?
|
|
324
|
+
remember_for.to_i.positive?
|
|
325
|
+
end
|
|
315
326
|
|
|
316
327
|
def initialize
|
|
317
328
|
@parent_controller = 'ActionController::Base'
|
|
@@ -329,6 +340,7 @@ module Bible270
|
|
|
329
340
|
end
|
|
330
341
|
@app_name = 'Daily Bread'
|
|
331
342
|
@tagline = 'Journeying through Scripture in 9 months'
|
|
343
|
+
@remember_for = 365 * 24 * 60 * 60
|
|
332
344
|
@require_sign_in_to_participate = true
|
|
333
345
|
self.mount_at = '/daily-bread'
|
|
334
346
|
@start_date = nil
|
data/lib/bible270/plan.rb
CHANGED
|
@@ -558,6 +558,42 @@ module Bible270
|
|
|
558
558
|
|
|
559
559
|
def required_track_count(day) = present_tracks(day).size
|
|
560
560
|
|
|
561
|
+
# ---- parts of a reading -------------------------------------------------
|
|
562
|
+
#
|
|
563
|
+
# An Old Testament reading is usually several whole chapters (260 of the 270
|
|
564
|
+
# days, up to six), which readers get through one at a time. Each chapter is
|
|
565
|
+
# a separately tickable part. A New Testament or Psalms/Proverbs reading is a
|
|
566
|
+
# single chapter or a portion of one, so it has exactly one part.
|
|
567
|
+
#
|
|
568
|
+
# Parts are identified by position, so part 0 is always the first chapter of
|
|
569
|
+
# that day's reading.
|
|
570
|
+
|
|
571
|
+
def parts_for(day, track)
|
|
572
|
+
return [] unless valid_day?(day)
|
|
573
|
+
|
|
574
|
+
case track.to_s
|
|
575
|
+
when 'ot'
|
|
576
|
+
groups = ot_groups[day - 1]
|
|
577
|
+
groups.nil? ? [] : groups.map { |book, chapter| format_reference([[book, chapter]]) }
|
|
578
|
+
when 'nt', 'pp'
|
|
579
|
+
reading = readings_for(day)[track.to_s]
|
|
580
|
+
reading.nil? ? [] : [reading]
|
|
581
|
+
else
|
|
582
|
+
[]
|
|
583
|
+
end
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def part_count(day, track) = parts_for(day, track).size
|
|
587
|
+
|
|
588
|
+
def valid_part?(day, track, part)
|
|
589
|
+
part.is_a?(Integer) && part >= 0 && part < part_count(day, track)
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
# Every tickable box on a day, across all three tracks.
|
|
593
|
+
def total_parts(day)
|
|
594
|
+
memo(:"total_parts_#{day}") { present_tracks(day).sum { |track| part_count(day, track) } }
|
|
595
|
+
end
|
|
596
|
+
|
|
561
597
|
def valid_day?(day) = day.is_a?(Integer) && day >= 1 && day <= DAYS
|
|
562
598
|
|
|
563
599
|
# Blank means "the whole day", which is stored as NULL. Select fields submit
|
|
@@ -93,7 +93,17 @@ module Bible270
|
|
|
93
93
|
|
|
94
94
|
module_function
|
|
95
95
|
|
|
96
|
-
|
|
96
|
+
# Guarded rather than indexing straight in: chapter 0 would become index -1
|
|
97
|
+
# and silently return the *last* chapter's verse count, and an over-large
|
|
98
|
+
# chapter would return nil for callers that expect a number. An unknown book
|
|
99
|
+
# still raises, since that is a programming error rather than bad data.
|
|
100
|
+
def verses(book, chapter)
|
|
101
|
+
counts = VERSES.fetch(book)
|
|
102
|
+
return 0 unless chapter.is_a?(Integer) && chapter.positive? && chapter <= counts.size
|
|
103
|
+
|
|
104
|
+
counts[chapter - 1]
|
|
105
|
+
end
|
|
106
|
+
|
|
97
107
|
def chapter_count(book) = VERSES.fetch(book).size
|
|
98
108
|
def total_verses(book) = VERSES.fetch(book).sum
|
|
99
109
|
end
|
data/lib/bible270/version.rb
CHANGED
|
@@ -73,7 +73,8 @@ Bible270.configure do |config|
|
|
|
73
73
|
# config.footer_partial = 'layouts/bible270_footer'
|
|
74
74
|
# config.footer_html = '<p>© 2026 …</p>'
|
|
75
75
|
# config.footer = false # no footer at all
|
|
76
|
-
#
|
|
76
|
+
#
|
|
77
77
|
# config.footer_placement = :after # engine's note, then yours
|
|
78
78
|
# config.footer_placement = :before # yours, then the engine's note
|
|
79
79
|
# config.footer_placement = :replace # default
|
|
80
|
+
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: 0.
|
|
4
|
+
version: 0.15.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrew vonderLuft
|
|
@@ -23,6 +23,20 @@ dependencies:
|
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '7.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: turbo-rails
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '1.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '1.0'
|
|
26
40
|
- !ruby/object:Gem::Dependency
|
|
27
41
|
name: omniauth
|
|
28
42
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -100,12 +114,16 @@ files:
|
|
|
100
114
|
- app/views/bible270/notice_mailer/new_reader.text.erb
|
|
101
115
|
- app/views/bible270/profiles/edit.html.erb
|
|
102
116
|
- app/views/bible270/readers/index.html.erb
|
|
117
|
+
- app/views/bible270/readers/progress.html.erb
|
|
103
118
|
- app/views/bible270/readers/show.html.erb
|
|
104
119
|
- app/views/bible270/sessions/new.html.erb
|
|
105
120
|
- app/views/bible270/shared/_day_index.html.erb
|
|
106
121
|
- app/views/bible270/shared/_footer.html.erb
|
|
107
122
|
- app/views/bible270/shared/_header.html.erb
|
|
123
|
+
- app/views/bible270/shared/_progress.html.erb
|
|
124
|
+
- app/views/bible270/shared/_reflections.html.erb
|
|
108
125
|
- app/views/bible270/shared/_styles.html.erb
|
|
126
|
+
- app/views/bible270/shared/_track_bars.html.erb
|
|
109
127
|
- app/views/bible270/sign_in_mailer/magic_link.html.erb
|
|
110
128
|
- app/views/bible270/sign_in_mailer/magic_link.text.erb
|
|
111
129
|
- app/views/layouts/bible270/application.html.erb
|
|
@@ -118,9 +136,10 @@ files:
|
|
|
118
136
|
- db/migrate/20260101000006_add_approval_to_bible270_comments.rb
|
|
119
137
|
- db/migrate/20260101000007_create_bible270_settings.rb
|
|
120
138
|
- db/migrate/20260101000008_add_bible_version_to_bible270_readers.rb
|
|
139
|
+
- db/migrate/20260101000009_add_part_to_bible270_checkoffs.rb
|
|
140
|
+
- db/migrate/20260101000010_add_remember_token_to_bible270_readers.rb
|
|
121
141
|
- lib/bible270.rb
|
|
122
142
|
- lib/bible270/avatars.rb
|
|
123
|
-
- lib/bible270/bible270.rb
|
|
124
143
|
- lib/bible270/configuration.rb
|
|
125
144
|
- lib/bible270/email_sign_in.rb
|
|
126
145
|
- lib/bible270/engine.rb
|
data/lib/bible270/bible270.rb
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'bible270/version'
|
|
4
|
-
require 'bible270/plan'
|
|
5
|
-
require 'bible270/email_sign_in'
|
|
6
|
-
require 'bible270/names'
|
|
7
|
-
require 'bible270/avatars'
|
|
8
|
-
require 'bible270/translations'
|
|
9
|
-
require 'bible270/favicon'
|
|
10
|
-
require 'bible270/configuration'
|
|
11
|
-
require 'bible270/engine' if defined?(Rails::Engine)
|
|
12
|
-
|
|
13
|
-
module Bible270
|
|
14
|
-
end
|