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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1717282682f9d19a378cd4416b50e5e3f3b63d110e855196042a6e090ed4bd92
4
- data.tar.gz: '0812f617706046de930ba84e4cfb6747fc73195b79818a05db11ae0e29484f6b'
3
+ metadata.gz: 167d9d42022755b068edda39ff37a00b1a159b05c2d0fadcce80173f1f4c6e7d
4
+ data.tar.gz: 2a97a8eab487cff56dc465e4e81eda2e4a63c60afde87f10f2ad018891cec67b
5
5
  SHA512:
6
- metadata.gz: 4d1fa7576be664152a2a64e5af1749e39d77fb6b7cd26ca5198a5017727c160e4a413a6337b4c222e7bf727a037fbcb56ea21972034712cf4da087e93e0a8636
7
- data.tar.gz: 89223b4dd7dbed64af6b4e4d90cbd3bb0e6b74c85b9b0b56baf64c9d8aa833710325965d43067a89dac67b754ad87bf192cc49a7996e2066daf0e3b82b164b36
6
+ metadata.gz: 0a8b0d0c963c24be7acfef4301be2ccbb2d95157a6aaa3177a42968a08e893e689ed0d747dce3375c02f3c912f1b23d4f69738659a96f3587d41cdb8bf2cd66c
7
+ data.tar.gz: 52dab82161413ee0aceecd4a67cd0a30411d36d0c4d3c6eeb03a6ea36e3ba98a754d29d36772c7e29aa5297bba31f323d1c19f17b68bc85ac6f154275b40569a
data/CHANGELOG.md CHANGED
@@ -4,6 +4,22 @@ All notable changes to bible270. Format follows [Keep a Changelog](https://keepa
4
4
 
5
5
  ## Unreleased Changes
6
6
 
7
+ ### Added
8
+
9
+ - recovery choices for readers whose next unfinished reading is behind the community day, including an optional schedule reset that preserves progress and reflections
10
+ - 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
+
14
+ ### Changed
15
+
16
+ - 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
20
+ - 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
22
+
7
23
  See the changes since the last release:
8
24
 
9
25
  **[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.18.2...main)**
data/README.md CHANGED
@@ -24,6 +24,12 @@ The schedule is pure deterministic Ruby (`Bible270::Plan`) — none of it is sto
24
24
  - **Rails** >= 7.0. On Ruby 4.0 your host app is the real constraint, not this engine — check your lockfile with RailsBump first.
25
25
  - A host app, and Turbo if you want check-offs without a page reload.
26
26
 
27
+ Mutation controls expose pending, success, and retry feedback when JavaScript runs, while ordinary HTML
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.
32
+
27
33
  ## Install
28
34
 
29
35
  ```ruby
@@ -272,6 +278,19 @@ Admins get an extra "Admin" link in the header. From a reader's page you can:
272
278
  written; hiding one takes it off the day and community pages without deleting it, and is
273
279
  reversible. Delete is there too, for anything that shouldn't be kept.
274
280
 
281
+ The Reflections page lists conversations by their latest approved reply, supports day and author
282
+ filters, and paginates older threads. Signed-in readers see which conversations became active since
283
+ their previous visit. Reflection and reply drafts are retained for up to 30 days in that browser and
284
+ cleared after a successful post. On a shared device, clearing browser storage also removes saved drafts.
285
+
286
+ After upgrading, copy and run migration `20260101000017`, which stores each reader's last visit to the
287
+ Reflections page:
288
+
289
+ ```bash
290
+ bin/rails bible270:install:migrations
291
+ bin/rails db:migrate
292
+ ```
293
+
275
294
  Break points can also live in your app rather than the gem, and be changed without a restart:
276
295
 
277
296
  ```ruby
@@ -313,6 +332,38 @@ Bible270::Plan.day_for(date, start, clamp: false) # => -3 or 271, to spot out-of
313
332
  Bible270::Plan.before_start?(date, start)
314
333
  ```
315
334
 
335
+ ## Daily reading reminders
336
+
337
+ Daily reminders are optional at both levels: the host enables the feature, then each reader chooses
338
+ whether to receive it and at what local time from their profile. The feature and reader opt-in both
339
+ default to off; each reader's time defaults to `08:00`.
340
+
341
+ ```ruby
342
+ # config/initializers/bible270.rb
343
+ Bible270.configure do |config|
344
+ config.daily_reminders = true
345
+ config.mailer_from = "no-reply@yourdomain.com"
346
+ config.mailer_host = "yourdomain.com" # builds links back to the day and profile
347
+ end
348
+ ```
349
+
350
+ After upgrading, copy and run migration `20260101000016`, which adds the reader opt-in, preferred
351
+ time, and last-sent date. Bible270 does not install a scheduler. The host application should run this
352
+ task periodically—every 15 minutes is recommended—using its existing cron, platform scheduler, or
353
+ recurring-job system:
354
+
355
+ ```bash
356
+ bin/rails bible270:reminders:send
357
+ ```
358
+
359
+ Each run converts `Time.now` through `Bible270.local_time`, waits until each reader's chosen `HH:MM`
360
+ time, sends inline with `deliver_now`, and prints the number delivered. Set `config.time_zone` when the
361
+ plan should not follow the server's local clock. Running periodically is safe because delivery is
362
+ idempotent once per local date: readers already marked for that date are skipped. Undated readers,
363
+ calendar dates before or after their plan, completed days, blank email addresses, and readers who have
364
+ not opted in are also skipped. A failure for one reader is logged and does not stop the rest. The sent
365
+ date is recorded only after delivery succeeds.
366
+
316
367
  ## Configuration reference
317
368
 
318
369
  ```ruby
@@ -326,6 +377,9 @@ Bible270.configure do |config|
326
377
  # config.start_date = Date.new(2026, 9, 6)
327
378
  config.allow_reader_start_date = true
328
379
 
380
+ config.daily_reminders = false # schedule reminders:send every 15 minutes
381
+ config.mailer_host = "example.com" # links in reminder/notice email
382
+
329
383
  config.parent_controller = "ActionController::Base" # or "::ApplicationController"
330
384
  config.layout = "bible270/application"
331
385
 
@@ -397,7 +451,7 @@ The CMS serves your content; this is a separate mounted app. Either **just link
397
451
 
398
452
  | Table | Holds |
399
453
  |-------|-------|
400
- | `bible270_readers` | identity, avatar, provider/uid or polymorphic `owner`, start date, translation and passage-reader preferences |
454
+ | `bible270_readers` | identity, avatar, provider/uid or polymorphic `owner`, start date, translation, passage-reader, and reminder preferences |
401
455
  | `bible270_checkoffs` | one row per reader per day per track (`ot`/`nt`/`pp`), unique-indexed |
402
456
  | `bible270_comments` | a reflection on a day, optionally scoped to a track, public to all |
403
457
  | `bible270_sign_in_tokens` | magic-link tokens: email, **digest only**, expiry, consumed-at |
@@ -471,7 +525,7 @@ All computed at load and memoized — no generated schedule, so nothing to migra
471
525
 
472
526
  ## Scaling
473
527
 
474
- The leaderboard counts completed days in Ruby from grouped check-offs — fine for hundreds of readers. Past a few thousand, cache a `days_completed` counter on `Reader` via a `Checkoff` `after_commit` and sort in SQL.
528
+ Community pages count completed days in Ruby from grouped check-offs — fine for hundreds of readers. Past a few thousand, cache a `days_completed` counter on `Reader` via a `Checkoff` `after_commit` and read it directly in SQL.
475
529
 
476
530
  ## Troubleshooting
477
531
 
@@ -499,9 +553,18 @@ The engine's CSS rides in a partial its own layout renders. Using your layout? A
499
553
 
500
554
  ```bash
501
555
  bundle install
502
- rake test
556
+ bundle exec rake test
557
+ bundle exec rubocop --cache false
503
558
  ```
504
559
 
560
+ `rake test` runs two isolated processes, streams colorized progress in real time, then prints one
561
+ consolidated result and duration. It merges their SimpleCov results, writes a browsable report to
562
+ `coverage/index.html`, and
563
+ records per-file runtimes in `tmp/parallel_runtime_test.log` to improve
564
+ subsequent balancing. Set `PARALLEL_WORKERS` to tune the worker count. Use
565
+ `bundle exec rake test:serial` when a serial coverage run is specifically needed; it writes to the same
566
+ project-local `coverage/` directory.
567
+
505
568
  ### Get an email link for local testing
506
569
 
507
570
  in the rails console: `rails c`
@@ -518,7 +581,7 @@ Bug reports and pull requests are welcome at <https://github.com/avonderluft/bib
518
581
 
519
582
  1. Fork it and clone your fork.
520
583
  2. Branch off `main`: `git checkout -b my-feature`.
521
- 3. Make your change, and add tests — `rake test` should stay green.
584
+ 3. Make your change, and add tests — `bundle exec rake test` and `bundle exec rubocop --cache false` should stay green.
522
585
  4. Commit with a clear message: `git commit -am "Add my feature"`.
523
586
  5. Push: `git push origin my-feature`.
524
587
  6. Open a pull request against `main`, saying what changed and why.
data/Rakefile CHANGED
@@ -1,7 +1,138 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'bundler/gem_tasks' # provides build / install / release
4
+ require 'open3'
4
5
  require 'rake/testtask'
6
+ require 'stringio'
7
+
8
+ module Bible270TestOutput
9
+ SUMMARY = %r{(?<assertions>\d+) assertions, (?<errors>\d+) errors, (?<failures>\d+) failures, (?<skips>\d+) skips?, (?<tests>\d+) tests}
10
+ COLORS = { green: 32, yellow: 33, red: 31, cyan: 36 }.freeze
11
+
12
+ class Progress
13
+ def initialize(io)
14
+ @io = io
15
+ @line = +''
16
+ @pending = +''
17
+ @awaiting_blank = false
18
+ @expecting_progress = false
19
+ @printing_progress = false
20
+ end
21
+
22
+ def feed(chunk)
23
+ chunk.each_char { |character| consume(character) }
24
+ flush
25
+ end
26
+
27
+ def finish
28
+ flush
29
+ @io.puts if @printing_progress
30
+ end
31
+
32
+ private
33
+
34
+ def consume(character)
35
+ return if character == "\r"
36
+ return finish_line if character == "\n"
37
+
38
+ if @expecting_progress && character.match?(%r{[.EFS]})
39
+ @printing_progress = true
40
+ @pending << character
41
+ elsif @printing_progress
42
+ flush
43
+ @io.puts
44
+ @printing_progress = false
45
+ @expecting_progress = false
46
+ @line = +character
47
+ else
48
+ @expecting_progress = false if @expecting_progress
49
+ @line << character
50
+ end
51
+ end
52
+
53
+ def finish_line
54
+ flush
55
+ if @printing_progress
56
+ @io.puts
57
+ @expecting_progress = false
58
+ elsif @line.start_with?('# Running tests')
59
+ @awaiting_blank = true
60
+ elsif @awaiting_blank && @line.empty?
61
+ @expecting_progress = true
62
+ @awaiting_blank = false
63
+ end
64
+ @line.clear
65
+ @printing_progress = false
66
+ end
67
+
68
+ def flush
69
+ return if @pending.empty?
70
+
71
+ @io.print Bible270TestOutput.progress_color(@pending)
72
+ @io.flush
73
+ @pending.clear
74
+ end
75
+ end
76
+
77
+ module_function
78
+
79
+ def capture(env, command)
80
+ output = +''
81
+ errors = +''
82
+ status = nil
83
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
84
+ progress = Progress.new($stdout)
85
+
86
+ Open3.popen3(env, *command) do |stdin, stdout, stderr, wait_thread|
87
+ stdin.close
88
+ error_reader = Thread.new { stderr.read }
89
+ loop do
90
+ chunk = stdout.readpartial(4096)
91
+ output << chunk
92
+ progress.feed(chunk)
93
+ end
94
+ rescue EOFError
95
+ errors = error_reader.value
96
+ status = wait_thread.value
97
+ ensure
98
+ progress.finish
99
+ end
100
+
101
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
102
+ [output, errors, status, elapsed]
103
+ end
104
+
105
+ def color(text, name)
106
+ return text unless $stdout.tty? && !ENV.key?('NO_COLOR')
107
+
108
+ "\e[#{COLORS.fetch(name)}m#{text}\e[0m"
109
+ end
110
+
111
+ def progress_color(text)
112
+ return text unless $stdout.tty? && !ENV.key?('NO_COLOR')
113
+
114
+ highlighted = text.gsub('S', "\e[33mS\e[32m")
115
+ .gsub(%r{[EF]}) { |character| "\e[31m#{character}\e[32m" }
116
+ "\e[32m#{highlighted}\e[0m"
117
+ end
118
+
119
+ def summary(output)
120
+ counts = output.match(SUMMARY)
121
+ raise 'Could not read the parallel test summary' unless counts
122
+
123
+ "#{counts[:tests]} tests, #{counts[:assertions]} assertions, " \
124
+ "#{counts[:failures]} failures, #{counts[:errors]} errors, " \
125
+ "#{counts[:skips]} #{counts[:skips] == '1' ? 'skip' : 'skips'}"
126
+ end
127
+
128
+ def quietly
129
+ previous = $stdout
130
+ $stdout = StringIO.new
131
+ yield
132
+ ensure
133
+ $stdout = previous
134
+ end
135
+ end
5
136
 
6
137
  desc 'Normalize permissions for files included in the gem'
7
138
  task :normalize_gem_permissions do
@@ -13,10 +144,53 @@ end
13
144
 
14
145
  Rake::Task[:build].enhance([:normalize_gem_permissions])
15
146
 
16
- Rake::TestTask.new(:test) do |t|
17
- t.libs << 'test' << 'lib'
18
- t.pattern = 'test/**/*_test.rb'
19
- t.warning = false
147
+ desc 'Run tests in isolated processes (default: 2 workers)'
148
+ namespace :test do
149
+ task :prepare_serial do
150
+ FileUtils.rm_rf('coverage')
151
+ ENV['TMPDIR'] = ENV.fetch('BIBLE270_TEST_TMPDIR', '/tmp')
152
+ end
153
+
154
+ desc 'Run tests serially with coverage'
155
+ Rake::TestTask.new(:serial) do |t|
156
+ t.libs << 'test' << 'lib'
157
+ t.pattern = 'test/**/*_test.rb'
158
+ t.warning = false
159
+ end
160
+ Rake::Task['test:serial'].enhance(['test:prepare_serial'])
161
+
162
+ task :parallel do
163
+ workers = ENV.fetch('PARALLEL_WORKERS', '2')
164
+ FileUtils.rm_rf('coverage')
165
+ env = {
166
+ 'PARALLEL_COVERAGE' => 'true',
167
+ 'RECORD_RUNTIME' => 'true',
168
+ 'SKIP_COV' => nil,
169
+ 'NO_COLOR' => '1',
170
+ 'TMPDIR' => ENV.fetch('BIBLE270_TEST_TMPDIR', '/tmp')
171
+ }
172
+ command = ['bundle', 'exec', 'parallel_test', 'test', '--type', 'test', '-n', workers]
173
+ puts Bible270TestOutput.color("Running tests (#{workers} workers)…", :cyan)
174
+ stdout, stderr, status, elapsed = Bible270TestOutput.capture(env, command)
175
+ unless status.success?
176
+ $stdout.write(stdout)
177
+ $stderr.write(stderr)
178
+ raise "Parallel tests failed with status #{status.exitstatus}"
179
+ end
180
+ $stderr.write(stderr) unless stderr.empty?
181
+ puts Bible270TestOutput.color(Bible270TestOutput.summary(stdout), :green)
182
+ puts Bible270TestOutput.color(format('Completed in %.1f seconds', elapsed), :cyan)
183
+
184
+ require 'simplecov'
185
+ SimpleCov.coverage_dir File.expand_path('coverage', __dir__)
186
+ result = SimpleCov::ResultMerger.merged_result
187
+ Bible270TestOutput.quietly { SimpleCov::Formatter::HTMLFormatter.new.format(result) }
188
+ coverage = format('Coverage: %<percent>.2f%% (%<covered>d/%<total>d) — coverage/index.html',
189
+ percent: result.covered_percent, covered: result.covered_lines, total: result.total_lines)
190
+ puts Bible270TestOutput.color(coverage, :green)
191
+ end
20
192
  end
21
193
 
194
+ desc 'Run tests in parallel'
195
+ task test: 'test:parallel'
22
196
  task default: :test
@@ -15,21 +15,46 @@ module Bible270
15
15
  Plan.present_tracks(@day).include?(@track) &&
16
16
  Plan.valid_part?(@day, @track, @part)
17
17
 
18
- existing = current_reader.checkoffs.find_by(day: @day, track: @track, part: @part)
19
- if existing
20
- existing.destroy
21
- else
22
- current_reader.ensure_started!
23
- current_reader.checkoffs.create(day: @day, track: @track, part: @part)
24
- end
18
+ requested_state = params[:checked] if params.key?(:checked)
19
+ head :bad_request and return if params.key?(:checked) && !%w[0 1].include?(requested_state)
20
+
21
+ marked_read = apply_checkoff(requested_state)
25
22
 
26
- @reader = current_reader.reload
23
+ @reader = current_reader.reload.reload_progress
27
24
  @reader_tracks = @reader.read_tracks_for(@day)
28
25
  @readings = Plan.readings_for(@day)
29
26
 
27
+ required = Plan.total_parts(@day)
28
+ completer_ids = Checkoff.where(day: @day)
29
+ .group(:reader_id)
30
+ .having('COUNT(*) >= ?', required)
31
+ .pluck(:reader_id)
32
+ @completers = Reader.where(id: completer_ids).order(:display_name)
33
+
30
34
  respond_to do |format|
31
35
  format.turbo_stream
32
- format.html { redirect_to day_path(@day) }
36
+ format.html do
37
+ message = marked_read ? 'Reading marked read.' : 'Reading marked unread.'
38
+ redirect_to day_path(@day), flash: { b270_interaction_status: message }
39
+ end
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def apply_checkoff(requested_state)
46
+ existing = current_reader.checkoffs.find_by(day: @day, track: @track, part: @part)
47
+ if requested_state == '1'
48
+ current_reader.ensure_started!
49
+ current_reader.checkoffs.create_or_find_by!(day: @day, track: @track, part: @part)
50
+ true
51
+ elsif requested_state == '0' || existing
52
+ existing&.destroy
53
+ false
54
+ else
55
+ current_reader.ensure_started!
56
+ current_reader.checkoffs.create(day: @day, track: @track, part: @part)
57
+ true
33
58
  end
34
59
  end
35
60
  end
@@ -3,8 +3,12 @@
3
3
  module Bible270
4
4
  class CommentsController < ApplicationController
5
5
  def index
6
+ visited_at = Time.current
6
7
  @days_with_reflections = Comment.approved.distinct.pluck(:day).sort
7
- @threads = recent_threads
8
+ load_reflection_filters
9
+ load_reflection_page
10
+ load_new_reflections
11
+ current_reader&.mark_reflections_seen!(visited_at)
8
12
  end
9
13
 
10
14
  def create
@@ -19,13 +23,18 @@ module Bible270
19
23
  if @comment.save
20
24
  respond_to do |format|
21
25
  format.turbo_stream
22
- format.html { redirect_to day_path(@day, anchor: "comment-#{@comment.id}") }
26
+ format.html do
27
+ message = @comment.reply? ? 'Reply posted.' : 'Reflection posted.'
28
+ redirect_to day_path(@day, anchor: "comment-#{@comment.id}"),
29
+ flash: { b270_interaction_status: message }
30
+ end
23
31
  end
24
32
  else
25
33
  respond_to do |format|
26
34
  format.turbo_stream do
27
35
  render turbo_stream: turbo_stream.replace('new_comment_form', partial: 'bible270/comments/form',
28
- locals: { day: @day, comment: @comment })
36
+ locals: { day: @day, comment: @comment }),
37
+ status: :unprocessable_entity
29
38
  end
