bible270 0.19.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -2
  3. data/README.md +71 -11
  4. data/Rakefile +178 -4
  5. data/app/controllers/bible270/admin_controller.rb +2 -3
  6. data/app/controllers/bible270/checkoffs_controller.rb +34 -9
  7. data/app/controllers/bible270/comments_controller.rb +63 -28
  8. data/app/controllers/bible270/days_controller.rb +1 -25
  9. data/app/controllers/bible270/likes_controller.rb +15 -4
  10. data/app/controllers/bible270/profiles_controller.rb +57 -1
  11. data/app/controllers/bible270/readers_controller.rb +13 -33
  12. data/app/helpers/bible270/plan_helper.rb +17 -3
  13. data/app/mailers/bible270/notice_mailer.rb +52 -7
  14. data/app/models/bible270/comment.rb +78 -9
  15. data/app/models/bible270/reader.rb +69 -27
  16. data/app/views/bible270/checkoffs/toggle.turbo_stream.erb +8 -0
  17. data/app/views/bible270/comments/_edit_fields.html.erb +3 -1
  18. data/app/views/bible270/comments/_form.html.erb +10 -1
  19. data/app/views/bible270/comments/create.turbo_stream.erb +1 -3
  20. data/app/views/bible270/comments/index.html.erb +56 -29
  21. data/app/views/bible270/days/_completers.html.erb +10 -0
  22. data/app/views/bible270/days/_day_progress.html.erb +47 -0
  23. data/app/views/bible270/days/_reading.html.erb +10 -2
  24. data/app/views/bible270/days/index.html.erb +2 -20
  25. data/app/views/bible270/days/show.html.erb +27 -43
  26. data/app/views/bible270/likes/_likes.html.erb +5 -1
  27. data/app/views/bible270/notice_mailer/{mentioned.html.erb → comment_notice.html.erb} +5 -2
  28. data/app/views/bible270/notice_mailer/{mentioned.text.erb → comment_notice.text.erb} +5 -2
  29. data/app/views/bible270/notice_mailer/daily_reminder.html.erb +30 -0
  30. data/app/views/bible270/notice_mailer/daily_reminder.text.erb +14 -0
  31. data/app/views/bible270/profiles/edit.html.erb +49 -1
  32. data/app/views/bible270/readers/_next_steps.html.erb +27 -0
  33. data/app/views/bible270/readers/index.html.erb +4 -8
  34. data/app/views/bible270/readers/progress.html.erb +7 -21
  35. data/app/views/bible270/readers/show.html.erb +1 -6
  36. data/app/views/bible270/shared/_comment_drafts.html.erb +169 -0
  37. data/app/views/bible270/shared/_day_index.html.erb +6 -4
  38. data/app/views/bible270/shared/_header.html.erb +21 -4
  39. data/app/views/bible270/shared/_interaction_status.html.erb +2 -0
  40. data/app/views/bible270/shared/_interaction_ui.html.erb +69 -0
  41. data/app/views/bible270/shared/_progress.html.erb +14 -7
  42. data/app/views/bible270/shared/_styles.html.erb +96 -19
  43. data/config/routes.rb +1 -4
  44. data/db/migrate/20260101000011_add_mention_notices_to_bible270_readers.rb +3 -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 +18 -9
  48. data/lib/bible270/daily_reminders.rb +57 -0
  49. data/lib/bible270/mentions.rb +0 -9
  50. data/lib/bible270/migration_reconciler.rb +13 -0
  51. data/lib/bible270/version.rb +1 -1
  52. data/lib/bible270.rb +1 -0
  53. data/lib/generators/bible270/install/install_generator.rb +8 -0
  54. data/lib/generators/bible270/install/templates/bible270.rb.tt +8 -0
  55. data/lib/tasks/bible270_reminders.rake +13 -0
  56. metadata +15 -4
  57. data/app/views/bible270/days/_start_date.html.erb +0 -43
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4e5f2002e8da19945110a09f27e658b7de650e8d63e4d0cc429e0c9c74bf6bf6
4
- data.tar.gz: '09e8a0851405b78ccb8bd39c2938546afe3bf2cf47cfe8e693d000f60713a16f'
3
+ metadata.gz: 48e80937debff292b2c896ae9eaba55fe5eae8624ef28c6eb9274236fed4fb2c
4
+ data.tar.gz: 72f8fbd17194fd00bbc8602fd69180cb91565d54f995868dacf8edfb4419657f
5
5
  SHA512:
