Class: Bible270::Reader

Inherits:
ApplicationRecord show all
Defined in:
app/models/bible270/reader.rb

Overview

A reader identity. Either self-contained (created via OmniAuth) or bridged to one of the host application's users through the polymorphic :owner.

Constant Summary collapse

AVATAR_UPLOADS =

Active Storage is optional: an app may have it disabled, and the engine has to keep working there — readers simply can't upload, and any avatar from their sign-in provider is used instead.

respond_to?(:has_one_attached)
PASSAGE_SOURCES =
%w[bible_com bible_gateway blue_letter_bible].freeze
DEFAULT_PASSAGE_SOURCE =
'bible_com'
DAILY_REMINDER_TIME_FORMAT =
%r{\A(?:[01]\d|2[0-3]):[0-5]\d\z}
DAILY_REMINDER_MINUTES =
%w[00 15 30 45].freeze
DAILY_REMINDER_COLUMNS =
%w[daily_reminders daily_reminder_time last_daily_reminder_sent_on].freeze
REFLECTIONS_SEEN_COLUMN =
'reflections_seen_at'
COMMENT_NOTIFICATION_LEVELS =
%w[all personal none].freeze
COMMENT_NOTIFICATION_COLUMNS =
%w[notify_on_mention notify_on_all_comments].freeze
MENTION_SUGGESTION_LIMIT =
5

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.all_comment_notification_recipientsObject



286
287
288
289
290
# File 'app/models/bible270/reader.rb', line 286

def self.all_comment_notification_recipients
  return none unless comment_notification_columns?

  where(notify_on_all_comments: true).where.not(email: [nil, ''])
end

.avatar_uploads?Boolean

Returns:

  • (Boolean)


20
# File 'app/models/bible270/reader.rb', line 20

def self.avatar_uploads? = AVATAR_UPLOADS

.canonical_mention_handle(reader) ⇒ Object



319
320
321
# File 'app/models/bible270/reader.rb', line 319

def self.canonical_mention_handle(reader)
  Mentions.handles_for(reader.first_name, reader.last_name).last
end

.comment_notification_columns?Boolean

Returns:

  • (Boolean)


274
275
276
277
278
279
280
281
282
283
284
# File 'app/models/bible270/reader.rb', line 274

def self.comment_notification_columns?
  return true if (COMMENT_NOTIFICATION_COLUMNS - column_names).empty?

  # A migration commonly runs in a one-off process while the web process keeps
  # its old schema cache. Refresh once before reporting that the migration is
  # still pending, so the controls appear without requiring an application restart.
  reset_column_information
  (COMMENT_NOTIFICATION_COLUMNS - column_names).empty?
rescue ActiveRecord::StatementInvalid
  false
end

.completed_days_by_idObject



61
62
63
64
65
# File 'app/models/bible270/reader.rb', line 61

def self.completed_days_by_id
  Checkoff.group(:reader_id, :day).count.each_with_object(Hash.new(0)) do |((reader_id, day), done), totals|
    totals[reader_id] += 1 if done >= Plan.total_parts(day)
  end
end

.daily_reminder_columns?Boolean

Returns:

  • (Boolean)


51
52
53
54
55
# File 'app/models/bible270/reader.rb', line 51

def self.daily_reminder_columns?
  (DAILY_REMINDER_COLUMNS - column_names).empty?
rescue ActiveRecord::StatementInvalid
  false
end

.email_reader_exists?(email) ⇒ Boolean

Find or create the reader behind a verified email address. Uses the same provider/uid identity columns as OmniAuth, with provider "email", so an email reader is indistinguishable from any other downstream. Used when enrolment is closed: an existing reader may still sign in, a new one may not be created.

Returns:

  • (Boolean)


147
148
149
150
151
152
# File 'app/models/bible270/reader.rb', line 147

def self.email_reader_exists?(email)
  address = EmailSignIn.normalize_email(email)
  return false if address.nil?

  exists?(provider: 'email', uid: address)
end

