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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/README.md +67 -4
  4. data/Rakefile +178 -4
  5. data/app/controllers/bible270/checkoffs_controller.rb +34 -9
  6. data/app/controllers/bible270/comments_controller.rb +63 -28
  7. data/app/controllers/bible270/days_controller.rb +17 -6
  8. data/app/controllers/bible270/likes_controller.rb +15 -4
  9. data/app/controllers/bible270/profiles_controller.rb +37 -4
  10. data/app/controllers/bible270/readers_controller.rb +48 -3
  11. data/app/helpers/bible270/plan_helper.rb +17 -3
  12. data/app/mailers/bible270/notice_mailer.rb +22 -1
  13. data/app/models/bible270/comment.rb +48 -3
  14. data/app/models/bible270/reader.rb +74 -7
  15. data/app/views/bible270/checkoffs/toggle.turbo_stream.erb +8 -0
  16. data/app/views/bible270/comments/_edit_fields.html.erb +3 -1
  17. data/app/views/bible270/comments/_form.html.erb +10 -1
  18. data/app/views/bible270/comments/create.turbo_stream.erb +1 -1
  19. data/app/views/bible270/comments/index.html.erb +61 -10
  20. data/app/views/bible270/days/_completers.html.erb +12 -0
  21. data/app/views/bible270/days/_day_progress.html.erb +47 -0
  22. data/app/views/bible270/days/_reading.html.erb +10 -2
  23. data/app/views/bible270/days/_start_date.html.erb +1 -1
  24. data/app/views/bible270/days/index.html.erb +16 -8
  25. data/app/views/bible270/days/show.html.erb +38 -42
  26. data/app/views/bible270/likes/_likes.html.erb +5 -1
  27. data/app/views/bible270/notice_mailer/daily_reminder.html.erb +30 -0
  28. data/app/views/bible270/notice_mailer/daily_reminder.text.erb +14 -0
  29. data/app/views/bible270/notice_mailer/mentioned.html.erb +0 -1
  30. data/app/views/bible270/notice_mailer/mentioned.text.erb +1 -2
  31. data/app/views/bible270/profiles/edit.html.erb +41 -8
  32. data/app/views/bible270/readers/_next_steps.html.erb +63 -0
  33. data/app/views/bible270/readers/index.html.erb +6 -9
  34. data/app/views/bible270/readers/progress.html.erb +13 -3
  35. data/app/views/bible270/shared/_comment_drafts.html.erb +169 -0
  36. data/app/views/bible270/shared/_day_index.html.erb +1 -1
  37. data/app/views/bible270/shared/_header.html.erb +14 -17
  38. data/app/views/bible270/shared/_interaction_status.html.erb +2 -0
  39. data/app/views/bible270/shared/_interaction_ui.html.erb +69 -0
  40. data/app/views/bible270/shared/_nav_links.html.erb +15 -0
  41. data/app/views/bible270/shared/_progress.html.erb +24 -6
  42. data/app/views/bible270/shared/_reading_mode.html.erb +62 -0
  43. data/app/views/bible270/shared/_styles.html.erb +147 -10
  44. data/config/routes.rb +4 -3
  45. data/db/migrate/20260101000016_add_daily_reminders_to_bible270_readers.rb +9 -0
  46. data/db/migrate/20260101000017_add_reflections_seen_at_to_bible270_readers.rb +7 -0
  47. data/lib/bible270/configuration.rb +10 -4
  48. data/lib/bible270/daily_reminders.rb +57 -0
  49. data/lib/bible270/migration_reconciler.rb +13 -0
  50. data/lib/bible270/version.rb +1 -1
  51. data/lib/bible270.rb +1 -0
  52. data/lib/generators/bible270/install/install_generator.rb +6 -0
  53. data/lib/generators/bible270/install/templates/bible270.rb.tt +6 -0
  54. data/lib/tasks/bible270_reminders.rake +13 -0
  55. 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
- # Progress first, then by name — the same name ordering the admin list uses.
11
- @readers.sort_by! { |r| [-@days_completed[r.id], r.sort_name] }
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
- # An empty relation rather than nil, so the view needs no safe navigation.
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, so hovering a square says what is
180
- # on that day — no JavaScript, and it works for keyboard focus too.
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
- # Notices to whoever runs the plan, as opposed to mail sent to readers.
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. Failures are logged rather than raised:
112
- # nobody should lose a reflection because the mail server is down.
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", data: { turbo_stream: true } do |f| %>
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", data: { turbo_stream: true } do |f| %>
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
- <h2 class="b270-section-h">Most recent</h2>
26
- <div id="comments">
27
- <% @threads.each do |thread| %>
28
- <div class="b270-thread">
29
- <p class="b270-threadday">
30
- <%= link_to "Day #{thread.day}", day_path(thread.day) %>
31
- · <%= b270_day_reading_links(thread.day) %>
32
- </p>
33
- <%= render partial: "bible270/comments/comment", locals: { comment: thread, reply: false } %>
34
- </div>
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
- form: { data: { turbo_stream: true } },
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
- form: { data: { turbo_stream: true } },
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 %>