bible270 1.12.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a699484a74364bfd2ca703e50a1ab634d9449691f9242acee726d2fbc5a27afc
4
- data.tar.gz: 17711f1bf0c60d00bb7b1cb0545e218a808e93226da8627c10c16f20c3f4473a
3
+ metadata.gz: 67a4da0561f2ba1326490041bdc95c5a12121c2ad2ebab526e8c99da823d1f02
4
+ data.tar.gz: 2e0faf9a902a23e4a4aa8df4bde6c9b89217073a175e1c154eaaeb722879ceba
5
5
  SHA512:
6
- metadata.gz: 66ed48de38122303a4fe50cfbdbf6251334ee9c67bb82be501a95c3c5d14fdcaa63f5356d4db1d7cf518307870fdc0e08b9045ea23e956a55f20ce7d46e771e9
7
- data.tar.gz: 6230d31ac89feb6a6d830c9ca2009edcb266ca9886be87a57bc6b1fed378f0580d0e76ae5ec4c51353b3751b1a52c214eca5dbf016eddbeecaa740abed162626
6
+ metadata.gz: d49d1ec657acffb1c61b881a21a302edb638afa298c24362703fb0e02033a6bc9d9db46c4aa1bd8170df9f3a7260e09ee7b914851c581f2791be816b0be25464
7
+ data.tar.gz: ae25c7f62f00bb8e15e0af6d60acb6b9b2ab9b6733c5524c269a0eeb8d2815ea8929a798169dc1a3822a43f06935f5d01c1673963096afa5853b54a10bb96457
data/CHANGELOG.md CHANGED
@@ -4,22 +4,43 @@ All notable changes to bible270. Format follows [Keep a Changelog](https://keepa
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ### Added
8
+
9
+ - add a Consistency ranking sort to Admin readers, based on average end-of-day schedule performance: 100 on track, +5 per day ahead capped at seven days, and −10 per day behind
10
+ - show consistency scores, the percentage of evaluated evenings on track or ahead, and evaluated-day counts when sorting by consistency; place provisional readers with fewer than seven days after established rankings, and unranked readers last
11
+ - include a leftmost Rank column in Reader Stats when sorting by consistency, sharing competition ranks for tied metrics (for example, `1, 1, 1, 4`) and showing a dash for unranked readers
12
+ - reconstruct consistency from recorded completion timestamps and current personal/shared schedules, excluding today and dates before enrollment or after the plan's end; changing check-offs or start dates can change reconstructed history
13
+
14
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.12.0...main)**
15
+
16
+ ## [1.12.0] - 2026-10-22
17
+
7
18
  ### Fixed
8
19
 
9
20
  - load avatars eagerly without forced asynchronous decoding, while retaining attachment preloading and bounded database queries
10
21
  - recover failed avatar requests with up to two automatic retries, refreshing same-origin uploaded-image redirects without modifying signed provider URLs
11
22
  - show accessible initials instead of broken images when avatars fail, including cached failures and images inserted during navigation or conversations
23
+ - preserve the previous picture and profile settings when upload processing or storage fails, and remove both original and thumbnail attachments when a picture is removed
24
+ - prevent duplicate or stale avatar attachments during concurrent uploads and backfills; retain unattached blob records for cleanup retries when storage deletion fails
12
25
 
13
26
  ### Added
14
27
 
15
- - automatically create 256×256, center-cropped static WebP avatars on Profile and Admin uploads, with orientation correction, color conversion, transparency preservation, and metadata stripping; animated images use their first frame
28
+ - automatically create 256×256, center-cropped static WebP avatars at quality 85 on Profile and Admin uploads, with orientation correction, sRGB color conversion, transparency preservation, and thumbnail metadata stripping; animated images use their first frame
16
29
  - preserve the current original upload separately and display the optimized thumbnail only after successful processing and storage, without request-time image processing
17
30
  - validate actual image data and enforce a 40-megapixel limit in addition to the existing configurable upload byte limit (2 MiB by default)
