bible270 0.18.3 → 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 (55) 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 -4
  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 +48 -3
  14. data/app/models/bible270/reader.rb +74 -7
  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/notice_mailer/mentioned.html.erb +0 -1
  30. data/app/views/bible270/notice_mailer/mentioned.text.erb +1 -2
  31. data/app/views/bible270/profiles/edit.html.erb +41 -8
  32. data/app/views/bible270/readers/_next_steps.html.erb +63 -0
  33. data/app/views/bible270/readers/index.html.erb +6 -9
  34. data/app/views/bible270/readers/progress.html.erb +13 -3
  35. data/app/views/bible270/shared/_comment_drafts.html.erb +169 -0
  36. data/app/views/bible270/shared/_day_index.html.erb +1 -1
  37. data/app/views/bible270/shared/_header.html.erb +14 -17
  38. data/app/views/bible270/shared/_interaction_status.html.erb +2 -0
  39. data/app/views/bible270/shared/_interaction_ui.html.erb +69 -0
  40. data/app/views/bible270/shared/_nav_links.html.erb +15 -0
  41. data/app/views/bible270/shared/_progress.html.erb +24 -6
  42. data/app/views/bible270/shared/_reading_mode.html.erb +62 -0
  43. data/app/views/bible270/shared/_styles.html.erb +147 -10
  44. data/config/routes.rb +4 -3
  45. data/db/migrate/20260101000016_add_daily_reminders_to_bible270_readers.rb +9 -0
  46. data/db/migrate/20260101000017_add_reflections_seen_at_to_bible270_readers.rb +7 -0
  47. data/lib/bible270/configuration.rb +10 -4
  48. data/lib/bible270/daily_reminders.rb +57 -0
  49. data/lib/bible270/migration_reconciler.rb +13 -0
  50. data/lib/bible270/version.rb +1 -1
  51. data/lib/bible270.rb +1 -0
  52. data/lib/generators/bible270/install/install_generator.rb +6 -0
  53. data/lib/generators/bible270/install/templates/bible270.rb.tt +6 -0
  54. data/lib/tasks/bible270_reminders.rake +13 -0
  55. metadata +15 -1
@@ -0,0 +1,69 @@
1
+ <script nonce="<%= content_security_policy_nonce %>">
2
+ (() => {
3
+ if (window.Bible270InteractionUI) {
4
+ window.Bible270InteractionUI.reset();
5
+ return;
6
+ }
7
+
8
+ function statusRegion() {
9
+ return document.getElementById("b270-interaction-status");
10
+ }
11
+
12
+ function announce(message, state = "") {
13
+ const region = statusRegion();
14
+ if (!region) return;
15
+
16
+ region.textContent = message || "";
17
+ region.dataset.state = state;
18
+ }
19
+
20
+ function resetForm(form) {
21
+ form.removeAttribute("aria-busy");
22
+ form.dataset.b270Submitting = "false";
23
+ form.querySelectorAll("[data-b270-disabled-by-submit]").forEach((control) => {
24
+ control.disabled = false;
25
+ delete control.dataset.b270DisabledBySubmit;
26
+ });
27
+ }
28
+
29
+ function reset() {
30
+ document.querySelectorAll("form[data-b270-submit='true']").forEach(resetForm);
31
+ }
32
+
33
+ document.addEventListener("submit", (event) => {
34
+ const form = event.target.closest("form[data-b270-submit='true']");
35
+ if (!form) return;
36
+ if (form.dataset.b270Submitting === "true") {
37
+ event.preventDefault();
38
+ return;
39
+ }
40
+
41
+ form.dataset.b270Submitting = "true";
42
+ form.setAttribute("aria-busy", "true");
43
+ const submitter = event.submitter || form.querySelector("button[type='submit'],input[type='submit']");
44
+ if (submitter) {
45
+ submitter.disabled = true;
46
+ submitter.dataset.b270DisabledBySubmit = "true";
47
+ }
48
+ announce(form.dataset.b270Pending || "Saving…", "pending");
49
+ });
50
+
51
+ document.addEventListener("turbo:submit-end", (event) => {
52
+ const form = event.target;
53
+ if (!form.matches("form[data-b270-submit='true']")) return;
54
+
55
+ resetForm(form);
56
+ if (event.detail.success) {
57
+ announce(form.dataset.b270Success || "Saved.", "success");
58
+ } else {
59
+ announce(form.dataset.b270Failure || "Couldn’t save that. Try again.", "error");
60
+ }
61
+ });
62
+
63
+ window.addEventListener("pageshow", reset);
64
+ document.addEventListener("turbo:load", reset);
65
+
66
+ window.Bible270InteractionUI = { reset };
67
+ reset();
68
+ })();
69
+ </script>
@@ -0,0 +1,15 @@
1
+ <%= link_to "Community", community_path %>
2
+ <%# Public, like the community page: anyone may read reflections. %>
3
+ <%= link_to "Reflections", reflections_path %>
4
+ <% if signed_in? %>
5
+ <%= link_to "My Progress", progress_path %>
6
+ <%= link_to "Profile", profile_path %>
7
+ <% if Bible270.config.admin?(current_reader) %><%= link_to "Admin", admin_path %><% end %>
8
+ <%= button_to "Sign out", sign_out_path, method: :delete, class: "b270-linkbtn",
9
+ form: { style: "display:inline" } %>
10
+ <% elsif Bible270.config.any_sign_in_method? %>
11
+ <%# Always route to the sign-in page: it offers email as well as any
12
+ social providers, so nobody is shut out. OmniAuth's own request phase
13
+ rejects GET, so its buttons live on that page as POST forms. %>
14
+ <%= link_to "Sign in", sign_in_path(origin: request.fullpath), class: "b270-signin" %>
15
+ <% end %>
@@ -23,6 +23,7 @@
23
23
  panel_today_day = today_day || panel_reader&.today_day