.for_owner(owner, display_name:, email: nil, avatar_url: nil) ⇒ Object

Find or create a reader bridged to a host user (or any model).



193
194
195
196
197
198
199
200
# File 'app/models/bible270/reader.rb', line 193

def self.for_owner(owner, display_name:, email: nil, avatar_url: nil)
  reader = find_or_initialize_by(owner: owner)
  reader.display_name = display_name.presence || reader.display_name || 'Reader'
  reader.email      ||= email
  reader.avatar_url ||= avatar_url
  reader.save!
  reader
end

.from_email(email, first_name: nil, last_name: nil, display_name: nil) ⇒ Object



160
161
162
163
164
165
166
167
168
169
170
171
172
# File 'app/models/bible270/reader.rb', line 160

def self.from_email(email, first_name: nil, last_name: nil, display_name: nil)
  address = EmailSignIn.normalize_email(email)
  return nil if address.nil?

  reader = find_or_initialize_by(provider: 'email', uid: address)
  reader.first_name = first_name.to_s.strip if first_name.present?
  reader.last_name  = last_name.to_s.strip  if last_name.present?
  reader.display_name = first_present(reader.full_name, display_name, reader.display_name,
                                      EmailSignIn.display_name_from(address), 'Reader')
  reader.email = address
  reader.save
  reader
end

.from_omniauth(auth) ⇒ Object

Build/refresh a reader from an OmniAuth auth hash. Tolerant of the various shapes strategies return (OmniAuth::AuthHash, plain Hash, missing info).



96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
# File 'app/models/bible270/reader.rb', line 96

def self.from_omniauth(auth)
  provider = dig_auth(auth, :provider).to_s
  uid      = dig_auth(auth, :uid).to_s
  return nil if provider.empty? || uid.empty?

  info = dig_auth(auth, :info) || {}
  name = first_present(dig_auth(info, :name), dig_auth(info, :nickname),
                       dig_auth(info, :first_name), dig_auth(info, :email))

  reader = find_or_initialize_by(provider: provider, uid: uid)
  reader.display_name = first_present(name, reader.display_name, 'Reader')
  email = dig_auth(info, :email)
  image = first_present(dig_auth(info, :image), dig_auth(info, :avatar_url))
  reader.email      = email if email.present?
  reader.avatar_url = image if image.present?
  assign_names_from(reader, info)
  reader.save
  reader
end


359
360
361
362
363
364
365
366
367
368
369
# File 'app/models/bible270/reader.rb', line 359

def self.from_remember_cookie(reader_id, token)
  return nil if reader_id.blank? || token.blank?

  reader = find_by(id: reader_id)
  return nil if reader.nil? || reader.remember_token.blank?

  # Constant-time comparison: the token is a credential.
  return nil unless ActiveSupport::SecurityUtils.secure_compare(reader.remember_token, token.to_s)

  reader
end

.mention_suggestions(query, except: nil) ⇒ Object

Small, deterministic suggestions for the reflection composer. Full-handle collisions are omitted because the server must never guess which reader a mention intended.



295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
# File 'app/models/bible270/reader.rb', line 295

def self.mention_suggestions(query, except: nil)
  needle = Mentions.normalize(query.to_s.first(61))
  candidates = where.not(first_name: [nil, '']).where.not(last_name: [nil, ''])
  candidates = candidates.where.not(id: except.id) if except
  unique = candidates.to_a.group_by { |reader| canonical_mention_handle(reader) }
    .select { |handle, readers| handle.present? && readers.one? }

  ranked = unique.filter_map do |handle, readers|
    reader = readers.first
    surname = Mentions.normalize(reader.last_name)
    rank = if needle.empty? || handle.start_with?(needle)
             0
           elsif surname.start_with?(needle)
             1
           end
    next unless rank

    [{ name: reader.display_name, handle: "@#{handle}" }, rank, handle, reader.id]
  end
  ranked.sort_by { |_, rank, handle, id| [rank, handle, id] }
    .first(MENTION_SUGGESTION_LIMIT)
    .map(&:first)
