bible270 0.19.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/README.md +67 -4
  4. data/Rakefile +178 -4
  5. data/app/controllers/bible270/checkoffs_controller.rb +34 -9
  6. data/app/controllers/bible270/comments_controller.rb +63 -28
  7. data/app/controllers/bible270/days_controller.rb +17 -6
  8. data/app/controllers/bible270/likes_controller.rb +15 -4
  9. data/app/controllers/bible270/profiles_controller.rb +37 -0
  10. data/app/controllers/bible270/readers_controller.rb +48 -3
  11. data/app/helpers/bible270/plan_helper.rb +17 -3
  12. data/app/mailers/bible270/notice_mailer.rb +22 -1
  13. data/app/models/bible270/comment.rb +45 -0
  14. data/app/models/bible270/reader.rb +74 -0
  15. data/app/views/bible270/checkoffs/toggle.turbo_stream.erb +8 -0
  16. data/app/views/bible270/comments/_edit_fields.html.erb +3 -1
  17. data/app/views/bible270/comments/_form.html.erb +10 -1
  18. data/app/views/bible270/comments/create.turbo_stream.erb +1 -1
  19. data/app/views/bible270/comments/index.html.erb +61 -10
  20. data/app/views/bible270/days/_completers.html.erb +12 -0
  21. data/app/views/bible270/days/_day_progress.html.erb +47 -0
  22. data/app/views/bible270/days/_reading.html.erb +10 -2
  23. data/app/views/bible270/days/_start_date.html.erb +1 -1
  24. data/app/views/bible270/days/index.html.erb +16 -8
  25. data/app/views/bible270/days/show.html.erb +38 -42
  26. data/app/views/bible270/likes/_likes.html.erb +5 -1
  27. data/app/views/bible270/notice_mailer/daily_reminder.html.erb +30 -0
  28. data/app/views/bible270/notice_mailer/daily_reminder.text.erb +14 -0
  29. data/app/views/bible270/profiles/edit.html.erb +38 -1
  30. data/app/views/bible270/readers/_next_steps.html.erb +63 -0
  31. data/app/views/bible270/readers/index.html.erb +4 -7
  32. data/app/views/bible270/readers/progress.html.erb +10 -0
  33. data/app/views/bible270/shared/_comment_drafts.html.erb +169 -0
  34. data/app/views/bible270/shared/_day_index.html.erb +1 -1
  35. data/app/views/bible270/shared/_interaction_status.html.erb +2 -0
  36. data/app/views/bible270/shared/_interaction_ui.html.erb +69 -0
  37. data/app/views/bible270/shared/_progress.html.erb +24 -6
  38. data/app/views/bible270/shared/_reading_mode.html.erb +62 -0
  39. data/app/views/bible270/shared/_styles.html.erb +115 -9
  40. data/config/routes.rb +4 -3
  41. data/db/migrate/20260101000016_add_daily_reminders_to_bible270_readers.rb +9 -0
  42. data/db/migrate/20260101000017_add_reflections_seen_at_to_bible270_readers.rb +7 -0
  43. data/lib/bible270/configuration.rb +10 -4
  44. data/lib/bible270/daily_reminders.rb +57 -0
  45. data/lib/bible270/migration_reconciler.rb +13 -0
  46. data/lib/bible270/version.rb +1 -1
  47. data/lib/bible270.rb +1 -0
  48. data/lib/generators/bible270/install/install_generator.rb +6 -0
  49. data/lib/generators/bible270/install/templates/bible270.rb.tt +6 -0
  50. data/lib/tasks/bible270_reminders.rake +13 -0
  51. metadata +14 -1
@@ -0,0 +1,62 @@
1
+ <script nonce="<%= content_security_policy_nonce %>">
2
+ (() => {
3
+ if (window.Bible270ReadingMode) {
4
+ window.Bible270ReadingMode.scan();
5
+ return;
6
+ }
7
+
8
+ const storageKey = "bible270:reading-mode";
9
+
10
+ function browserStorage() {
11
+ try { return window.localStorage; } catch (_error) { return null; }
12
+ }
13
+
14
+ const storage = browserStorage();
15
+ let mode = "full";
16
+ if (storage) {
17
+ try { mode = storage.getItem(storageKey) === "compact" ? "compact" : "full"; } catch (_error) { /* Use full mode. */ }
18
+ }
19
+
20
+ function remember(nextMode) {
21
+ mode = nextMode;
22
+ if (!storage) return;
23
+ try { storage.setItem(storageKey, nextMode); } catch (_error) { /* Keep this page's mode. */ }
24
+ }
25
+
26
+ function apply(page) {
27
+ const compact = mode === "compact";
28
+ page.dataset.b270ReadingMode = mode;
29
+ page.querySelectorAll("[data-b270-compact-toggle]").forEach((button) => {
30
+ button.setAttribute("aria-pressed", String(compact));
31
+ });
32
+ page.querySelectorAll("[data-b270-compact-hide]").forEach((element) => {
33
+ element.hidden = compact;
34
+ });
35
+
36
+ const options = page.querySelector("[data-b270-reading-options]");
37
+ if (options) options.hidden = false;
38
+ }
39
+
40
+ function scan() {
41
+ document.querySelectorAll("[data-b270-day-page]").forEach(apply);
42
+ }
43
+
44
+ document.addEventListener("click", (event) => {
45
+ const button = event.target.closest?.("[data-b270-compact-toggle]");
46
+ if (!button) return;
47
+
48
+ const page = button.closest("[data-b270-day-page]");
49
+ if (!page) return;
50
+
51
+ remember(page.dataset.b270ReadingMode === "compact" ? "full" : "compact");
52
+ apply(page);
53
+ });
54
+
55
+ document.addEventListener("turbo:load", scan);
56
+ window.addEventListener("pageshow", scan);
57
+ new MutationObserver(scan).observe(document.documentElement, { childList: true, subtree: true });
58
+
59
+ window.Bible270ReadingMode = { scan };
60
+ scan();
61
+ })();
62
+ </script>
@@ -1,8 +1,8 @@
1
1
  <style>
