bible270 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e581a537cf6100f7d0012cd3de3de1d24b53d908fadb928ae38f09b4fcd4ef65
4
- data.tar.gz: '025209163a4d12210d243596129b2c440f7ed2096acb8b3d64a88486f960c07d'
3
+ metadata.gz: 8975632aa3e90165634356f25fc0c8cff2909e84b3177a58e192b16ee325b745
4
+ data.tar.gz: 543aa029054018c26f87d2d1836302ab671de51b70caa7a531ed0011a405f5e6
5
5
  SHA512:
6
- metadata.gz: bf4564bfb9c6edfca1bb7b1dbff2bcb93505ae26c04b8a07cfdfe441394e1d5ef757746acc81244c82f70ec5b9ef6498f191c57be537b814d4245492730ec8f3
7
- data.tar.gz: 6e95808d78d6c9224989be8664fcf2ae0e82cc61ae4a0d91ced27c830eebd77daa81596e3741aa63797001c62d50563c3751db12deeb54a36a61860beb5b744f
6
+ metadata.gz: c76e854eb6449addef5b1deb2066d70cc6c51e8650817efa7c4fef8efa0d847d0b1e85c75d9faa91dde497495a01c980c674342148326f8c43f146a3321175d4
7
+ data.tar.gz: 80761d784e504c167954827dbe3e3e8193e571e91eee67b8bd9aa12b7990b9845020f20399c3367f63b41d441107fa15748e6e4199d327ce2588d1976a6227ca
data/CHANGELOG.md CHANGED
@@ -4,9 +4,15 @@ All notable changes to bible270. Format follows [Keep a Changelog](https://keepa
4
4
 
5
5
  ## Unreleased Changes
6
6
 
7
+ ### Added
8
+
9
+ - mark a newly completed day with a brief, translucent dove animation that begins immediately on the final checkoff in JavaScript-capable browsers and remains available after Turbo or HTML-fallback responses; it respects reduced-motion preferences, replaces the final generic success message, and never blocks interaction
10
+
7
11
  ### Fixed
8
12
 
9
13
  - correct Day 27's New Testament reading to include Matthew 26:75
14
+ - silently recover all Turbo interactions from stale sessions, retry once with a fresh CSRF token, and reload rather than submit with a known-stale token if refreshing fails
15
+ - keep repeated or concurrent explicit checkoffs idempotent instead of raising a duplicate-validation Rails 422 error
10
16
 
11
17
  **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.3.0...main)**
12
18
 
@@ -116,18 +116,15 @@ module Bible270
116
116
  reason = error ? error.class : 'an unverified request'
117
117
  Rails.logger.info("[bible270] silently refreshing a page after #{reason}")
118
118
 
119
- if stale_turbo_checkoff?
119
+ if request.format.turbo_stream?
120
120
  response.set_header('X-Bible270-Refresh-Session', 'true')
121
+ response.set_header('Cache-Control', 'no-store')
121
122
  head :conflict
122
123
  else
123
124
  redirect_back fallback_location: root_path, allow_other_host: false, status: :see_other
124
125
  end
125
126
  end
126
127
 
127
- def stale_turbo_checkoff?
128
- controller_name == 'checkoffs' && action_name == 'toggle' && request.format.turbo_stream?
129
- end
130
-
131
128
  # Guard participation (checking off / commenting). Viewing is always open.
132
129
  def require_reader!
133
130
  return true if signed_in?
@@ -18,9 +18,10 @@ module Bible270
18
18
  requested_state = params[:checked] if params.key?(:checked)
19
19
  head :bad_request and return if params.key?(:checked) && !%w[0 1].include?(requested_state)
20
20
 
21
- marked_read = apply_checkoff(requested_state)
21
+ marked_read, checkoff_changed = apply_checkoff(requested_state)
22
22
 
23
23
  @reader = current_reader.reload.reload_progress
24
+ @day_just_completed = marked_read && checkoff_changed && @reader.day_complete?(@day)
24
25
  @reader_tracks = @reader.read_tracks_for(@day)
25
26
  @readings = Plan.readings_for(@day)
26
27
 
@@ -34,8 +35,12 @@ module Bible270
34
35
  respond_to do |format|
35
36
  format.turbo_stream
36
37
  format.html do
37
- message = marked_read ? 'Reading marked read.' : 'Reading marked unread.'
38
- redirect_to day_path(@day), flash: { b270_interaction_status: message }
38
+ if @day_just_completed
39
+ redirect_to day_path(@day), flash: { b270_day_just_completed: @day }
40
+ else
41
+ message = marked_read ? 'Reading marked read.' : 'Reading marked unread.'
42
+ redirect_to day_path(@day), flash: { b270_interaction_status: message }
43
+ end
39
44
  end