24
24
  panel_community_date = community_start_date == :unset ? Bible270.config.start_date : community_start_date
25
25
  day_verb = panel_start_day == 1 ? 'Start' : 'Continue'
26
+ panel_behind = panel_today_day.present? && panel_start_day < panel_today_day
26
27
  %>
27
28
  <div class="b270-panel">
28
29
  <% if panel_reader %>
@@ -34,12 +35,29 @@
34
35
 
35
36
  <%= render "bible270/shared/track_bars", reader: panel_reader %>
36
37
 
37
- <p style="margin:16px 0 0">
38
- <%= link_to "#{day_verb} reading — Day #{panel_start_day} →", day_path(panel_start_day), class: "b270-btn" %>
39
- <% if panel_today_day && panel_today_day != panel_start_day %>
40
- <%= link_to "Go to today (Day #{panel_today_day})", day_path(panel_today_day), style: "margin-left:12px" %>
41
- <% end %>
42
- </p>
38
+ <% if panel_behind %>
39
+ <div class="b270-recovery">
40
+ <p class="b270-recovery-heading">Choose the reading that works for you today</p>
41
+ <p class="b270-recovery-copy">Your next unfinished reading is Day <%= panel_start_day %>, while the community is on Day <%= panel_today_day %>. Whichever you choose, your completed readings stay saved.</p>
42
+ <div class="b270-progress-actions b270-recovery-actions">
43
+ <%= link_to "Catch up from Day #{panel_start_day} →", day_path(panel_start_day), class: "b270-btn" %>
44
+ <%= link_to "Read with the community — Day #{panel_today_day}", day_path(panel_today_day), class: "b270-todaylink" %>
45
+ <% if Bible270.config.allow_reader_start_date %>
46
+ <%= button_to "Make Day #{panel_start_day} my new today", restart_schedule_path,
47
+ method: :patch, class: "b270-linkbtn",
48
+ form: { class: "b270-restart-form",
49
+ data: { turbo_confirm: "Make Day #{panel_start_day} today? Your existing check-offs and reflections will not change." } } %>
50
+ <% end %>
51
+ </div>
52
+ </div>
53
+ <% else %>
54
+ <div class="b270-progress-actions">
55
+ <%= link_to "#{day_verb} reading — Day #{panel_start_day} →", day_path(panel_start_day), class: "b270-btn" %>
56
+ <% if panel_today_day && panel_today_day != panel_start_day %>
57
+ <%= link_to "Go to today (Day #{panel_today_day})", day_path(panel_today_day), class: "b270-todaylink" %>
58
+ <% end %>
59
+ </div>
60
+ <% end %>
43
61
 
44
62
  <%= render "bible270/days/start_date", reader: panel_reader %>