end

.mentioned_in(text) ⇒ Object

The readers a piece of text mentions. A handle matching more than one reader resolves to nobody: mailing the wrong person is worse than mailing none, and the writer sees the mention left unlinked.



326
327
328
329
330
331
332
333
334
335
# File 'app/models/bible270/reader.rb', line 326

def self.mentioned_in(text)
  handles = Mentions.extract(text)
  return [] if handles.empty?

  candidates = where.not(first_name: [nil, '']).to_a
  handles.filter_map do |handle|
    matches = candidates.select { |reader| reader.answers_to?(handle) }
    matches.first if matches.one?
  end.uniq
end

.omniauth_reader_exists?(provider, uid) ⇒ Boolean

Returns:

  • (Boolean)


154
155
156
157
158
# File 'app/models/bible270/reader.rb', line 154

def self.omniauth_reader_exists?(provider, uid)
  return false if provider.blank? || uid.blank?

  exists?(provider: provider.to_s, uid: uid.to_s)
end

.reflections_seen_column?Boolean

Returns:

  • (Boolean)


67
68
69
70
71
# File 'app/models/bible270/reader.rb', line 67

def self.reflections_seen_column?
  column_names.include?(REFLECTIONS_SEEN_COLUMN)
rescue ActiveRecord::StatementInvalid
  false
end

.valid_daily_reminder_time?(value) ⇒ Boolean

Returns:

  • (Boolean)


57
58
59
# File 'app/models/bible270/reader.rb', line 57

def self.valid_daily_reminder_time?(value)
  value.to_s.match?(DAILY_REMINDER_TIME_FORMAT)
end

Instance Method Details

#answers_to?(handle) ⇒ Boolean

Returns:

  • (Boolean)


337
338
339
# File 'app/models/bible270/reader.rb', line 337

def answers_to?(handle)
  Mentions.handles_for(first_name, last_name).include?(handle)
end

#attach_avatar(upload) ⇒ Object

Returns false and sets an error when the upload isn't acceptable.



376
377
378
379
380
381
382
383
384
385
386
387
# File 'app/models/bible270/reader.rb', line 376

def attach_avatar(upload)
  return false unless self.class.avatar_uploads?

  problem = Avatars.problem_with(content_type: upload.content_type, byte_size: upload.size)
  if problem
    errors.add(:avatar, problem)
    return false
  end

  avatar.attach(upload)
  true
end

#avatar_uploaded?Boolean

Returns:

  • (Boolean)


371
372
373
# File 'app/models/bible270/reader.rb', line 371

def avatar_uploaded?
  self.class.avatar_uploads? && avatar.attached?
end

#bible_com?Boolean

Returns:

  • (Boolean)


239
# File 'app/models/bible270/reader.rb', line 239

def bible_com? = passage_source == 'bible_com'

#bible_gateway?Boolean

Returns:

  • (Boolean)


237
# File 'app/models/bible270/reader.rb', line 237

def bible_gateway? = passage_source == 'bible_gateway'

#bible_version_labelObject



220
221
222
# File 'app/models/bible270/reader.rb', line 220

def bible_version_label
  Translations.label(effective_bible_version)
end

#blue_letter_bible?Boolean

Returns:

  • (Boolean)


238
# File 'app/models/bible270/reader.rb', line 238

def blue_letter_bible? = passage_source == 'blue_letter_bible'

#calendar_dayObject

The plan day that today corresponds to (clamped into range), or nil when undated.



613
614
615
# File 'app/models/bible270/reader.rb', line 613

def calendar_day
  Plan.day_for(Bible270.today, effective_start_date)
end

#checked_count(day) ⇒ Object

Tracks ticked on a day, counted from the single grouped query in checked_counts. Use this rather than read_tracks_for when rendering a grid: read_tracks_for costs a query per day, which is 270 of them per page.



406
407
408
# File 'app/models/bible270/reader.rb', line 406