2
2
  .b270{
3
- --paper:#ece6da; --card:#f4efe6; --ink:#231f18; --muted:#6d6558; --faint:#9a9082;
4
- --line:#d7cfbf; --line-soft:#e2dbcd; --teal:#2f6a67; --oxblood:#8a2f3b; --gold:#a5812c;
5
- --gold-lite:#c6a24a; --shadow:0 1px 0 rgba(35,31,24,.04),0 10px 30px -18px rgba(35,31,24,.35);
3
+ --paper:#ece6da; --card:#f4efe6; --ink:#231f18; --muted:#5d564c; --faint:#6e665b;
4
+ --line:#d7cfbf; --line-soft:#e2dbcd; --teal:#2f6a67; --oxblood:#8a2f3b; --gold:#80631d;
5
+ --gold-lite:#80631d; --shadow:0 1px 0 rgba(35,31,24,.04),0 10px 30px -18px rgba(35,31,24,.35);
6
6
  margin:0; background:radial-gradient(120% 80% at 100% 0%,rgba(165,129,44,.06),transparent 55%),var(--paper);
7
7
  color:var(--ink); font-family:"Inter",system-ui,sans-serif; line-height:1.2; -webkit-font-smoothing:antialiased;
8
8
  }
@@ -11,6 +11,7 @@
11
11
  colouring an <a> (.b270-btn, .b270-cell, .b270-arrow ...) must be
12
12
  qualified with .b270 to outrank it, or the link inherits body colour. */
13
13
  .b270 a{color:inherit}
14
+ .b270 :where(a,button,input,select,textarea,summary):focus-visible{outline:2px solid var(--gold);outline-offset:2px}
14
15
  .b270-wrap{max-width:820px;margin:0 auto;padding:clamp(14px,3.5vw,36px) clamp(14px,4vw,40px) 64px}
15
16
 
16
17
  .b270-topbar{display:flex;align-items:center;gap:16px;flex-wrap:wrap;padding-bottom:20px;border-bottom:1px solid var(--line-soft);margin-bottom:26px}
@@ -41,6 +42,29 @@
41
42
  .b270-field{display:grid;gap:5px;font-size:12.5px;color:var(--muted)}