45
63
  <% else %>
@@ -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}
@@ -22,7 +23,9 @@
22
23
  .b270-mark svg,.b270-mark img{display:block;border-radius:8px}
23
24
  .b270-title{font-family:"Spectral",Georgia,serif;font-weight:600;font-size:22px;text-decoration:none}
24
25
  .b270-tagline{font-size:12px;color:var(--faint);letter-spacing:.04em}
25
- .b270-nav{display:flex;align-items:center;gap:16px;font-size:14px}
26
+ .b270-nav{display:flex;align-items:center;font-size:14px}
27
+ .b270-desktopnav{display:flex;align-items:center;gap:16px}
28
+ .b270-navmenu{display:none}
26
29
  .b270-nav a{color:var(--muted);text-decoration:none}
27
30
  .b270-nav a:hover{color:var(--gold)}
28
31
  /* button_to renders a form; make it a flex item so the button sits on the same
@@ -39,6 +42,29 @@
39
42
  .b270-field{display:grid;gap:5px;font-size:12.5px;color:var(--muted)}
40
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%}
41
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)}
42
68
  .b270-emailform input[type=submit]{justify-self:start}
43
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}
44
70
  .b270-or::before,.b270-or::after{content:"";flex:1;height:1px;background:var(--line-soft)}
@@ -52,6 +78,11 @@
52
78
  .b270-flash{border-radius:10px;padding:11px 15px;margin-bottom:18px;font-size:14px}
53
79
  .b270-flash.alert{background:#f6e4e2;color:#7a2b28;border:1px solid #e6c3bf}
54
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}
55
86
 
56
87
  .b270-eyebrow{font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--gold);font-weight:600;margin:0 0 10px}
57
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}
@@ -63,6 +94,35 @@
63
94
  .b270-bignum .n{font-family:"Spectral",serif;font-size:38px;font-weight:600;line-height:1}
64
95
  .b270-bignum .of{color:var(--muted);font-size:15px}
65
96
  .b270-bignum .pct{margin-left:auto;color:var(--gold);font-weight:600;font-variant-numeric:tabular-nums}
97
+ .b270-progress-actions{display:flex;flex-direction:column;align-items:flex-start;gap:7px;margin:16px 0 0}
98
+ .b270-todaylink{margin-left:16px;font-size:12px;color:var(--muted);text-decoration:none}
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}
66
126
  .b270-signedout{color:var(--muted);font-size:14px;margin:4px 0 0}
67
127
 
68
128
  .b270-startdate{margin-top:18px;padding-top:16px;border-top:1px solid var(--line-soft)}
@@ -86,16 +146,29 @@
86
146
  .b270-tm .val{color:var(--faint);font-variant-numeric:tabular-nums;min-width:56px;text-align:right}
87
147
 
88
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}
89
155
  .b270-daynav{display:flex;align-items:center;gap:10px;margin-bottom:12px}
90
- .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}
91
157
  .b270-arrow:hover{border-color:var(--gold);color:var(--gold)}
92
- .b270-arrow.disabled{opacity:.35;pointer-events:none}
158
+ .b270-arrow.disabled{opacity:.45;cursor:not-allowed}
93
159
  .b270-dayhead{flex:1}
94
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}
95
161
  .b270-dayhead .date{display:flex;align-items:center;flex-wrap:wrap;gap:9px;color:var(--faint);font-size:13px;margin-top:4px}
96
162
  .b270-dayhead .b270-badge{margin-left:0}
97
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}
98
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
+
99
172
  /* readings */
100
173
  .b270-readings{background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);overflow:hidden}
101
174
  .b270-reading{display:flex;gap:15px;align-items:center;padding:16px 20px;border-left:4px solid transparent}
@@ -181,10 +254,11 @@
181
254
  .b270-likebtn.liked{filter:none;opacity:1}
182
255
  .b270-likecount{font-size:11.5px;color:var(--faint)}
183
256
  .b270-liketooltip{display:none;position:absolute;z-index:10;top:calc(100% + 6px);left:0;
184
- 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);
185
258
  border-radius:7px;background:var(--ink);color:var(--paper);font-size:12px;
186
259
  line-height:1.45;box-shadow:0 3px 10px rgba(0,0,0,.14)}
187
- .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}
188
262
  .b270-liker{display:block;white-space:nowrap}