30
39
  format.html { redirect_to day_path(@day), alert: @comment.errors.full_messages.to_sentence }
31
40
  end
@@ -42,14 +51,18 @@ module Bible270
42
51
  if @comment.update(comment_params.except(:parent_id))
43
52
  respond_to do |format|
44
53
  format.turbo_stream
45
- format.html { redirect_to day_path(@day, anchor: "comment-#{@comment.id}") }
54
+ format.html do
55
+ redirect_to day_path(@day, anchor: "comment-#{@comment.id}"),
56
+ flash: { b270_interaction_status: 'Changes saved.' }
57
+ end
46
58
  end
47
59
  else
48
60
  respond_to do |format|
49
61
  format.turbo_stream do
50
62
  render turbo_stream: turbo_stream.replace("comment-#{@comment.id}",
51
63
  partial: 'bible270/comments/edit_form',
52
- locals: { comment: @comment })
64
+ locals: { comment: @comment }),
65
+ status: :unprocessable_entity
53
66
  end
54
67
  format.html { redirect_to day_path(@day), alert: @comment.errors.full_messages.to_sentence }
55
68
  end
@@ -72,30 +85,52 @@ module Bible270
72
85
 
73
86
  private
74
87
 
75
- # The most recently active conversations, newest first. A reply counts as
76
- # activity, so answering an old reflection brings the whole thread back up —
77
- # otherwise a lively discussion on day 3 would stay buried while quieter, newer
78
- # reflections sat above it.
79
- def recent_threads
80
- wanted = Bible270.config.reflections_page_size.to_i
81
- wanted = 10 unless wanted.positive?
82
-
83
- # Look at rather more messages than threads, since several may belong to one.
84
- recent = Comment.approved.order(created_at: :desc).limit(wanted * 5)
85
- .pluck(:id, :parent_id, :created_at)
86
-
87
- roots = {}
88
- recent.each do |id, parent_id, created_at|
89
- root = parent_id || id
90
- roots[root] ||= created_at
91
- roots[root] = created_at if created_at > roots[root]
92
- end
88
+ def load_reflection_filters
89
+ root_reflections = Comment.approved.where(parent_id: nil)
90
+ reader_ids = root_reflections.distinct.pluck(:reader_id)
91
+ @reflection_readers = Reader.where(id: reader_ids).to_a.sort_by(&:sort_name)
92
+
93
+ requested_day = params[:day].presence&.to_i
94
+ @reflection_day = requested_day if @days_with_reflections.include?(requested_day)
95
+
96
+ requested_reader = params[:reader_id].presence&.to_i
97
+ @reflection_reader_id = requested_reader if reader_ids.include?(requested_reader)
98
+ end
99
+
100
+ def load_reflection_page
101
+ result = Comment.thread_page(
102
+ day: @reflection_day,
103
+ reader_id: @reflection_reader_id,
104
+ page: params[:page],
105
+ per_page: Bible270.config.reflections_page_size
106
+ )
107
+ @threads = result.threads
108
+ @thread_activity = result.activity_by_id
109
+ @reflection_page = result.page
110
+ @reflection_pages = result.pages
111
+ @reflection_total = result.total
112
+ end
113
+
114
+ def load_new_reflections
115
+ @reflections_since = reflections_since
116
+ @reflections_since_param = @reflections_since&.iso8601(6) || 'none'
117
+ @new_thread_ids = if @reflections_since
118
+ @thread_activity.filter_map do |id, activity_at|
119
+ id if activity_at > @reflections_since
120
+ end
121
+ else
122
+ []
123
+ end
124
+ end
125
+
126
+ def reflections_since
127
+ return nil unless current_reader&.class&.reflections_seen_column?
128
+ return current_reader.reflections_seen_at unless params.key?(:since)
129
+ return nil if params[:since] == 'none'
93
130
 