18
- - add `bible270:avatars:optimize` to backfill existing uploaded avatars, with `DRY_RUN=1`, `FORCE=1`, bounded batches, rerun-safe version tracking, and protection against concurrent picture changes
31
+ - add `bible270:avatars:optimize` to backfill existing uploaded avatars, with metadata-only `DRY_RUN=1`, `FORCE=1`, configurable `BATCH_SIZE`, rerun-safe version tracking, and protection against concurrent picture changes
32
+ - report backfill counts and original/optimized display-byte totals, continue after individual image failures, and exit nonzero when any images fail
33
+
34
+ ### Security
35
+
36
+ - validate PNG chunk structure and checksums, discard text metadata before native decoding, and limit expanded PNG color profiles to 4 MiB
37
+ - prevent redundant Active Storage analysis of retained originals from bypassing the sanitized image-processing pipeline
19
38
 
20
39
  Avatar processing requires libvips with PNG, JPEG, GIF and WebP support on upload/backfill hosts; the gem now depends on `image_processing` and `ruby-vips`. No additional database migration is required for hosts already using Active Storage. Existing avatars continue displaying their originals until optimized; remote provider images are unchanged.
21
40
 
22
- **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.11.1...main)**
41
+ Run the backfill from the host Rails application after deploying. Originals are retained, so smaller display images reduce download sizes rather than total storage use. Upload and backfill service calls must run outside caller-owned database transactions to avoid orphaning stored files on a later rollback.
42
+
43
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.11.1...v1.12.0)**
23
44
 
24
45
  ## [1.11.1] - 2026-10-22
25
46
 
@@ -931,62 +952,67 @@ This is the initial public release.
931
952
 
932
953
  * 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.
933
954
 
