bible270 1.3.2 → 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: 1045882b2a544c39d4f9c4b6282c4dd67861f1af380d4af7ad1cd226b57dcaee
4
- data.tar.gz: 03c9162733bfaf96cc08fcd101231174efbf97378cdefe47e55d91d603c0b6bd
3
+ metadata.gz: 8975632aa3e90165634356f25fc0c8cff2909e84b3177a58e192b16ee325b745
4
+ data.tar.gz: 543aa029054018c26f87d2d1836302ab671de51b70caa7a531ed0011a405f5e6
5
5
  SHA512:
6
- metadata.gz: 8bc8c454821233d8c899af8c6224b08d5c48b615ac2c6bbf5dc03140cce041c60c7f1208c78a631576875b3eb3507ca82ae61e2c57a19b5c4fe0e6aab3d30c1d
7
- data.tar.gz: 735cf1ba5f33e2f56bd87cbd2720eeb6b685822b148b7b2a13fd8e7537dcd63cf73e6253e050445be136087bcacc4802dcda36c939f1080994c2a8733a1f4b78
6
+ metadata.gz: c76e854eb6449addef5b1deb2066d70cc6c51e8650817efa7c4fef8efa0d847d0b1e85c75d9faa91dde497495a01c980c674342148326f8c43f146a3321175d4
7
+ data.tar.gz: 80761d784e504c167954827dbe3e3e8193e571e91eee67b8bd9aa12b7990b9845020f20399c3367f63b41d441107fa15748e6e4199d327ce2588d1976a6227ca
data/CHANGELOG.md CHANGED
@@ -4,10 +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
10
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
11
16
 
12
17
  **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.3.0...main)**
13
18
 
@@ -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>
@@ -177,7 +177,11 @@
177
177
  staleSessionResponse(event.detail.fetchResponse)) return;
178
178
 
179
179
  delete form.dataset.b270RetriedAfterStaleSession;
180
- if (event.detail.success) {
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) {
181
185
  announce(form.dataset.b270Success || "Saved.", "success");
182
186
  } else {
183
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.2'
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.2
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