94
- ids = roots.sort_by { |_root, at| -at.to_i }.first(wanted).map(&:first)
95
- threads = Comment.approved.where(id: ids)
96
- .includes(:reader, { likes: :reader }, { replies: [:reader, { likes: :reader }] })
97
- .index_by(&:id)
98
- ids.filter_map { |id| threads[id] }
131
+ Time.iso8601(params[:since].to_s)
132
+ rescue ArgumentError
133
+ current_reader.reflections_seen_at
99
134
  end
100
135
 
101
136
  def comment_params
@@ -5,12 +5,26 @@ module Bible270
5
5
  def index
6
6
  @plan_totals = Plan.totals
7
7
 
8
- # Community leaderboard (small-community friendly; see README on scaling).
9
- @readers = Reader.order(updated_at: :desc).to_a
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
+
10
25
  counts = Checkoff.group(:reader_id, :day).count
11
26
  @days_completed = Hash.new(0)
12
27
  counts.each { |(rid, day), n| @days_completed[rid] += 1 if n >= Plan.total_parts(day) }
13
- @readers = @readers.sort_by { |r| -@days_completed[r.id] }
14
28
 
15
29
  @start_day = current_reader&.current_day || 1
16
30
  # nil outside the plan's window, so no "Go to today" before it begins