40
45
  end
41
46
  end
@@ -43,19 +48,33 @@ module Bible270
43
48
  private
44
49
 
45
50
  def apply_checkoff(requested_state)
46
- existing = current_reader.checkoffs.find_by(day: @day, track: @track, part: @part)
51
+ existing = find_checkoff
47
52
  if requested_state == '1'
48
53
  current_reader.ensure_started!
49
- current_reader.checkoffs.create_or_find_by!(day: @day, track: @track, part: @part)
50
- true
54
+ [true, existing.nil? && create_checkoff]
51
55
  elsif requested_state == '0' || existing
52
- existing&.destroy
53
- false
56
+ [false, existing ? existing.destroy.present? : false]
54
57
  else
55
58
  current_reader.ensure_started!
56
- current_reader.checkoffs.create(day: @day, track: @track, part: @part)
57
- true
59
+ [true, create_checkoff]
58
60
  end
59
61
  end
62
+
63
+ def find_checkoff
64
+ Checkoff.uncached do
65
+ Checkoff.find_by(reader_id: current_reader.id, day: @day, track: @track, part: @part)
66
+ end
67
+ end
68
+
69
+ def create_checkoff
70
+ Checkoff.create!(reader: current_reader, day: @day, track: @track, part: @part)
71
+ true
72
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
73
+ # A repeated or concurrent desired-state request is already successful. Do
74
+ # not hide any other validation failure under the same recovery.
75
+ raise unless find_checkoff
76
+
77
+ false
78
+ end
60
79
  end
61
80
  end
@@ -20,6 +20,7 @@ module Bible270
20
20
  @editing_comment_id = editing_comment_id
21
21
  @new_comment = Comment.new(day: @day, parent_id: reply_parent&.id)
22
22
  @reader_tracks = current_reader ? current_reader.read_tracks_for(@day) : []
23
+ @day_just_completed = flash[:b270_day_just_completed].to_i == @day
23
24
 
24
25
  # Every box on the day, not every track: an Old Testament reading of three
25
26
  # chapters is three rows, so counting tracks would call someone finished
@@ -4,7 +4,8 @@
4
4
  <% end %>
5
5
 
6
6
  <%= turbo_stream.replace "day_progress_#{@day}" do %>
7
- <%= render "bible270/days/day_progress", day: @day, reader: @reader %>
7
+ <%= render "bible270/days/day_progress", day: @day, reader: @reader,
8
+ just_completed: @day_just_completed %>
8
9
  <% end %>
9
10
 
10
11
  <%= turbo_stream.replace "completers_#{@day}" do %>
@@ -1,9 +1,10 @@
1
- <%# locals: (day:, reader:) %>
1
+ <%# locals: (day:, reader:, just_completed: false) %>
2
2
  <%
3
3
  previous_day = day > 1 ? day - 1 : nil
4
4
  next_day = day < Bible270::Plan::DAYS ? day + 1 : nil
5
5
  total_parts = Bible270::Plan.total_parts(day)
6
6
  complete = reader&.day_complete?(day)
7
+ completion_data = just_completed ? { b270_day_just_completed: true, b270_day: day } : {}
7
8
  %>
8
9
  <div id="day_progress_<%= day %>">
9
10
  <div class="b270-daynav">
@@ -32,7 +33,9 @@
32
33
 
33
34
 
34
35
  <% if complete %>
35
- <div class="b270-day-complete" role="status" aria-live="polite">
36
+ <%= render "bible270/shared/day_completion_dove", day: day if just_completed %>
37
+ <div class="b270-day-complete" role="status" aria-live="polite"
38
+ <%= tag.attributes(data: completion_data) %>>
36
39
  <div>
37
40
  <strong>Day <%= day %> complete</strong>
38
41
  <span>All <%= total_parts %> portions are checked off.</span>
@@ -1,7 +1,8 @@
1
1
  <% content_for :title, "Day #{@day} — #{Bible270.config.app_name}" %>
2
2
 
3
3
  <div class="b270-day-page">
4
- <%= render "bible270/days/day_progress", day: @day, reader: current_reader %>
4
+ <%= render "bible270/days/day_progress", day: @day, reader: current_reader,
5
+ just_completed: @day_just_completed %>
5
6
 
6
7
  <%= render "bible270/shared/interaction_status" %>
7
8
 
@@ -33,3 +34,4 @@
33
34
  </div>