934
- [Unreleased]: https://github.com/avonderluft/bible270/compare/v1.11.1...HEAD
935
- [1.11.1]: https://github.com/avonderluft/bible270/releases/tag/v1.11.1
936
- [1.11.0]: https://github.com/avonderluft/bible270/releases/tag/v1.11.0
937
- [1.10.2]: https://github.com/avonderluft/bible270/releases/tag/v1.10.2
938
- [1.10.1]: https://github.com/avonderluft/bible270/releases/tag/v1.10.1
939
- [1.10.0]: https://github.com/avonderluft/bible270/releases/tag/v1.10.0
940
- [1.9.1]: https://github.com/avonderluft/bible270/releases/tag/v1.9.1
941
- [1.9.0]: https://github.com/avonderluft/bible270/releases/tag/v1.9.0
942
- [1.8.5]: https://github.com/avonderluft/bible270/releases/tag/v1.8.5
943
- [1.8.4]: https://github.com/avonderluft/bible270/releases/tag/v1.8.4
944
- [1.8.3]: https://github.com/avonderluft/bible270/releases/tag/v1.8.3
945
- [1.8.1]: https://github.com/avonderluft/bible270/releases/tag/v1.8.1
946
- [1.8.0]: https://github.com/avonderluft/bible270/releases/tag/v1.8.0
947
- [1.7.3]: https://github.com/avonderluft/bible270/releases/tag/v1.7.3
948
- [1.7.2]: https://github.com/avonderluft/bible270/releases/tag/v1.7.2
949
- [1.7.1]: https://github.com/avonderluft/bible270/releases/tag/v1.7.1
950
- [1.7.0]: https://github.com/avonderluft/bible270/releases/tag/v1.7.0
951
- [1.6.2]: https://github.com/avonderluft/bible270/releases/tag/v1.6.2
952
- [1.6.1]: https://github.com/avonderluft/bible270/releases/tag/v1.6.1
953
- [1.6.0]: https://github.com/avonderluft/bible270/releases/tag/v1.6.0
954
- [1.5.1]: https://github.com/avonderluft/bible270/releases/tag/v1.5.1
955
- [1.5.0]: https://github.com/avonderluft/bible270/releases/tag/v1.5.0
956
- [1.4.2]: https://github.com/avonderluft/bible270/releases/tag/v1.4.2
957
- [1.4.1]: https://github.com/avonderluft/bible270/releases/tag/v1.4.1
958
- [1.4.0]: https://github.com/avonderluft/bible270/releases/tag/v1.4.0
959
- [1.3.2]: https://github.com/avonderluft/bible270/releases/tag/v1.3.2
960
- [1.3.1]: https://github.com/avonderluft/bible270/releases/tag/v1.3.1
961
- [1.3.0]: https://github.com/avonderluft/bible270/releases/tag/v1.3.0
962
- [1.2.0]: https://github.com/avonderluft/bible270/releases/tag/v1.2.0
963
- [1.1.7]: https://github.com/avonderluft/bible270/releases/tag/v1.1.7
964
- [1.1.6]: https://github.com/avonderluft/bible270/releases/tag/v1.1.6
965
- [1.1.5]: https://github.com/avonderluft/bible270/releases/tag/v1.1.5
966
- [1.1.4]: https://github.com/avonderluft/bible270/releases/tag/v1.1.4
967
- [1.1.3]: https://github.com/avonderluft/bible270/releases/tag/v1.1.3
968
- [1.1.2]: https://github.com/avonderluft/bible270/releases/tag/v1.1.2
969
- [1.1.1]: https://github.com/avonderluft/bible270/releases/tag/v1.1.1
970
- [1.1.0]: https://github.com/avonderluft/bible270/releases/tag/v1.1.0
971
- [1.0.0]: https://github.com/avonderluft/bible270/releases/tag/v1.0.0
972
- [0.19.0]: https://github.com/avonderluft/bible270/releases/tag/v0.19.0
973
- [0.18.2]: https://github.com/avonderluft/bible270/releases/tag/v0.18.2
974
- [0.18.1]: https://github.com/avonderluft/bible270/releases/tag/v0.18.1
975
- [0.18.0]: https://github.com/avonderluft/bible270/releases/tag/v0.18.0
976
- [0.17.2]: https://github.com/avonderluft/bible270/releases/tag/v0.17.2
977
- [0.17.1]: https://github.com/avonderluft/bible270/releases/tag/v0.17.1
978
- [0.17.0]: https://github.com/avonderluft/bible270/releases/tag/v0.17.0
979
- [0.16.1]: https://github.com/avonderluft/bible270/releases/tag/v0.16.1
980
- [0.16.0]: https://github.com/avonderluft/bible270/releases/tag/v0.16.0
981
- [0.15.3]: https://github.com/avonderluft/bible270/releases/tag/v0.15.3
982
- [0.15.2]: https://github.com/avonderluft/bible270/releases/tag/v0.15.2
983
- [0.15.1]: https://github.com/avonderluft/bible270/releases/tag/v0.15.1
984
- [0.15.0]: https://github.com/avonderluft/bible270/releases/tag/v0.15.0
985
- [0.14.1]: https://github.com/avonderluft/bible270/releases/tag/v0.14.1
986
- [0.14.0]: https://github.com/avonderluft/bible270/releases/tag/v0.14.0
987
- [0.13.0]: https://github.com/avonderluft/bible270/releases/tag/v0.13.0
988
- [0.12.0]: https://github.com/avonderluft/bible270/releases/tag/v0.12.0
989
- [0.11.0]: https://github.com/avonderluft/bible270/releases/tag/v0.11.0
990
- [0.10.0]: https://github.com/avonderluft/bible270/releases/tag/v0.10.0
991
- [0.9.0]: https://github.com/avonderluft/bible270/releases/tag/v0.9.0
955
+ [Unreleased]: https://github.com/avonderluft/bible270/compare/v1.12.0...main
956
+ [1.12.0]: https://github.com/avonderluft/bible270/compare/v1.11.1...v1.12.0
957
+ [1.11.1]: https://github.com/avonderluft/bible270/compare/v1.11.0...v1.11.1
958
+ [1.11.0]: https://github.com/avonderluft/bible270/compare/v1.10.2...v1.11.0
959
+ [1.10.2]: https://github.com/avonderluft/bible270/compare/v1.10.1...v1.10.2
960
+ [1.10.1]: https://github.com/avonderluft/bible270/compare/v1.10.0...v1.10.1
961
+ [1.10.0]: https://github.com/avonderluft/bible270/compare/v1.9.1...v1.10.0
962
+ [1.9.1]: https://github.com/avonderluft/bible270/compare/v1.9.0...v1.9.1
963
+ [1.9.0]: https://github.com/avonderluft/bible270/compare/v1.8.5...v1.9.0
964
+ [1.8.5]: https://github.com/avonderluft/bible270/compare/v1.8.4...v1.8.5
965
+ [1.8.4]: https://github.com/avonderluft/bible270/compare/v1.8.3...v1.8.4
966
+ [1.8.3]: https://github.com/avonderluft/bible270/compare/v1.8.1...v1.8.3
967
+ [1.8.1]: https://github.com/avonderluft/bible270/compare/v1.8.0...v1.8.1
968
+ [1.8.0]: https://github.com/avonderluft/bible270/compare/v1.7.3...v1.8.0
969
+ [1.7.3]: https://github.com/avonderluft/bible270/compare/v1.7.2...v1.7.3
970
+ [1.7.2]: https://github.com/avonderluft/bible270/compare/v1.7.1...v1.7.2
971
+ [1.7.1]: https://github.com/avonderluft/bible270/compare/v1.7.0...v1.7.1
972
+ [1.7.0]: https://github.com/avonderluft/bible270/compare/v1.6.2...v1.7.0
973
+ [1.6.2]: https://github.com/avonderluft/bible270/compare/v1.6.1...v1.6.2
974
+ [1.6.1]: https://github.com/avonderluft/bible270/compare/v1.6.0...v1.6.1
975
+ [1.6.0]: https://github.com/avonderluft/bible270/compare/v1.5.1...v1.6.0
976
+ [1.5.1]: https://github.com/avonderluft/bible270/compare/v1.5.0...v1.5.1
977
+ [1.5.0]: https://github.com/avonderluft/bible270/compare/v1.4.2...v1.5.0
978
+ [1.4.2]: https://github.com/avonderluft/bible270/compare/v1.4.1...v1.4.2
979
+ [1.4.1]: https://github.com/avonderluft/bible270/compare/v1.4.0...v1.4.1
980
+ [1.4.0]: https://github.com/avonderluft/bible270/compare/v1.3.2...v1.4.0
981
+ [1.3.2]: https://github.com/avonderluft/bible270/compare/v1.3.1...v1.3.2
982
+ [1.3.1]: https://github.com/avonderluft/bible270/compare/v1.3.0...v1.3.1
983
+ [1.3.0]: https://github.com/avonderluft/bible270/compare/v1.2.0...v1.3.0
984
+ [1.2.0]: https://github.com/avonderluft/bible270/compare/v1.1.7...v1.2.0
985
+ [1.1.7]: https://github.com/avonderluft/bible270/compare/v1.1.6...v1.1.7
986
+ [1.1.6]: https://github.com/avonderluft/bible270/compare/v1.1.5...v1.1.6
987
+ [1.1.5]: https://github.com/avonderluft/bible270/compare/v1.1.4...v1.1.5
988
+ [1.1.4]: https://github.com/avonderluft/bible270/compare/v1.1.3...v1.1.4
989
+ [1.1.3]: https://github.com/avonderluft/bible270/compare/v1.1.2...v1.1.3
990
+ [1.1.2]: https://github.com/avonderluft/bible270/compare/v1.1.1...v1.1.2
991
+ [1.1.1]: https://github.com/avonderluft/bible270/compare/v1.1.0...v1.1.1
992
+ [1.1.0]: https://github.com/avonderluft/bible270/compare/v1.0.0...v1.1.0
993
+ [1.0.0]: https://github.com/avonderluft/bible270/compare/v0.19.0...v1.0.0
994
+ [0.19.0]: https://github.com/avonderluft/bible270/compare/v0.18.3...v0.19.0
995
+ [0.18.3]: https://github.com/avonderluft/bible270/compare/v0.18.2...v0.18.3
996
+ [0.18.2]: https://github.com/avonderluft/bible270/compare/v0.18.1...v0.18.2
997
+ [0.18.1]: https://github.com/avonderluft/bible270/compare/v0.18.0...v0.18.1
998
+ [0.18.0]: https://github.com/avonderluft/bible270/compare/v0.17.2...v0.18.0
999
+ [0.17.2]: https://github.com/avonderluft/bible270/compare/v0.17.1...v0.17.2
1000
+ [0.17.1]: https://github.com/avonderluft/bible270/compare/v0.17.0...v0.17.1
1001
+ [0.17.0]: https://github.com/avonderluft/bible270/compare/v0.16.1...v0.17.0
1002
+ [0.16.1]: https://github.com/avonderluft/bible270/compare/v0.16.0...v0.16.1
1003
+ [0.16.0]: https://github.com/avonderluft/bible270/compare/v0.15.3...v0.16.0
1004
+ [0.15.3]: https://github.com/avonderluft/bible270/compare/v0.15.2...v0.15.3
1005
+ [0.15.2]: https://github.com/avonderluft/bible270/compare/v0.15.1...v0.15.2
1006
+ [0.15.1]: https://github.com/avonderluft/bible270/compare/v0.15.0...v0.15.1
1007
+ [0.15.0]: https://github.com/avonderluft/bible270/compare/v0.14.1...v0.15.0
1008
+ [0.14.1]: https://github.com/avonderluft/bible270/compare/v0.14.0...v0.14.1
1009
+ [0.14.0]: https://github.com/avonderluft/bible270/compare/v0.13.1...v0.14.0
1010
+ [0.13.1]: https://github.com/avonderluft/bible270/compare/v0.13.0...v0.13.1
1011
+ [0.13.0]: https://github.com/avonderluft/bible270/compare/v0.12.0...v0.13.0
1012
+ [0.12.0]: https://github.com/avonderluft/bible270/compare/v0.11.2...v0.12.0
1013
+ [0.11.2]: https://github.com/avonderluft/bible270/compare/v0.11.1...v0.11.2
1014
+ [0.11.1]: https://github.com/avonderluft/bible270/compare/v0.11.0...v0.11.1
1015
+ [0.11.0]: https://github.com/avonderluft/bible270/compare/v0.10.0...v0.11.0
1016
+ [0.10.0]: https://github.com/avonderluft/bible270/compare/v0.9.0...v0.10.0
1017
+ [0.9.0]: https://github.com/avonderluft/bible270/compare/v0.6.2...v0.9.0
992
1018
  [0.6.2]: https://github.com/avonderluft/bible270/releases/tag/v0.6.2
