bible270 1.0.0 → 1.1.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -9
  3. data/README.md +7 -10
  4. data/app/controllers/bible270/admin_controller.rb +2 -3
  5. data/app/controllers/bible270/days_controller.rb +1 -36
  6. data/app/controllers/bible270/profiles_controller.rb +20 -1
  7. data/app/controllers/bible270/readers_controller.rb +1 -66
  8. data/app/mailers/bible270/notice_mailer.rb +32 -8
  9. data/app/models/bible270/comment.rb +33 -9
  10. data/app/models/bible270/reader.rb +8 -40
  11. data/app/views/bible270/comments/create.turbo_stream.erb +0 -2
  12. data/app/views/bible270/comments/index.html.erb +0 -24
  13. data/app/views/bible270/days/_completers.html.erb +0 -2
  14. data/app/views/bible270/days/_day_progress.html.erb +3 -3
  15. data/app/views/bible270/days/index.html.erb +1 -27
  16. data/app/views/bible270/days/show.html.erb +4 -16
  17. data/app/views/bible270/notice_mailer/{mentioned.html.erb → comment_notice.html.erb} +5 -2
  18. data/app/views/bible270/notice_mailer/{mentioned.text.erb → comment_notice.text.erb} +5 -2
  19. data/app/views/bible270/profiles/edit.html.erb +11 -0
  20. data/app/views/bible270/readers/_next_steps.html.erb +24 -60
  21. data/app/views/bible270/readers/index.html.erb +2 -3
  22. data/app/views/bible270/readers/progress.html.erb +6 -30
  23. data/app/views/bible270/readers/show.html.erb +1 -6
  24. data/app/views/bible270/shared/_day_index.html.erb +6 -4
  25. data/app/views/bible270/shared/_header.html.erb +21 -4
  26. data/app/views/bible270/shared/_progress.html.erb +3 -14
  27. data/app/views/bible270/shared/_styles.html.erb +3 -32
  28. data/config/routes.rb +0 -4
  29. data/db/migrate/20260101000011_add_mention_notices_to_bible270_readers.rb +3 -3
  30. data/lib/bible270/configuration.rb +8 -5
  31. data/lib/bible270/mentions.rb +0 -9
  32. data/lib/bible270/version.rb +1 -1
  33. data/lib/generators/bible270/install/install_generator.rb +2 -0
  34. data/lib/generators/bible270/install/templates/bible270.rb.tt +2 -0
  35. metadata +3 -5
  36. data/app/views/bible270/days/_start_date.html.erb +0 -43
  37. data/app/views/bible270/shared/_reading_mode.html.erb +0 -62
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 167d9d42022755b068edda39ff37a00b1a159b05c2d0fadcce80173f1f4c6e7d
4
- data.tar.gz: 2a97a8eab487cff56dc465e4e81eda2e4a63c60afde87f10f2ad018891cec67b
3
+ metadata.gz: 48e80937debff292b2c896ae9eaba55fe5eae8624ef28c6eb9274236fed4fb2c
4
+ data.tar.gz: 72f8fbd17194fd00bbc8602fd69180cb91565d54f995868dacf8edfb4419657f
5
5
  SHA512:
6
- metadata.gz: 0a8b0d0c963c24be7acfef4301be2ccbb2d95157a6aaa3177a42968a08e893e689ed0d747dce3375c02f3c912f1b23d4f69738659a96f3587d41cdb8bf2cd66c
7
- data.tar.gz: 52dab82161413ee0aceecd4a67cd0a30411d36d0c4d3c6eeb03a6ea36e3ba98a754d29d36772c7e29aa5297bba31f323d1c19f17b68bc85ac6f154275b40569a
6
+ metadata.gz: b493f52caadc3fa2bbb7e3ef4dd59c9822d0045b9fb6f42151f4fad0c9057d017b7604064188e35a3dce03c8c6c3c02cabce8a95a342e84e6fbf5a2682a46309
7
+ data.tar.gz: a38f6e3b979b76ccaf5c666e3698d29302ac97b82692f7c2a33ac1ef0d28f139b28ddaf89705e4a13c132713533edd87c8e804eff06974c089ad2bb8b1322c0b
data/CHANGELOG.md CHANGED
@@ -4,25 +4,71 @@ All notable changes to bible270. Format follows [Keep a Changelog](https://keepa
4
4
 
5
5
  ## Unreleased Changes
6
6
 
7
+ ### Removed
8
+
9
+ - remove the Compact reading preference so daily reading pages have one simple, predictable presentation
10
+ - replace the homepage reader preview with one clear link to the Community page
11
+ - remove the redundant schedule-restart shortcut from progress recovery choices
12
+ - remove reader-facing start-date status, controls, and self-service endpoints while retaining automatic and admin-managed personal calendars
13
+ - remove weekly progress, its unused schedule helper, milestones, and explanatory schedule-recovery copy from My Progress
14
+ - replace the Reflections page’s separate day grid with the standard “View all 270 days” control
15
+
16
+ ### Changed
17
+
18
+ - notify reflection authors directly about replies, share one opt-out with explicit mentions, and stop inserting handles into reply text
19
+ - consolidate duplicate visitor sign-in prompts on day pages into one invitation
20
+ - show “Finish what you started” on My Progress only when partially read days need attention
21
+ - clarify the global day-index control as “View all 270 days” and use that standard collapsed control on My Progress
22
+
23
+ ### Fixed
24
+
25
+ - make the mobile navigation menu reliably open and close when its button is tapped
26
+
27
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.0.0...main)**
28
+
29
+ ## [1.0.0] - 2026-08-20
30
+
7
31
  ### Added
8
32
 
9
- - recovery choices for readers whose next unfinished reading is behind the community day, including an optional schedule reset that preserves progress and reflections
33
+ - optional daily reading reminders with per-reader opt-in and delivery time, reminder emails, and a scheduler-friendly Rake task
34
+ - immediate day-completion feedback with a preserved next-day or Progress action
35
+ - actionable Progress sections for unfinished portions, the current calendar week, milestones, and schedule recovery without losing reading or reflection history
36
+ - browser-local reflection and reply drafts, day and author filters, pagination, and new-activity markers on the Reflections page
37
+ - a remembered compact reading mode that keeps passages, checkoffs, navigation, and completion next steps in focus
10
38
  - a two-worker default `test` task with real-time colorized progress, one aggregate result and duration, merged coverage, a browsable project-local `coverage/` report, and per-file runtime recording, plus `test:serial` when a serial run is needed
11
- - browser-local reflection and reply drafts that survive refreshes, failed submissions, and accidental navigation
12
- - day and author filters, pagination, and new-activity markers on the Reflections page
13
39
 
14
40
  ### Changed
15
41
 
16
42
  - replace progress-ranked community lists with transparent recent-participation and alphabetical views that emphasize reading together
17
- - make Progress actionable with partial-day links, a calendar-week summary, and the reader's next milestone
18
- - improve accessibility with AA text contrast, consistent focus indicators, descriptive day-grid labels, semantic boundary navigation, touch-friendly tooltips, responsive forms, and reduced-motion support
19
- - keep profiles and scheduled jobs available while a newly installed daily-reminder database migration is pending
43
+ - show why each reader appears in Reading together, including their latest reading, reflection, or join activity
20
44
  - add consistent pending, success, and retry feedback to reading, like, reflection, and reply actions, with repeat-safe checkoff and like requests
21
- - add a remembered compact reading mode that keeps passages, checkoffs, navigation, and completion next steps in focus
45
+ - improve accessibility with AA text contrast, consistent focus indicators, descriptive day-grid labels, semantic boundary navigation, touch-friendly tooltips, responsive forms, and reduced-motion support
46
+ - replace the reminder time field with aligned hour and minute selectors and an accessible time-zone tooltip
47
+ - hide personal start-date controls and calendar prompts when the community configuration does not allow readers to change dates
48
+
49
+ ### Fixed
50
+
51
+ - keep profiles and scheduled reminder jobs available while a newly installed daily-reminder database migration is pending
52
+
53
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.19.0...v1.0.0)**
54
+
55
+ ## [0.19.0] - 2026-08-17
56
+
57
+ ### Added
58
+
59
+ - shared responsive navigation with clearer Community, Reflections, My Progress, and Profile labels plus a mobile menu
60
+
61
+ ### Changed
62
+
63
+ - align page headings with navigation destinations and clarify the Start or Continue reading and Go to today actions
64
+ - always email explicitly mentioned readers, including the original reflection author when a reply prefills their mention
65
+ - normalize packaged file permissions before gem builds
66
+
67
+ ### Removed
22
68
 