6
- metadata.gz: 516d821a92220f758959fe337d4cb2591ffe7785102f1c3e6d230ae31d801175b563d2b004dad1f77829d7fbc19cce1d5f87cb1f13a96405d77b81c0d2de7e1a
7
- data.tar.gz: 27ba2c3d4f110bd64e8fc26a40b03e98e7b2f512cffd13f79faf267d35ba32f69e03834ec1dd678696941adc5c35ad11e804b7a6547bbeddac7561ed02e8a47f
6
+ metadata.gz: b493f52caadc3fa2bbb7e3ef4dd59c9822d0045b9fb6f42151f4fad0c9057d017b7604064188e35a3dce03c8c6c3c02cabce8a95a342e84e6fbf5a2682a46309
7
+ data.tar.gz: a38f6e3b979b76ccaf5c666e3698d29302ac97b82692f7c2a33ac1ef0d28f139b28ddaf89705e4a13c132713533edd87c8e804eff06974c089ad2bb8b1322c0b
data/CHANGELOG.md CHANGED
@@ -4,9 +4,71 @@ All notable changes to bible270. Format follows [Keep a Changelog](https://keepa
4
4
 
5
5
  ## Unreleased Changes
6
6
 
7
- See the changes since the last release:
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
+
31
+ ### Added
32
+
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
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
39
+
40
+ ### Changed
41
+
42
+ - replace progress-ranked community lists with transparent recent-participation and alphabetical views that emphasize reading together
43
+ - show why each reader appears in Reading together, including their latest reading, reflection, or join activity
44
+ - add consistent pending, success, and retry feedback to reading, like, reflection, and reply actions, with repeat-safe checkoff and like requests
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
68
+
69
+ - the per-reader mention-email opt-out; writing a reader's name explicitly now always sends the mention notice
8
70
 
9
- **[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)**
10
72
 
11
73
  ## [0.18.2] - 2026-08-15
12
74
 
@@ -426,6 +488,8 @@ This is the initial public release.
426
488
 
427
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.
428
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
429
493
  [0.18.2]: https://github.com/avonderluft/bible270/releases/tag/v0.18.2
430
494
  [0.18.1]: https://github.com/avonderluft/bible270/releases/tag/v0.18.1
431
495
  [0.18.0]: https://github.com/avonderluft/bible270/releases/tag/v0.18.0
data/README.md CHANGED
@@ -24,6 +24,10 @@ 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.
30
+
27
31
  ## Install
28
32
 
29
33
  ```ruby
@@ -272,6 +276,19 @@ Admins get an extra "Admin" link in the header. From a reader's page you can:
272
276
  written; hiding one takes it off the day and community pages without deleting it, and is
273
277
  reversible. Delete is there too, for anything that shouldn't be kept.
274
278
 
279
+ The Reflections page lists conversations by their latest approved reply, supports day and author
280
+ filters, and paginates older threads. Signed-in readers see which conversations became active since
281
+ their previous visit. Reflection and reply drafts are retained for up to 30 days in that browser and
282
+ cleared after a successful post. On a shared device, clearing browser storage also removes saved drafts.
283
+
284
+ After upgrading, copy and run migration `20260101000017`, which stores each reader's last visit to the
285
+ Reflections page:
286
+
287
+ ```bash
288
+ bin/rails bible270:install:migrations
289
+ bin/rails db:migrate
290
+ ```
291
+
275
292
  Break points can also live in your app rather than the gem, and be changed without a restart:
276
293
 
277
294
  ```ruby
@@ -291,17 +308,17 @@ The plan runs **undated** (day numbers only) or **dated** (days mapped onto a ca
291
308
 
292
309
  ```ruby
293
310
  config.start_date = Date.new(2026, 9, 6) # Date, Time, or "YYYY-MM-DD". nil = undated
294
- config.allow_reader_start_date = true # can readers set their own?
311
+ config.allow_reader_start_date = true # allow personal per-reader calendars
295
312
  ```
296
313
 
297
314
  | `start_date` | `allow_reader_start_date` | What happens |
298
315
  |---|---|---|
299
- | nil | true *(default)* | Each reader gets stamped on first check-off, and can change it |
300
- | a date | true | Community date is the default; anyone may override |
301
- | 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 |
302
319
  | nil | false | Fully undated — no calendar anywhere |
303
320
 
304
- 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.
305
322
 
306
323
  Date helpers are pure functions, usable anywhere:
307
324
 
@@ -313,6 +330,38 @@ Bible270::Plan.day_for(date, start, clamp: false) # => -3 or 271, to spot out-of
313
330
  Bible270::Plan.before_start?(date, start)
314
331
  ```
315
332
 
333
+ ## Daily reading reminders
334
+
335
+ Daily reminders are optional at both levels: the host enables the feature, then each reader chooses
336
+ whether to receive it and at what local time from their profile. The feature and reader opt-in both
337
+ default to off; each reader's time defaults to `08:00`.
338
+
339
+ ```ruby
340
+ # config/initializers/bible270.rb
341
+ Bible270.configure do |config|
342
+ config.daily_reminders = true
343
+ config.mailer_from = "no-reply@yourdomain.com"
344
+ config.mailer_host = "yourdomain.com" # builds links back to the day and profile
345
+ end
346
+ ```
347
+
348
+ After upgrading, copy and run migration `20260101000016`, which adds the reader opt-in, preferred
349
+ time, and last-sent date. Bible270 does not install a scheduler. The host application should run this
350
+ task periodically—every 15 minutes is recommended—using its existing cron, platform scheduler, or
351
+ recurring-job system:
352
+
353
+ ```bash
354
+ bin/rails bible270:reminders:send
355
+ ```
356
+
357
+ Each run converts `Time.now` through `Bible270.local_time`, waits until each reader's chosen `HH:MM`
358
+ time, sends inline with `deliver_now`, and prints the number delivered. Set `config.time_zone` when the
359
+ plan should not follow the server's local clock. Running periodically is safe because delivery is
360
+ idempotent once per local date: readers already marked for that date are skipped. Undated readers,
361
+ calendar dates before or after their plan, completed days, blank email addresses, and readers who have
362
+ not opted in are also skipped. A failure for one reader is logged and does not stop the rest. The sent
363
+ date is recorded only after delivery succeeds.
364
+
316
365
  ## Configuration reference
317
366
 
318
367
  ```ruby
@@ -326,6 +375,10 @@ Bible270.configure do |config|
326
375
  # config.start_date = Date.new(2026, 9, 6)
327
376
  config.allow_reader_start_date = true
328
377
 
378
+ config.daily_reminders = false # schedule reminders:send every 15 minutes
379
+ config.mention_notifications = true # replies and @mentions; readers may opt out
380
+ config.mailer_host = "example.com" # links in reminder/notice email
381
+
329
382
  config.parent_controller = "ActionController::Base" # or "::ApplicationController"
330
383
  config.layout = "bible270/application"
331
384
 
@@ -397,7 +450,7 @@ The CMS serves your content; this is a separate mounted app. Either **just link
397
450
 
398
451
  | Table | Holds |
399
452
  |-------|-------|
400
- | `bible270_readers` | identity, avatar, provider/uid or polymorphic `owner`, start date, translation and passage-reader preferences |
453
+ | `bible270_readers` | identity, avatar, provider/uid or polymorphic `owner`, start date, translation, passage-reader, and reminder preferences |
401
454
  | `bible270_checkoffs` | one row per reader per day per track (`ot`/`nt`/`pp`), unique-indexed |
402
455
  | `bible270_comments` | a reflection on a day, optionally scoped to a track, public to all |
403
456
  | `bible270_sign_in_tokens` | magic-link tokens: email, **digest only**, expiry, consumed-at |
@@ -413,8 +466,6 @@ GET /day/:day days#show readings, who's finished, refle
413
466
  POST /day/:day/toggle/:track checkoffs#toggle
414
467
  POST /day/:day/comments comments#create
415
468
  DELETE /comments/:id comments#destroy
416
- PATCH /start-date readers#update_start_date
417
- DELETE /start-date readers#clear_start_date
418
469
  GET /community readers#index leaderboard
419
470
  GET /readers/:id readers#show
420
471
  GET /sign_in sessions#new email form + provider list
@@ -471,7 +522,7 @@ All computed at load and memoized — no generated schedule, so nothing to migra
471
522
 
472
523
  ## Scaling
473
524
 
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.
525
+ 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
526
 
476
527
  ## Troubleshooting
477
528
 
@@ -499,9 +550,18 @@ The engine's CSS rides in a partial its own layout renders. Using your layout? A
499
550
 
500
551
  ```bash
501
552
  bundle install
502
- rake test
553
+ bundle exec rake test
554
+ bundle exec rubocop --cache false
503
555
  ```
504
556
 
557
+ `rake test` runs two isolated processes, streams colorized progress in real time, then prints one
558
+ consolidated result and duration. It merges their SimpleCov results, writes a browsable report to
559
+ `coverage/index.html`, and
560
+ records per-file runtimes in `tmp/parallel_runtime_test.log` to improve
561
+ subsequent balancing. Set `PARALLEL_WORKERS` to tune the worker count. Use
562
+ `bundle exec rake test:serial` when a serial coverage run is specifically needed; it writes to the same
563
+ project-local `coverage/` directory.
564
+
505
565
  ### Get an email link for local testing
506
566
 
507
567
  in the rails console: `rails c`
@@ -518,7 +578,7 @@ Bug reports and pull requests are welcome at <https://github.com/avonderluft/bib
518
578
 
519
579
  1. Fork it and clone your fork.
520
580
  2. Branch off `main`: `git checkout -b my-feature`.
521
- 3. Make your change, and add tests — `rake test` should stay green.
581
+ 3. Make your change, and add tests — `bundle exec rake test` and `bundle exec rubocop --cache false` should stay green.
522
582
  4. Commit with a clear message: `git commit -am "Add my feature"`.
523
583
  5. Push: `git push origin my-feature`.
524
584
  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
@@ -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')}."
@@ -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