Class: Bible270::Reader
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- Bible270::Reader
- 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
- .all_comment_notification_recipients ⇒ Object
- .avatar_uploads? ⇒ Boolean
- .canonical_mention_handle(reader) ⇒ Object
- .comment_notification_columns? ⇒ Boolean
- .completed_days_by_id ⇒ Object
- .daily_reminder_columns? ⇒ Boolean
-
.email_reader_exists?(email) ⇒ Boolean
Find or create the reader behind a verified email address.
-
.for_owner(owner, display_name:, email: nil, avatar_url: nil) ⇒ Object
Find or create a reader bridged to a host user (or any model).
- .from_email(email, first_name: nil, last_name: nil, display_name: nil) ⇒ Object
-
.from_omniauth(auth) ⇒ Object
Build/refresh a reader from an OmniAuth auth hash.
- .from_remember_cookie(reader_id, token) ⇒ Object
-
.mention_suggestions(query, except: nil) ⇒ Object
Small, deterministic suggestions for the reflection composer.
-
.mentioned_in(text) ⇒ Object
The readers a piece of text mentions.
- .omniauth_reader_exists?(provider, uid) ⇒ Boolean
- .reflections_seen_column? ⇒ Boolean
- .valid_daily_reminder_time?(value) ⇒ Boolean
Instance Method Summary collapse
- #answers_to?(handle) ⇒ Boolean
-
#attach_avatar(upload) ⇒ Object
Returns false and sets an error when the upload isn't acceptable.
- #avatar_uploaded? ⇒ Boolean
- #bible_com? ⇒ Boolean
- #bible_gateway? ⇒ Boolean
- #bible_version_label ⇒ Object
- #blue_letter_bible? ⇒ Boolean
-
#calendar_day ⇒ Object
The plan day that today corresponds to (clamped into range), or nil when undated.
-
#checked_count(day) ⇒ Object
Tracks ticked on a day, counted from the single grouped query in checked_counts.
-
#checked_counts ⇒ Object
=> number of tracks checked off.
- #clear_day!(day) ⇒ Object
- #comment_notification_level ⇒ Object
- #completion_percent ⇒ Object
-
#current_day ⇒ Object
First day not yet fully complete (where the reader "is").
- #daily_reminder_due_at?(local_time) ⇒ Boolean
- #date_for_day(day) ⇒ Object
- #dated? ⇒ Boolean
- #day_complete?(day) ⇒ Boolean
- #day_status(day) ⇒ Object
- #days_completed ⇒ Object
-
#days_off_pace ⇒ Object
How many days behind (positive) or ahead (negative) of the calendar the reader's actual progress is.
-
#days_read_in(track) ⇒ Object
Days on which this track is finished.
-
#effective_bible_version ⇒ Object
The translation this reader reads in.
-
#effective_start_date ⇒ Object
The start date that actually governs this reader.
-
#ensure_started! ⇒ Object
Called when a reader first participates.
-
#first_with_last_initial ⇒ Object
Shorter than the full name, for lists where a surname is more than needed.
-
#forget! ⇒ Object
Rotating the token invalidates every device at once.
- #full_name ⇒ Object
- #initials ⇒ Object
-
#mark_day_complete!(day) ⇒ Object
Tick every track that has content on this day.
- #mark_reflections_seen!(at) ⇒ Object
-
#mark_through!(day) ⇒ Object
Mark everything up to and including
daycomplete, and clear anything after. - #not_started_yet? ⇒ Boolean
-
#own_start_date? ⇒ Boolean
Whether this reader is following a personal date or the shared cohort one.
- #partial_days ⇒ Object
- #past_end_date? ⇒ Boolean
- #plan_end_date ⇒ Object
- #raw_calendar_day ⇒ Object
- #read?(day, track, part = nil) ⇒ Boolean
-
#read_parts_for(day, track) ⇒ Object
Which chapters of a track the reader has ticked on this day.
- #read_tracks_for(day) ⇒ Object
- #reload_progress ⇒ Object
- #remaining_parts_for(day) ⇒ Object
-
#remember_token! ⇒ Object
Generated on first use rather than at sign-up, so readers who never stay signed in never carry one.
- #remove_avatar! ⇒ Object
-
#restart_on!(day:, on: Bible270.today) ⇒ Object
Put this reader on
dayas ofon— i.e. -
#set_start_date!(value) ⇒ Object
Set an individual start date for administrative use.
-
#sort_name ⇒ Object
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.
- #start_date ⇒ Object
-
#start_date=(value) ⇒ Object
Set or change this reader's own start date.
-
#suggested_names ⇒ Object
Fill first/last from the display name where we only have one string, e.g.
- #today?(day) ⇒ Boolean
-
#today_day ⇒ Object
Raw, unclamped — lets callers distinguish "not started yet" / "finished".
- #toggle_day!(day) ⇒ Object
- #track_partially_read?(day, track) ⇒ Boolean
- #update_bible_version(code) ⇒ Object
- #update_comment_notification_level!(level) ⇒ Object
-
#update_names(first, last) ⇒ Object
Day number implied by the calendar, if a start date is set.
- #update_passage_source(source) ⇒ Object
- #wants_all_comment_notifications? ⇒ Boolean
-
#wants_comment_notifications? ⇒ Boolean
Older copied databases may briefly lack preference columns while their migrations are being reconciled.
Class Method Details
.all_comment_notification_recipients ⇒ Object
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
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
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_id ⇒ Object
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
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.
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 |
.from_remember_cookie(reader_id, token) ⇒ Object
359 360 361 362 363 364 365 366 367 368 369 |
# File 'app/models/bible270/reader.rb', line 359 def self.(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
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
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
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
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
371 372 373 |
# File 'app/models/bible270/reader.rb', line 371 def avatar_uploaded? self.class.avatar_uploads? && avatar.attached? end |
#bible_com? ⇒ Boolean
239 |
# File 'app/models/bible270/reader.rb', line 239 def bible_com? = passage_source == 'bible_com' |
#bible_gateway? ⇒ Boolean
237 |
# File 'app/models/bible270/reader.rb', line 237 def bible_gateway? = passage_source == 'bible_gateway' |
#bible_version_label ⇒ Object
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
238 |
# File 'app/models/bible270/reader.rb', line 238 def blue_letter_bible? = passage_source == 'blue_letter_bible' |
#calendar_day ⇒ Object
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_counts ⇒ Object
=> 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_level ⇒ Object
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_percent ⇒ Object
466 467 468 |
# File 'app/models/bible270/reader.rb', line 466 def completion_percent (days_completed.to_f / Plan::DAYS * 100).round end |
#current_day ⇒ Object
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
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
603 604 605 |
# File 'app/models/bible270/reader.rb', line 603 def dated? effective_start_date.present? end |
#day_complete?(day) ⇒ 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_completed ⇒ Object
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_pace ⇒ Object
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_version ⇒ Object
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_date ⇒ Object
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_initial ⇒ Object
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_name ⇒ Object
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 |
#initials ⇒ Object
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
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.
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_days ⇒ Object
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
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_date ⇒ Object
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_day ⇒ Object
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
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_progress ⇒ Object
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_name ⇒ Object
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_date ⇒ Object
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_names ⇒ Object
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
628 629 630 |
# File 'app/models/bible270/reader.rb', line 628 def today?(day) today_day == day end |
#today_day ⇒ Object
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
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
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
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.
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 |