34
35
 
35
36
  <%= render "bible270/shared/interaction_ui" %>
37
+ <%= render "bible270/shared/day_completion_effect", day: @day %>
@@ -0,0 +1,17 @@
1
+ <%# locals: (day:) %>
2
+ <svg class="b270-completion-dove" data-b270-completion-dove data-b270-day="<%= day %>"
3
+ viewBox="0 0 260 200" aria-hidden="true" focusable="false">
4
+ <defs>
5
+ <radialGradient id="b270-dove-light" cx="50%" cy="43%" r="68%">
6
+ <stop offset="0%" stop-color="#fff" />
7
+ <stop offset="58%" stop-color="#fffef8" />
8
+ <stop offset="100%" stop-color="#fff9e2" />
9
+ </radialGradient>
10
+ </defs>
11
+ <g class="b270-dove-silhouette">
12
+ <path d="M122 94C103 82 87 69 72 53L39 17l11 28-23-23 15 32-23-20 19 30-24-16 22 26-25-12 27 23-24-7 30 19-22-3 31 17c20 11 42 16 59 8 5-9 8-18 10-25Z" />
13
+ <path d="M138 94c19-12 35-25 50-41l33-36-11 28 23-23-15 32 23-20-19 30 24-16-22 26 25-12-27 23 24-7-30 19 22-3-31 17c-20 11-42 16-59 8-5-9-8-18-10-25Z" />
14
+ <path d="M148 140c6 7 16 14 24 19 2 2 1 5-2 6-5 2-10-1-14-5 4 8 7 17 4 22-2 3-5 1-7-2l-6-11c3 10 2 19-3 24-2 3-5 1-6-3l-4-15c0 11-3 21-8 24-3 2-5-1-6-5l-1-19c-3 10-6 17-11 20-3 2-5-1-5-5l2-19c-4 8-9 13-14 14-3 1-5-2-4-6l6-18c-5 5-11 7-16 4-3-1-3-4 0-7 7-7 17-13 26-19Z" />
15
+ <path d="M108 116c5-10 9-17 14-17-1-11-2-23 4-29 5-4 12-3 15 1 2 3 2 6 1 9 3 .2 6 .8 8 1.5-2.5 1.3-5.5 2.1-8 2.5-1 5-3 10-5 15 6 3 11 9 15 17-1 15-6 28-16 39-3 4-7 7-10 9-4-2-8-5-11-9-9-11-14-25-11-37 1-1 2-2 4-2Z" />
16
+ </g>
17
+ </svg>
@@ -0,0 +1,67 @@
1
+ <%# locals: (day:) %>
2
+ <template data-b270-completion-dove-template data-b270-day="<%= day %>">
3
+ <%= render "bible270/shared/day_completion_dove", day: day %>
4
+ </template>
5
+ <script nonce="<%= content_security_policy_nonce %>">
6
+ (() => {
7
+ if (window.Bible270DayCompletionEffect) return;
8
+
9
+ function willCompleteDay(form) {
10
+ const desiredState = form.querySelector("input[name='checked']");
11
+ if (!desiredState || desiredState.value !== "1") return false;
12
+
13
+ const unfinished = Array.from(
14
+ document.querySelectorAll("form[data-b270-checkoff='true'] input[name='checked'][value='1']")
15
+ );
16
+ return unfinished.length === 1 && unfinished[0].form === form;
17
+ }
18
+
19
+ function startDove(day) {
20
+ if (document.querySelector("[data-b270-completion-dove][data-b270-immediate]")) return;
21
+
22
+ const template = document.querySelector("[data-b270-completion-dove-template]");
23
+ if (!template || template.dataset.b270Day !== day) return;
24
+
25
+ const dove = template.content.querySelector("[data-b270-completion-dove]").cloneNode(true);
26
+ dove.dataset.b270Immediate = "true";
27
+ document.body.append(dove);
28
+ }
29
+
30
+ function removeServerDuplicate() {
31
+ const immediate = document.querySelector("[data-b270-completion-dove][data-b270-immediate]");
32
+ if (!immediate) return;
33
+
34
+ document.querySelectorAll("[data-b270-completion-dove]").forEach((dove) => {
35
+ if (dove !== immediate) dove.remove();
36
+ });
37
+ }
38
+
39
+ document.addEventListener("submit", (event) => {
40
+ const form = event.target.closest("form[data-b270-checkoff='true']");
41
+ const check = form && form.querySelector(".b270-check");
42
+ if (!check) return;
43
+
44
+ const bounds = check.getBoundingClientRect();
45
+ document.documentElement.style.setProperty(
46
+ "--b270-dove-origin-x",
47
+ `${bounds.left + bounds.width / 2}px`
48
+ );
49
+ document.documentElement.style.setProperty(
50
+ "--b270-dove-origin-y",
51
+ `${bounds.top + bounds.height / 2}px`
52
+ );
53
+
54
+ if (willCompleteDay(form)) startDove(form.action.match(/\/day\/(\d+)/)?.[1] || "");
55
+ });
56
+
57
+ document.addEventListener("animationend", (event) => {
58
+ if (event.target.matches("[data-b270-completion-dove]")) event.target.remove();
59
+ });
60
+
61
+ new MutationObserver(removeServerDuplicate).observe(document.documentElement, {
62
+ childList: true,
63
+ subtree: true
64
+ });
65
+ window.Bible270DayCompletionEffect = true;
66
+ })();
67
+ </script>
@@ -35,6 +35,7 @@
35
35
  let sessionRefreshedAt = Date.now();