def checked_count(day)
  checked_counts[day].to_i
end

#checked_countsObject

=> number of tracks checked off



399
400
401
# File 'app/models/bible270/reader.rb', line 399

def checked_counts
  @checked_counts ||= checkoffs.group(:day).count
end

#clear_day!(day) ⇒ Object



530
531
532
533
534
535
536
# File 'app/models/bible270/reader.rb', line 530

def clear_day!(day)
  return false unless Plan.valid_day?(day)

  checkoffs.where(day: day).destroy_all
  reload_progress
  true
end

#comment_notification_levelObject



262
263
264
265
266
# File 'app/models/bible270/reader.rb', line 262

def comment_notification_level
  return 'all' if wants_all_comment_notifications?

  wants_comment_notifications? ? 'personal' : 'none'
end

#completion_percentObject



466
467
468
# File 'app/models/bible270/reader.rb', line 466

def completion_percent
  (days_completed.to_f / Plan::DAYS * 100).round
end

#current_dayObject

First day not yet fully complete (where the reader "is").



471
472
473
# File 'app/models/bible270/reader.rb', line 471

def current_day
  (1..Plan::DAYS).find { |d| !day_complete?(d) } || Plan::DAYS
end

#daily_reminder_due_at?(local_time) ⇒ Boolean

Returns:

  • (Boolean)


82
83
84
85
86
87
88
89
90
91
92
# File 'app/models/bible270/reader.rb', line 82

def daily_reminder_due_at?(local_time)
  return false unless self.class.daily_reminder_columns?
  return false unless daily_reminders?
  return false unless self.class.valid_daily_reminder_time?(daily_reminder_time)
  return false unless local_time.respond_to?(:hour) && local_time.respond_to?(:min)

  hour, minute = daily_reminder_time.split(':').map(&:to_i)
  (local_time.hour * 60) + local_time.min >= (hour * 60) + minute
rescue StandardError
  false
end

#date_for_day(day) ⇒ Object



648
649
650
# File 'app/models/bible270/reader.rb', line 648

def date_for_day(day)
  Plan.date_for(day, effective_start_date)
end

#dated?Boolean

Returns:

  • (Boolean)


603
604
605
# File 'app/models/bible270/reader.rb', line 603

def dated?
  effective_start_date.present?
end

#day_complete?(day) ⇒ Boolean

Returns:

  • (Boolean)


450
451
452
453
# File 'app/models/bible270/reader.rb', line 450

def day_complete?(day)
  n = checked_counts[day].to_i
  n.positive? && n >= Plan.total_parts(day)
end

#day_status(day) ⇒ Object



410
411
412
413
414
415
# File 'app/models/bible270/reader.rb', line 410

def day_status(day)
  done = checked_count(day)
  return :none if done.zero?

  done >= Plan.total_parts(day) ? :complete : :partial
end

#days_completedObject



455
456
457
# File 'app/models/bible270/reader.rb', line 455

def days_completed
  checked_counts.count { |day, n| n >= Plan.total_parts(day) }
end

#days_off_paceObject

How many days behind (positive) or ahead (negative) of the calendar the reader's actual progress is. Nil when undated.



654
655
656
657
658
659
# File 'app/models/bible270/reader.rb', line 654

def days_off_pace
  today = calendar_day
  return nil unless today

  today - days_completed
end

#days_read_in(track) ⇒ Object

Days on which this track is finished. Counting rows would count chapters now that an Old Testament reading has one per chapter.



461
462
463
464
# File 'app/models/bible270/reader.rb', line 461

def days_read_in(track)
  checkoffs.where(track: track.to_s).group(:day).count
    .count { |day, done| done >= Plan.part_count(day, track) }
end

#effective_bible_versionObject

The translation this reader reads in. Null means "whatever the site default is", so changing config.bible_version moves everyone who hasn't chosen.



216
217
218
# File 'app/models/bible270/reader.rb', line 216

def effective_bible_version
  Translations.resolve(bible_version)
end

