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
@@ -13,7 +13,7 @@
13
13
  · <em>the 270 days have elapsed</em>
14
14
  <% elsif (off = reader.days_off_pace) %>
15
15
  <% if off > 0 %>
16
- · <em><%= off %> day<%= "s" unless off == 1 %> behind</em>
16
+ · <em>reading at your own pace</em>
17
17
  <% elsif off < 0 %>
18
18
  · <em><%= -off %> day<%= "s" unless off == -1 %> ahead</em>
19
19
  <% else %>
@@ -1,26 +1,34 @@
1
1
  <% content_for :title, Bible270.config.app_name %>
2
2
  <p class="b270-eyebrow"><%= Bible270.config.tagline %></p>
3
3
  <h1><%= Bible270.config.app_name %> <em>— read God's whole story</em></h1>
4
- <p class="lede">Daily passages from the Old Testament, New Testament, and Psalms or Proverbs — portions sized to take about the same time to read. Check off what you read; your place is saved for you, and you can see how your friends are progressing as well.</p>
4
+ <p class="lede">Daily passages from the Old Testament, New Testament, and Psalms or Proverbs — portions sized to take about the same time to read. Check off what you read; your place is saved for you, and you can share reflections and encourage one another along the way.</p>
5
5
 
6
6
  <%= render "bible270/shared/progress" %>
7
7
 
8
8
  <% if @readers.any? %>
9
- <% rankings= @readers.count < 5 ? @readers.count : 5 %>
10
- <h2 class="b270-section-h">Reader Leaders</h2>
9
+ <h2 class="b270-section-h">Reading together</h2>
10
+ <p class="b270-community-intro">
11
+ Showing <%= [@readers.size, 5].min %> of <%= pluralize(@readers.size, "reader") %>, ordered by recent
12
+ checkoffs and reflections; new readers by join date.
13
+ </p>
11
14
  <div class="b270-list">
12
- <% @readers.first(rankings).each_with_index do |r, i| %>
15
+ <% @readers.first(5).each do |r| %>
13
16
  <div class="b270-li">
14
- <span class="rank"><%= i + 1 %></span>
15
17
  <%= b270_avatar(r) %>
16
18
  <div>
17
19
  <div class="name"><%= link_to r.display_name, reader_path(r), style: "text-decoration:none" %></div>
18
20
  <% day_word = @days_completed[r.id] == 1 ? "day" : "days" %>
19
- <div class="sub"><%= @days_completed[r.id] %> <%= day_word %> read</div>
21
+ <% activity = @reader_activity.fetch(r.id) %>
22
+ <% activity_verb = { reading: "read", reflection: "reflected", joined: "joined" }.fetch(activity[:kind]) %>
23
+ <div class="sub">
24
+ <%= @days_completed[r.id] %> <%= day_word %> read ·
25
+ <span class="b270-reader-activity"><%= activity_verb %>
26
+ <time datetime="<%= activity[:at].iso8601 %>" title="<%= b270_datetime(activity[:at]) %>"><%= time_ago_in_words(activity[:at]) %> ago</time>
27
+ </span>
28
+ </div>
20
29
  </div>
21
- <div class="stat"><span class="big"><%= (@days_completed[r.id].to_f / Bible270::Plan::DAYS * 100).round %>%</span></div>
22
30
  </div>
23
31
  <% end %>
24
32
  </div>
25
- <p style="margin:12px 0 0"><%= link_to "See everyone →", community_path %></p>
33
+ <p style="margin:12px 0 0"><%= link_to "Meet the community →", community_path %></p>
26
34
  <% end %>
@@ -1,50 +1,46 @@
1
1
  <% content_for :title, "Day #{@day} — #{Bible270.config.app_name}" %>