@@ -41,9 +55,6 @@ module Bible270
41
55
  .having('COUNT(*) >= ?', required)
42
56
  .pluck(:reader_id)
43
57
  @completers = Reader.where(id: completer_ids).order(:display_name)
44
-
45
- @prev_day = @day > 1 ? @day - 1 : nil
46
- @next_day = @day < Plan::DAYS ? @day + 1 : nil
47
58
  end
48
59
 
49
60
  private
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bible270
4
- # A heart on someone's reflection. One route, because liking and unliking are
5
- # the same gesture tapping the heart again takes it back.
4
+ # A heart on someone's reflection. The route accepts the intended state from
5
+ # current forms and retains toggle behavior for cached or external callers.
6
6
  class LikesController < ApplicationController
7
7
  def toggle
8
8
  return unless require_reader!
@@ -10,12 +10,23 @@ module Bible270
10
10
  @comment = Comment.approved.find_by(id: params[:id])
11
11
  head :not_found and return unless @comment
12
12
 
13
- @comment.toggle_like!(current_reader)
13
+ requested_state = params[:liked] if params.key?(:liked)
14
+ head :bad_request and return if params.key?(:liked) && !%w[0 1].include?(requested_state)
15
+
16
+ liked = if requested_state
17
+ @comment.set_like!(current_reader, liked: requested_state == '1')
18
+ else
19
+ @comment.toggle_like!(current_reader)
20
+ end
14
21
  @comment.reload
15
22
 
16
23
  respond_to do |format|
17
24
  format.turbo_stream
18
- format.html { redirect_to day_path(@comment.day, anchor: "comment-#{@comment.id}") }
25
+ format.html do
26
+ message = liked ? 'Reflection liked.' : 'Like removed.'
27
+ redirect_to day_path(@comment.day, anchor: "comment-#{@comment.id}"),
28
+ flash: { b270_interaction_status: message }
29
+ end
19
30
  end
20
31
  end
21
32
  end