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 +4 -4
- data/CHANGELOG.md +87 -61
- data/README.md +9 -1
- data/app/controllers/bible270/admin_controller.rb +2 -1
- data/app/views/bible270/admin/index.html.erb +20 -0
- data/lib/bible270/avatar_backfill.rb +1 -1
- data/lib/bible270/reader_consistency.rb +93 -0
- data/lib/bible270/reader_progress_report.rb +35 -5
- data/lib/bible270/version.rb +1 -1
- data/lib/bible270.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 67a4da0561f2ba1326490041bdc95c5a12121c2ad2ebab526e8c99da823d1f02
|
|
4
|
+
data.tar.gz: 2e0faf9a902a23e4a4aa8df4bde6c9b89217073a175e1c154eaaeb722879ceba
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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`,
|
|
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
|
-
|
|
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.
|
|
935
|
-
[1.
|
|
936
|
-
[1.11.
|
|
937
|
-
[1.
|
|
938
|
-
[1.10.
|
|
939
|
-
[1.10.
|
|
940
|
-
[1.
|
|
941
|
-
[1.9.
|
|
942
|
-
[1.
|
|
943
|
-
[1.8.
|
|
944
|
-
[1.8.
|
|
945
|
-
[1.8.
|
|
946
|
-
[1.8.
|
|
947
|
-
[1.
|
|
948
|
-
[1.7.
|
|
949
|
-
[1.7.
|
|
950
|
-
[1.7.
|
|
951
|
-
[1.
|
|
952
|
-
[1.6.
|
|
953
|
-
[1.6.
|
|
954
|
-
[1.
|
|
955
|
-
[1.5.
|
|
956
|
-
[1.
|
|
957
|
-
[1.4.
|
|
958
|
-
[1.4.
|
|
959
|
-
[1.
|
|
960
|
-
[1.3.
|
|
961
|
-
[1.3.
|
|
962
|
-
[1.
|
|
963
|
-
[1.
|
|
964
|
-
[1.1.
|
|
965
|
-
[1.1.
|
|
966
|
-
[1.1.
|
|
967
|
-
[1.1.
|
|
968
|
-
[1.1.
|
|
969
|
-
[1.1.
|
|
970
|
-
[1.1.
|
|
971
|
-
[1.
|
|
972
|
-
[0.
|
|
973
|
-
[0.
|
|
974
|
-
[0.18.
|
|
975
|
-
[0.18.
|
|
976
|
-
[0.
|
|
977
|
-
[0.
|
|
978
|
-
[0.17.
|
|
979
|
-
[0.
|
|
980
|
-
[0.
|
|
981
|
-
[0.
|
|
982
|
-
[0.
|
|
983
|
-
[0.15.
|
|
984
|
-
[0.15.
|
|
985
|
-
[0.
|
|
986
|
-
[0.
|
|
987
|
-
[0.
|
|
988
|
-
[0.
|
|
989
|
-
[0.
|
|
990
|
-
[0.
|
|
991
|
-
[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,
|
|
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
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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'
|
data/lib/bible270/version.rb
CHANGED
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.
|
|
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
|