bible270 0.18.3 → 1.0.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 +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +67 -4
- data/Rakefile +178 -4
- data/app/controllers/bible270/checkoffs_controller.rb +34 -9
- data/app/controllers/bible270/comments_controller.rb +63 -28
- data/app/controllers/bible270/days_controller.rb +17 -6
- data/app/controllers/bible270/likes_controller.rb +15 -4
- data/app/controllers/bible270/profiles_controller.rb +37 -4
- data/app/controllers/bible270/readers_controller.rb +48 -3
- data/app/helpers/bible270/plan_helper.rb +17 -3
- data/app/mailers/bible270/notice_mailer.rb +22 -1
- data/app/models/bible270/comment.rb +48 -3
- data/app/models/bible270/reader.rb +74 -7
- data/app/views/bible270/checkoffs/toggle.turbo_stream.erb +8 -0
- data/app/views/bible270/comments/_edit_fields.html.erb +3 -1
- data/app/views/bible270/comments/_form.html.erb +10 -1
- data/app/views/bible270/comments/create.turbo_stream.erb +1 -1
- data/app/views/bible270/comments/index.html.erb +61 -10
- data/app/views/bible270/days/_completers.html.erb +12 -0
- data/app/views/bible270/days/_day_progress.html.erb +47 -0
- data/app/views/bible270/days/_reading.html.erb +10 -2
- data/app/views/bible270/days/_start_date.html.erb +1 -1
- data/app/views/bible270/days/index.html.erb +16 -8
- data/app/views/bible270/days/show.html.erb +38 -42
- data/app/views/bible270/likes/_likes.html.erb +5 -1
- data/app/views/bible270/notice_mailer/daily_reminder.html.erb +30 -0
- data/app/views/bible270/notice_mailer/daily_reminder.text.erb +14 -0
- data/app/views/bible270/notice_mailer/mentioned.html.erb +0 -1
- data/app/views/bible270/notice_mailer/mentioned.text.erb +1 -2
- data/app/views/bible270/profiles/edit.html.erb +41 -8
- data/app/views/bible270/readers/_next_steps.html.erb +63 -0
- data/app/views/bible270/readers/index.html.erb +6 -9
- data/app/views/bible270/readers/progress.html.erb +13 -3
- data/app/views/bible270/shared/_comment_drafts.html.erb +169 -0
- data/app/views/bible270/shared/_day_index.html.erb +1 -1
- data/app/views/bible270/shared/_header.html.erb +14 -17
- data/app/views/bible270/shared/_interaction_status.html.erb +2 -0
- data/app/views/bible270/shared/_interaction_ui.html.erb +69 -0
- data/app/views/bible270/shared/_nav_links.html.erb +15 -0
- data/app/views/bible270/shared/_progress.html.erb +24 -6
- data/app/views/bible270/shared/_reading_mode.html.erb +62 -0
- data/app/views/bible270/shared/_styles.html.erb +147 -10
- data/config/routes.rb +4 -3
- data/db/migrate/20260101000016_add_daily_reminders_to_bible270_readers.rb +9 -0
- data/db/migrate/20260101000017_add_reflections_seen_at_to_bible270_readers.rb +7 -0
- data/lib/bible270/configuration.rb +10 -4
- data/lib/bible270/daily_reminders.rb +57 -0
- data/lib/bible270/migration_reconciler.rb +13 -0
- data/lib/bible270/version.rb +1 -1
- data/lib/bible270.rb +1 -0
- data/lib/generators/bible270/install/install_generator.rb +6 -0
- data/lib/generators/bible270/install/templates/bible270.rb.tt +6 -0
- data/lib/tasks/bible270_reminders.rake +13 -0
- metadata +15 -1
|
@@ -8,14 +8,12 @@ module Bible270
|
|
|
8
8
|
|
|
9
9
|
def edit
|
|
10
10
|
@names = current_reader.suggested_names
|
|
11
|
+
load_daily_reminder_state
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def update
|
|
15
|
+
@daily_reminders_available = daily_reminders_available?
|
|
14
16
|
problems = []
|
|
15
|
-
# A checkbox sends nothing when unticked, so absence means off. Set before
|
|
16
|
-
# the name check, so the preference is saved even if a name is rejected.
|
|
17
|
-
current_reader.update_column(:notify_on_mention, params[:notify_on_mention].present?)
|
|
18
|
-
|
|
19
17
|
problems << 'both a first and last name' unless current_reader.update_names(params[:first_name],
|
|
20
18
|
params[:last_name])
|
|
21
19
|
if params[:bible_version].present? && !current_reader.update_bible_version(params[:bible_version])
|
|
@@ -27,10 +25,22 @@ module Bible270
|
|
|
27
25
|
problems << (current_reader.errors[:avatar].first || 'a valid image')
|
|
28
26
|
end
|
|
29
27
|
|
|
28
|
+
requested_daily_reminders = params[:daily_reminders] == '1'
|
|
29
|
+
requested_daily_reminder_time = daily_reminder_time_param
|
|
30
|
+
if @daily_reminders_available && !Reader.valid_daily_reminder_time?(requested_daily_reminder_time)
|
|
31
|
+
problems << 'a reminder time in 24-hour HH:MM format'
|
|
32
|
+
end
|
|
33
|
+
|
|
30
34
|
if problems.empty?
|
|
35
|
+
if @daily_reminders_available
|
|
36
|
+
current_reader.update!(daily_reminders: requested_daily_reminders,
|
|
37
|
+
daily_reminder_time: requested_daily_reminder_time)
|
|
38
|
+
end
|
|
31
39
|
redirect_to reader_path(current_reader), notice: 'Your profile has been updated.'
|
|
32
40
|
else
|
|
33
41
|
@names = { first_name: params[:first_name], last_name: params[:last_name] }
|
|
42
|
+
@daily_reminders = requested_daily_reminders
|
|
43
|
+
@daily_reminder_time = requested_daily_reminder_time
|
|
34
44
|
flash.now[:alert] = "Please give #{problems.to_sentence}."
|
|
35
45
|
render :edit, status: :unprocessable_entity
|
|
36
46
|
end
|
|
@@ -43,6 +53,29 @@ module Bible270
|
|
|
43
53
|
|
|
44
54
|
private
|
|
45
55
|
|
|
56
|
+
def load_daily_reminder_state
|
|
57
|
+
@daily_reminders_available = daily_reminders_available?
|
|
58
|
+
if @daily_reminders_available
|
|
59
|
+
@daily_reminders = current_reader.daily_reminders
|
|
60
|
+
@daily_reminder_time = current_reader.daily_reminder_time
|
|
61
|
+
else
|
|
62
|
+
@daily_reminders = false
|
|
63
|
+
@daily_reminder_time = '08:00'
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def daily_reminders_available?
|
|
68
|
+
Bible270.config.daily_reminders? && Reader.daily_reminder_columns?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def daily_reminder_time_param
|
|
72
|
+
hour = params[:daily_reminder_hour]
|
|
73
|
+
minute = params[:daily_reminder_minute]
|
|
74
|
+
return "#{hour}:#{minute}" if hour.present? || minute.present?
|
|
75
|
+
|
|
76
|
+
params[:daily_reminder_time].to_s
|
|
77
|
+
end
|
|
78
|
+
|
|
46
79
|
# Unlike checking off a reading, there is nothing sensible to do here for a
|
|
47
80
|
# visitor who isn't signed in, so send them to sign in rather than 404.
|
|
48
81
|
def require_signed_in_reader
|
|
@@ -2,13 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
module Bible270
|
|
4
4
|
class ReadersController < ApplicationController
|
|
5
|
+
MILESTONES = {
|
|
6
|
+
30 => '30-day milestone',
|
|
7
|
+
90 => 'one-third complete',
|
|
8
|
+
135 => 'halfway',
|
|
9
|
+
180 => 'two-thirds complete',
|
|
10
|
+
Plan::DAYS => 'plan complete'
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
5
13
|
def index
|
|
6
14
|
@readers = Reader.all.to_a
|
|
7
15
|
counts = Checkoff.group(:reader_id, :day).count
|
|
8
16
|
@days_completed = Hash.new(0)
|
|
9
17
|
counts.each { |(rid, day), n| @days_completed[rid] += 1 if n >= Plan.total_parts(day) }
|
|
10
|
-
#
|
|
11
|
-
|
|
18
|
+
# Community is a directory of fellow readers, not a leaderboard. Keep the
|
|
19
|
+
# order predictable without implying that progress determines standing.
|
|
20
|
+
@readers.sort_by!(&:sort_name)
|
|
12
21
|
end
|
|
13
22
|
|
|
14
23
|
# The reader's own progress. Deliberately open to visitors: the panel shows an
|
|
@@ -16,8 +25,26 @@ module Bible270
|
|
|
16
25
|
# from the reader it is given.
|
|
17
26
|
def progress
|
|
18
27
|
@reader = current_reader
|
|
19
|
-
#
|
|
28
|
+
# Defaults keep the open visitor page simple and make every view input explicit.
|
|
20
29
|
@recent_comments = @reader ? @reader.comments.recent.limit(5) : Comment.none
|
|
30
|
+
@partial_day_tasks = []
|
|
31
|
+
@remaining_partial_days = 0
|
|
32
|
+
@week_days = []
|
|
33
|
+
@week_completed = 0
|
|
34
|
+
@next_milestone = nil
|
|
35
|
+
@days_to_milestone = nil
|
|
36
|
+
return unless @reader
|
|
37
|
+
|
|
38
|
+
partial_days = @reader.partial_days
|
|
39
|
+
@partial_day_tasks = partial_days.first(5).map do |day|
|
|
40
|
+
{ day: day, remaining: @reader.remaining_parts_for(day) }
|
|
41
|
+
end
|
|
42
|
+
@remaining_partial_days = [partial_days.size - @partial_day_tasks.size, 0].max
|
|
43
|
+
@week_days = @reader.scheduled_days_this_week
|
|
44
|
+
@week_completed = @week_days.count { |day| @reader.day_complete?(day) }
|
|
45
|
+
completed = @reader.days_completed
|
|
46
|
+
@next_milestone = MILESTONES.find { |day, _label| day > completed }
|
|
47
|
+
@days_to_milestone = @next_milestone&.first.to_i - completed if @next_milestone
|
|
21
48
|
end
|
|
22
49
|
|
|
23
50
|
def show
|
|
@@ -53,5 +80,23 @@ module Bible270
|
|
|
53
80
|
redirect_to root_path, alert: 'The start date is set for the whole community.'
|
|
54
81
|
end
|
|
55
82
|
end
|
|
83
|
+
|
|
84
|
+
def restart_schedule
|
|
85
|
+
return unless require_reader!
|
|
86
|
+
|
|
87
|
+
unless Bible270.config.allow_reader_start_date
|
|
88
|
+
redirect_to progress_path, alert: 'The start date is set for the whole community.' and return
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
next_day = current_reader.current_day
|
|
92
|
+
today = current_reader.today_day
|
|
93
|
+
unless today && next_day < today
|
|
94
|
+
redirect_to progress_path, notice: 'Your reading schedule is already up to date.' and return
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
current_reader.restart_on!(day: next_day)
|
|
98
|
+
redirect_to progress_path,
|
|
99
|
+
notice: "Day #{next_day} is now today. All your reading progress is unchanged."
|
|
100
|
+
end
|
|
56
101
|
end
|
|
57
102
|
end
|
|
@@ -176,12 +176,26 @@ module Bible270
|
|
|
176
176
|
# One cell of the 270-day grid. Four views drew this by hand in four slightly
|
|
177
177
|
# different ways; now they all call this.
|
|
178
178
|
#
|
|
179
|
-
# The title attribute carries the readings
|
|
180
|
-
#
|
|
179
|
+
# The title attribute carries the readings for pointer users; aria-label also
|
|
180
|
+
# includes date, progress, today, and reflection state for assistive technology.
|
|
181
181
|
def b270_day_cell(day, reader: nil, today: nil)
|
|
182
182
|
link_to day, day_path(day),
|
|
183
183
|
class: b270_day_cell_classes(day, reader: reader, today: today),
|
|
184
|
-
title: b270_day_readings(day)
|
|
184
|
+
title: b270_day_readings(day),
|
|
185
|
+
aria: { label: b270_day_cell_label(day, reader: reader, today: today) }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def b270_day_cell_label(day, reader: nil, today: nil)
|
|
189
|
+
parts = ["Day #{day}"]
|
|
190
|
+
parts << reader.date_for_day(day).strftime('%A, %B %-d, %Y') if reader&.dated?
|
|
191
|
+
if reader
|
|
192
|
+
status = { complete: 'complete', partial: 'partially read', none: 'not read' }.fetch(reader.day_status(day))
|
|
193
|
+
parts << status
|
|
194
|
+
end
|
|
195
|
+
parts << 'today' if today == day
|
|
196
|
+
parts << 'has reflections' if b270_days_with_reflections.include?(day)
|
|
197
|
+
parts << b270_day_readings(day)
|
|
198
|
+
parts.join('. ')
|
|
185
199
|
end
|
|
186
200
|
|
|
187
201
|
# Shared with the admin grid, which is a button_to rather than a link because
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Bible270
|
|
4
|
-
#
|
|
4
|
+
# Transactional mail for readers and notices for whoever runs the plan.
|
|
5
5
|
class NoticeMailer < ApplicationMailer
|
|
6
6
|
def new_reader(reader_id:, recipients:)
|
|
7
7
|
@reader = Reader.find_by(id: reader_id)
|
|
@@ -31,6 +31,23 @@ module Bible270
|
|
|
31
31
|
subject: "#{@app_name}: #{@author.display_name} mentioned you on day #{@comment.day}"
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
def daily_reminder(reader_id:, day:, on: Bible270.today)
|
|
35
|
+
@reader = Reader.find_by(id: reader_id)
|
|
36
|
+
return message.perform_deliveries = false unless Bible270.config.daily_reminders?
|
|
37
|
+
return message.perform_deliveries = false if @reader.nil? || !@reader.daily_reminders? || @reader.email.blank?
|
|
38
|
+
|
|
39
|
+
@day = day.to_i
|
|
40
|
+
@on = Plan.to_date(on)
|
|
41
|
+
return message.perform_deliveries = false unless Plan.valid_day?(@day) && @on
|
|
42
|
+
|
|
43
|
+
@readings = Plan.readings_for(@day).select { |_, reference| reference }
|
|
44
|
+
@app_name = Bible270.config.app_name
|
|
45
|
+
@day_url = day_url_for(@day)
|
|
46
|
+
@profile_url = profile_url
|
|
47
|
+
|
|
48
|
+
mail to: @reader.email, subject: "#{@app_name}: day #{@day} reading reminder"
|
|
49
|
+
end
|
|
50
|
+
|
|
34
51
|
# A message from whoever runs the plan to one reader. Sent individually rather
|
|
35
52
|
# than as one email with everyone in bcc: a bcc list of a hundred addresses is
|
|
36
53
|
# a spam signal, and it means nobody can be greeted by name or unsubscribe
|
|
@@ -69,6 +86,10 @@ module Bible270
|
|
|
69
86
|
engine_url(:admin_reader_url, reader)
|
|
70
87
|
end
|
|
71
88
|
|
|
89
|
+
def profile_url
|
|
90
|
+
engine_url(:profile_url)
|
|
91
|
+
end
|
|
92
|
+
|
|
72
93
|
def engine_url(helper, *)
|
|
73
94
|
host = Bible270.config.mailer_host
|
|
74
95
|
return nil if host.blank?
|
|
@@ -11,6 +11,7 @@ module Bible270
|
|
|
11
11
|
# the paper background; an outline glyph like U+2661 is drawn by the font
|
|
12
12
|
# rather than the emoji set and needs hand-tuning to match.
|
|
13
13
|
HEART = '❤️'
|
|
14
|
+
ThreadPage = Struct.new(:threads, :activity_by_id, :page, :pages, :total, keyword_init: true)
|
|
14
15
|
|
|
15
16
|
self.table_name = 'bible270_comments'
|
|
16
17
|
|
|
@@ -52,6 +53,40 @@ module Bible270
|
|
|
52
53
|
}
|
|
53
54
|
scope :recent, -> { approved.order(created_at: :desc) }
|
|
54
55
|
|
|
56
|
+
# One page of top-level reflections ordered by the newest activity in each
|
|
57
|
+
# conversation. Loading only IDs and timestamps keeps older archives cheap to
|
|
58
|
+
# sort, while the displayed page still eager-loads its readers, replies and likes.
|
|
59
|
+
def self.thread_page(day: nil, reader_id: nil, page: 1, per_page: 10)
|
|
60
|
+
roots = approved.where(parent_id: nil)
|
|
61
|
+
roots = roots.where(day: day) if day
|
|
62
|
+
roots = roots.where(reader_id: reader_id) if reader_id
|
|
63
|
+
|
|
64
|
+
root_rows = roots.pluck(:id, :created_at)
|
|
65
|
+
reply_activity = approved.where.not(parent_id: nil).group(:parent_id).maximum(:created_at)
|
|
66
|
+
ordered = root_rows.map do |id, created_at|
|
|
67
|
+
[id, [created_at, reply_activity[id]].compact.max]
|
|
68
|
+
end
|
|
69
|
+
ordered.sort_by! { |id, activity_at| [-activity_at.to_f, -id] }
|
|
70
|
+
|
|
71
|
+
per_page = per_page.to_i
|
|
72
|
+
per_page = 10 unless per_page.positive?
|
|
73
|
+
pages = [(ordered.length.to_f / per_page).ceil, 1].max
|
|
74
|
+
page = page.to_i.clamp(1, pages)
|
|
75
|
+
page_rows = ordered.slice((page - 1) * per_page, per_page) || []
|
|
76
|
+
ids = page_rows.map(&:first)
|
|
77
|
+
loaded = approved.where(id: ids)
|
|
78
|
+
.includes(:reader, { likes: :reader }, { replies: [:reader, { likes: :reader }] })
|
|
79
|
+
.index_by(&:id)
|
|
80
|
+
|
|
81
|
+
ThreadPage.new(
|
|
82
|
+
threads: ids.filter_map { |id| loaded[id] },
|
|
83
|
+
activity_by_id: page_rows.to_h,
|
|
84
|
+
page: page,
|
|
85
|
+
pages: pages,
|
|
86
|
+
total: ordered.length
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
55
90
|
# Every reflection is visible when written; these are the moderation actions.
|
|
56
91
|
def hide! = update!(approved: false, moderated_at: Time.current)
|
|
57
92
|
def unhide! = update!(approved: true, moderated_at: Time.current)
|
|
@@ -79,6 +114,16 @@ module Bible270
|
|
|
79
114
|
end
|
|
80
115
|
end
|
|
81
116
|
|
|
117
|
+
def set_like!(reader, liked:)
|
|
118
|
+
if liked
|
|
119
|
+
likes.create_or_find_by!(reader: reader)
|
|
120
|
+
else
|
|
121
|
+
likes.where(reader: reader).destroy_all
|
|
122
|
+
end
|
|
123
|
+
likes.reload
|
|
124
|
+
liked
|
|
125
|
+
end
|
|
126
|
+
|
|
82
127
|
# A second's grace: created_at and updated_at differ by microseconds on
|
|
83
128
|
# insert, which would mark every reflection as edited.
|
|
84
129
|
def edited? = updated_at - created_at > 1
|
|
@@ -108,8 +153,9 @@ module Bible270
|
|
|
108
153
|
errors.add(:parent, "is on day #{parent.day}, not #{day}")
|
|
109
154
|
end
|
|
110
155
|
|
|
111
|
-
# A mention emails the person named.
|
|
112
|
-
#
|
|
156
|
+
# A mention always emails the person named. There is no per-reader opt-out;
|
|
157
|
+
# failures are logged rather than raised so nobody loses a reflection because
|
|
158
|
+
# the mail server is down.
|
|
113
159
|
def notify_newly_mentioned_readers
|
|
114
160
|
return unless saved_change_to_body?
|
|
115
161
|
|
|
@@ -123,7 +169,6 @@ module Bible270
|
|
|
123
169
|
Reader.mentioned_in(body).each do |mentioned|
|
|
124
170
|
next if mentioned.id == reader_id
|
|
125
171
|
next if except.include?(mentioned.id)
|
|
126
|
-
next unless mentioned.wants_mention_notices?
|
|
127
172
|
|
|
128
173
|
notice = NoticeMailer.mentioned(comment_id: id, reader_id: mentioned.id)
|
|
129
174
|
Bible270.config.registration_notice_deliver_later ? notice.deliver_later : notice.deliver_now
|
|
@@ -27,10 +27,59 @@ module Bible270
|
|
|
27
27
|
|
|
28
28
|
PASSAGE_SOURCES = %w[bible_gateway blue_letter_bible].freeze
|
|
29
29
|
DEFAULT_PASSAGE_SOURCE = 'bible_gateway'
|
|
30
|
+
DAILY_REMINDER_TIME_FORMAT = %r{\A(?:[01]\d|2[0-3]):[0-5]\d\z}
|
|
31
|
+
DAILY_REMINDER_COLUMNS = %w[daily_reminders daily_reminder_time last_daily_reminder_sent_on].freeze
|
|
32
|
+
REFLECTIONS_SEEN_COLUMN = 'reflections_seen_at'
|
|
30
33
|
|
|
31
34
|
validates :display_name, presence: true
|
|
32
35
|
validates :uid, uniqueness: { scope: :provider }, allow_nil: true
|
|
33
36
|
validates :passage_source, inclusion: { in: PASSAGE_SOURCES }
|
|
37
|
+
validates :daily_reminder_time,
|
|
38
|
+
format: { with: DAILY_REMINDER_TIME_FORMAT, message: 'must use 24-hour HH:MM format' },
|
|
39
|
+
if: -> { self.class.daily_reminder_columns? }
|
|
40
|
+
|
|
41
|
+
scope :daily_reminder_recipients, ->(on:) {
|
|
42
|
+
where(daily_reminders: true)
|
|
43
|
+
.where.not(email: [nil, ''])
|
|
44
|
+
.where.not(id: where(last_daily_reminder_sent_on: on).select(:id))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
def self.daily_reminder_columns?
|
|
48
|
+
(DAILY_REMINDER_COLUMNS - column_names).empty?
|
|
49
|
+
rescue ActiveRecord::StatementInvalid
|
|
50
|
+
false
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.valid_daily_reminder_time?(value)
|
|
54
|
+
value.to_s.match?(DAILY_REMINDER_TIME_FORMAT)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.reflections_seen_column?
|
|
58
|
+
column_names.include?(REFLECTIONS_SEEN_COLUMN)
|
|
59
|
+
rescue ActiveRecord::StatementInvalid
|
|
60
|
+
false
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def mark_reflections_seen!(at)
|
|
64
|
+
return false unless self.class.reflections_seen_column?
|
|
65
|
+
|
|
66
|
+
update_column(:reflections_seen_at, at)
|
|
67
|
+
true
|
|
68
|
+
rescue ActiveRecord::StatementInvalid
|
|
69
|
+
false
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def daily_reminder_due_at?(local_time)
|
|
73
|
+
return false unless self.class.daily_reminder_columns?
|
|
74
|
+
return false unless daily_reminders?
|
|
75
|
+
return false unless self.class.valid_daily_reminder_time?(daily_reminder_time)
|
|
76
|
+
return false unless local_time.respond_to?(:hour) && local_time.respond_to?(:min)
|
|
77
|
+
|
|
78
|
+
hour, minute = daily_reminder_time.split(':').map(&:to_i)
|
|
79
|
+
(local_time.hour * 60) + local_time.min >= (hour * 60) + minute
|
|
80
|
+
rescue StandardError
|
|
81
|
+
false
|
|
82
|
+
end
|
|
34
83
|
|
|
35
84
|
# Build/refresh a reader from an OmniAuth auth hash. Tolerant of the various
|
|
36
85
|
# shapes strategies return (OmniAuth::AuthHash, plain Hash, missing info).
|
|
@@ -221,13 +270,6 @@ module Bible270
|
|
|
221
270
|
Mentions.handles_for(first_name, last_name).include?(handle)
|
|
222
271
|
end
|
|
223
272
|
|
|
224
|
-
def wants_mention_notices?
|
|
225
|
-
return false if email.blank?
|
|
226
|
-
return true unless respond_to?(:notify_on_mention)
|
|
227
|
-
|
|
228
|
-
notify_on_mention != false
|
|
229
|
-
end
|
|
230
|
-
|
|
231
273
|
# ---- staying signed in -------------------------------------------------
|
|
232
274
|
|
|
233
275
|
# Generated on first use rather than at sign-up, so readers who never stay
|
|
@@ -304,6 +346,18 @@ module Bible270
|
|
|
304
346
|
done >= Plan.total_parts(day) ? :complete : :partial
|
|
305
347
|
end
|
|
306
348
|
|
|
349
|
+
def partial_days
|
|
350
|
+
checked_counts.keys.select { |day| day_status(day) == :partial }.sort
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def remaining_parts_for(day)
|
|
354
|
+
return [] unless Plan.valid_day?(day)
|
|
355
|
+
|
|
356
|
+
missing_parts_on(day).map do |track, part|
|
|
357
|
+
{ track: track, part: part, reference: Plan.parts_for(day, track).fetch(part) }
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
307
361
|
def read_tracks_for(day)
|
|
308
362
|
checkoffs.where(day: day).pluck(:track).uniq
|
|
309
363
|
end
|
|
@@ -524,6 +578,19 @@ module Bible270
|
|
|
524
578
|
Plan.end_date_for(effective_start_date)
|
|
525
579
|
end
|
|
526
580
|
|
|
581
|
+
def scheduled_days_this_week(on: Bible270.today)
|
|
582
|
+
return [] unless dated?
|
|
583
|
+
|
|
584
|
+
date = Plan.to_date(on)
|
|
585
|
+
return [] unless date
|
|
586
|
+
|
|
587
|
+
week_start = date - ((date.wday + 6) % 7)
|
|
588
|
+
(week_start..date).filter_map do |scheduled_date|
|
|
589
|
+
day = Plan.day_for(scheduled_date, effective_start_date, clamp: false)
|
|
590
|
+
day if Plan.valid_day?(day)
|
|
591
|
+
end
|
|
592
|
+
end
|
|
593
|
+
|
|
527
594
|
def date_for_day(day)
|
|
528
595
|
Plan.date_for(day, effective_start_date)
|
|
529
596
|
end
|
|
@@ -2,3 +2,11 @@
|
|
|
2
2
|
<%= render partial: "bible270/days/reading", locals: {
|
|
3
3
|
track: @track, day: @day, readings: @readings, reader_tracks: @reader_tracks } %>
|
|
4
4
|
<% end %>
|
|
5
|
+
|
|
6
|
+
<%= turbo_stream.replace "day_progress_#{@day}" do %>
|
|
7
|
+
<%= render "bible270/days/day_progress", day: @day, reader: @reader %>
|
|
8
|
+
<% end %>
|
|
9
|
+
|
|
10
|
+
<%= turbo_stream.replace "completers_#{@day}" do %>
|
|
11
|
+
<%= render "bible270/days/completers", day: @day, completers: @completers %>
|
|
12
|
+
<% end %>
|
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
it is not.
|
|
7
7
|
%>
|
|
8
8
|
<%= form_with model: comment, url: comment_path(comment), method: :patch,
|
|
9
|
-
class: "b270-form b270-editform",
|
|
9
|
+
class: "b270-form b270-editform",
|
|
10
|
+
data: { turbo_stream: true, b270_submit: true, b270_pending: "Saving changes…",
|
|
11
|
+
b270_success: "Changes saved.", b270_failure: "Couldn’t save the changes. Try again." } do |f| %>
|
|
10
12
|
<% if comment.errors.any? %>
|
|
11
13
|
<p class="b270-editerror"><%= comment.errors.full_messages.to_sentence %></p>
|
|
12
14
|
<% end %>
|
|
@@ -1,5 +1,13 @@
|
|
|
1
|
+
<% draft_key = ["bible270", "draft", "v1", Bible270.config.mount_at, current_reader.id,
|
|
2
|
+
day, comment.parent_id.presence || "root"].join(":") %>
|
|
3
|
+
<% posting_reply = comment.parent_id.present? %>
|
|
1
4
|
<%= form_with model: comment, url: day_comments_path(day), method: :post,
|
|
2
|
-
id: "new_comment_form", class: "b270-form",
|
|
5
|
+
id: "new_comment_form", class: "b270-form",
|
|
6
|
+
data: { turbo_stream: true, b270_draft: true, b270_draft_key: draft_key,
|
|
7
|
+
b270_draft_errors: comment.errors.any?, b270_draft_posted: local_assigns.fetch(:posted, false),
|
|
8
|
+
b270_submit: true, b270_pending: (posting_reply ? "Posting reply…" : "Posting reflection…"),
|
|
9
|
+
b270_success: (posting_reply ? "Reply posted." : "Reflection posted."),
|
|
10
|
+
b270_failure: (posting_reply ? "Couldn’t post the reply. Try again." : "Couldn’t post the reflection. Try again.") } do |f| %>
|
|
3
11
|
<% if comment.errors.any? %>
|
|
4
12
|
<p style="color:var(--oxblood);font-size:13px"><%= comment.errors.full_messages.to_sentence %></p>
|
|
5
13
|
<% end %>
|
|
@@ -11,6 +19,7 @@
|
|
|
11
19
|
<% end %>
|
|
12
20
|
<%= f.hidden_field :parent_id %>
|
|
13
21
|
<%= f.text_area :body, placeholder: comment.parent_id.present? ? "Write your reply…" : "Share a reflection on today's reading…" %>
|
|
22
|
+
<p class="b270-draft-status" data-b270-draft-status aria-live="polite"></p>
|
|
14
23
|
<div class="row">
|
|
15
24
|
<%= f.select :track,
|
|
16
25
|
options_for_select([["The whole day", ""]] + Bible270::Plan::TRACKS.map { |k, v| [v[:label], k] }),
|
|
@@ -12,5 +12,5 @@
|
|
|
12
12
|
<% end %>
|
|
13
13
|
|
|
14
14
|
<%= turbo_stream.replace "new_comment_form" do %>
|
|
15
|
-
<%= render "bible270/comments/form", day: @day, comment: Bible270::Comment.new(day: @day) %>
|
|
15
|
+
<%= render "bible270/comments/form", day: @day, comment: Bible270::Comment.new(day: @day), posted: true %>
|
|
16
16
|
<% end %>
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
<% content_for :title, "Reflections — #{Bible270.config.app_name}" %>
|
|
2
2
|
<% content_for :hide_day_index, true %><%# this page has its own, filtered grid %>
|
|
3
|
+
<% browse_params = { day: @reflection_day, reader_id: @reflection_reader_id,
|
|
4
|
+
since: @reflections_since_param }.compact %>
|
|
3
5
|
|
|
4
6
|
<p class="b270-eyebrow">Reflections</p>
|
|
5
7
|
<h1>Shared gleanings</h1>
|
|
8
|
+
<%= render "bible270/shared/interaction_status" %>
|
|
6
9
|
|
|
7
10
|
<% if @days_with_reflections.any? %>
|
|
8
11
|
<div class="b270-panel">
|
|
@@ -22,19 +25,65 @@
|
|
|
22
25
|
</div>
|
|
23
26
|
</div>
|
|
24
27
|
|
|
25
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
28
|
+
<div class="b270-reflection-tools">
|
|
29
|
+
<div>
|
|
30
|
+
<h2 class="b270-section-h">Conversations</h2>
|
|
31
|
+
<p class="b270-reflection-count"><%= pluralize(@reflection_total, "reflection") %></p>
|
|
32
|
+
</div>
|
|
33
|
+
<%= form_with url: reflections_path, method: :get, class: "b270-reflection-filters" do %>
|
|
34
|
+
<%= hidden_field_tag :since, @reflections_since_param %>
|
|
35
|
+
<label>
|
|
36
|
+
<span>Day</span>
|
|
37
|
+
<%= select_tag :day,
|
|
38
|
+
options_for_select([["All days", ""]] + @days_with_reflections.map { |day| ["Day #{day}", day] },
|
|
39
|
+
@reflection_day) %>
|
|
40
|
+
</label>
|
|
41
|
+
<label>
|
|
42
|
+
<span>Reflection by</span>
|
|
43
|
+
<%= select_tag :reader_id,
|
|
44
|
+
options_for_select([["All readers", ""]] + @reflection_readers.map { |reader| [reader.display_name, reader.id] },
|
|
45
|
+
@reflection_reader_id) %>
|
|
46
|
+
</label>
|
|
47
|
+
<%= submit_tag "Apply", class: "b270-btn" %>
|
|
48
|
+
<% if @reflection_day || @reflection_reader_id %>
|
|
49
|
+
<%= link_to "Clear", reflections_path(since: @reflections_since_param), class: "b270-filter-clear" %>
|
|
50
|
+
<% end %>
|
|
35
51
|
<% end %>
|
|
36
52
|
</div>
|
|
37
53
|
|
|
54
|
+
<% if @threads.any? %>
|
|
55
|
+
<div id="comments">
|
|
56
|
+
<% @threads.each do |thread| %>
|
|
57
|
+
<% is_new = @new_thread_ids.include?(thread.id) %>
|
|
58
|
+
<div class="b270-thread <%= "b270-thread-new" if is_new %>" id="thread-<%= thread.id %>">
|
|
59
|
+
<p class="b270-threadday">
|
|
60
|
+
<%= link_to "Day #{thread.day}", day_path(thread.day) %>
|
|
61
|
+
· <%= b270_day_reading_links(thread.day) %>
|
|
62
|
+
<% if is_new %><span class="b270-new-reflection">New since your last visit</span><% end %>
|
|
63
|
+
</p>
|
|
64
|
+
<%= render partial: "bible270/comments/comment", locals: { comment: thread, reply: false } %>
|
|
65
|
+
</div>
|
|
66
|
+
<% end %>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<% if @reflection_pages > 1 %>
|
|
70
|
+
<nav class="b270-pagination" aria-label="Reflection pages">
|
|
71
|
+
<% if @reflection_page > 1 %>
|
|
72
|
+
<%= link_to "← Newer", reflections_path(**browse_params, page: @reflection_page - 1) %>
|
|
73
|
+
<% end %>
|
|
74
|
+
<span>Page <%= @reflection_page %> of <%= @reflection_pages %></span>
|
|
75
|
+
<% if @reflection_page < @reflection_pages %>
|
|
76
|
+
<%= link_to "Older →", reflections_path(**browse_params, page: @reflection_page + 1) %>
|
|
77
|
+
<% end %>
|
|
78
|
+
</nav>
|
|
79
|
+
<% end %>
|
|
80
|
+
<% else %>
|
|
81
|
+
<div class="b270-panel b270-filter-empty">
|
|
82
|
+
<p>No reflections match these filters.</p>
|
|
83
|
+
<%= link_to "Show all reflections", reflections_path(since: @reflections_since_param) %>
|
|
84
|
+
</div>
|
|
85
|
+
<% end %>
|
|
86
|
+
|
|
38
87
|
<p style="margin-top:18px">
|
|
39
88
|
<%= link_to "Browse the whole plan →", root_path %>
|
|
40
89
|
</p>
|
|
@@ -50,3 +99,5 @@
|
|
|
50
99
|
</p>
|
|
51
100
|
</div>
|
|
52
101
|
<% end %>
|
|
102
|
+
|
|
103
|
+
<%= render "bible270/shared/interaction_ui" %>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<%# locals: (day:, completers:) %>
|
|
2
|
+
<div class="b270-completers" id="completers_<%= day %>">
|
|
3
|
+
<% if completers.any? %>
|
|
4
|
+
<span>Finished this day:</span>
|
|
5
|
+
<% completers.first(12).each do |reader| %>
|
|
6
|
+
<%= link_to b270_avatar(reader), reader_path(reader), title: reader.display_name %>
|
|
7
|
+
<% end %>
|
|
8
|
+
<% if completers.size > 12 %><span><%= completers.size - 12 %> more</span><% end %>
|
|
9
|
+
<% else %>
|
|
10
|
+
<span>No one has finished this day yet — be the first.</span>
|
|
11
|
+
<% end %>
|
|
12
|
+
</div>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<%# locals: (day:, reader:) %>
|
|
2
|
+
<%
|
|
3
|
+
previous_day = day > 1 ? day - 1 : nil
|
|
4
|
+
next_day = day < Bible270::Plan::DAYS ? day + 1 : nil
|
|
5
|
+
total_parts = Bible270::Plan.total_parts(day)
|
|
6
|
+
complete = reader&.day_complete?(day)
|
|
7
|
+
%>
|
|
8
|
+
<div id="day_progress_<%= day %>">
|
|
9
|
+
<div class="b270-daynav">
|
|
10
|
+
<% if previous_day %>
|
|
11
|
+
<%= link_to "‹", day_path(previous_day), class: "b270-arrow", "aria-label": "Previous day" %>
|
|
12
|
+
<% else %>
|
|
13
|
+
<%= button_tag "‹", type: "button", class: "b270-arrow disabled", disabled: true,
|
|
14
|
+
"aria-label": "Previous day unavailable" %>
|
|
15
|
+
<% end %>
|
|
16
|
+
<div class="b270-dayhead">
|
|
17
|
+
<div class="k">Day <%= day %><% if complete %><span class="b270-badge" data-b270-compact-hide>Complete</span><% end %></div>
|
|
18
|
+
<% if reader&.dated? %>
|
|
19
|
+
<div class="date">
|
|
20
|
+
<%= reader.date_for_day(day).strftime("%A, %B %-d, %Y") %>
|
|
21
|
+
<% if reader.today?(day) %><span class="b270-badge" data-b270-compact-hide>Today</span><% end %>
|
|
22
|
+
</div>
|
|
23
|
+
<% end %>
|
|
24
|
+
</div>
|
|
25
|
+
<% if next_day %>
|
|
26
|
+
<%= link_to "›", day_path(next_day), class: "b270-arrow", "aria-label": "Next day" %>
|
|
27
|
+
<% else %>
|
|
28
|
+
<%= button_tag "›", type: "button", class: "b270-arrow disabled", disabled: true,
|
|
29
|
+
"aria-label": "Next day unavailable" %>
|
|
30
|
+
<% end %>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
<% if complete %>
|
|
35
|
+
<div class="b270-day-complete" role="status" aria-live="polite">
|
|
36
|
+
<div data-b270-compact-hide>
|
|
37
|
+
<strong>Day <%= day %> complete</strong>
|
|
38
|
+
<span>All <%= total_parts %> portions are checked off.</span>
|
|
39
|
+
</div>
|
|
40
|
+
<% if next_day %>
|
|
41
|
+
<%= link_to "Continue to Day #{next_day} →", day_path(next_day), class: "b270-btn" %>
|
|
42
|
+
<% else %>
|
|
43
|
+
<%= link_to "View My Progress →", progress_path, class: "b270-btn" %>
|
|
44
|
+
<% end %>
|
|
45
|
+
</div>
|
|
46
|
+
<% end %>
|
|
47
|
+
</div>
|
|
@@ -21,7 +21,11 @@
|
|
|
21
21
|
<li class="b270-part <%= 'done' if part_done %>">
|
|
22
22
|
<% if signed_in? %>
|
|
23
23
|
<%= button_to toggle_checkoff_part_path(day, track, index), class: "b270-toggle",
|
|
24
|
-
|
|
24
|
+
params: { checked: part_done ? "0" : "1" },
|
|
25
|
+
form: { data: { turbo_stream: true, b270_submit: true,
|
|
26
|
+
b270_pending: "Saving reading…",
|
|
27
|
+
b270_success: (part_done ? "Reading marked unread." : "Reading marked read."),
|
|
28
|
+
b270_failure: "Couldn’t update that reading. Try again." } },
|
|
25
29
|
aria: { label: (part_done ? "Mark #{part_ref} unread" : "Mark #{part_ref} read") } do %>
|
|
26
30
|
<span class="b270-check"><svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg></span>
|
|
27
31
|
<% end %>
|
|
@@ -41,7 +45,11 @@
|
|
|
41
45
|
<% else %>
|
|
42
46
|
<% if signed_in? %>
|
|
43
47
|
<%= button_to toggle_checkoff_part_path(day, track, 0), class: "b270-toggle",
|
|
44
|
-
|
|
48
|
+
params: { checked: done ? "0" : "1" },
|
|
49
|
+
form: { data: { turbo_stream: true, b270_submit: true,
|
|
50
|
+
b270_pending: "Saving reading…",
|
|
51
|
+
b270_success: (done ? "Reading marked unread." : "Reading marked read."),
|
|
52
|
+
b270_failure: "Couldn’t update that reading. Try again." } },
|
|
45
53
|
aria: { label: (done ? "Mark #{t[:label]} unread" : "Mark #{t[:label]} read") } do %>
|
|
46
54
|
<span class="b270-check"><svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg></span>
|
|
47
55
|
<% end %>
|