bible270 0.14.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 663793ae728057f408f03f490d324349dc105dc07395b1e3f29ee6c410331039
4
- data.tar.gz: 9243a5ee0e3f921de058155825317684776babbbac0488cfca6bb2cbf5c810ad
3
+ metadata.gz: b1a3221c4afb7b3ba3e568f4b783a763aed4d86080dfbd13807bae0bc71e61a1
4
+ data.tar.gz: 30b56e503e05712358fe1314c657aa4224e68eee742cbfaad53f5b4587625c96
5
5
  SHA512:
6
- metadata.gz: 393484c84f487178ba20d52191a8bbdb79d7e9009e1f7761c0c9514ab894923c52c021b6f9a0c66e8dfcaa5563d953e44e702c49cc800dfedf4ad43037e5b79d
7
- data.tar.gz: 5c9dee8f97be46ae34b22c6cdf11c6fba418ad3f914847cd57d191d45de72fb77e152d92b44b32bbf8505f0d1bb1557e3e10dcacc3261c46a83bb3c4dc4540b3
6
+ metadata.gz: 622094718971b6b74ea4e5d81440d19eb1dd4eb6d2d9ae6ce2695e51b6a4f80d413c6fc7bcd08f30e428cb8808b596a875370730e21836999c08293adf7778a6
7
+ data.tar.gz: 67437bd6d453421a84c6c08e8589b4fb92666e387d107d4dd7c4828b6b264eace3d2dc88d5ed0e7bb3e0da8581323db66d9c257bd0c88748b60ec4699fc0e2aa
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://coveralls.io/repos/github/avonderluft/bible270/badge.svg?branch=main)](https://coveralls.io/github/avonderluft/bible270?branch=main?cacheSeconds=60) [![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,7 +6,7 @@ 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
 
@@ -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
@@ -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 %>">
@@ -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>
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,
@@ -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.0'
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.0
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
@@ -118,9 +132,10 @@ files:
118
132
  - db/migrate/20260101000006_add_approval_to_bible270_comments.rb
119
133
  - db/migrate/20260101000007_create_bible270_settings.rb
120
134
  - db/migrate/20260101000008_add_bible_version_to_bible270_readers.rb
135
+ - db/migrate/20260101000009_add_part_to_bible270_checkoffs.rb
136
+ - db/migrate/20260101000010_add_remember_token_to_bible270_readers.rb
121
137
  - lib/bible270.rb
122
138
  - lib/bible270/avatars.rb
123
- - lib/bible270/bible270.rb
124
139
  - lib/bible270/configuration.rb
125
140
  - lib/bible270/email_sign_in.rb
126
141
  - 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