189
263
  .b270-creply{margin-top:6px}
190
264
  .b270-cbody{margin:3px 0 0;white-space:pre-wrap}
@@ -199,7 +273,9 @@
199
273
  .b270-cdel{font-size:12px;color:var(--oxblood);background:none;border:none;cursor:pointer;padding:0;margin-left:8px}
200
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}
201
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)}
202
277
  .b270-form .row{display:flex;align-items:center;gap:10px;margin-top:10px}
278
+ .b270-form .row select{max-width:100%}
203
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}
204
280
  .b270-btn:hover{background:#3a352b}
205
281
 
@@ -211,7 +287,7 @@
211
287
  .b270-li .sub{font-size:12.5px;color:var(--faint)}
212
288
  .b270-li .stat{margin-left:auto;text-align:right}
213
289
  .b270-li .stat .big{font-family:"Spectral",serif;font-size:20px;font-weight:600}
214
- .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}
215
291
 
216
292
  /* calendar grid */
217
293
  .b270-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(30px,1fr));gap:6px;margin-top:16px}
@@ -229,8 +305,18 @@
229
305
 
230
306
  /* On the Reflections page each thread carries the day it belongs to, since
231
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)}
232
316
  .b270-thread{margin-bottom:8px}
317
+ .b270-thread-new{border-left:3px solid var(--gold);padding-left:10px}
233
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}
234
320
  .b270-threadday a{color:var(--gold);font-weight:600;text-decoration:none}
235
321
  .b270-threadday a:hover{text-decoration:underline}
236
322
  /* Scripture references stay undecorated, hover included, so a line of three of
@@ -239,10 +325,61 @@
239
325
  .b270-threadday .b270-reflink:hover{color:var(--muted);font-weight:400;text-decoration:none}
240
326
  .b270-threadday .b270-reflink:hover{color:var(--ink)}
241
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)}
242
334
 
243
335
  .b270-footer{margin-top:20px;padding-top:11px;border-top:1px solid var(--line-soft);color:var(--faint);font-size:13px}
244
336
  .b270-footer p{margin:0;line-height:1.5}
245
337
  details.b270-index summary{cursor:pointer;font-family:"Spectral",serif;font-size:18px;font-weight:600;list-style:none;margin-top:30px}
246
338
  details.b270-index summary::-webkit-details-marker{display:none}
247
- @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} }
339
+ @media (max-width:620px){
340
+ .b270-topbar{flex-wrap:nowrap;align-items:center}
341
+ .b270-brand{min-width:0}
342
+ .b270-brandtext{min-width:0}
343
+ .b270-tagline{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
344
+ .b270-nav{position:relative;flex:none}
345
+ .b270-desktopnav{display:none}
346
+ .b270-navmenu{display:block}
347
+ .b270-navtoggle{display:flex;align-items:center;justify-content:center;width:40px;height:38px;
348
+ list-style:none;cursor:pointer;padding:0;border:1px solid var(--line);
349
+ border-radius:8px;background:var(--card)}
350
+ .b270-navtoggle::-webkit-details-marker{display:none}
351
+ .b270-navtoggle:focus-visible{outline:2px solid var(--gold);outline-offset:2px}
352
+ .b270-menuicon{display:flex;flex-direction:column;gap:4px}
353
+ .b270-menuicon span{display:block;width:18px;height:2px;border-radius:2px;background:var(--ink)}
354
+ .b270-navmenu[open] .b270-menuicon span{background:var(--gold)}
355
+ .b270-navmenu:not([open])>.b270-navlinks{display:none}
356
+ .b270-navlinks{position:absolute;z-index:20;right:0;top:calc(100% + 7px);min-width:180px;
357
+ flex-direction:column;align-items:stretch;gap:0;padding:6px;
358
+ border:1px solid var(--line);border-radius:10px;background:var(--card);
359
+ box-shadow:var(--shadow)}
360
+ .b270-navlinks a,.b270-nav .b270-linkbtn{display:block;width:100%;padding:9px 10px;
361
+ border-radius:6px;text-align:left;white-space:nowrap}
362
+ .b270-navlinks a:hover,.b270-nav .b270-linkbtn:hover{background:var(--paper)}
363
+ .b270-nav form{display:block;width:100%}
364
+ }
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
+ }
248
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.18.3'
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