42
43
  .b270-field input{font:inherit;font-size:15px;padding:10px 12px;border:1px solid var(--line);border-radius:9px;background:#fff;color:var(--ink);width:100%}
43
44
  .b270-field input:focus{outline:none;border-color:var(--gold);box-shadow:0 0 0 3px rgba(165,129,44,.13)}
45
+ .b270-reminder-row{display:flex;align-items:center;gap:12px;width:100%;flex-wrap:wrap}
46
+ .b270-reminder-optin,.b270-reminder-time{display:inline-flex;align-items:center;gap:7px;color:var(--muted);white-space:nowrap}
47
+ .b270-reminder-optin{position:relative;min-width:0}
48
+ .b270-reminder-time{margin-left:auto;gap:3px;font-size:12px}
49
+ .b270-time-control{position:relative;display:inline-grid}
50
+ .b270-reminder-tooltip{visibility:hidden;opacity:0;position:absolute;z-index:20;right:0;bottom:calc(100% + 7px);
51
+ width:240px;max-width:calc(100vw - 28px);padding:7px 9px;border:1px solid var(--line-soft);border-radius:7px;
52
+ background:var(--ink);color:var(--paper);font-size:12px;line-height:1.4;
53
+ white-space:normal;box-shadow:0 3px 10px rgba(0,0,0,.14);pointer-events:none;
54
+ transition:opacity .12s}
55
+ .b270-time-control:hover .b270-reminder-tooltip,
56
+ .b270-time-control:focus-within .b270-reminder-tooltip,
57
+ .b270-reminder-optin:hover .b270-reminder-tooltip,
58
+ .b270-reminder-optin:focus-within .b270-reminder-tooltip,
59
+ .b270-reminder-optin:active .b270-reminder-tooltip,
60
+ .b270-time-control:active .b270-reminder-tooltip{visibility:visible;opacity:1}
61
+ .b270-field .b270-reminder-optin input[type=checkbox]{width:auto;margin:0;flex:none}
62
+ .b270-time-control{grid-template-columns:48px 5px 48px;align-items:center;gap:2px}
63
+ .b270-field .b270-time-select{width:48px;height:30px;margin:0;padding:4px 2px;border:1px solid var(--line);
64
+ border-radius:7px;background:#fff;color:var(--ink);font:12px/16px system-ui,sans-serif;
65
+ font-variant-numeric:tabular-nums;text-align:center}
66
+ .b270-time-select:focus{outline:none;border-color:var(--gold);box-shadow:0 0 0 3px rgba(165,129,44,.13)}
67
+ .b270-time-separator{display:grid;place-items:center;height:30px;font:12px/30px system-ui,sans-serif;color:var(--muted)}
44
68
  .b270-emailform input[type=submit]{justify-self:start}
45
69
  .b270-or{display:flex;align-items:center;gap:12px;margin:22px 0 18px;color:var(--faint);font-size:12px;letter-spacing:.1em;text-transform:uppercase}
46
70
  .b270-or::before,.b270-or::after{content:"";flex:1;height:1px;background:var(--line-soft)}
@@ -54,6 +78,11 @@
54
78
  .b270-flash{border-radius:10px;padding:11px 15px;margin-bottom:18px;font-size:14px}
55
79
  .b270-flash.alert{background:#f6e4e2;color:#7a2b28;border:1px solid #e6c3bf}
56
80
  .b270-flash.notice{background:#e7efe6;color:#2f5d34;border:1px solid #c6dcc6}
81
+ .b270-interaction-status{margin:8px 0 12px;padding:8px 11px;border-radius:8px;background:rgba(47,106,103,.08);color:var(--teal);font-size:12.5px}
82
+ .b270-interaction-status:empty{display:none}
83
+ .b270-interaction-status[data-state="pending"]{color:var(--muted)}
84
+ .b270-interaction-status[data-state="error"]{background:#f6e4e2;color:#7a2b28}
85
+ .b270 form[aria-busy="true"] [data-b270-disabled-by-submit]{cursor:wait;opacity:.55}
57
86
 
58
87
  .b270-eyebrow{font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--gold);font-weight:600;margin:0 0 10px}
59
88
  .b270 h1{font-family:"Spectral",serif;font-weight:600;font-size:clamp(22px,6vw,33px);line-height:1.03;letter-spacing:-.01em;margin:0 0 6px}
@@ -68,6 +97,32 @@
68
97
  .b270-progress-actions{display:flex;flex-direction:column;align-items:flex-start;gap:7px;margin:16px 0 0}
69
98
  .b270-todaylink{margin-left:16px;font-size:12px;color:var(--muted);text-decoration:none}
70
99
  .b270-todaylink:hover{color:var(--gold);text-decoration:underline}
100
+ .b270-recovery{margin-top:16px;padding:14px 16px;border:1px solid var(--line-soft);border-radius:10px;background:rgba(165,129,44,.06)}
101
+ .b270-recovery-heading{margin:0;font-family:"Spectral",serif;font-size:17px;font-weight:600}
102
+ .b270-recovery-copy{margin:4px 0 0;color:var(--muted);font-size:13px;line-height:1.45}
103
+ .b270-recovery-actions{margin-top:12px}
104
+ .b270-recovery-actions .b270-todaylink{margin-left:0}
105
+ .b270-restart-form{margin:0}
106
+ .b270-recovery-actions .b270-linkbtn{margin:2px 0 0;color:var(--muted)}
107
+ .b270-next-steps>.b270-subhead{margin-bottom:2px}
108
+ .b270-next-section{padding-top:15px;margin-top:15px;border-top:1px solid var(--line-soft)}
109
+ .b270-next-section h3{margin:0 0 9px;font-size:13px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}
110
+ .b270-task-list{list-style:none;margin:0;padding:0;display:grid;gap:8px}
111
+ .b270-task{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:10px 12px;border:1px solid var(--line-soft);border-radius:9px;background:rgba(255,255,255,.35)}
112
+ .b270-task-body{display:grid;gap:3px;min-width:0}
113
+ .b270-task-body strong{font-size:14px}
114
+ .b270-task-body span{color:var(--muted);font-size:12px;line-height:1.4}
115
+ .b270-task-body span a{color:var(--muted);text-decoration:underline;text-decoration-color:var(--line)}
116
+ .b270-task-body span a:hover{color:var(--gold);text-decoration-color:var(--gold)}
117
+ .b270 .b270-task-link{flex:none;color:var(--gold);font-size:12.5px;font-weight:600;text-decoration:none;white-space:nowrap}
118
+ .b270-task-link:hover{text-decoration:underline}
119
+ .b270-next-note{margin:0;color:var(--faint);font-size:12.5px;line-height:1.45}
120
+ .b270-task-list+.b270-next-note{margin-top:8px}
121
+ .b270-week-count{margin:0 0 7px;color:var(--muted);font-size:13px}
122
+ .b270-week-count strong{color:var(--ink)}
123
+ .b270-week-progress{display:block;width:100%;height:8px;accent-color:var(--teal)}
124
+ .b270-milestone{margin:0 0 3px;color:var(--muted);font-size:13px}
125
+ .b270-milestone strong{color:var(--ink);font-family:"Spectral",serif;font-size:17px}
71
126
  .b270-signedout{color:var(--muted);font-size:14px;margin:4px 0 0}
72
127
 
73
128
  .b270-startdate{margin-top:18px;padding-top:16px;border-top:1px solid var(--line-soft)}
@@ -91,16 +146,29 @@
91
146
  .b270-tm .val{color:var(--faint);font-variant-numeric:tabular-nums;min-width:56px;text-align:right}
92
147
 
93
148
  /* day nav */
149
+ .b270-day-page [hidden]{display:none!important}
150
+ .b270-reading-options{display:flex;justify-content:flex-end;margin:-4px 0 10px}
151
+ .b270-reading-options .b270-linkbtn{margin:0;color:var(--muted)}
152
+ .b270-reading-mode-toggle[aria-pressed="true"]{color:var(--gold);font-weight:600}
153
+ .b270-day-page[data-b270-reading-mode="compact"] [data-b270-compact-hide]{display:none!important}
154
+ .b270-day-page[data-b270-reading-mode="compact"] .b270-day-complete{justify-content:flex-end;padding:0;border:0;background:transparent;box-shadow:none}
94
155
  .b270-daynav{display:flex;align-items:center;gap:10px;margin-bottom:12px}
95
- .b270 .b270-arrow{width:38px;height:38px;border-radius:10px;border:1px solid var(--line);background:var(--card);cursor:pointer;font-size:17px;color:var(--muted);display:grid;place-items:center;text-decoration:none}
156
+ .b270 .b270-arrow{width:38px;height:38px;padding:0;border-radius:10px;border:1px solid var(--line);background:var(--card);cursor:pointer;font:inherit;font-size:17px;line-height:1;color:var(--muted);display:grid;place-items:center;text-decoration:none}
96
157
  .b270-arrow:hover{border-color:var(--gold);color:var(--gold)}
97
- .b270-arrow.disabled{opacity:.35;pointer-events:none}
158
+ .b270-arrow.disabled{opacity:.45;cursor:not-allowed}
98
159
  .b270-dayhead{flex:1}
99
160
  .b270-dayhead .k{display:flex;align-items:center;flex-wrap:wrap;gap:9px;font-family:"Spectral",serif;font-size:24px;font-weight:600;line-height:1}
100
161
  .b270-dayhead .date{display:flex;align-items:center;flex-wrap:wrap;gap:9px;color:var(--faint);font-size:13px;margin-top:4px}
101
162
  .b270-dayhead .b270-badge{margin-left:0}
102
163
  .b270-badge{display:inline-block;vertical-align:middle;line-height:1;font-size:10.5px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;color:#fff;background:var(--gold-lite);padding:5px 8px 4px;border-radius:20px;margin-left:9px}
103
164
 
165
+ .b270-day-complete{display:flex;align-items:center;gap:14px;margin:0 0 14px;padding:11px 13px;
166
+ border:1px solid var(--line);border-radius:10px;background:var(--card);box-shadow:var(--shadow)}
167
+ .b270-day-complete div{display:flex;flex-direction:column;gap:2px;min-width:0}
168
+ .b270-day-complete strong{font-family:"Spectral",serif;font-size:16px}
169
+ .b270-day-complete span{font-size:12px;color:var(--muted)}
170
+ .b270 .b270-day-complete .b270-btn{flex:none;margin:0 0 0 auto}
171
+
104
172
  /* readings */
105
173
  .b270-readings{background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);overflow:hidden}
106
174
  .b270-reading{display:flex;gap:15px;align-items:center;padding:16px 20px;border-left:4px solid transparent}
@@ -186,10 +254,11 @@
186
254
  .b270-likebtn.liked{filter:none;opacity:1}
187
255
  .b270-likecount{font-size:11.5px;color:var(--faint)}
188
256
  .b270-liketooltip{display:none;position:absolute;z-index:10;top:calc(100% + 6px);left:0;
189
- min-width:max-content;padding:6px 9px;border:1px solid var(--line-soft);
257
+ min-width:max-content;max-width:calc(100vw - 28px);padding:6px 9px;border:1px solid var(--line-soft);
190
258
  border-radius:7px;background:var(--ink);color:var(--paper);font-size:12px;
191
259
  line-height:1.45;box-shadow:0 3px 10px rgba(0,0,0,.14)}
192
- .b270-likes:hover .b270-liketooltip,.b270-likes:focus-within .b270-liketooltip{display:block}
260
+ .b270-likes:hover .b270-liketooltip,.b270-likes:focus-within .b270-liketooltip,
261
+ .b270-likes:active .b270-liketooltip{display:block}
193
262
  .b270-liker{display:block;white-space:nowrap}
194
263
  .b270-creply{margin-top:6px}
195
264
  .b270-cbody{margin:3px 0 0;white-space:pre-wrap}
@@ -204,7 +273,9 @@
204
273
  .b270-cdel{font-size:12px;color:var(--oxblood);background:none;border:none;cursor:pointer;padding:0;margin-left:8px}
205
274
  .b270-input{width:100%;padding:9px 11px;border:1px solid var(--line);border-radius:9px;background:#fff;font:inherit;font-size:14px;margin-bottom:8px}
206
275
  .b270-form textarea{width:100%;font:inherit;font-size:15px;border:1px solid var(--line);border-radius:10px;padding:11px 13px;background:#fff;resize:vertical;min-height:76px}
276
+ .b270-draft-status{min-height:16px;margin:4px 0 -4px;font-size:11.5px;color:var(--faint)}
207
277
  .b270-form .row{display:flex;align-items:center;gap:10px;margin-top:10px}
278
+ .b270-form .row select{max-width:100%}
208
279
  .b270 .b270-btn{border:none;border-radius:10px;padding:8px 16px;margin-top:4px;font:inherit;font-weight:600;font-size:14px;cursor:pointer;background:var(--ink);color:var(--paper);text-decoration:none;display:inline-block}
209
280
  .b270-btn:hover{background:#3a352b}
210
281
 
@@ -216,7 +287,7 @@
216
287
  .b270-li .sub{font-size:12.5px;color:var(--faint)}
217
288
  .b270-li .stat{margin-left:auto;text-align:right}
218
289
  .b270-li .stat .big{font-family:"Spectral",serif;font-size:20px;font-weight:600}
219
- .b270-li .rank{width:26px;color:var(--faint);font-variant-numeric:tabular-nums;text-align:center}
290
+ .b270-community-intro{margin:-8px 0 12px;color:var(--muted);font-size:13px}
220
291
 
221
292
  /* calendar grid */
222
293
  .b270-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(30px,1fr));gap:6px;margin-top:16px}
@@ -234,8 +305,18 @@
234
305
 
235
306
  /* On the Reflections page each thread carries the day it belongs to, since
236
307
  there is no day heading above it. */
308
+ .b270-reflection-tools{display:flex;align-items:flex-end;justify-content:space-between;gap:18px;margin:30px 0 14px}
309
+ .b270-reflection-tools .b270-section-h{margin:0}
310
+ .b270-reflection-count{margin:3px 0 0;font-size:12px;color:var(--faint)}
311
+ .b270-reflection-filters{display:flex;align-items:flex-end;justify-content:flex-end;gap:8px;flex-wrap:wrap}
312
+ .b270-reflection-filters label{display:grid;gap:3px;font-size:11.5px;color:var(--muted)}
313
+ .b270-reflection-filters select{min-width:112px;max-width:180px;padding:7px 25px 7px 9px;border:1px solid var(--line);border-radius:8px;background:#fff;color:var(--ink);font:inherit;font-size:12.5px}
314
+ .b270 .b270-reflection-filters .b270-btn{margin:0;padding:8px 13px;font-size:12.5px}
315
+ .b270 .b270-filter-clear{align-self:center;font-size:12px;color:var(--muted)}
237
316
  .b270-thread{margin-bottom:8px}
317
+ .b270-thread-new{border-left:3px solid var(--gold);padding-left:10px}
238
318
  .b270-threadday{margin:0;font-size:12px;letter-spacing:.04em;color:var(--faint)}
319
+ .b270-new-reflection{display:inline-block;margin-left:7px;padding:3px 6px 2px;border-radius:10px;background:rgba(128,99,29,.12);color:var(--gold);font-size:10px;font-weight:600;letter-spacing:.04em;text-transform:uppercase}
239
320
  .b270-threadday a{color:var(--gold);font-weight:600;text-decoration:none}
240
321
  .b270-threadday a:hover{text-decoration:underline}
241
322
  /* Scripture references stay undecorated, hover included, so a line of three of
@@ -244,6 +325,12 @@
244
325
  .b270-threadday .b270-reflink:hover{color:var(--muted);font-weight:400;text-decoration:none}
245
326
  .b270-threadday .b270-reflink:hover{color:var(--ink)}
246
327
  .b270-gridnote{margin:10px 0 0;font-size:11.5px;color:var(--faint)}
328
+ .b270-pagination{display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:12px;margin:20px 0;color:var(--faint);font-size:12.5px}
329
+ .b270-pagination a{color:var(--gold);font-weight:600;text-decoration:none}
330
+ .b270-pagination a:last-child{justify-self:end}
331
+ .b270-pagination span{grid-column:2;text-align:center}
332
+ .b270-filter-empty{margin-top:12px}
333
+ .b270-filter-empty p{margin:0 0 8px;color:var(--muted)}
247
334
 
248
335
  .b270-footer{margin-top:20px;padding-top:11px;border-top:1px solid var(--line-soft);color:var(--faint);font-size:13px}
249
336
  .b270-footer p{margin:0;line-height:1.5}
@@ -275,5 +362,24 @@
275
362
  .b270-navlinks a:hover,.b270-nav .b270-linkbtn:hover{background:var(--paper)}
276
363
  .b270-nav form{display:block;width:100%}
277
364
  }
278
- @media (max-width:480px){ .b270-tm{grid-template-columns:104px 1fr auto} .b270-reading{padding:14px 15px;gap:12px} .b270-reading.multi .b270-cat,.b270-reading.multi .b270-parts-meta{margin-left:38px} .b270-part{gap:12px} }
365
+ @media (max-width:480px){
366
+ .b270-tm{grid-template-columns:104px 1fr auto}
367
+ .b270-avatarrow{align-items:flex-start;flex-wrap:wrap}
368
+ .b270-form .row{align-items:stretch;flex-direction:column}
369
+ .b270-form .row select{width:100%}
370
+ .b270-form .row .b270-btn{align-self:flex-start}
371
+ .b270-reading{padding:14px 15px;gap:12px}
372
+ .b270-reading.multi .b270-cat,.b270-reading.multi .b270-parts-meta{margin-left:38px}
373
+ .b270-part{gap:12px}
374
+ .b270-task{align-items:flex-start;flex-direction:column;gap:7px}
375
+ .b270-day-complete{align-items:flex-start;flex-direction:column}
376
+ .b270-reflection-tools{align-items:stretch;flex-direction:column;gap:10px}
377
+ .b270-reflection-filters{align-items:stretch;justify-content:flex-start}
378
+ .b270-reflection-filters label{flex:1;min-width:120px}
379
+ .b270-reflection-filters select{width:100%;max-width:none}
380
+ .b270 .b270-day-complete .b270-btn{margin:3px 0 0}
381
+ }
382
+ @media (prefers-reduced-motion:reduce){
383
+ .b270 *,.b270 *::before,.b270 *::after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}
384
+ }
279
385
  </style>
data/config/routes.rb CHANGED
@@ -45,12 +45,13 @@ Bible270::Engine.routes.draw do
45
45
  get 'community', to: 'readers#index', as: :community
46
46
  get 'readers/:id', to: 'readers#show', as: :reader
47
47
 
48
- patch 'start-date', to: 'readers#update_start_date', as: :start_date
49
- delete 'start-date', to: 'readers#clear_start_date', as: :clear_start_date
48
+ patch 'start-date', to: 'readers#update_start_date', as: :start_date
49
+ delete 'start-date', to: 'readers#clear_start_date', as: :clear_start_date
50
+ patch 'schedule/restart', to: 'readers#restart_schedule', as: :restart_schedule
50
51
 
51
52
  # Built-in OmniAuth sign-in. The request phase (POST <mount>/auth/:provider)
52
53
  # is handled by the OmniAuth middleware in the host app, not by these routes.
53
- get 'sign_in', to: 'sessions#new', as: :sign_in
54
+ get 'sign_in', to: 'sessions#new', as: :sign_in
54
55
 
55
56
  # Email (magic link) sign-in, for readers without a social account.
56
57
  post 'sign_in/email', to: 'sessions#email_link', as: :email_sign_in_link
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddDailyRemindersToBible270Readers < ActiveRecord::Migration[7.0]
4
+ def change
5
+ add_column :bible270_readers, :daily_reminders, :boolean, default: false, null: false
6
+ add_column :bible270_readers, :daily_reminder_time, :string, default: '08:00', null: false
7
+ add_column :bible270_readers, :last_daily_reminder_sent_on, :date
8
+ end
9
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddReflectionsSeenAtToBible270Readers < ActiveRecord::Migration[7.0]
4
+ def change
5
+ add_column :bible270_readers, :reflections_seen_at, :datetime
6
+ end
7
+ end
@@ -234,6 +234,7 @@ module Bible270
234
234
  attr_accessor :email_sign_in_deliver_later
235
235
 
236
236
  def email_sign_in? = !!@email_sign_in
237
+ def daily_reminders? = !!@daily_reminders
237
238
 
238
239
  # Reserved domains from RFC 2606 — a From: address at one of these will fail
239
240
  # SPF for your real domain, and receiving servers routinely drop it without
@@ -241,9 +242,9 @@ module Bible270
241
242
  # changed, but nothing was checking that it had been.
242
243
  PLACEHOLDER_DOMAINS = %w[example.com example.org example.net example.edu invalid localhost].freeze
243
244
 
244
- # Why the sign-in mail is likely to vanish, or nil if it looks fine.
245
+ # Why reader-facing mail is likely to vanish, or nil if it looks fine.
245
246
  def mailer_from_problem
246
- return nil unless email_sign_in?
247
+ return nil unless email_sign_in? || daily_reminders?
247
248
 
248
249
  address = mailer_from.to_s.strip
249
250
  return 'config.mailer_from is blank' if address.empty?
@@ -292,6 +293,10 @@ module Bible270
292
293
  # from, so without this the notice names the reader but cannot link to them.
293
294
  attr_accessor :mailer_host
294
295
 
296
+ # Allow readers to opt in to a daily email with that date's readings. The host
297
+ # application owns scheduling the bible270:reminders:send task.
298
+ attr_accessor :daily_reminders
299
+
295
300
  attr_accessor :after_sign_out_path, :email_sign_in_max_per_window, :passage_url_builder, :tagline, :footer_html, :footer, :favicon, :avatar_max_bytes, :chapter_breaks, :admin_emails, :admin_resolver, :bible_version, :header_mark, :remember_for, :require_sign_in_to_participate, :mention_notifications
296
301
 
297
302
  # Public labels.
@@ -331,8 +336,8 @@ module Bible270
331
336
 
332
337
  # Email a reader when someone writes "@them" in a reflection. Readers can opt
333
338
  # out individually on their profile; this switches the whole feature off.
334
- # How many recent threads the Reflections page shows. A thread resurfaces when
335
- # someone replies to it, so this is a count of conversations, not of messages.
339
+ # How many threads each Reflections page shows. A thread resurfaces when someone
340
+ # replies to it, so this is a count of conversations, not of messages.
336
341
  attr_accessor :reflections_page_size
337
342
 
338
343
  def remember_signed_in?
@@ -381,6 +386,7 @@ module Bible270
381
386
  @registration_notice_emails = []
382
387
  @registration_notice_deliver_later = false
383
388
  @mailer_host = nil
389
+ @daily_reminders = false
384
390
  @admin_emails = []
385
391
  @admin_resolver = nil
386
392
  @email_sign_in_deliver_later = false
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'bible270/plan'
5
+
6
+ module Bible270
7
+ # Delivers one reminder for each reader whose dated plan has an unfinished
8
+ # reading and whose chosen local reminder time has arrived. Scheduling belongs
9
+ # to the host application; repeated runs on the same local date are safe.
10
+ module DailyReminders
11
+ module_function
12
+
13
+ def deliver(at: Time.now)
14
+ return 0 unless Bible270.config.daily_reminders?
15
+
16
+ unless Reader.daily_reminder_columns?
17
+ Rails.logger.error(
18
+ '[bible270] daily reminders unavailable: run bible270:install:migrations and db:migrate'
19
+ )
20
+ return 0
21
+ end
22
+
23
+ local_time = local_time_for(at)
24
+ return 0 unless local_time.respond_to?(:to_date)
25
+
26
+ date = local_time.to_date
27
+ sent = 0
28
+ Reader.daily_reminder_recipients(on: date).find_each do |reader|
29
+ next unless reader.daily_reminder_due_at?(local_time)
30
+
31
+ day = Plan.day_for(date, reader.effective_start_date, clamp: false)
32
+ next unless Plan.valid_day?(day)
33
+ next if reader.day_complete?(day)
34
+
35
+ delivery = NoticeMailer.daily_reminder(reader_id: reader.id, day: day, on: date)
36
+ next unless delivery.message.perform_deliveries
37
+
38
+ delivery.deliver_now
39
+ reader.update_column(:last_daily_reminder_sent_on, date)
40
+ sent += 1
41
+ rescue StandardError => e
42
+ Rails.logger.error(
43
+ "[bible270] daily reminder for reader #{reader.id} failed: #{e.class}: #{e.message}"
44
+ )
45
+ end
46
+ sent
47
+ end
48
+
49
+ def local_time_for(at)
50
+ return nil if at.nil?
51
+
52
+ Bible270.local_time(at)
53
+ rescue StandardError
54
+ nil
55
+ end
56
+ end
57
+ end
@@ -25,6 +25,8 @@ module Bible270
25
25
  'CreateBible270Likes' => :check_likes,
26
26
  'AddBlueLetterBibleToBible270Readers' => :check_blue_letter_bible,
27
27
  'AddPassageSourceToBible270Readers' => :check_passage_source,
28
+ 'AddDailyRemindersToBible270Readers' => :check_daily_reminders,
29
+ 'AddReflectionsSeenAtToBible270Readers' => :check_reflections_seen_at,
28
30
  'CreateActiveStorageTables' => :check_active_storage
29
31
  }.freeze
30
32
 
@@ -177,6 +179,17 @@ module Bible270
177
179
  table_missing(:bible270_readers, columns: %i[passage_source])
178
180
  end
179
181
 
182
+ def check_daily_reminders
183
+ table_missing(
184
+ :bible270_readers,
185
+ columns: %i[daily_reminders daily_reminder_time last_daily_reminder_sent_on]
186
+ )
187
+ end
188
+
189
+ def check_reflections_seen_at
190
+ table_missing(:bible270_readers, columns: %i[reflections_seen_at])
191
+ end
192
+
180
193
  # The Bible270 installer can install Active Storage for reader avatars. A
181
194
  # copied database can therefore have the same timestamp mismatch for Rails'
182
195
  # generated Active Storage migration as it has for the engine migrations.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bible270
4
- VERSION = '0.19.0'
4
+ VERSION = '1.0.0'
5
5
  end
data/lib/bible270.rb CHANGED
@@ -11,6 +11,7 @@ require 'bible270/avatars'
11
11
  require 'bible270/translations'
12
12
  require 'bible270/favicon'
13
13
  require 'bible270/configuration'
14
+ require 'bible270/daily_reminders'
14
15
  require 'bible270/engine' if defined?(Rails::Engine)
15
16
 
16
17
  module Bible270
@@ -512,6 +512,12 @@ module Bible270
512
512
  # config.start_date = Date.new(#{Date.today.year}, 1, 1)
513
513
  config.allow_reader_start_date = true
514
514
 
515
+ # --- Daily reading reminders -----------------------------------------------
516
+ # Readers choose an opt-in time only when enabled. This application owns
517
+ # scheduling: run `bin/rails bible270:reminders:send` every 15 minutes.
518
+ config.daily_reminders = false
519
+ # config.mailer_host = 'example.com' # links back to the day and profile
520
+
515
521
  # --- Reading links --------------------------------------------------------
516
522
  config.bible_version = 'NKJV'
517
523
  end
@@ -55,6 +55,12 @@ Bible270.configure do |config|
55
55
  # config.start_date = Date.new(2026, 9, 6)
56
56
  config.allow_reader_start_date = true
57
57
 
58
+ # --- Daily reading reminders -----------------------------------------------
59
+ # Readers choose an opt-in time on their profile only when this is enabled.
60
+ # The host app owns scheduling: run `bin/rails bible270:reminders:send` every 15 minutes.
61
+ config.daily_reminders = false
62
+ # config.mailer_host = "example.com" # required for day/profile links in email
63
+
58
64
  # --- Reading links ---------------------------------------------------------
59
65
  config.bible_version = "NKJV"
60
66
 
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bible270/daily_reminders'
4
+
5
+ namespace :bible270 do
6
+ namespace :reminders do
7
+ desc 'Send Bible270 reminders now due at each opted-in reader time'
8
+ task send: :environment do
9
+ count = Bible270::DailyReminders.deliver
10
+ puts "Sent #{count} daily reading #{'reminder'.pluralize(count)}."
11
+ end
12
+ end
13
+ end
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: 0.19.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew vonderLuft
@@ -115,6 +115,8 @@ files:
115
115
  - app/views/bible270/comments/edit.turbo_stream.erb
116
116
  - app/views/bible270/comments/index.html.erb
117
117
  - app/views/bible270/comments/update.turbo_stream.erb
118
+ - app/views/bible270/days/_completers.html.erb
119
+ - app/views/bible270/days/_day_progress.html.erb
118
120
  - app/views/bible270/days/_reading.html.erb
119
121
  - app/views/bible270/days/_start_date.html.erb
120
122
  - app/views/bible270/days/index.html.erb
@@ -123,22 +125,29 @@ files:
123
125
  - app/views/bible270/likes/toggle.turbo_stream.erb
124
126
  - app/views/bible270/notice_mailer/broadcast.html.erb
125
127
  - app/views/bible270/notice_mailer/broadcast.text.erb
128
+ - app/views/bible270/notice_mailer/daily_reminder.html.erb
129
+ - app/views/bible270/notice_mailer/daily_reminder.text.erb
126
130
  - app/views/bible270/notice_mailer/mentioned.html.erb
127
131
  - app/views/bible270/notice_mailer/mentioned.text.erb
128
132
  - app/views/bible270/notice_mailer/new_reader.html.erb
129
133
  - app/views/bible270/notice_mailer/new_reader.text.erb
130
134
  - app/views/bible270/profiles/edit.html.erb
135
+ - app/views/bible270/readers/_next_steps.html.erb
131
136
  - app/views/bible270/readers/index.html.erb
132
137
  - app/views/bible270/readers/progress.html.erb
133
138
  - app/views/bible270/readers/show.html.erb
134
139
  - app/views/bible270/sessions/new.html.erb
140
+ - app/views/bible270/shared/_comment_drafts.html.erb
135
141
  - app/views/bible270/shared/_day_index.html.erb
136
142
  - app/views/bible270/shared/_footer.html.erb
137
143
  - app/views/bible270/shared/_grid_legend.html.erb
138
144
  - app/views/bible270/shared/_header.html.erb
145
+ - app/views/bible270/shared/_interaction_status.html.erb
146
+ - app/views/bible270/shared/_interaction_ui.html.erb
139
147
  - app/views/bible270/shared/_nav_links.html.erb
140
148
  - app/views/bible270/shared/_passage_source.html.erb
141
149
  - app/views/bible270/shared/_progress.html.erb
150
+ - app/views/bible270/shared/_reading_mode.html.erb
142
151
  - app/views/bible270/shared/_reflections.html.erb
143
152
  - app/views/bible270/shared/_styles.html.erb
144
153
  - app/views/bible270/shared/_track_bars.html.erb
@@ -161,9 +170,12 @@ files:
161
170
  - db/migrate/20260101000013_create_bible270_likes.rb
162
171
  - db/migrate/20260101000014_add_blue_letter_bible_to_bible270_readers.rb
163
172
  - db/migrate/20260101000015_add_passage_source_to_bible270_readers.rb
173
+ - db/migrate/20260101000016_add_daily_reminders_to_bible270_readers.rb
174
+ - db/migrate/20260101000017_add_reflections_seen_at_to_bible270_readers.rb
164
175
  - lib/bible270.rb
165
176
  - lib/bible270/avatars.rb
166
177
  - lib/bible270/configuration.rb
178
+ - lib/bible270/daily_reminders.rb
167
179
  - lib/bible270/email_sign_in.rb
168
180
  - lib/bible270/engine.rb
169
181
  - lib/bible270/favicon.rb
@@ -178,6 +190,7 @@ files:
178
190
  - lib/generators/bible270/install/templates/bible270.rb.tt
179
191
  - lib/generators/bible270/install/templates/omniauth.rb.tt
180
192
  - lib/tasks/bible270_migrations.rake
193
+ - lib/tasks/bible270_reminders.rake
181
194
  homepage: https://github.com/avonderluft/bible270
182
195
  licenses:
183
196
  - MIT