#effective_start_dateObject

The start date that actually governs this reader. A reader's own started_on wins when per-reader dates are allowed; otherwise (or if they haven't got one) the current run's shared date applies. Nil means the plan is undated for this reader and no calendar mapping exists.



595
596
597
598
599
600
601
# File 'app/models/bible270/reader.rb', line 595

def effective_start_date
  if Bible270.config.allow_reader_start_date && started_on
    started_on
  else
    Setting.run_start_date
  end
end

#ensure_started!Object

Called when a reader first participates. Only stamps a personal start date when per-reader dates are enabled and a shared date isn't already in force.



683
684
685
686
687
688
689
# File 'app/models/bible270/reader.rb', line 683

def ensure_started!
  return unless Bible270.config.allow_reader_start_date
  return if started_on.present?
  return if Setting.run_start_date.present?

  update!(started_on: Bible270.today)
end

#first_with_last_initialObject

Shorter than the full name, for lists where a surname is more than needed. Falls back to the display name for readers who arrived with only one.



208
209
210
211
212
# File 'app/models/bible270/reader.rb', line 208

def first_with_last_initial
  return display_name if first_name.blank?

  Names.first_with_last_initial(first_name, last_name).presence || display_name
end

#forget!Object

Rotating the token invalidates every device at once.



354
355
356
357
# File 'app/models/bible270/reader.rb', line 354

def forget!
  update_column(:remember_token, nil)
  true
end

#full_nameObject



202
203
204
# File 'app/models/bible270/reader.rb', line 202

def full_name
  [first_name, last_name].map { |n| n.to_s.strip }.reject(&:empty?).join(' ').presence
end

#initialsObject



394
395
396
# File 'app/models/bible270/reader.rb', line 394

def initials
  display_name.to_s.split(%r{\s+}).first(2).map { |w| w[0] }.join.upcase.presence || '?'
end

#mark_day_complete!(day) ⇒ Object

Tick every track that has content on this day.



496
497
498
499
500
501
502
503
504
505
506
507
508
509
# File 'app/models/bible270/reader.rb', line 496

def mark_day_complete!(day)
  return false unless Plan.valid_day?(day)

  # Deliberately not find_or_create_by!: since Rails 8.1 that creates first
  # and rescues RecordNotUnique, but Checkoff validates uniqueness, so a
  # duplicate raises RecordInvalid before the database is reached and is
  # never rescued. Reading what exists first is also one query per day rather
  # than one per chapter.
  missing = missing_parts_on(day)
  insert_checkoffs(day, missing) if missing.any?

  reload_progress
  true
end

#mark_reflections_seen!(at) ⇒ Object



73
74
75
76
77
78
79
80
# File 'app/models/bible270/reader.rb', line 73

def mark_reflections_seen!(at)
  return false unless self.class.reflections_seen_column?

  update_column(:reflections_seen_at, at)
  true
rescue ActiveRecord::StatementInvalid
  false
end

#mark_through!(day) ⇒ Object

Mark everything up to and including day complete, and clear anything after.



543
544
545
546
547
548
549
550
551
552
553
# File 'app/models/bible270/reader.rb', line 543

def mark_through!(day)
  day = day.to_i
  return false unless day.between?(0, Plan::DAYS)

  transaction do
    checkoffs.where(day: (day + 1)..).delete_all
    (1..day).each { |d| mark_day_complete!(d) }
  end
  reload_progress
  true
end

#not_started_yet?Boolean

Returns:

  • (Boolean)


636
637
638
# File 'app/models/bible270/reader.rb', line 636

def not_started_yet?
  Plan.before_start?(Bible270.today, effective_start_date)
end

#own_start_date?Boolean

Whether this reader is following a personal date or the shared cohort one.

Returns:

  • (Boolean)


608
609
610
# File 'app/models/bible270/reader.rb', line 608

def own_start_date?
  Bible270.config.allow_reader_start_date && started_on.present?
end

#partial_daysObject