36
36
  let sessionRefresh = null;
37
37
  let hiddenAt = null;
38
+ const submitters = new WeakMap();
38
39
 
39
40
  function updateAuthenticityToken(token) {
40
41
  const meta = document.querySelector("meta[name='csrf-token']");
@@ -97,6 +98,9 @@
97
98
  });
98
99
 
99
100
  document.addEventListener("submit", (event) => {
101
+ const submittedForm = event.target.closest("form");
102
+ if (submittedForm && event.submitter) submitters.set(submittedForm, event.submitter);
103
+
100
104
  const form = event.target.closest("form[data-b270-submit='true']");
101
105
  if (!form) return;
102
106
 
@@ -111,8 +115,7 @@
111
115
  form.requestSubmit(submitter || undefined);
112
116
  }).catch(() => {
113
117
  delete form.dataset.b270AwaitingSession;
114
- sessionRefreshedAt = Date.now();
115
- form.requestSubmit(submitter || undefined);
118
+ window.location.reload();
116
119
  });
117
120
  return;
118
121
  }
@@ -134,17 +137,22 @@
134
137
 
135
138
  function staleSessionResponse(fetchResponse) {
136
139
  if (!fetchResponse) return false;
137
- if (fetchResponse.statusCode === 422) return true;
138
140
 
139
- return fetchResponse.statusCode === 409 &&
140
- fetchResponse.response.headers.get("X-Bible270-Refresh-Session") === "true";
141
+ const response = fetchResponse.response;
142
+ if (fetchResponse.statusCode === 409 &&
143
+ response.headers.get("X-Bible270-Refresh-Session") === "true") return true;
144
+
145
+ // Older deployments can still return Rails' HTML 422 page. Do not confuse
146
+ // an intentional Turbo Stream validation response with an expired session.
147
+ const contentType = response.headers.get("Content-Type") || "";
148
+ return fetchResponse.statusCode === 422 && contentType.includes("text/html");
141
149
  }
142
150
 
143
151
  document.addEventListener("turbo:before-fetch-response", (event) => {
144
152
  const target = event.target;
145
- const form = target.closest && target.closest("form[data-b270-checkoff='true']");
153
+ const form = target.closest && target.closest("form");
146
154
  const response = event.detail.fetchResponse;
147
- if (!form || !staleSessionResponse(response)) return;
155
+ if (!form || form.method.toLowerCase() === "get" || !staleSessionResponse(response)) return;
148
156
 
149
157
  event.preventDefault();
150
158
  if (form.dataset.b270RetriedAfterStaleSession === "true") {
@@ -152,9 +160,12 @@
152
160
  return;
153
161
  }
154
162
 
163
+ const submitter = submitters.get(form);
155
164
  form.dataset.b270RetriedAfterStaleSession = "true";
156
165
  resetForm(form);
157
- refreshSession().then(() => form.requestSubmit()).catch(() => window.location.reload());
166
+ refreshSession()
167
+ .then(() => form.requestSubmit(submitter || undefined))
168
+ .catch(() => window.location.reload());
158
169
  });
159
170
 