23
- See the changes since the last release:
69
+ - the per-reader mention-email opt-out; writing a reader's name explicitly now always sends the mention notice
24
70
 
25
- **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.18.2...main)**
71
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.18.2...v0.19.0)**
26
72
 
27
73
  ## [0.18.2] - 2026-08-15
28
74
 
@@ -442,6 +488,8 @@ This is the initial public release.
442
488
 
443
489
  * Email sign-in requires working Action Mailer delivery in the host application. Set `config.mailer_from`; delivery is inline by default, or set `email_sign_in_deliver_later` when a queue backend is available.
444
490
 
491
+ [1.0.0]: https://github.com/avonderluft/bible270/releases/tag/v1.0.0
492
+ [0.19.0]: https://github.com/avonderluft/bible270/releases/tag/v0.19.0
445
493
  [0.18.2]: https://github.com/avonderluft/bible270/releases/tag/v0.18.2
446
494
  [0.18.1]: https://github.com/avonderluft/bible270/releases/tag/v0.18.1
447
495
  [0.18.0]: https://github.com/avonderluft/bible270/releases/tag/v0.18.0
data/README.md CHANGED
@@ -26,9 +26,7 @@ The schedule is pure deterministic Ruby (`Bible270::Plan`) — none of it is sto
26
26
 
27
27
  Mutation controls expose pending, success, and retry feedback when JavaScript runs, while ordinary HTML
28
28
  forms remain the fallback. Repeated checkoff and like requests converge on the intended state rather
29
- than accidentally reversing it. Day pages also offer a browser-local **Compact reading** preference
30
- that keeps passages, checkoffs, day navigation, and the completion next step while hiding community
31
- and reflection content.
29
+ than accidentally reversing it.
32
30
 
33
31
  ## Install
34
32
 