417
418
419
# File 'app/models/bible270/reader.rb', line 417

def partial_days
  checked_counts.keys.select { |day| day_status(day) == :partial }.sort
end

#past_end_date?Boolean

Returns:

  • (Boolean)


640
641
642
# File 'app/models/bible270/reader.rb', line 640

def past_end_date?
  Plan.after_end?(Bible270.today, effective_start_date)
end

#plan_end_dateObject



644
645
646
# File 'app/models/bible270/reader.rb', line 644

def plan_end_date
  Plan.end_date_for(effective_start_date)
end

#raw_calendar_dayObject



632
633
634
# File 'app/models/bible270/reader.rb', line 632

def raw_calendar_day
  Plan.day_for(Bible270.today, effective_start_date, clamp: false)
end

#read?(day, track, part = nil) ⇒ Boolean

Returns:

  • (Boolean)


438
439
440
441
442
443
# File 'app/models/bible270/reader.rb', line 438

def read?(day, track, part = nil)
  return read_parts_for(day, track).include?(part) if part

  # No part given: the track counts as read only when every chapter is.
  read_parts_for(day, track).size >= Plan.part_count(day, track)
end

#read_parts_for(day, track) ⇒ Object

Which chapters of a track the reader has ticked on this day.



434
435
436
# File 'app/models/bible270/reader.rb', line 434

def read_parts_for(day, track)
  checkoffs.where(day: day, track: track.to_s).pluck(:part)
end

#read_tracks_for(day) ⇒ Object



429
430
431
# File 'app/models/bible270/reader.rb', line 429

def read_tracks_for(day)
  checkoffs.where(day: day).pluck(:track).uniq
end

#reload_progressObject



584
585
586
587
# File 'app/models/bible270/reader.rb', line 584

def reload_progress
  @checked_counts = nil
  self
end

#remaining_parts_for(day) ⇒ Object



421
422
423
424
425
426
427
# File 'app/models/bible270/reader.rb', line 421

def remaining_parts_for(day)
  return [] unless Plan.valid_day?(day)

  missing_parts_on(day).map do |track, part|
    { track: track, part: part, reference: Plan.parts_for(day, track).fetch(part) }
  end
end

#remember_token!Object

Generated on first use rather than at sign-up, so readers who never stay signed in never carry one.



345
346
347
348
349
350
351
# File 'app/models/bible270/reader.rb', line 345

def remember_token!
  return remember_token if remember_token.present?

  token = SecureRandom.urlsafe_base64(32)
  update_column(:remember_token, token)
  token
end

#remove_avatar!Object



389
390
391
392
# File 'app/models/bible270/reader.rb', line 389

def remove_avatar!
  avatar.purge if avatar_uploaded?
  true
end

#restart_on!(day:, on: Bible270.today) ⇒ Object

Put this reader on day as of on — i.e. back-date the start so that the given date lands on the given day of the plan.



557
558
559
560
561
562
# File 'app/models/bible270/reader.rb', line 557

def restart_on!(day:, on: Bible270.today)
  day = day.to_i
  return false unless Plan.valid_day?(day)

  update!(started_on: Plan.to_date(on) - (day - 1))
end

#set_start_date!(value) ⇒ Object

Set an individual start date for administrative use. This remains ungated so an administrator can prepare or preserve a personal calendar even when config.allow_reader_start_date currently makes the community date authoritative. Returns false only when the value isn't a date.



674
675
676
677
678
679
# File 'app/models/bible270/reader.rb', line 674

def set_start_date!(value)
  date = Plan.to_date(value)
  return false if date.nil?

  update!(started_on: date)
end

#sort_nameObject

Readers are listed by first name, matching how they are shown — the display name is "First Last", so sorting on it needs only case folding. Surname order was inconsistent with the community page and read oddly next to names displayed first-name-first.



245
246
247
# File 'app/models/bible270/reader.rb', line 245

def sort_name
  display_name.to_s.strip.downcase
end

#start_dateObject



666
667
668
# File 'app/models/bible270/reader.rb', line 666

