bible270 1.11.1 → 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 +119 -58
- data/README.md +36 -2
- data/app/controllers/bible270/admin_controller.rb +13 -15
- data/app/controllers/bible270/profiles_controller.rb +5 -7
- data/app/helpers/bible270/plan_helper.rb +14 -8
- data/app/models/bible270/comment.rb +2 -2
- data/app/models/bible270/reader.rb +22 -10
- data/app/views/bible270/admin/index.html.erb +20 -0
- data/app/views/bible270/admin/show.html.erb +2 -1
- data/app/views/bible270/profiles/edit.html.erb +2 -1
- data/app/views/bible270/shared/_avatar_ui.html.erb +85 -0
- data/app/views/bible270/shared/_styles.html.erb +5 -0
- data/app/views/layouts/bible270/application.html.erb +1 -0
- data/lib/bible270/avatar_backfill.rb +93 -0
- data/lib/bible270/avatar_images.rb +159 -0
- data/lib/bible270/avatar_optimizer.rb +257 -0
- data/lib/bible270/avatars.rb +2 -2
- 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 +3 -0
- data/lib/tasks/bible270_avatars.rake +21 -0
- metadata +35 -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,7 +4,62 @@ All notable changes to bible270. Format follows [Keep a Changelog](https://keepa
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
-
|
|
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
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- load avatars eagerly without forced asynchronous decoding, while retaining attachment preloading and bounded database queries
|
|
21
|
+
- recover failed avatar requests with up to two automatic retries, refreshing same-origin uploaded-image redirects without modifying signed provider URLs
|
|
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
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
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
|
|
29
|
+
- preserve the current original upload separately and display the optimized thumbnail only after successful processing and storage, without request-time image processing
|
|
30
|
+
- validate actual image data and enforce a 40-megapixel limit in addition to the existing configurable upload byte limit (2 MiB by default)
|
|
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
|
|
38
|
+
|
|
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.
|
|
40
|
+
|
|
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)**
|
|
44
|
+
|
|
45
|
+
## [1.11.1] - 2026-10-22
|
|
46
|
+
|
|
47
|
+
### Changed
|
|
48
|
+
|
|
49
|
+
- simplify artwork on phone-sized screens: hide decorative scripture and word reflections, lower landscape scenes, and reduce fine detail while preserving desktop artwork
|
|
50
|
+
- further mute Ocean's mobile colors and wave lines while keeping the shoreline and gulls
|
|
51
|
+
- use a soft moonlight reflection on phones and retain opaque foreground trees in the simplified Forest scene
|
|
52
|
+
- draw Forest trees as direct SVG paths with explicit green fills instead of reused SVG instances
|
|
53
|
+
- increase the space between the app title and compact theme selector
|
|
54
|
+
|
|
55
|
+
### Fixed
|
|
56
|
+
|
|
57
|
+
- declare the selected color scheme early in the page and synchronize root settings and metadata on theme changes and Turbo navigation
|
|
58
|
+
- request `color-scheme: only light` for Papyrus, Meadow, and Ocean while preserving System's device-following behavior
|
|
59
|
+
|
|
60
|
+
Browser website-darkening features can still override theme colors. In iOS Brave, disabling Night Mode resolved the reported darkened light themes and white Forest trees.
|
|
61
|
+
|
|
62
|
+
**[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.11.0...v1.11.1)**
|
|
8
63
|
|
|
9
64
|
## [1.11.0] - 2026-10-22
|
|
10
65
|
|
|
@@ -897,61 +952,67 @@ This is the initial public release.
|
|
|
897
952
|
|
|
898
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.
|
|
899
954
|
|
|
900
|
-
[Unreleased]: https://github.com/avonderluft/bible270/compare/v1.
|
|
901
|
-
[1.
|
|
902
|
-
[1.
|
|
903
|
-
[1.
|
|
904
|
-
[1.10.
|
|
905
|
-
[1.
|
|
906
|
-
[1.
|
|
907
|
-
[1.
|
|
908
|
-
[1.
|
|
909
|
-
[1.8.
|
|
910
|
-
[1.8.
|
|
911
|
-
[1.8.
|
|
912
|
-
[1.
|
|
913
|
-
[1.
|
|
914
|
-
[1.7.
|
|
915
|
-
[1.7.
|
|
916
|
-
[1.
|
|
917
|
-
[1.
|
|
918
|
-
[1.6.
|
|
919
|
-
[1.
|
|
920
|
-
[1.
|
|
921
|
-
[1.
|
|
922
|
-
[1.
|
|
923
|
-
[1.4.
|
|
924
|
-
[1.
|
|
925
|
-
[1.
|
|
926
|
-
[1.3.
|
|
927
|
-
[1.
|
|
928
|
-
[1.
|
|
929
|
-
[1.
|
|
930
|
-
[1.1.
|
|
931
|
-
[1.1.
|
|
932
|
-
[1.1.
|
|
933
|
-
[1.1.
|
|
934
|
-
[1.1.
|
|
935
|
-
[1.1.
|
|
936
|
-
[1.
|
|
937
|
-
[
|
|
938
|
-
[0.
|
|
939
|
-
[0.
|
|
940
|
-
[0.18.
|
|
941
|
-
[0.
|
|
942
|
-
[0.
|
|
943
|
-
[0.
|
|
944
|
-
[0.
|
|
945
|
-
[0.
|
|
946
|
-
[0.
|
|
947
|
-
[0.
|
|
948
|
-
[0.
|
|
949
|
-
[0.15.
|
|
950
|
-
[0.
|
|
951
|
-
[0.
|
|
952
|
-
[0.
|
|
953
|
-
[0.
|
|
954
|
-
[0.
|
|
955
|
-
[0.
|
|
956
|
-
[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
|
|
957
1018
|
[0.6.2]: https://github.com/avonderluft/bible270/releases/tag/v0.6.2
|
data/README.md
CHANGED
|
@@ -21,14 +21,40 @@ The schedule is pure deterministic Ruby (`Bible270::Plan`) — none of it is sto
|
|
|
21
21
|
|
|
22
22
|
## Requirements
|
|
23
23
|
|
|
24
|
-
- **Ruby** >= 3.2.
|
|
24
|
+
- **Ruby** >= 3.2. Engine code is plain Ruby; avatar processing uses native libvips through `ruby-vips`. Runs on 4.0.6 — it avoids everything 4.0 dropped or unbundled (notably `cgi`; URL escaping uses `URI.encode_www_form_component`).
|
|
25
25
|
- **Rails** >= 7.2. On Ruby 4.0 your host app is the real constraint, not this engine — check your lockfile with RailsBump first.
|
|
26
26
|
- A host app, and Turbo if you want check-offs without a page reload.
|
|
27
|
+
- For picture uploads and avatar backfills: **libvips** with PNG, JPEG, GIF and WebP support. The gem supplies the `image_processing` and `ruby-vips` Ruby dependencies. Install libvips on every upload/backfill host (for example, `sudo apt-get install libvips-tools` on Ubuntu or `sudo pacman -S libvips` on Arch). Native processing is loaded only when optimizing an image; existing pages and provider avatars do not require it.
|
|
27
28
|
|
|
28
29
|
Mutation controls expose pending, success, and retry feedback when JavaScript runs, while ordinary HTML
|
|
29
30
|
forms remain the fallback. Repeated checkoff and like requests converge on the intended state rather
|
|
30
31
|
than accidentally reversing it.
|
|
31
32
|
|
|
33
|
+
## Avatar optimization
|
|
34
|
+
|
|
35
|
+
Profile and Admin picture uploads produce a **256×256 static WebP thumbnail at quality 85** before switching the displayed avatar. Images are auto-oriented, center-cropped, converted to sRGB, and stripped of metadata; transparency is preserved. Animated GIF/WebP uploads use the first frame. Small images are upscaled to the same dimensions. Invalid or corrupt image data is rejected, even if its filename or declared type looks valid.
|
|
36
|
+
|
|
37
|
+
Uploads are limited to **2 MiB by default** (`config.avatar_max_bytes`) and **40 million pixels** in the selected frame/container canvas. The pixel limit bounds dimensions, not total processor memory. A processing or storage failure leaves the previous picture and profile details unchanged.
|
|
38
|
+
|
|
39
|
+
The current original remains in the `avatar` attachment, including its original metadata; the display image is stored separately as `avatar_thumbnail`. This preserves the source for future reprocessing, not a history of past pictures. Removing/replacing a picture detaches the old pair; Active Storage's purge jobs clean up their files. Keep the host's Active Job worker running for that cleanup. Original retention increases storage use even though avatar downloads become smaller. No new database migration is needed if Active Storage is already installed.
|
|
40
|
+
|
|
41
|
+
Existing uploaded avatars continue displaying their originals until optimized. Run the following **from the host Rails application**, with its normal database/storage credentials and production environment when appropriate:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
# Metadata-only preview: no image downloads or attachment changes
|
|
45
|
+
RAILS_ENV=production bundle exec rake bible270:avatars:optimize DRY_RUN=1
|
|
46
|
+
|
|
47
|
+
# Process existing uploaded reader avatars, sequentially in bounded batches
|
|
48
|
+
RAILS_ENV=production bundle exec rake bible270:avatars:optimize BATCH_SIZE=100
|
|
49
|
+
|
|
50
|
+
# Rebuild even current thumbnails from the retained originals
|
|
51
|
+
RAILS_ENV=production bundle exec rake bible270:avatars:optimize FORCE=1
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The task skips current thumbnails, protects concurrent avatar changes, continues after per-image failures, and exits nonzero if any fail. It prints reader IDs, counts, and original/optimized display-byte totals (not total storage savings). Correct failed files, limits, storage access, or processor dependencies and rerun. `BATCH_SIZE` defaults to 100 and must be between 1 and 1000. Dry-run checks eligibility only; it does not verify file availability or decodability. Take a normal database/storage backup before a production backfill. The task reads originals and writes thumbnails to the configured storage service; remote storage can incur request and transfer costs. It does **not** download remote sign-in-provider avatars or modify unrelated host attachments.
|
|
55
|
+
|
|
56
|
+
For host code that uploads reader pictures, use `reader.attach_avatar(upload)` or `Bible270::AvatarImages.update!(reader, attributes, upload: upload)` rather than attaching directly with Active Storage, which bypasses this processing pipeline. Uploads and backfills must run **outside a caller-owned database transaction**: the service owns the commit boundary so a later outer rollback cannot orphan already-uploaded files. Failed cleanup retains unattached blob records with their storage keys for retry; check logged blob IDs and include these in the host's unattached-blob maintenance.
|
|
57
|
+
|
|
32
58
|
## Install
|
|
33
59
|
|
|
34
60
|
```ruby
|
|
@@ -257,7 +283,7 @@ end
|
|
|
257
283
|
|
|
258
284
|
## Admin panel
|
|
259
285
|
|
|
260
|
-
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
|
|
261
287
|
the same progress report as `bible270:readers:list`, ordered by the current admin sort selection; remove
|
|
262
288
|
readers; adjust their completions; and move them to a given day. The panel is unreachable — every route
|
|
263
289
|
404s rather than 403s — unless you say who may use it:
|
|
@@ -268,6 +294,14 @@ config.admin_emails = %w[andrew@example.org]
|
|
|
268
294
|
config.admin_resolver = ->(reader) { reader.email.to_s.end_with?('@example.org') }
|
|
269
295
|
```
|
|
270
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
|
+
|
|
271
305
|
Admins get an extra "Admin" link in the header. The broadcast form can send an individually addressed
|
|
272
306
|
message either to every reader with an email address or only to readers recognized as administrators.
|
|
273
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!
|
|
@@ -229,23 +230,20 @@ module Bible270
|
|
|
229
230
|
# Name and picture together, mirroring what a reader can change about
|
|
230
231
|
# themselves on their own profile.
|
|
231
232
|
def update_profile
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
233
|
+
attributes = {}
|
|
234
|
+
if params[:first_name].present? || params[:last_name].present?
|
|
235
|
+
attributes = Names.normalize(params[:first_name], params[:last_name])
|
|
236
|
+
unless attributes
|
|
237
|
+
return redirect_to admin_reader_path(@reader), alert: 'Needs both a first and last name.'
|
|
237
238
|
end
|
|
238
|
-
if problems.empty? && params[:avatar].present? && !@reader.attach_avatar(params[:avatar])
|
|
239
|
-
problems << (@reader.errors[:avatar].first || 'a valid image')
|
|
240
|
-
end
|
|
241
|
-
raise ActiveRecord::Rollback if problems.any?
|
|
242
239
|
end
|
|
243
240
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
241
|
+
AvatarImages.update!(@reader, attributes, upload: params[:avatar].presence)
|
|
242
|
+
redirect_to admin_reader_path(@reader), notice: 'Updated.'
|
|
243
|
+
rescue AvatarOptimizer::Error, AvatarImages::Error => e
|
|
244
|
+
redirect_to admin_reader_path(@reader), alert: e.message
|
|
245
|
+
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved
|
|
246
|
+
redirect_to admin_reader_path(@reader), alert: 'The profile could not be saved. Please try again.'
|
|
249
247
|
end
|
|
250
248
|
|
|
251
249
|
def update_theme
|
|
@@ -35,6 +35,10 @@ module Bible270
|
|
|
35
35
|
|
|
36
36
|
save_profile(names.merge(passage_source: source))
|
|
37
37
|
redirect_to profile_path, notice: 'Your profile has been updated.'
|
|
38
|
+
rescue AvatarOptimizer::Error, AvatarImages::Error => e
|
|
39
|
+
current_reader.attachment_changes.clear if Reader.avatar_uploads?
|
|
40
|
+
current_reader.reload
|
|
41
|
+
render_profile_error(e.message)
|
|
38
42
|
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e
|
|
39
43
|
message = e.record ? e.record.errors.full_messages.to_sentence : nil
|
|
40
44
|
# Reload does not discard Active Storage's deferred attachment changes.
|
|
@@ -103,13 +107,7 @@ module Bible270
|
|
|
103
107
|
attributes[:daily_reminder_time] = @daily_reminder_time
|
|
104
108
|
end
|
|
105
109
|
|
|
106
|
-
|
|
107
|
-
current_reader.assign_attributes(attributes)
|
|
108
|
-
# Unlike attach, assignment never saves early. Active Storage persists the
|
|
109
|
-
# attachment with the reader and uploads only after a successful commit.
|
|
110
|
-
current_reader.avatar = params[:avatar] if params[:avatar].present?
|
|
111
|
-
current_reader.save!
|
|
112
|
-
end
|
|
110
|
+
AvatarImages.update!(current_reader, attributes, upload: params[:avatar].presence)
|
|
113
111
|
end
|
|
114
112
|
|
|
115
113
|
def render_profile_error(message)
|
|
@@ -154,19 +154,25 @@ module Bible270
|
|
|
154
154
|
end
|
|
155
155
|
|
|
156
156
|
def b270_avatar_src(reader)
|
|
157
|
-
return main_app.rails_blob_path(reader.
|
|
157
|
+
return main_app.rails_blob_path(reader.display_avatar, only_path: true) if reader.avatar_uploaded?
|
|
158
158
|
|
|
159
159
|
reader.avatar_url.presence
|
|
160
160
|
end
|
|
161
161
|
|
|
162
|
-
def b270_avatar(reader, size: 72, loading: '
|
|
162
|
+
def b270_avatar(reader, size: 72, loading: 'eager')
|
|
163
163
|
src = b270_avatar_src(reader)
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
164
|
+
dimensions = "width:#{size}px;height:#{size}px;line-height:#{size}px"
|
|
165
|
+
unless src
|
|
166
|
+
return content_tag :span, reader.initials, class: 'b270-avatar b270-avatar-fallback',
|
|
167
|
+
style: dimensions, role: 'img', 'aria-label': reader.display_name
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
content_tag :span, class: 'b270-avatar-frame', style: dimensions,
|
|
171
|
+
role: 'img', 'aria-label': reader.display_name do
|
|
172
|
+
image = image_tag src, class: 'b270-avatar', width: size, height: size, alt: reader.display_name,
|
|
173
|
+
loading: loading, data: { b270_avatar: true, b270_avatar_uploaded: reader.avatar_uploaded? }
|
|
174
|
+
fallback = content_tag :span, reader.initials, class: 'b270-avatar b270-avatar-fallback', 'aria-hidden': true
|
|
175
|
+
safe_join([image, fallback])
|
|
170
176
|
end
|
|
171
177
|
end
|
|
172
178
|
|
|
@@ -53,10 +53,10 @@ module Bible270
|
|
|
53
53
|
# listing them again at the top level would show each twice.
|
|
54
54
|
scope :for_day, ->(d) { approved.where(day: d, parent_id: nil).order(created_at: :asc) }
|
|
55
55
|
scope :with_reader_avatars, -> {
|
|
56
|
-
Reader.avatar_uploads? ? includes(reader:
|
|
56
|
+
Reader.avatar_uploads? ? includes(reader: Reader::AVATAR_ASSOCIATIONS) : includes(:reader)
|
|
57
57
|
}
|
|
58
58
|
scope :with_thread_details, -> {
|
|
59
|
-
reply_reader = Reader.avatar_uploads? ? { reader:
|
|
59
|
+
reply_reader = Reader.avatar_uploads? ? { reader: Reader::AVATAR_ASSOCIATIONS } : :reader
|
|
60
60
|
with_reader_avatars.includes({ likes: :reader }, { replies: [reply_reader, { likes: :reader }] })
|
|
61
61
|
}
|
|
62
62
|
scope :threads_for_day, ->(d) { for_day(d).with_thread_details }
|
|
@@ -15,11 +15,16 @@ module Bible270
|
|
|
15
15
|
# to keep working there — readers simply can't upload, and any avatar from
|
|
16
16
|
# their sign-in provider is used instead.
|
|
17
17
|
AVATAR_UPLOADS = respond_to?(:has_one_attached)
|
|
18
|
-
|
|
18
|
+
if AVATAR_UPLOADS
|
|
19
|
+
has_one_attached :avatar
|
|
20
|
+
has_one_attached :avatar_thumbnail
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
AVATAR_ASSOCIATIONS = { avatar_attachment: :blob, avatar_thumbnail_attachment: :blob }.freeze
|
|
19
24
|
|
|
20
25
|
def self.avatar_uploads? = AVATAR_UPLOADS
|
|
21
26
|
|
|
22
|
-
scope :with_avatar, -> { avatar_uploads? ? includes(
|
|
27
|
+
scope :with_avatar, -> { avatar_uploads? ? includes(AVATAR_ASSOCIATIONS) : all }
|
|
23
28
|
|
|
24
29
|
# Fires for every way a reader comes into existence — email sign-in, OmniAuth,
|
|
25
30
|
# or a bridged host user — rather than in each controller, so no path can
|
|
@@ -426,22 +431,29 @@ module Bible270
|
|
|
426
431
|
self.class.avatar_uploads? && avatar.attached?
|
|
427
432
|
end
|
|
428
433
|
|
|
434
|
+
def avatar_optimized?
|
|
435
|
+
avatar_uploaded? && avatar_thumbnail.attached? &&
|
|
436
|
+
avatar_thumbnail.blob.metadata['bible270_avatar_source_id'] == avatar.blob.id &&
|
|
437
|
+
avatar_thumbnail.blob.metadata['bible270_avatar_version'] == AvatarOptimizer::VERSION
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def display_avatar
|
|
441
|
+
avatar_optimized? ? avatar_thumbnail : avatar
|
|
442
|
+
end
|
|
443
|
+
|
|
429
444
|
# Returns false and sets an error when the upload isn't acceptable.
|
|
430
445
|
def attach_avatar(upload)
|
|
431
446
|
return false unless self.class.avatar_uploads?
|
|
432
447
|
|
|
433
|
-
|
|
434
|
-
if problem
|
|
435
|
-
errors.add(:avatar, problem)
|
|
436
|
-
return false
|
|
437
|
-
end
|
|
438
|
-
|
|
439
|
-
avatar.attach(upload)
|
|
448
|
+
AvatarImages.update!(self, {}, upload: upload)
|
|
440
449
|
true
|
|
450
|
+
rescue AvatarOptimizer::Error, AvatarImages::Error => e
|
|
451
|
+
errors.add(:avatar, e.message)
|
|
452
|
+
false
|
|
441
453
|
end
|
|
442
454
|
|
|
443
455
|
def remove_avatar!
|
|
444
|
-
|
|
456
|
+
AvatarImages.remove!(self)
|
|
445
457
|
true
|
|
446
458
|
end
|
|
447
459
|
|
|
@@ -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) %>
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
<span>Picture</span>
|
|
24
24
|
<%= file_field_tag :avatar, accept: Bible270::Avatars.content_types.join(',') %>
|
|
25
25
|
<span class="b270-hint" style="margin:0">
|
|
26
|
-
PNG, JPEG, GIF or WebP, up to <%= Bible270::Avatars.human_size(Bible270::Avatars.max_bytes)
|
|
26
|
+
PNG, JPEG, GIF or WebP, up to <%= Bible270::Avatars.human_size(Bible270::Avatars.max_bytes) %> and <%= Bible270::AvatarOptimizer::MAX_PIXELS / 1_000_000 %> megapixels.
|
|
27
|
+
Automatically center-cropped to a square. Animated pictures use the first frame.
|
|
27
28
|
</span>
|
|
28
29
|
</label>
|
|
29
30
|
<% if @reader.avatar_uploaded? %>
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
<span>Picture</span>
|
|
16
16
|
<%= file_field_tag :avatar, accept: Bible270::Avatars.content_types.join(',') %>
|
|
17
17
|
<span class="b270-hint" style="margin:0">
|
|
18
|
-
PNG, JPEG, GIF or WebP, up to <%= Bible270::Avatars.human_size(Bible270::Avatars.max_bytes)
|
|
18
|
+
PNG, JPEG, GIF or WebP, up to <%= Bible270::Avatars.human_size(Bible270::Avatars.max_bytes) %> and <%= Bible270::AvatarOptimizer::MAX_PIXELS / 1_000_000 %> megapixels.
|
|
19
|
+
Automatically center-cropped to a square. Animated pictures use the first frame.
|
|
19
20
|
</span>
|
|
20
21
|
</label>
|
|
21
22
|
<% if current_reader.avatar_uploaded? %>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<script nonce="<%= content_security_policy_nonce %>">
|
|
2
|
+
(() => {
|
|
3
|
+
if (window.Bible270AvatarUI) {
|
|
4
|
+
window.Bible270AvatarUI.initialize();
|
|
5
|
+
return;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const selector = "img[data-b270-avatar]";
|
|
9
|
+
const states = new WeakMap();
|
|
10
|
+
const delays = [500, 1500];
|
|
11
|
+
|
|
12
|
+
function stateFor(image) {
|
|
13
|
+
const source = image.getAttribute("src");
|
|
14
|
+
let state = states.get(image);
|
|
15
|
+
if (!state || state.current !== source) {
|
|
16
|
+
if (state) clearTimeout(state.timer);
|
|
17
|
+
state = { original: source, current: source, attempts: 0, timer: null };
|
|
18
|
+
states.set(image, state);
|
|
19
|
+
}
|
|
20
|
+
return state;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function loaded(image) {
|
|
24
|
+
const state = stateFor(image);
|
|
25
|
+
clearTimeout(state.timer);
|
|
26
|
+
state.timer = null;
|
|
27
|
+
image.removeAttribute("data-b270-avatar-failed");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function failed(image) {
|
|
31
|
+
image.setAttribute("data-b270-avatar-failed", "");
|
|
32
|
+
const state = stateFor(image);
|
|
33
|
+
if (!state.original || state.timer !== null || state.attempts >= delays.length) return;
|
|
34
|
+
|
|
35
|
+
state.timer = setTimeout(() => {
|
|
36
|
+
state.timer = null;
|
|
37
|
+
if (!image.isConnected || image.getAttribute("src") !== state.current) return;
|
|
38
|
+
if (image.complete && image.naturalWidth > 0) {
|
|
39
|
+
loaded(image);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
state.attempts += 1;
|
|
43
|
+
let source = state.original;
|
|
44
|
+
// Refresh our blob redirect, which may have cached an expired storage URL.
|
|
45
|
+
// Never alter provider URLs: their query parameters may be signed.
|
|
46
|
+
const url = new URL(source, document.baseURI);
|
|
47
|
+
if (image.dataset.b270AvatarUploaded === "true" && url.origin === window.location.origin) {
|
|
48
|
+
url.searchParams.set("_b270_avatar_retry", `${Date.now()}-${state.attempts}`);
|
|
49
|
+
source = url.href;
|
|
50
|
+
}
|
|
51
|
+
state.current = source;
|
|
52
|
+
image.setAttribute("src", source);
|
|
53
|
+
}, delays[state.attempts]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function inspect(image) {
|
|
57
|
+
if (!image.complete) return;
|
|
58
|
+
if (image.naturalWidth > 0) loaded(image);
|
|
59
|
+
else failed(image);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function initialize(root = document) {
|
|
63
|
+
if (root.matches?.(selector)) inspect(root);
|
|
64
|
+
root.querySelectorAll?.(selector).forEach(inspect);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Image events do not bubble. Capture also handles images inserted by replies
|
|
68
|
+
// and Turbo; the scan catches cached failures before this script was installed.
|
|
69
|
+
document.addEventListener("load", (event) => {
|
|
70
|
+
if (event.target.matches?.(selector)) loaded(event.target);
|
|
71
|
+
}, true);
|
|
72
|
+
document.addEventListener("error", (event) => {
|
|
73
|
+
if (event.target.matches?.(selector)) failed(event.target);
|
|
74
|
+
}, true);
|
|
75
|
+
document.addEventListener("turbo:load", () => initialize());
|
|
76
|
+
document.addEventListener("turbo:render", () => initialize());
|
|
77
|
+
window.addEventListener("pageshow", () => initialize());
|
|
78
|
+
new MutationObserver((records) => {
|
|
79
|
+
records.forEach((record) => record.addedNodes.forEach((node) => initialize(node)));
|
|
80
|
+
}).observe(document.documentElement, { childList: true, subtree: true });
|
|
81
|
+
|
|
82
|
+
window.Bible270AvatarUI = { initialize };
|
|
83
|
+
initialize();
|
|
84
|
+
})();
|
|
85
|
+
</script>
|
|
@@ -424,6 +424,11 @@
|
|
|
424
424
|
.b270-completers{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:16px;font-size:13px;color:var(--muted)}
|
|
425
425
|
.b270-avatar{border-radius:50%;object-fit:cover;display:inline-block;text-align:center;background:var(--teal-fill);color:var(--on-accent);font-size:12px;font-weight:600}
|
|
426
426
|
.b270-avatar-fallback{border-radius:50%}
|
|
427
|
+
.b270-avatar-frame{position:relative;display:inline-block;vertical-align:middle;flex-shrink:0}
|
|
428
|
+
.b270-avatar-frame>img{display:block}
|
|
429
|
+
.b270-avatar-frame>.b270-avatar-fallback{position:absolute;inset:0;visibility:hidden}
|
|
430
|
+
.b270-avatar-frame>img[data-b270-avatar-failed]{visibility:hidden}
|
|
431
|
+
.b270-avatar-frame>img[data-b270-avatar-failed]+.b270-avatar-fallback{visibility:visible}
|
|
427
432
|
|
|
428
433
|
/* comments */
|
|
429
434
|
.b270-section-h{font-family:"Spectral",serif;font-size:20px;font-weight:600;margin:34px 0 14px}
|