2
- <div class="b270-daynav">
3
- <%= link_to "‹", (@prev_day ? day_path(@prev_day) : "#"), class: "b270-arrow #{'disabled' unless @prev_day}", "aria-label": "Previous day" %>
4
- <div class="b270-dayhead">
5
- <div class="k">Day <%= @day %><% if signed_in? && current_reader.day_complete?(@day) %><span class="b270-badge">Complete</span><% end %></div>
6
- <% if signed_in? && current_reader.dated? %>
7
- <div class="date">
8
- <%= current_reader.date_for_day(@day).strftime("%A, %B %-d, %Y") %>
9
- <% if current_reader.today?(@day) %><span class="b270-badge">Today</span><% end %>
10
- </div>
11
- <% end %>
2
+
3
+ <div class="b270-day-page" data-b270-day-page data-b270-reading-mode="full">
4
+ <%= render "bible270/days/day_progress", day: @day, reader: current_reader %>
5
+
6
+ <div class="b270-reading-options" data-b270-reading-options hidden>
7
+ <button type="button" class="b270-linkbtn b270-reading-mode-toggle"
8
+ data-b270-compact-toggle aria-pressed="false">Compact reading</button>
12
9
  </div>
13
- <%= link_to "›", (@next_day ? day_path(@next_day) : "#"), class: "b270-arrow #{'disabled' unless @next_day}", "aria-label": "Next day" %>
14
- </div>
15
10
 
16
- <div id="readings" class="b270-readings">
17
- <%= render partial: "bible270/days/reading", collection: %w[ot nt pp],
18
- as: :track, locals: { day: @day, readings: @readings, reader_tracks: @reader_tracks } %>
19
- </div>
11
+ <%= render "bible270/shared/interaction_status" %>
20
12
 
21
- <% unless signed_in? %>
22
- <p class="b270-signin-hint" style="margin-top:12px"><%= link_to "Sign in", sign_in_path(origin: request.fullpath) %> to check off these readings.</p>
23
- <% end %>
24
-
25
- <div class="b270-completers" id="completers_<%= @day %>">
26
- <% if @completers.any? %>
27
- <span>Finished this day:</span>
28
- <% @completers.first(12).each { |r| %><%= link_to b270_avatar(r), reader_path(r), title: r.display_name %><% } %>
29
- <% if @completers.size > 12 %><span><%= @completers.size - 12 %> more</span><% end %>
30
- <% else %>
31
- <span>No one has finished this day yet — be the first.</span>
32
- <% end %>
33
- </div>
13
+ <div id="readings" class="b270-readings">
14
+ <%= render partial: "bible270/days/reading", collection: %w[ot nt pp],
15
+ as: :track, locals: { day: @day, readings: @readings, reader_tracks: @reader_tracks } %>
16
+ </div>
34
17
 
35
- <h2 class="b270-section-h">Reflections</h2>
36
- <div id="comments">
37
- <% if @comments.any? %>
38
- <%= render partial: "bible270/comments/comment", collection: @comments, as: :comment %>
39
- <% else %>
40
- <p style="color:var(--faint)" id="no_comments">No reflections yet. Share the first.</p>
18
+ <% unless signed_in? %>
19
+ <p class="b270-signin-hint" style="margin-top:12px"><%= link_to "Sign in", sign_in_path(origin: request.fullpath) %> to check off these readings.</p>
41
20
  <% end %>
42
- </div>
43
21
 
44
- <% if signed_in? %>
45
- <div style="margin-top:18px">
46
- <%= render "bible270/comments/form", day: @day, comment: @new_comment %>
22
+ <div class="b270-day-community" data-b270-compact-hide>
23
+ <%= render "bible270/days/completers", day: @day, completers: @completers %>
24
+
25
+ <h2 class="b270-section-h">Reflections</h2>
26
+ <div id="comments">
27
+ <% if @comments.any? %>
28
+ <%= render partial: "bible270/comments/comment", collection: @comments, as: :comment %>
29
+ <% else %>
30
+ <p style="color:var(--faint)" id="no_comments">No reflections yet. Share the first.</p>
31
+ <% end %>
32
+ </div>
33
+
34
+ <% if signed_in? %>
35
+ <div style="margin-top:18px">
36
+ <%= render "bible270/comments/form", day: @day, comment: @new_comment %>
37
+ </div>
38
+ <%= render "bible270/shared/comment_drafts" %>
39
+ <% else %>
40
+ <p class="b270-signin-hint" style="margin-top:14px"><%= link_to "Sign in", sign_in_path(origin: request.fullpath) %> to leave a reflection.</p>
41
+ <% end %>
47
42
  </div>