@@ -310,17 +308,17 @@ The plan runs **undated** (day numbers only) or **dated** (days mapped onto a ca
310
308
 
311
309
  ```ruby
312
310
  config.start_date = Date.new(2026, 9, 6) # Date, Time, or "YYYY-MM-DD". nil = undated
313
- config.allow_reader_start_date = true # can readers set their own?
311
+ config.allow_reader_start_date = true # allow personal per-reader calendars
314
312
  ```
315
313
 
316
314
  | `start_date` | `allow_reader_start_date` | What happens |
317
315
  |---|---|---|
318
- | nil | true *(default)* | Each reader gets stamped on first check-off, and can change it |
319
- | a date | true | Community date is the default; anyone may override |
320
- | a date | false | Everyone pinned to the community date, form hidden |
316
+ | nil | true *(default)* | Each reader gets stamped on their first check-off; an admin may adjust the date |
317
+ | a date | true | The community date is the default; an admin may assign an individual date |
318
+ | a date | false | Everyone is pinned to the community date; stored individual dates are ignored |
321
319
  | nil | false | Fully undated — no calendar anywhere |
322
320
 
323
- Dated plans show each day's date with a **Today** badge, a "Go to today" link, and whether you're ahead or behind. Readers set their own from the overview. Changing a start date only re-maps the calendar — check-offs and reflections are keyed to day numbers, so nothing moves.
321
+ Dated plans show each day's date with a **Today** badge and a "Go to today" link. Administrators may re-map an individual reader's calendar from the admin panel; check-offs and reflections remain keyed to day numbers, so nothing in their history moves.
324
322
 
325
323
  Date helpers are pure functions, usable anywhere:
326
324
 
@@ -378,6 +376,7 @@ Bible270.configure do |config|
378
376
  config.allow_reader_start_date = true
379
377
 
380
378
  config.daily_reminders = false # schedule reminders:send every 15 minutes
379
+ config.mention_notifications = true # replies and @mentions; readers may opt out
381
380
  config.mailer_host = "example.com" # links in reminder/notice email
382
381
 
383
382
  config.parent_controller = "ActionController::Base" # or "::ApplicationController"
@@ -467,8 +466,6 @@ GET /day/:day days#show readings, who's finished, refle
467
466
  POST /day/:day/toggle/:track checkoffs#toggle
468
467
  POST /day/:day/comments comments#create
469
468
  DELETE /comments/:id comments#destroy
470
- PATCH /start-date readers#update_start_date
471
- DELETE /start-date readers#clear_start_date
472
469
  GET /community readers#index leaderboard
473
470
  GET /readers/:id readers#show
474
471
  GET /sign_in sessions#new email form + provider list
@@ -130,9 +130,8 @@ module Bible270
130
130
 
131
131
  def update_start
132
132
  if params[:start_date].present?
133
- # set_start_date!, not update_start_date!: an admin is not subject to
134
- # config.allow_reader_start_date, which governs what readers may do to
135
- # their own dates.
133
+ # The administrator may store an individual date even when personal
134
+ # calendars are currently disabled for this installation.
136
135
  if @reader.set_start_date!(params[:start_date])
137
136
  redirect_to admin_reader_path(@reader),
138
137
  notice: "Start date set to #{@reader.started_on.strftime('%B %-d, %Y')}."
@@ -5,32 +5,10 @@ module Bible270
5
5
  def index
6
6
  @plan_totals = Plan.totals
7
7
 
8
- # Show recent participation without ranking readers by completion. A checkoff
9
- # or reflection counts as activity; joining is the fallback for new readers.
10
- @readers = Reader.all.to_a
11
- checkoff_activity = Checkoff.group(:reader_id).maximum(:updated_at)
12
- reflection_activity = Comment.approved.group(:reader_id).maximum(:updated_at)
13
- @reader_activity = @readers.to_h do |reader|
14
- candidates = [
15
- { kind: :joined, at: reader.created_at },
16
- { kind: :reading, at: checkoff_activity[reader.id] },
17
- { kind: :reflection, at: reflection_activity[reader.id] }
18
- ].select { |activity| activity[:at] }
19
- [reader.id, candidates.max_by { |activity| activity[:at] }]
20
- end
21
- @readers.sort_by! do |reader|
22
- [-@reader_activity.fetch(reader.id)[:at].to_f, reader.sort_name]
23
- end
24
-
25
- counts = Checkoff.group(:reader_id, :day).count
26
- @days_completed = Hash.new(0)
27
- counts.each { |(rid, day), n| @days_completed[rid] += 1 if n >= Plan.total_parts(day) }
28
-
29
8
  @start_day = current_reader&.current_day || 1
30
9
  # nil outside the plan's window, so no "Go to today" before it begins
31
10
  @today_day = current_reader&.today_day
32
11
  @community_start_date = Bible270.config.start_date
33
- @allow_reader_start_date = Bible270.config.allow_reader_start_date
34
12
  end
35
13
 
36
14
  def show
@@ -39,11 +17,8 @@ module Bible270
39
17
 
40
18
  @readings = Plan.readings_for(@day)
41
19
  @comments = Comment.threads_for_day(@day)
42
- # Replying prefills the form with the handle rather than needing JavaScript,
43
- # so it works with Turbo and without an asset pipeline.
44
20
  @editing_comment_id = editing_comment_id
45
- parent = reply_parent
46
- @new_comment = Comment.new(day: @day, parent_id: parent&.id, body: reply_prefix(parent))
21
+ @new_comment = Comment.new(day: @day, parent_id: reply_parent&.id)
47
22
  @reader_tracks = current_reader ? current_reader.read_tracks_for(@day) : []
48
23
 
49
24
  # Every box on the day, not every track: an Old Testament reading of three
@@ -59,9 +34,6 @@ module Bible270
59
34
 
60
35
  private
61
36
 
62
- # "@handle " for the reflection being replied to, or nothing. Private, but
63
- # declared with the keyword rather than trailing the file, so it is obvious
64
- # this is not an action.
65
37
  # The reflection the reader has asked to edit: their own, on this day. Anything
66
38
  # else is ignored rather than refused — the page still renders.
67
39
  def editing_comment_id
@@ -76,12 +48,5 @@ module Bible270
76
48
  parent = Comment.approved.find_by(id: params[:reply_to])
77
49
  parent if parent && parent.day == @day && parent.parent_id.nil?
78
50
  end
79
-
80
- # "@handle " so the author is mentioned — and so gets the email — without the
81
- # reader having to type it.
82
- def reply_prefix(parent)
83
- handle = parent&.reader&.mention_handle
84
- handle ? "@#{handle} " : nil
85
- end
86
51
  end
87
52
  end
@@ -8,10 +8,13 @@ module Bible270
8
8
 
9
9
  def edit
10
10
  @names = current_reader.suggested_names
11
+ load_comment_notification_state
11
12
  load_daily_reminder_state
12
13
  end
13
14
 
14
15
  def update
16
+ @comment_notifications_available = comment_notifications_available?
17
+ @notify_on_mention = requested_comment_notifications
15
18
  @daily_reminders_available = daily_reminders_available?
16
19
  problems = []
17
20
  problems << 'both a first and last name' unless current_reader.update_names(params[:first_name],
@@ -32,11 +35,12 @@ module Bible270
32
35
  end
33
36
 
34
37
  if problems.empty?
38
+ current_reader.update!(notify_on_mention: @notify_on_mention) if @comment_notifications_available
35
39
  if @daily_reminders_available
36
40
  current_reader.update!(daily_reminders: requested_daily_reminders,
37
41
  daily_reminder_time: requested_daily_reminder_time)
38
42
  end
39
- redirect_to reader_path(current_reader), notice: 'Your profile has been updated.'
43
+ redirect_to profile_path, notice: 'Your profile has been updated.'
40
44
  else
41
45
  @names = { first_name: params[:first_name], last_name: params[:last_name] }
42
46
  @daily_reminders = requested_daily_reminders
@@ -53,6 +57,21 @@ module Bible270
53
57
 
54
58
  private
55
59
 
60
+ def load_comment_notification_state
61
+ @comment_notifications_available = comment_notifications_available?
62
+ @notify_on_mention = current_reader.wants_comment_notifications?
63
+ end
64
+
65
+ def comment_notifications_available?
66
+ Bible270.config.mention_notifications && current_reader.has_attribute?(:notify_on_mention)
67
+ end
68
+
69
+ def requested_comment_notifications
70
+ return current_reader.wants_comment_notifications? unless params.key?(:notify_on_mention)
71
+
72
+ params[:notify_on_mention] == '1'
73
+ end
74
+
56
75
  def load_daily_reminder_state
57
76
  @daily_reminders_available = daily_reminders_available?
58
77
  if @daily_reminders_available
@@ -2,22 +2,10 @@
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
-
13
5
  def index
14
- @readers = Reader.all.to_a
15
- counts = Checkoff.group(:reader_id, :day).count
16
- @days_completed = Hash.new(0)
17
- counts.each { |(rid, day), n| @days_completed[rid] += 1 if n >= Plan.total_parts(day) }
18
6
  # Community is a directory of fellow readers, not a leaderboard. Keep the
19
7
  # order predictable without implying that progress determines standing.
20
- @readers.sort_by!(&:sort_name)
8
+ @readers = Reader.all.to_a.sort_by(&:sort_name)
21
9
  end
22
10
 
23
11
  # The reader's own progress. Deliberately open to visitors: the panel shows an
@@ -29,10 +17,6 @@ module Bible270
29
17
  @recent_comments = @reader ? @reader.comments.recent.limit(5) : Comment.none
30
18
  @partial_day_tasks = []
31
19
  @remaining_partial_days = 0
32
- @week_days = []
33
- @week_completed = 0
34
- @next_milestone = nil
35
- @days_to_milestone = nil
36
20
  return unless @reader
37
21
 
38
22
  partial_days = @reader.partial_days
@@ -40,11 +24,6 @@ module Bible270
40
24
  { day: day, remaining: @reader.remaining_parts_for(day) }
41
25
  end
42
26
  @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
48
27
  end
49
28
 
50
29
  def show
@@ -54,49 +33,5 @@ module Bible270
54
33
  @days_completed = @reader.days_completed
55
34
  @recent_comments = @reader.comments.recent.limit(20)
56
35
  end
57
-
58
- def update_start_date
59
- return unless require_reader!
60
-
61
- unless Bible270.config.allow_reader_start_date
62
- redirect_to(root_path, alert: 'The start date is set for the whole community.') and return
63
- end
64
-
65
- if current_reader.update_start_date!(params[:start_date])
66
- redirect_to root_path,
67
- notice: "Start date set to #{current_reader.started_on.strftime('%B %-d, %Y')}."
68
- else
69
- redirect_to root_path, alert: "That doesn't look like a valid date."
70
- end
71
- end
72
-
73
- def clear_start_date
74
- return unless require_reader!
75
-
76
- if Bible270.config.allow_reader_start_date
77
- current_reader.clear_start_date!
78
- redirect_to root_path, notice: 'Start date cleared — the plan is now undated for you.'
79
- else
80
- redirect_to root_path, alert: 'The start date is set for the whole community.'
81
- end
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
101
36
  end
102
37
  end
@@ -19,16 +19,21 @@ module Bible270
19
19
  end
20
20
 
21
21
  def mentioned(comment_id:, reader_id:)
22
- @comment = Comment.find_by(id: comment_id)
23
- @reader = Reader.find_by(id: reader_id)
24
- return message.perform_deliveries = false if @comment.nil? || @reader.nil? || @reader.email.blank?
22
+ return message.perform_deliveries = false unless prepare_comment_notice(comment_id, reader_id)
25
23
 
26
- @author = @comment.reader
27
- @app_name = Bible270.config.app_name
28
- @day_url = day_url_for(@comment.day)
24
+ @heading = "#{@author.display_name} mentioned you"
25
+ @introduction = "#{@author.display_name} mentioned you in a reflection on day #{@comment.day}."
26
+ @reason = 'You are getting this because you asked to be emailed about replies and mentions.'
27
+ deliver_comment_notice("#{@app_name}: #{@author.display_name} mentioned you on day #{@comment.day}")
28
+ end
29
+
30
+ def replied(comment_id:, reader_id:)
31
+ return message.perform_deliveries = false unless prepare_comment_notice(comment_id, reader_id)
29
32
 
30
- mail to: @reader.email,
31
- subject: "#{@app_name}: #{@author.display_name} mentioned you on day #{@comment.day}"
33
+ @heading = "#{@author.display_name} replied to your reflection"
34
+ @introduction = "#{@author.display_name} replied to your reflection on day #{@comment.day}."
35
+ @reason = 'You are getting this because you asked to be emailed about replies and mentions.'
36
+ deliver_comment_notice("#{@app_name}: #{@author.display_name} replied on day #{@comment.day}")
32
37
  end
33
38
 
34
39
  def daily_reminder(reader_id:, day:, on: Bible270.today)
@@ -90,6 +95,25 @@ module Bible270
90
95
  engine_url(:profile_url)
91
96
  end
92
97
 
98
+ def prepare_comment_notice(comment_id, reader_id)
99
+ return false unless Bible270.config.mention_notifications
100
+
101
+ @comment = Comment.find_by(id: comment_id)
102
+ @reader = Reader.find_by(id: reader_id)
103
+ return false if @comment.nil? || @reader.nil? || @reader.email.blank?
104
+ return false unless @reader.wants_comment_notifications?
105
+
106
+ @author = @comment.reader
107
+ @app_name = Bible270.config.app_name
108
+ @day_url = day_url_for(@comment.day)
109
+ @profile_url = profile_url
110
+ true
111
+ end
112
+
113
+ def deliver_comment_notice(subject)
114
+ mail to: @reader.email, subject: subject, template_name: 'comment_notice'
115
+ end
116
+
93
117
  def engine_url(helper, *)
94
118
  host = Bible270.config.mailer_host
95
119
  return nil if host.blank?
@@ -37,10 +37,11 @@ module Bible270
37
37
  validate :parent_is_on_the_same_day
38
38
 
39
39
  # Fires once the reflection is really saved, so nothing is sent for one whose
40
- # transaction rolls back.
41
- after_create_commit :notify_mentioned_readers
40
+ # transaction rolls back. Replies notify their parent author directly; explicit
41
+ # mentions notify everyone else named in the body.
42
+ after_create_commit :notify_comment_readers
42
43
  # Only the people newly named: editing a reflection five times must not mail
43
- # the same reader five times.
44
+ # the same reader five times or repeat the original reply notice.
44
45
  after_update_commit :notify_newly_mentioned_readers
45
46
 
46
47
  scope :approved, -> { where(approved: true) }
@@ -153,13 +154,19 @@ module Bible270
153
154
  errors.add(:parent, "is on day #{parent.day}, not #{day}")
154
155
  end
155
156
 
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.
157
+ def notify_comment_readers
158
+ return unless Bible270.config.mention_notifications
159
+
160
+ replied_to = reply_notification_reader
161
+ deliver_comment_notice(replied_to, :replied) if replied_to
162
+ notify_mentioned_readers(except: [replied_to&.id].compact)
163
+ end
164
+
159
165
  def notify_newly_mentioned_readers
160
166
  return unless saved_change_to_body?
161
167
 
162
168
  already = Reader.mentioned_in(body_before_last_save).map(&:id)
169
+ already << parent.reader_id if reply?
163
170
  notify_mentioned_readers(except: already)
164
171
  end
165
172
 
@@ -170,11 +177,28 @@ module Bible270
170
177
  next if mentioned.id == reader_id
171
178
  next if except.include?(mentioned.id)
172
179
 
173
- notice = NoticeMailer.mentioned(comment_id: id, reader_id: mentioned.id)
174
- Bible270.config.registration_notice_deliver_later ? notice.deliver_later : notice.deliver_now
180
+ deliver_comment_notice(mentioned, :mentioned)
175
181
  end
182
+ end
183
+
184
+ def reply_notification_reader
185
+ return unless reply?
186
+
187
+ recipient = parent&.reader
188
+ recipient unless recipient&.id == reader_id
189
+ end
190
+
191
+ # Each recipient is isolated so a mail-server failure for one person cannot
192
+ # prevent other people named in the same reflection from being notified.
193
+ def deliver_comment_notice(recipient, action)
194
+ return unless recipient&.wants_comment_notifications?
195
+
196
+ notice = NoticeMailer.public_send(action, comment_id: id, reader_id: recipient.id)
197
+ Bible270.config.registration_notice_deliver_later ? notice.deliver_later : notice.deliver_now
176
198
  rescue StandardError => e
177
- Rails.logger.error("[bible270] could not send mention notices for comment #{id}: #{e.class}: #{e.message}")
199
+ Rails.logger.error(
200
+ "[bible270] could not send #{action} notice for comment #{id}: #{e.class}: #{e.message}"
201
+ )
178
202
  end
179
203
  end
180
204
  end
@@ -237,19 +237,11 @@ module Bible270
237
237
 
238
238
  # ---- mentions ----------------------------------------------------------
239
239
 
240
- # The handle to write when mentioning this reader: their first name, unless
241
- # someone else shares it, in which case first.last.
242
- def mention_handle
243
- first = first_name.presence || display_name.to_s.split.first
244
- return nil if first.blank?
245
-
246
- Mentions.preferred_handle(first, last_name, ambiguous: self.class.shared_first_name?(first, self))
247
- end
248
-
249
- def self.shared_first_name?(first, except = nil)
250
- scope = where('LOWER(first_name) = ?', first.to_s.downcase)
251
- scope = scope.where.not(id: except.id) if except&.id
252
- scope.exists?
240
+ # Older copied databases may briefly lack the preference column while their
241
+ # migrations are being reconciled. Preserve the historical opted-in behavior
242
+ # until the column is available rather than breaking reflection delivery.
243
+ def wants_comment_notifications?
244
+ !has_attribute?(:notify_on_mention) || self[:notify_on_mention] != false
253
245
  end
254
246
 
255
247
  # The readers a piece of text mentions. A handle matching more than one reader
@@ -578,19 +570,6 @@ module Bible270
578
570
  Plan.end_date_for(effective_start_date)
579
571
  end
580
572
 
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
-
594
573
  def date_for_day(day)
595
574
  Plan.date_for(day, effective_start_date)
596
575
  end
@@ -613,9 +592,9 @@ module Bible270
613
592
  started_on
614
593
  end
615
594
 
616
- # Set the start date regardless of whether readers are allowed to set their
617
- # own. For administrative use: config.allow_reader_start_date governs what a
618
- # reader may do to themselves, not what an admin may do on their behalf.
595
+ # Set an individual start date for administrative use. This remains ungated
596
+ # so an administrator can prepare or preserve a personal calendar even when
597
+ # config.allow_reader_start_date currently makes the community date authoritative.
619
598
  # Returns false only when the value isn't a date.
620
599
  def set_start_date!(value)
621
600
  date = Plan.to_date(value)
@@ -624,17 +603,6 @@ module Bible270
624
603
  update!(started_on: date)
625
604
  end
626
605
 
627
- # The reader-facing version, which does respect that permission.
628
- def update_start_date!(value)
629
- return false unless Bible270.config.allow_reader_start_date
630
-
631
- set_start_date!(value)
632
- end
633
-
634
- def clear_start_date!
635
- update!(started_on: nil)
636
- end
637
-
638
606
  # Called when a reader first participates. Only stamps a personal start date
639
607
  # when per-reader dates are enabled and a shared date isn't already in force.
640
608
  def ensure_started!
@@ -1,5 +1,3 @@
1
- <%= turbo_stream.remove "no_comments" %>
2
-
3
1
  <%# A reply belongs under its parent; a new reflection goes at the end. %>
4
2
  <% if @comment.reply? %>
5
3
  <%= turbo_stream.append "replies-#{@comment.parent_id}" do %>
@@ -1,5 +1,4 @@
1
1
  <% content_for :title, "Reflections — #{Bible270.config.app_name}" %>
2
- <% content_for :hide_day_index, true %><%# this page has its own, filtered grid %>
3
2
  <% browse_params = { day: @reflection_day, reader_id: @reflection_reader_id,
4
3
  since: @reflections_since_param }.compact %>
5
4
 
@@ -8,26 +7,8 @@
8
7
  <%= render "bible270/shared/interaction_status" %>
9
8
 
10
9
  <% if @days_with_reflections.any? %>
11
- <div class="b270-panel">
12
- <h2 class="b270-subhead">Days with reflections</h2>
13
- <p class="b270-hint" style="margin-top:0">
14
- <%= @days_with_reflections.size %>
15
- of <%= Bible270::Plan::DAYS %> days have been written about.
16
- </p>
17
-
18
- <%# Only the days that have something on them, so the grid is a map of the
19
- conversation rather than of the plan. Squares keep their usual meaning:
20
- the fill is reading progress, and hovering shows that day's readings. %>
21
- <div class="b270-grid">
22
- <% @days_with_reflections.each do |day| %>
23
- <%= b270_day_cell(day, reader: current_reader) %>
24
- <% end %>
25
- </div>
26
- </div>
27
-
28
10
  <div class="b270-reflection-tools">
29
11
  <div>
30
- <h2 class="b270-section-h">Conversations</h2>
31
12
  <p class="b270-reflection-count"><%= pluralize(@reflection_total, "reflection") %></p>
32
13
  </div>
33
14
  <%= form_with url: reflections_path, method: :get, class: "b270-reflection-filters" do %>
@@ -83,10 +64,6 @@
83
64
  <%= link_to "Show all reflections", reflections_path(since: @reflections_since_param) %>
84
65
  </div>
85
66
  <% end %>
86
-
87
- <p style="margin-top:18px">
88
- <%= link_to "Browse the whole plan →", root_path %>
89
- </p>
90
67
  <% else %>
91
68
  <div class="b270-panel">
92
69
  <p class="b270-signedout" style="margin:0">
@@ -99,5 +76,4 @@
99
76
  </p>
100
77
  </div>
101
78
  <% end %>
102
-
103
79
  <%= render "bible270/shared/interaction_ui" %>
@@ -6,7 +6,5 @@
6
6
  <%= link_to b270_avatar(reader), reader_path(reader), title: reader.display_name %>
7
7
  <% end %>
8
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
9
  <% end %>
12
10
  </div>