def start_date
  started_on
end

#start_date=(value) ⇒ Object

Set or change this reader's own start date. Accepts a Date or a string.



662
663
664
# File 'app/models/bible270/reader.rb', line 662

def start_date=(value)
  self.started_on = Plan.to_date(value)
end

#suggested_namesObject

Fill first/last from the display name where we only have one string, e.g. a reader who arrived through OmniAuth.



487
488
489
490
491
# File 'app/models/bible270/reader.rb', line 487

def suggested_names
  return { first_name: first_name, last_name: last_name } if full_name

  Names.split_display_name(display_name) || { first_name: display_name, last_name: nil }
end

#today?(day) ⇒ Boolean

Returns:

  • (Boolean)


628
629
630
# File 'app/models/bible270/reader.rb', line 628

def today?(day)
  today_day == day
end

#today_dayObject

Raw, unclamped — lets callers distinguish "not started yet" / "finished". The plan day that today actually is, or nil when today falls outside the plan's window. calendar_day clamps, so before the start date it reports day 1 — which made day 1 claim to be "today" for anyone whose plan hadn't begun.



621
622
623
624
625
626
# File 'app/models/bible270/reader.rb', line 621

def today_day
  raw = raw_calendar_day
  return nil if raw.nil?

  Plan.valid_day?(raw) ? raw : nil
end

#toggle_day!(day) ⇒ Object



538
539
540
# File 'app/models/bible270/reader.rb', line 538

def toggle_day!(day)
  day_complete?(day) ? clear_day!(day) : mark_day_complete!(day)
end

#track_partially_read?(day, track) ⇒ Boolean

Returns:

  • (Boolean)


445
446
447
448
# File 'app/models/bible270/reader.rb', line 445

def track_partially_read?(day, track)
  done = read_parts_for(day, track).size
  done.positive? && done < Plan.part_count(day, track)
end

#update_bible_version(code) ⇒ Object



224
225
226
227
228
# File 'app/models/bible270/reader.rb', line 224

def update_bible_version(code)
  return false unless Translations.valid?(code)

  update(bible_version: Translations.normalize(code))
end

#update_comment_notification_level!(level) ⇒ Object

Raises:

  • (ArgumentError)


268
269
270
271
272
# File 'app/models/bible270/reader.rb', line 268

def update_comment_notification_level!(level)
  raise ArgumentError, 'unknown reflection email preference' unless COMMENT_NOTIFICATION_LEVELS.include?(level)

  update!(notify_on_mention: level != 'none', notify_on_all_comments: level == 'all')
end

#update_names(first, last) ⇒ Object

Day number implied by the calendar, if a start date is set. Set the name shown beside this reader's reflections. Returns false when either half is missing, so callers can re-render with a message.



478
479
480
481
482
483
# File 'app/models/bible270/reader.rb', line 478

def update_names(first, last)
  names = Names.normalize(first, last)
  return false if names.nil?

  update(**names)
end

#update_passage_source(source) ⇒ Object



230
231
232
233
234
235
# File 'app/models/bible270/reader.rb', line 230

def update_passage_source(source)
  source = source.to_s
  return false unless PASSAGE_SOURCES.include?(source)

  update(passage_source: source)
end

#wants_all_comment_notifications?Boolean

Returns:

  • (Boolean)


258
259
260
# File 'app/models/bible270/reader.rb', line 258

def wants_all_comment_notifications?
  has_attribute?(:notify_on_all_comments) && self[:notify_on_all_comments] == true
end

#wants_comment_notifications?Boolean

Older copied databases may briefly lack preference columns while their migrations are being reconciled. Preserve the historical opted-in behavior until the original column is available rather than breaking reflection delivery.

Returns:

  • (Boolean)


254
255
256
# File 'app/models/bible270/reader.rb', line 254

def wants_comment_notifications?
  wants_all_comment_notifications? || !has_attribute?(:notify_on_mention) || self[:notify_on_mention] != false
end