48
- <% else %>
49
- <p class="b270-signin-hint" style="margin-top:14px"><%= link_to "Sign in", sign_in_path(origin: request.fullpath) %> to leave a reflection.</p>
50
- <% end %>
43
+ </div>
44
+
45
+ <%= render "bible270/shared/reading_mode" %>
46
+ <%= render "bible270/shared/interaction_ui" %>
@@ -15,7 +15,11 @@
15
15
 
16
16
  <% if signed_in? %>
17
17
  <%= button_to like_comment_path(comment), method: :post, class: "b270-likebtn#{' liked' if likes.any?}",
18
- form: { style: "display:inline", data: { turbo_stream: true } },
18
+ params: { liked: liked ? "0" : "1" },
19
+ form: { style: "display:inline",
20
+ data: { turbo_stream: true, b270_submit: true, b270_pending: "Saving like…",
21
+ b270_success: (liked ? "Like removed." : "Reflection liked."),
22
+ b270_failure: "Couldn’t update that like. Try again." } },
19
23
  aria: {
20
24
  label: liked ? "Take back your like" : "Like this reflection",
21
25
  describedby: (tooltip_id if likes.any?)
@@ -0,0 +1,30 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <body style="margin:0;padding:24px;background:#ece6da;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#231f18">
4
+ <div style="max-width:520px;margin:0 auto;background:#f4efe6;border:1px solid #d7cfbf;border-radius:14px;padding:28px">
5
+ <h1 style="margin:0 0 6px;font-family:Georgia,serif;font-size:20px;font-weight:600">
6
+ Day <%= @day %> is ready, <%= @reader.first_name.presence || @reader.display_name %>
7
+ </h1>
8
+ <p style="margin:0 0 18px;font-size:14px;color:#6d6558">
9
+ <%= @on.strftime("%A, %B %-d, %Y") %> · <%= @app_name %>
10
+ </p>
11
+
12
+ <ul style="margin:0;padding-left:22px;font-size:15px;line-height:1.7">
13
+ <% @readings.each do |track, reference| %>
14
+ <li><strong><%= Bible270::Plan::TRACKS.fetch(track).fetch(:label) %>:</strong> <%= reference %></li>
15
+ <% end %>
16
+ </ul>
17
+
18
+ <% if @day_url %>
19
+ <p style="margin:20px 0 0">
20
+ <a href="<%= @day_url %>" style="display:inline-block;background:#231f18;color:#ece6da;text-decoration:none;padding:10px 18px;border-radius:10px;font-weight:600;font-size:14px">Open day <%= @day %></a>
21
+ </p>
22
+ <% end %>
23
+
24
+ <p style="margin:22px 0 0;font-size:12px;color:#9a9082">
25
+ You opted in to daily reading reminders.
26
+ <% if @profile_url %><a href="<%= @profile_url %>" style="color:#6d6558">Change this in your profile</a>.<% end %>
27
+ </p>
28
+ </div>
29
+ </body>
30
+ </html>
@@ -0,0 +1,14 @@
1
+ Day <%= @day %> is ready, <%= @reader.first_name.presence || @reader.display_name %>.
2
+ <%= @on.strftime("%A, %B %-d, %Y") %> · <%= @app_name %>
3
+
4
+ <% @readings.each do |track, reference| %>
5
+ <%= Bible270::Plan::TRACKS.fetch(track).fetch(:label) %>: <%= reference %>
6
+ <% end %>
7
+ <% if @day_url %>
8
+ Open day <%= @day %>: <%= @day_url %>
9
+ <% end %>
10
+ --
11
+ You opted in to daily reading reminders.
12
+ <% if @profile_url %>
13
+ Change this in your profile: <%= @profile_url %>
14
+ <% end %>
@@ -21,7 +21,6 @@
21
21
 
22
22
  <p style="margin:22px 0 0;font-size:12px;color:#9a9082">
23
23
  You are getting this because someone wrote your name in a reflection.
24
- You can turn these off on your profile page.
25
24
  </p>
26
25
  </div>
27
26
  </body>
@@ -8,5 +8,4 @@ Read it and reply: <%= @day_url %>
8
8
  You can find it on day <%= @comment.day %> of <%= @app_name %>.
9
9
  <% end %>
10
10
  --
11
- You are getting this because someone wrote your name in a reflection. You can
12
- turn these off on your profile page.
11
+ You are getting this because someone wrote your name in a reflection.
@@ -1,6 +1,6 @@
1
- <% content_for :title, "Your profile — #{Bible270.config.app_name}" %>
2
- <p class="b270-eyebrow">Your details</p>
3
- <h1>Your profile</h1>
1
+ <% content_for :title, "Profile — #{Bible270.config.app_name}" %>
2
+ <p class="b270-eyebrow">Profile</p>
3
+ <h1>Your details</h1>
4
4
  <p class="lede">This is how you appear beside your reflections and on the community page.</p>
5
5
 
6
6
  <div class="b270-panel">
@@ -19,10 +19,6 @@
19
19
  <% end %>
20
20
  </div>
21
21
 
22
- <label class="b270-field" style="flex-direction:row;align-items:center;gap:10px">
23
- <%= check_box_tag :notify_on_mention, "1", current_reader.notify_on_mention %>
24
- <span style="margin:0">Email me when someone mentions me in a reflection</span>
25
- </label>
26
22
 
27
23
  <label class="b270-field">
28
24
  <span>First name</span>
@@ -41,6 +37,43 @@
41
37
  </label>
42
38
  <%= render "bible270/shared/passage_source", reader: current_reader %>
43
39
 
40
+ <% if @daily_reminders_available %>
41
+ <% reminder_hour, reminder_minute = @daily_reminder_time.to_s.split(":", 2) %>
42
+ <div class="b270-field">
43
+ <span>Daily reading reminder</span>
44
+ <div class="b270-reminder-row">
45
+ <label class="b270-reminder-optin">
46
+ <%= check_box_tag :daily_reminders, "1", @daily_reminders,
47
+ aria: { describedby: "daily-reminder-optin-hint" } %>
48
+ <span>Email me each day's readings</span>
49
+ <span class="b270-reminder-tooltip" id="daily-reminder-optin-hint" role="tooltip">
50
+ Uses the plan's configured time zone. You can turn reminders off here at any time.
51
+ </span>
52
+ </label>
53
+ <div class="b270-reminder-time">
54
+ <span>at</span>
55
+ <span class="b270-time-control" role="group" aria-label="Reminder time"
56
+ aria-describedby="daily-reminder-time-hint">
57
+ <%= select_tag :daily_reminder_hour,
58
+ options_for_select((0..23).map { |hour| format("%02d", hour) }, reminder_hour),
59
+ class: "b270-time-select", required: true, aria: { label: "Hour" } %>
60
+ <span class="b270-time-separator" aria-hidden="true">:</span>
61
+ <%= select_tag :daily_reminder_minute,
62
+ options_for_select((0..59).map { |minute| format("%02d", minute) }, reminder_minute),
63
+ class: "b270-time-select", required: true, aria: { label: "Minute" } %>
64
+ <span class="b270-reminder-tooltip" id="daily-reminder-time-hint" role="tooltip">
65
+ Uses the plan's configured time zone. You can turn reminders off here at any time.
66
+ </span>
67
+ </span>
68
+ </div>
69
+ </div>
70
+ </div>
71
+ <% elsif Bible270.config.daily_reminders? %>
72
+ <p class="b270-flash alert" style="margin:0">
73
+ Daily reminders are temporarily unavailable while this installation finishes a database update.
74
+ </p>
75
+ <% end %>
76
+
44
77
  <%= submit_tag "Save", class: "b270-btn" %>
45
78
  <% end %>
46
79
 
@@ -55,4 +88,4 @@
55
88
  <% end %>
56
89
  </div>
57
90
 
58
- <p style="margin-top:18px"><%= link_to "← Back to your progress", reader_path(current_reader) %></p>
91
+ <p style="margin-top:18px"><%= link_to "← Back to your progress", progress_path %></p>
@@ -0,0 +1,63 @@
1
+ <%# locals: (reader:, partial_day_tasks:, remaining_partial_days:, week_days:, week_completed:, next_milestone:, days_to_milestone:) %>
2
+ <div class="b270-panel b270-next-steps">
3
+ <h2 class="b270-subhead">Your next steps</h2>
4
+
5
+ <section class="b270-next-section" aria-labelledby="finish-started-heading">
6
+ <h3 id="finish-started-heading">Finish what you started</h3>
7
+ <% if partial_day_tasks.any? %>
8
+ <ul class="b270-task-list">
9
+ <% partial_day_tasks.each do |task| %>
10
+ <% day = task[:day] %>
11
+ <% first_track = task[:remaining].first&.fetch(:track) %>
12
+ <li class="b270-task">
13
+ <div class="b270-task-body">
14
+ <strong><%= link_to "Day #{day}", day_path(day, anchor: "reading_#{day}_#{first_track}") %></strong>
15
+ <span>
16
+ Still to read:
17
+ <%= safe_join(task[:remaining].map { |part|
18
+ link_to(part[:reference], day_path(day, anchor: "reading_#{day}_#{part[:track]}"))
19
+ }, " · ") %>
20
+ </span>
21
+ </div>
22
+ <%= link_to "Continue →", day_path(day, anchor: "reading_#{day}_#{first_track}"),
23
+ class: "b270-task-link" %>
24
+ </li>
25
+ <% end %>
26
+ </ul>
27
+ <% if remaining_partial_days.positive? %>
28
+ <p class="b270-next-note"><%= remaining_partial_days %> more partially read
29
+ <%= "day".pluralize(remaining_partial_days) %> are highlighted in the grid below.</p>
30
+ <% end %>
31
+ <% else %>
32
+ <p class="b270-next-note">No partially read days. Your next reading is ready above.</p>
33
+ <% end %>
34
+ </section>
35
+
36
+ <section class="b270-next-section" aria-labelledby="this-week-heading">
37
+ <h3 id="this-week-heading">This week</h3>
38
+ <% if week_days.any? %>
39
+ <p class="b270-week-count"><strong><%= week_completed %> of <%= week_days.size %></strong>
40
+ scheduled <%= "day".pluralize(week_days.size) %> complete so far this week</p>
41
+ <progress class="b270-week-progress" value="<%= week_completed %>" max="<%= week_days.size %>"
42
+ aria-label="<%= week_completed %> of <%= week_days.size %> scheduled days complete this week"></progress>
43
+ <% elsif reader.dated? %>
44
+ <p class="b270-next-note">No plan days are scheduled so far this week.</p>
45
+ <% elsif Bible270.config.allow_reader_start_date %>
46
+ <p class="b270-next-note">Set a start date to see your weekly progress.</p>
47
+ <% else %>
48
+ <p class="b270-next-note">This plan is not tied to calendar dates.</p>
49
+ <% end %>
50
+ </section>
51
+
52
+ <section class="b270-next-section" aria-labelledby="milestone-heading">
53
+ <h3 id="milestone-heading">Next milestone</h3>
54
+ <% if next_milestone %>
55
+ <% milestone_day, milestone_label = next_milestone %>
56
+ <p class="b270-milestone"><strong><%= milestone_day %> days</strong> · <%= milestone_label %></p>
57
+ <p class="b270-next-note"><%= days_to_milestone %>
58
+ <%= "day".pluralize(days_to_milestone) %> to go.</p>
59
+ <% else %>
60
+ <p class="b270-milestone"><strong>All <%= Bible270::Plan::DAYS %> days complete.</strong></p>
61
+ <% end %>
62
+ </section>
63
+ </div>
@@ -1,20 +1,17 @@
1
1
  <% content_for :title, "Community — #{Bible270.config.app_name}" %>
2
- <p class="b270-eyebrow">Reading together</p>
3
- <h1>The community</h1>
4
- <p class="lede">Everyone walking through the Word, seeing each pilgrim's progress.</p>
2
+ <p class="b270-eyebrow">Community</p>
3
+ <h1>Reading together</h1>
4
+ <p class="lede">A community walking through the Word, sharing reflections and encouraging one another.</p>
5
5
 
6
6
  <% if @readers.any? %>
7
7
  <div class="b270-list">
8
- <% @readers.each_with_index do |r, i| %>
8
+ <% @readers.each do |r| %>
9
9
  <div class="b270-li">
10
- <span class="rank"><%= i + 1 %></span>
11
10
  <%= b270_avatar(r, size: 72) %>
12
11
  <div>
13
12
  <div class="name"><%= link_to r.display_name, reader_path(r), style: "text-decoration:none" %></div>
14
- <div class="sub"><%= @days_completed[r.id] %> of <%= Bible270::Plan::DAYS %> days · joined <%= Bible270.local_time(r.created_at).strftime("%b %Y") %></div>
15
- </div>
16
- <div class="stat">
17
- <span class="big"><%= (@days_completed[r.id].to_f / Bible270::Plan::DAYS * 100).round %>%</span>
13
+ <% day_word = @days_completed[r.id] == 1 ? "day" : "days" %>
14
+ <div class="sub"><%= @days_completed[r.id] %> <%= day_word %> read · joined <%= Bible270.local_time(r.created_at).strftime("%b %Y") %></div>
18
15
  </div>
19
16
  </div>
20
17
  <% end %>
@@ -1,11 +1,21 @@
1
- <% content_for :title, "Your progress — #{Bible270.config.app_name}" %>
1
+ <% content_for :title, "My Progress — #{Bible270.config.app_name}" %>
2
2
  <% content_for :hide_day_index, true %><%# the panel is followed by its own grid below %>
3
3
 
4
- <p class="b270-eyebrow">Your progress</p>
5
- <h1><%= @reader ? @reader.display_name : "Your progress" %></h1>
4
+ <p class="b270-eyebrow">My Progress</p>
5
+ <h1>Your reading journey</h1>
6
6
 
7
7
  <%= render "bible270/shared/progress" %>
8
8
 
9
+ <% if @reader %>
10
+ <%= render "bible270/readers/next_steps", reader: @reader,
11
+ partial_day_tasks: @partial_day_tasks,
12
+ remaining_partial_days: @remaining_partial_days,
13
+ week_days: @week_days,
14
+ week_completed: @week_completed,
15
+ next_milestone: @next_milestone,
16
+ days_to_milestone: @days_to_milestone %>
17
+ <% end %>
18
+
9
19
  <div class="b270-panel">
10
20
  <%= render "bible270/shared/reflections", comments: @recent_comments,
11
21
  title: "Your recent reflections",
@@ -0,0 +1,169 @@
1
+ <script nonce="<%= content_security_policy_nonce %>">
2
+ (() => {
3
+ if (window.Bible270Drafts) {
4
+ window.Bible270Drafts.scan();
5
+ return;
6
+ }
7
+
8
+ const maxAge = 30 * 24 * 60 * 60 * 1000;
9
+ const pendingKey = "bible270:draft:pending";
10
+
11
+ function browserStorage(name) {
12
+ try { return window[name]; } catch (_error) { return null; }
13
+ }
14
+
15
+ const local = browserStorage("localStorage");
16
+ const session = browserStorage("sessionStorage");
17
+ const storage = {
18
+ get(area, key) {
19
+ if (!area) return null;
20
+ try { return area.getItem(key); } catch (_error) { return null; }
21
+ },
22
+ set(area, key, value) {
23
+ if (!area) return;
24
+ try { area.setItem(key, value); } catch (_error) { /* Drafts degrade safely. */ }
25
+ },
26
+ remove(area, key) {
27
+ if (!area) return;
28
+ try { area.removeItem(key); } catch (_error) { /* Drafts degrade safely. */ }
29
+ }
30
+ };
31
+
32
+ function readDraft(key) {
33
+ const raw = storage.get(local, key);
34
+ if (!raw) return null;
35
+
36
+ try {
37
+ const draft = JSON.parse(raw);
38
+ if (typeof draft.body !== "string" || typeof draft.savedAt !== "number" ||
39
+ Date.now() - draft.savedAt > maxAge) {
40
+ storage.remove(local, key);
41
+ return null;
42
+ }
43
+ return draft;
44
+ } catch (_error) {
45
+ storage.remove(local, key);
46
+ return null;
47
+ }
48
+ }
49
+
50
+ function setStatus(form, message) {
51
+ const status = form.querySelector("[data-b270-draft-status]");
52
+ if (status) status.textContent = message;
53
+ }
54
+
55
+ function save(form) {
56
+ const key = form.dataset.b270DraftKey;
57
+ const body = form.querySelector("textarea[name='comment[body]']");
58
+ const track = form.querySelector("select[name='comment[track]']");
59
+ if (!key || !body) return;
60
+
61
+ if (!body.value.trim()) {
62
+ storage.remove(local, key);
63
+ setStatus(form, "");
64
+ return;
65
+ }
66
+
67
+ const trackValue = track ? track.value : "";
68
+ const current = readDraft(key);
69
+ if (current && current.body === body.value && current.track === trackValue) {
70
+ setStatus(form, "Draft saved in this browser.");
71
+ return;
72
+ }
73
+
74
+ const draft = {
75
+ body: body.value,
76
+ track: trackValue,
77
+ savedAt: Date.now(),
78
+ revision: `${Date.now().toString(36)}:${Math.random().toString(36).slice(2)}`
79
+ };
80
+ storage.set(local, key, JSON.stringify(draft));
81
+ setStatus(form, "Draft saved in this browser.");
82
+ }
83
+
84
+ function settlePending(key = null) {
85
+ const raw = storage.get(session, pendingKey);
86
+ if (!raw) return;
87
+
88
+ try {
89
+ const pending = JSON.parse(raw);
90
+ if (key && pending.key !== key) return;
91
+ const current = readDraft(pending.key);
92
+ if (current && current.revision === pending.revision) {
93
+ storage.remove(local, pending.key);
94
+ }
95
+ } catch (_error) {
96
+ // A malformed pending marker should never prevent the form from working.
97
+ }
98
+ storage.remove(session, pendingKey);
99
+ }
100
+
101
+ function rememberSubmission(form) {
102
+ save(form);
103
+ const key = form.dataset.b270DraftKey;
104
+ const draft = readDraft(key);
105
+ if (draft) {
106
+ storage.set(session, pendingKey, JSON.stringify({ key, revision: draft.revision }));
107
+ }
108
+ }
109
+
110
+ function restore(form) {
111
+ if (form.dataset.b270DraftErrors === "true") return;
112
+
113
+ const draft = readDraft(form.dataset.b270DraftKey);
114
+ if (!draft) return;
115
+
116
+ const body = form.querySelector("textarea[name='comment[body]']");
117
+ const track = form.querySelector("select[name='comment[track]']");
118
+ if (body) body.value = draft.body;
119
+ if (track && Array.from(track.options).some((option) => option.value === draft.track)) {
120
+ track.value = draft.track;
121
+ }
122
+ setStatus(form, "Draft restored from this browser.");
123
+ }
124
+
125
+ function initialize(form) {
126
+ if (form.dataset.b270DraftReady === "true") return;
127
+ form.dataset.b270DraftReady = "true";
128
+
129
+ if (form.dataset.b270DraftPosted === "true") {
130
+ settlePending(form.dataset.b270DraftKey);
131
+ } else {
132
+ restore(form);
133
+ }
134
+
135
+ form.addEventListener("input", () => save(form));
136
+ form.addEventListener("change", () => save(form));
137
+ form.addEventListener("submit", () => rememberSubmission(form));
138
+ }
139
+
140
+ function scan() {
141
+ document.querySelectorAll("form[data-b270-draft='true']").forEach(initialize);
142
+ }
143
+
144
+ if (/^#comment-\d+$/.test(window.location.hash)) {
145
+ settlePending();
146
+ } else {
147
+ storage.remove(session, pendingKey);
148
+ }
149
+ scan();
150
+
151
+ new MutationObserver(scan).observe(document.documentElement, { childList: true, subtree: true });
152
+ window.addEventListener("pageshow", scan);
153
+ window.addEventListener("pagehide", () => {
154
+ document.querySelectorAll("form[data-b270-draft='true']").forEach(save);
155
+ });
156
+ document.addEventListener("turbo:submit-end", (event) => {
157
+ const form = event.target;
158
+ if (!form.matches("form[data-b270-draft='true']")) return;
159
+
160
+ if (event.detail.success) {
161
+ settlePending(form.dataset.b270DraftKey);
162
+ } else {
163
+ storage.remove(session, pendingKey);
164
+ }
165
+ });
166
+
167
+ window.Bible270Drafts = { scan };
168
+ })();
169
+ </script>
@@ -2,7 +2,7 @@
2
2
  <details class="b270-index">
3
3
  <summary>All <%= Bible270::Plan::DAYS %> days</summary>
4
4
  <div class="b270-grid">
5
- <% today = signed_in? ? current_reader.current_day : nil %>
5
+ <% today = signed_in? ? current_reader.today_day : nil %>
6
6
  <% (1..Bible270::Plan::DAYS).each do |d| %>
7
7
  <%= b270_day_cell(d, reader: current_reader, today: today) %>
8
8
  <% end %>
@@ -8,22 +8,19 @@
8
8
  </span>
9
9
  <% end %>
10
10
  </div>
11
- <nav class="b270-nav">
12
- <!--<%= link_to "Plan", root_path %>-->
13
- <%= link_to "People", community_path %>
14
- <%# Public, like the community page: anyone may read reflections. %>
15
- <%= link_to "Posts", reflections_path %>
16
- <% if signed_in? %>
17
- <%= link_to "Progress", progress_path %>
18
- <%= link_to "Profile", profile_path %>
19
- <% if Bible270.config.admin?(current_reader) %><%= link_to "Admin", admin_path %><% end %>
20
- <%= button_to "Sign out", sign_out_path, method: :delete, class: "b270-linkbtn",
21
- form: { style: "display:inline" } %>
22
- <% elsif Bible270.config.any_sign_in_method? %>
23
- <%# Always route to the sign-in page: it offers email as well as any
24
- social providers, so nobody is shut out. OmniAuth's own request phase
25
- rejects GET, so its buttons live on that page as POST forms. %>
26
- <%= link_to "Sign in", sign_in_path(origin: request.fullpath), class: "b270-signin" %>
27
- <% end %>
11
+ <nav class="b270-nav" aria-label="Primary navigation">
12
+ <div class="b270-desktopnav">
13
+ <%= render "bible270/shared/nav_links" %>
14
+ </div>
15
+ <details class="b270-navmenu">
16
+ <summary class="b270-navtoggle" aria-label="Menu">
17
+ <span class="b270-menuicon" aria-hidden="true">
18
+ <span></span><span></span><span></span>
19
+ </span>
20
+ </summary>
21
+ <div class="b270-navlinks">
22
+ <%= render "bible270/shared/nav_links" %>
23
+ </div>
24
+ </details>
28
25
  </nav>
29
26
  </header>
@@ -0,0 +1,2 @@
1
+ <p id="b270-interaction-status" class="b270-interaction-status" role="status"
2
+ aria-live="polite" aria-atomic="true"><%= flash[:b270_interaction_status] %></p>