160
171
  document.addEventListener("turbo:submit-end", (event) => {
@@ -165,7 +176,12 @@
165
176
  if (form.dataset.b270RetriedAfterStaleSession === "true" &&
166
177
  staleSessionResponse(event.detail.fetchResponse)) return;
167
178
 
168
- if (event.detail.success) {
179
+ delete form.dataset.b270RetriedAfterStaleSession;
180
+ const completedDay = form.dataset.b270Checkoff === "true" &&
181
+ document.querySelector("[data-b270-day-just-completed='true']");
182
+ if (event.detail.success && completedDay) {
183
+ announce("");
184
+ } else if (event.detail.success) {
169
185
  announce(form.dataset.b270Success || "Saved.", "success");
170
186
  } else {
171
187
  announce(form.dataset.b270Failure || "Couldn’t save that. Try again.", "error");
@@ -147,6 +147,18 @@
147
147
  .b270-day-complete strong{font-family:"Spectral",serif;font-size:16px}
148
148
  .b270-day-complete span{font-size:12px;color:var(--muted)}
149
149
  .b270 .b270-day-complete .b270-btn{flex:none;margin:0 0 0 auto}
150
+ .b270-completion-dove{position:fixed;z-index:1000;top:var(--b270-dove-origin-y,72vh);left:var(--b270-dove-origin-x,50vw);
151
+ width:min(75vw,97.5vh);height:auto;overflow:visible;pointer-events:none;opacity:0;
152
+ filter:drop-shadow(0 0 8px rgba(255,255,255,1)) drop-shadow(0 0 24px rgba(255,252,220,1)) drop-shadow(0 0 52px rgba(225,191,92,.82));
153
+ will-change:top,left,transform,opacity;animation:b270-dove-flight 1.6s cubic-bezier(.18,.72,.28,1) both}
154
+ .b270-dove-silhouette{fill:url(#b270-dove-light);stroke:none}
155
+ @keyframes b270-dove-flight{
156
+ 0%{top:var(--b270-dove-origin-y,72vh);left:var(--b270-dove-origin-x,50vw);opacity:.22;visibility:visible;transform:translate(-50%,-50%) scale(.02) rotate(-8deg)}
157
+ 8%{top:calc(var(--b270-dove-origin-y,72vh) - 10px);left:var(--b270-dove-origin-x,50vw);opacity:.78;transform:translate(-50%,-50%) scale(.08) rotate(-5deg)}
158
+ 64%{top:38vh;left:52vw;opacity:.92;transform:translate(-50%,-50%) scale(1) rotate(4deg)}
159
+ 99%{visibility:visible}
160
+ 100%{top:-25vh;left:115vw;opacity:0;visibility:hidden;transform:translate(-50%,-50%) scale(1.55) rotate(12deg)}
161
+ }
150
162
 
151
163
  /* readings */
152
164
  .b270-readings{background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);overflow:hidden}
@@ -156,7 +168,7 @@
156
168
  .b270-reading.rest{opacity:.65}
157
169
  .b270-toggle{border:none;background:none;padding:0;cursor:pointer}
158
170
  .b270-toggle form{margin:0}
159
- .b270-check{width:26px;height:26px;border-radius:8px;border:2px solid var(--line);flex:none;display:grid;place-items:center;background:#fff;transition:all .18s}
171
+ .b270-check{width:26px;height:26px;border-radius:8px;border:2px solid var(--line);flex:none;display:grid;place-items:center;background:#fff;transition:background-color .18s,border-color .18s,opacity .18s}
160
172
  .b270-check svg{width:15px;height:15px;stroke:#fff;stroke-width:3;fill:none;stroke-linecap:round;stroke-linejoin:round;opacity:0}
161
173
  .b270-reading.done.ot .b270-check{background:var(--teal);border-color:var(--teal)}
162
174
  .b270-reading.done.nt .b270-check{background:var(--oxblood);border-color:var(--oxblood)}
@@ -366,6 +378,7 @@
366
378
  .b270 .b270-day-complete .b270-btn{margin:3px 0 0}
367
379
  }
368
380
  @media (prefers-reduced-motion:reduce){
381
+ .b270-completion-dove{display:none!important}
369
382
  .b270 *,.b270 *::before,.b270 *::after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}
370
383
  }
371
384
  </style>
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bible270
4
- VERSION = '1.3.1'
4
+ VERSION = '1.4.0'
5
5
  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: 1.3.1
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew vonderLuft
@@ -138,6 +138,8 @@ files:
138
138
  - app/views/bible270/sessions/new.html.erb
139
139
  - app/views/bible270/shared/_comment_drafts.html.erb
140
140
  - app/views/bible270/shared/_comment_notification_choices.html.erb
141
+ - app/views/bible270/shared/_day_completion_dove.html.erb
142
+ - app/views/bible270/shared/_day_completion_effect.html.erb
141
143
  - app/views/bible270/shared/_day_index.html.erb
142
144
  - app/views/bible270/shared/_footer.html.erb
143
145
  - app/views/bible270/shared/_grid_legend.html.erb