data/README.md CHANGED
@@ -283,7 +283,7 @@ end
283
283
 
284
284
  ## Admin panel
285
285
 
286
- At `<mount>/admin`: sort readers by first name, last name, or completed days; open **Reader Stats** for
286
+ At `<mount>/admin`: sort readers by first name, last name, completed days, recent activity, or **Consistency ranking**; open **Reader Stats** for
287
287
  the same progress report as `bible270:readers:list`, ordered by the current admin sort selection; remove
288
288
  readers; adjust their completions; and move them to a given day. The panel is unreachable — every route
289
289
  404s rather than 403s — unless you say who may use it:
@@ -294,6 +294,14 @@ config.admin_emails = %w[andrew@example.org]
294
294
  config.admin_resolver = ->(reader) { reader.email.to_s.end_with?('@example.org') }
295
295
  ```
296
296
 
297
+ **Consistency ranking** measures recorded schedule performance over completed evenings, not just today's progress. Each evening scores `100 + 5 × min(days ahead, 7) − 10 × days behind`; the ranking uses the average. A score of 100 means an average on-track baseline, not 100%. Being behind can produce negative scores.
298
+
299
+ The evaluation starts on the later of the reader's effective plan start and account-creation date, and stops at the earlier of yesterday and the scheduled plan end, using the plan's time zone. Late enrollment shortens the evaluation window without resetting the scheduled day. A plan day counts only after all required readings are checked off; its completion time is the latest of those check-offs. Ahead-of-schedule completions carry forward, while today's catch-up does not rewrite prior evenings.
300
+
301
+ Readers with at least seven evaluated evenings rank first, followed by **Provisional** readers with one to six, then **Not ranked** readers without an eligible evening on a dated schedule. Within each ranked group, ties use the percentage of evenings on track/ahead, then the number of evaluated days, name, and ID. Sorting uses unrounded values. The selected sort displays each reader's score, on-track/ahead percentage, and evaluation length, and adds a leftmost **Rank** column to Reader Stats (a dash means unranked). Readers with the same ranking group, exact score, on-track/ahead percentage, and evaluation length share a competition rank, such as `1, 1, 1, 4`; names and IDs only order those ties. Other sorts do not calculate this history.
302
+
303
+ This is reconstructed history, not an immutable audit: unchecked/rechecked readings, admin bulk progress changes, and start-date changes can alter scores. Existing check-off timestamps record when completion was entered, not necessarily when reading actually occurred. No additional migration or daily snapshot job is required.
304
+
297
305
  Admins get an extra "Admin" link in the header. The broadcast form can send an individually addressed
298
306
  message either to every reader with an email address or only to readers recognized as administrators.
299
307
  From a reader's page you can:
@@ -17,7 +17,8 @@ module Bible270
17
17
  'most_completed' => 'Most days completed',
18
18
  'least_completed' => 'Least days completed',
19
19
  'most_recent_activity' => 'Most recent activity',
20
- 'least_recent_activity' => 'Least recent activity'
20
+ 'least_recent_activity' => 'Least recent activity',
21
+ 'consistency' => 'Consistency ranking'
21
22
  }.freeze
22
23
 
23
24
  before_action :require_admin!
@@ -75,6 +75,13 @@
75
75
  aria-controls="b270-reader-stats-dialog" data-b270-reader-stats-open hidden>Reader Stats</button>
76
76
  </div>
77
77
 
78
+ <% if @reader_sort == 'consistency' %>
79
+ <p class="b270-hint">
80
+ Average daily score through yesterday: 100 on track, +5 per day ahead (max +35), −10 per day behind.
81
+ Under 7 days is provisional; no history is unranked. Check-off or schedule changes can alter scores.
82
+ </p>
83
+ <% end %>
84
+
78
85
  <div class="b270-list">
79
86
  <% @readers.each do |reader| %>
80
87
  <div class="b270-li b270-reader-theme" data-display-mode="<%= reader.effective_display_mode %>">
@@ -94,6 +101,19 @@
94
101
  · <%= @days_completed[reader.id] %>/<%= Bible270::Plan::DAYS %> days
95
102
  · <%= @reader_report.status_for(reader) %>
96
103
  </div>
104
+ <% if @reader_sort == 'consistency' %>
105
+ <% consistency = @reader_report.consistency_for(reader) %>
106
+ <div class="sub b270-reader-consistency">
107
+ <% if consistency.ranked? %>
108
+ <% if consistency.provisional? %><strong>Provisional</strong> · <% end %>
109
+ Consistency: <%= number_with_precision(consistency.score.to_f, precision: 1) %>
110
+ · On track/ahead: <%= consistency.on_track_percentage.round %>%
111
+ · <%= pluralize(consistency.days_evaluated, 'day') %> evaluated
112
+ <% else %>
113
+ Consistency: Not ranked
114
+ <% end %>
115
+ </div>
116
+ <% end %>
97
117
  <div class="sub b270-reader-activity">
98
118
  <% if (activity = @recent_reader_activities[reader.id]) %>
99
119
  <%= b270_activity_datetime(activity.occurred_at) %>: <%= b270_reader_activity_description(activity) %>
@@ -38,7 +38,7 @@ module Bible270
38
38
  @output.puts "processed=#{summary[:processed]} skipped=#{summary[:skipped]} " \
39
39
  "changed=#{summary[:changed]} failed=#{summary[:failed]}"
40
40
  @output.puts "Display bytes (optimized/skipped readers): original=#{summary[:original_bytes]} " \
41
- "optimized=#{summary[:optimized_bytes]}. Originals retained; not total storage savings."
41
+ "optimized=#{summary[:optimized_bytes]}. Originals retained."
42
42
  end
43
43
  summary
44
44
  end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bible270/plan'
4
+
5
+ module Bible270
6
+ # Reconstructs completed evenings from surviving checkoffs and current start dates.
7
+ class ReaderConsistency
8
+ Result = Struct.new(:score, :on_track_percentage, :days_evaluated, keyword_init: true) do
9
+ def ranked? = days_evaluated.positive?
10
+ def provisional? = ranked? && days_evaluated < 7
11
+
12
+ def rank_key
13
+ group = if !ranked?
14
+ 2
15
+ elsif provisional?
16
+ 1
17
+ else
18
+ 0
19
+ end
20
+ [group, -(score || 0), -(on_track_percentage || 0), -days_evaluated]
21
+ end
22
+
23
+ def sort_key(reader)
24
+ [*rank_key, reader.sort_name, reader.id]
25
+ end
26
+ end
27
+
28
+ def initialize(readers:, today: Bible270.today)
29
+ readers = readers.to_a
30
+ @today = today
31
+ dates_by_reader = completion_dates(readers.map(&:id))
32
+ @results = readers.to_h do |reader|
33
+ [reader.id, evaluate(reader, dates_by_reader.fetch(reader.id, []))]
34
+ end
35
+ end
36
+
37
+ def for(reader)
38
+ @results.fetch(reader.id)
39
+ end
40
+
41
+ private
42
+
43
+ def completion_dates(reader_ids)
44
+ return {} if reader_ids.empty?
45
+
46
+ checkoffs = Checkoff.where(reader_id: reader_ids, day: 1..Plan::DAYS).group(:reader_id, :day)
47
+ counts = checkoffs.count
48
+ latest = checkoffs.maximum(:created_at)
49
+ dates = Hash.new { |hash, id| hash[id] = [] }
50
+ counts.each do |(reader_id, day), count|
51
+ next if count < Plan.total_parts(day)
52
+
53
+ # A reader may clear a day between the count and timestamp queries.
54
+ completed_at = latest[[reader_id, day]]
55
+ dates[reader_id] << Bible270.local_time(completed_at).to_date if completed_at
56
+ end
57
+ dates.each_value(&:sort!)
58
+ dates
59
+ end
60
+
61
+ def start_date_for(reader)
62
+ return reader.effective_start_date if reader.own_start_date?
63
+ return @shared_start_date if defined?(@shared_start_date)
64
+
65
+ # effective_start_date reads the shared Setting; do that only once per collection.
66
+ @shared_start_date = reader.effective_start_date
67
+ end
68
+
69
+ def evaluate(reader, dates)
70
+ start_date = start_date_for(reader)
71
+ unranked = Result.new(score: nil, on_track_percentage: nil, days_evaluated: 0)
72
+ return unranked unless start_date
73
+
74
+ first = [start_date, Bible270.local_time(reader.created_at).to_date].max
75
+ last = [@today - 1, start_date + Plan::DAYS - 1].min
76
+ return unranked if first > last
77
+
78
+ completed = 0
79
+ total_score = 0
80
+ on_track = 0
81
+ (first..last).each do |date|
82
+ # Earlier completions carry into the window; later ones never improve its past evenings.
83
+ completed += 1 while completed < dates.length && dates[completed] <= date
84
+ balance = completed - (date - start_date).to_i - 1
85
+ total_score += 100 + (5 * balance.clamp(0, 7)) - (10 * [-balance, 0].max)
86
+ on_track += 1 if balance >= 0
87
+ end
88
+ days = (last - first).to_i + 1
89
+ Result.new(score: Rational(total_score, days), on_track_percentage: Rational(100 * on_track, days),
90
+ days_evaluated: days)
91
+ end
92
+ end
93
+ end
@@ -5,7 +5,7 @@ module Bible270
5
5
  class ReaderProgressReport
6
6
  HEADERS = ['Name', 'Days Read', 'Status', 'Last Activity'].freeze
7
7
  SORTS = %w[
8
- first_name last_name most_completed least_completed most_recent_activity least_recent_activity
8
+ first_name last_name most_completed least_completed most_recent_activity least_recent_activity consistency
9
9
  ].freeze
10
10
  DEFAULT_SORT = 'most_completed'
11
11
  Row = Struct.new(:name, :days_read, :status, :last_activity, keyword_init: true)
@@ -38,15 +38,29 @@ module Bible270
38
38
  reader.progress_status(completed_days: completed_days.fetch(reader.id, 0))
39
39
  end
40
40
 
41
+ def consistency_for(reader)
42
+ @consistency ||= ReaderConsistency.new(readers: readers)
43
+ @consistency.for(reader)
44
+ end
45
+
41
46
  def empty? = rows.empty?
42
47
 
43
48
  def to_table
44
49
  values = rows.map { |row| [row.name, row.days_read.to_s, row.status, row.last_activity] }
45
- widths = HEADERS.each_index.map do |index|
46
- ([HEADERS[index].length] + values.map { |value| value[index].length }).max
50
+ headers = HEADERS
51
+ if sort == 'consistency'
52
+ headers = ['Rank', *HEADERS]
53
+ ranks = consistency_ranks
54
+ values.each_with_index { |value, index| value.unshift(ranks[index]) }
47
55
  end
48
- format = "%-#{widths[0]}s %#{widths[1]}s %-#{widths[2]}s %-#{widths[3]}s"
49
- lines = [format % HEADERS, format % widths.map { |width| '-' * width }].map(&:rstrip)
56
+ widths = headers.each_index.map do |index|
57
+ ([headers[index].length] + values.map { |value| value[index].length }).max
58
+ end
59
+ format = headers.each_with_index.map do |header, index|
60
+ alignment = ['Rank', 'Days Read'].include?(header) ? '' : '-'
61
+ "%#{alignment}#{widths[index]}s"
62
+ end.join(' ')
63
+ lines = [format % headers, format % widths.map { |width| '-' * width }].map(&:rstrip)
50
64
  lines.concat(values.map { |value| (format % value).rstrip })
51
65
  lines.join("\n")
52
66
  end
@@ -55,6 +69,20 @@ module Bible270
55
69
 
56
70
  attr_reader :readers, :completed_days, :recent_activities, :sort
57
71
 
72
+ def consistency_ranks
73
+ previous_key = nil
74
+ rank = 0
75
+ sorted_readers.each_with_index.map do |reader, index|
76
+ result = consistency_for(reader)
77
+ next '—' unless result.ranked?
78
+
79
+ key = result.rank_key
80
+ rank = index + 1 if key != previous_key
81
+ previous_key = key
82
+ rank.to_s
83
+ end
84
+ end
85
+
58
86
  def reader_sort_key(reader)
59
87
  case sort
60
88
  when 'first_name'
@@ -64,6 +92,8 @@ module Bible270
64
92
  [last_name.to_s.downcase, reader.sort_name, reader.id]
65
93
  when 'least_completed'
66
94
  [completed_days[reader.id].to_i, reader.sort_name, reader.id]
95
+ when 'consistency'
96
+ consistency_for(reader).sort_key(reader)
67
97
  when 'most_recent_activity'
68
98
  activity_sort_key(reader, descending: true)
69
99
  when 'least_recent_activity'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bible270
4
- VERSION = '1.12.0'
4
+ VERSION = '1.13.0'
5
5
  end
data/lib/bible270.rb CHANGED
@@ -15,6 +15,7 @@ require 'bible270/translations'
15
15
  require 'bible270/favicon'
16
16
  require 'bible270/configuration'
17
17
  require 'bible270/daily_reminders'
18
+ require 'bible270/reader_consistency'
18
19
  require 'bible270/reader_progress_report'
19
20
  require 'bible270/engine' if defined?(Rails::Engine)
20
21
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bible270
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.12.0
4
+ version: 1.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew vonderLuft
@@ -264,6 +264,7 @@ files:
264
264
  - lib/bible270/migration_reconciler.rb
265
265
  - lib/bible270/names.rb
266
266
  - lib/bible270/plan.rb
267
+ - lib/bible270/reader_consistency.rb
267
268
  - lib/bible270/reader_progress_report.rb
268
269
  - lib/bible270/translations.rb
269
270
  - lib/bible270/versification.rb