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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -1
  3. data/README.md +2 -2
  4. data/app/controllers/bible270/admin_controller.rb +1 -1
  5. data/app/controllers/bible270/application_controller.rb +40 -0
  6. data/app/controllers/bible270/checkoffs_controller.rb +8 -3
  7. data/app/controllers/bible270/days_controller.rb +6 -3
  8. data/app/controllers/bible270/readers_controller.rb +10 -1
  9. data/app/controllers/bible270/sessions_controller.rb +10 -0
  10. data/app/models/bible270/checkoff.rb +12 -1
  11. data/app/models/bible270/reader.rb +85 -15
  12. data/app/views/bible270/admin/show.html.erb +1 -26
  13. data/app/views/bible270/days/_reading.html.erb +51 -11
  14. data/app/views/bible270/days/index.html.erb +2 -39
  15. data/app/views/bible270/readers/progress.html.erb +33 -0
  16. data/app/views/bible270/readers/show.html.erb +6 -23
  17. data/app/views/bible270/shared/_header.html.erb +1 -1
  18. data/app/views/bible270/shared/_progress.html.erb +55 -0
  19. data/app/views/bible270/shared/_reflections.html.erb +44 -0
  20. data/app/views/bible270/shared/_styles.html.erb +20 -1
  21. data/app/views/bible270/shared/_track_bars.html.erb +21 -0
  22. data/config/routes.rb +5 -0
  23. data/db/migrate/20260101000009_add_part_to_bible270_checkoffs.rb +59 -0
  24. data/db/migrate/20260101000010_add_remember_token_to_bible270_readers.rb +11 -0
  25. data/lib/bible270/configuration.rb +14 -2
  26. data/lib/bible270/plan.rb +36 -0
  27. data/lib/bible270/versification.rb +11 -1
  28. data/lib/bible270/version.rb +1 -1
  29. data/lib/generators/bible270/install/templates/bible270.rb.tt +2 -1
  30. metadata +21 -2
  31. data/lib/bible270/bible270.rb +0 -14
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 663793ae728057f408f03f490d324349dc105dc07395b1e3f29ee6c410331039
4
- data.tar.gz: 9243a5ee0e3f921de058155825317684776babbbac0488cfca6bb2cbf5c810ad
3
+ metadata.gz: b0d1b963495f4293f7e9c2a2bfdd6e6bf22c70d632ee6c233f7a977c2e46e124
4
+ data.tar.gz: 5ef68e1d13d4e475a674bea5301ba2eed3e7054b1acb29a8cabce68700bf3870
5
5
  SHA512:
6
- metadata.gz: 393484c84f487178ba20d52191a8bbdb79d7e9009e1f7761c0c9514ab894923c52c021b6f9a0c66e8dfcaa5563d953e44e702c49cc800dfedf4ad43037e5b79d
7
- data.tar.gz: 5c9dee8f97be46ae34b22c6cdf11c6fba418ad3f914847cd57d191d45de72fb77e152d92b44b32bbf8505f0d1bb1557e3e10dcacc3261c46a83bb3c4dc4540b3
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.14.0...main)**
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
- [![Gem Version](https://img.shields.io/gem/v/bible270)](https://rubygems.org/gems/bible270) [![Downloads](https://img.shields.io/gem/dt/bible270)](https://rubygems.org/gems/bible270) [![GitHub Release Date - Published_At](https://img.shields.io/github/release-date/avonderluft/bible270?label=last%20release&color=seagreen)](https://github.com/avonderluft/bible270/releases)
3
+ [![Gem Version](https://img.shields.io/gem/v/bible270)](https://rubygems.org/gems/bible270) [![Coverage Status](https://img.shields.io/coverallsCoverage/github/avonderluft/bible270?branch=main&cacheSeconds=300)](https://coveralls.io/github/avonderluft/bible270?branch=main) [![Downloads](https://img.shields.io/gem/dt/bible270)](https://rubygems.org/gems/bible270) [![GitHub Release Date - Published_At](https://img.shields.io/github/release-date/avonderluft/bible270?label=last%20release&color=seagreen)](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.required_track_count(day) }
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) && Plan.present_tracks(@day).include?(@track)
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.required_track_count(day) }
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
- req = Plan.required_track_count(@day)
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(*) >= ?', req)
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.required_track_count(day) }
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.required_track_count(day) ? :complete : :partial
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
- def read?(day, track)
194
- read_tracks_for(day).include?(track.to_s)
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.required_track_count(day)
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.required_track_count(day) }
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
- Plan.present_tracks(day).each do |track|
244
- checkoffs.find_or_create_by!(day: day, track: track)
245
- end
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
- return false unless day.to_i.between?(0, Plan::DAYS)
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.to_i + 1)..).destroy_all
268
- (1..day.to_i).each do |d|
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
- <h2 class="b270-subhead">Their reflections</h2>
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
- <% t = b270_track(track); ref = readings[track]; done = reader_tracks.include?(track) %>
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 %> <%= 'done' if done %>" id="reading_<%= day %>_<%= track %>">
4
- <% if signed_in? %>
5
- <%= button_to toggle_checkoff_path(day, track), class: "b270-toggle", form: { data: { turbo_stream: true } },
6
- aria: { label: (done ? "Mark #{t[:label]} unread" : "Mark #{t[:label]} read") } do %>
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
- <% else %>
10
- <span class="b270-check"><svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg></span>
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
- <div class="b270-panel">
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 ...</h2>
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
- <div class="b270-tracks">
16
- <% [["ot", @reader.days_read_in("ot"), Bible270::Plan::DAYS],
17
- ["nt", @reader.days_read_in("nt"), Bible270::Plan.nt_content_days],
18
- ["pp", @reader.days_read_in("pp"), Bible270::Plan::DAYS]].each do |track, read, total| %>
19
- <% t = b270_track(track) %>
20
- <div class="b270-tm">
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", reader_path(current_reader) %>
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
- attr_accessor :require_sign_in_to_participate
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
- def verses(book, chapter) = VERSES.fetch(book)[chapter - 1]
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bible270
4
- VERSION = '0.14.1'
4
+ VERSION = '0.15.1'
5
5
  end
@@ -73,7 +73,8 @@ Bible270.configure do |config|
73
73
  # config.footer_partial = 'layouts/bible270_footer'
74
74
  # config.footer_html = '<p>&copy; 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.14.1
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
@@ -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