Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ Recent cluster (PRs #139–#153):
- The search filter is verse-level. In a verse Jesus speaks for part of, a match can sit in the narration. Matching only inside the red ranges would need the ranges indexed as text.
- Concept search is still English-only, so the filter does nothing for RV1909 there. Same dependency as the multilingual embeddings item in `HANDOFF.md`.
- The sayings and parables show the telling only; Jesus's own explanations (the Sower, the Weeds) are not listed.
- A study of Highlighted in Red steps through red chapters. Stepping a study through a list (the parables, a theme) was not built.
- **Semantic search provider for Sprint 9** — decide before Sprint 8 ends. Options: Anthropic Voyage, OpenAI embeddings, or a local open-source model (e.g., `all-MiniLM-L6-v2` via pgvector). Tradeoffs: cost, latency, privacy, quality.
- **Moderation staffing model for Sprint 7** — single admin (you) for v1, or early invite of trusted moderators? Affects admin UX.
- ~~**Group Bible default visibility**~~ — resolved 2026-06-12 (Sprint R6): stays opt-in per note; the Post-to menu makes the audience an explicit per-note choice.
Expand Down Expand Up @@ -274,6 +273,8 @@ Append-only. Each entry: date-ish, decision, rationale.
- **2026-10-11 Highlighted in Red** — **Study focus is a default, not a wall.** `users.study_focus` and `groups.focus` change where Read, "continue reading" and a study's reader lead. The ordinary reader stays one click away, and a reader whose last highlight is in a chapter with no red text continues there.
- **2026-10-11** — **About page and donate heading rewritten at the owner's direction** (#198). The owner's notes were sharper than the shipped copy in three places (Christianity as such, "take down the oppressors", "unite against those who oppose the weak"); the PR lists each softening so it can be reversed. What surprised us: a CI-only flake in `public_bible_spec.rb:54` (admin hides a note) failed once and passed on re-run, and the local browser suite is still unreliable under load despite the window-size work.

- **2026-10-11 Study reading lists** — **A study can work through a list, and "where we are" is set by hand.** `groups.reading_list` names one of the curated lists or a theme (`parables`, `encounters`, `sayings`, `theme:<slug>`); `groups.current_passage` is the slug a manager last marked. It is deliberately not derived from activity the way the chapter-based "reading together" card is: a group meets weekly and one member reading ahead shouldn't move everyone. Choosing a list makes the study a Highlighted in Red study. A passage is read at `/studies/:id/passages/:slug` in the study's own reader, on that chapter's existing live-update stream.

---

## Retrospectives
Expand Down
79 changes: 72 additions & 7 deletions app/controllers/groups/bible_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,46 @@ def show
@red_index = Bible::RedLetterIndex.for(@translation)
end

load_shared_layer
end

# /studies/:id/passages/:slug -> one passage from the study's list,
# in the study's reader. Every telling sits inside one chapter, so
# the page subscribes to that chapter's stream like any other and
# live updates work unchanged.
def passage
@passage = @group.passages.detect { |candidate| candidate.slug == params[:slug] }
return head(:not_found) unless @passage

@lens = :red
@translation = passage_translation
@appearances = appearances_available_in(@translation, @passage)
@appearance = chosen_appearance
return head(:not_found) unless @appearance

@book = @translation.books.find_by!(osis_code: @appearance.book)
@chapter = @book.chapters.find_by!(number: @appearance.chapter)
@verses = @chapter.verses.where(number: @appearance.verses).order(:number)

load_shared_layer
keep_notes_on_passage
render :show
end

private

# Highlights anchored to a note shared with this study, and those
# notes, for the chapter on screen.
#
# Sprint 5: the group bible shows only highlights anchored to a
# note shared with this group. Each viewer's private highlights
# stay on /bible/... — keeping group-view content identical
# across members means a single broadcast can replace a verse for
# everyone. A future sprint can layer per-user highlights as a
# client-side overlay without re-rendering the server HTML.
def load_shared_layer
prefix = "Bible.#{@translation.code}.#{@book.osis_code}.#{@chapter.number}."

# Sprint 5: the group bible shows only highlights anchored to a
# note shared with this group. Each viewer's private highlights
# stay on /bible/... — keeping group-view content identical
# across members means a single broadcast can replace a verse for
# everyone. A future sprint can layer per-user highlights as a
# client-side overlay without re-rendering the server HTML.
@highlights = Highlight
.joins(highlight_notes: { note: :note_shares })
.where(note_shares: { shareable_type: "Group", shareable_id: @group.id })
Expand All @@ -45,7 +77,40 @@ def show
.distinct
end

private
# ?translation= wins, then the reader's own default, then Spanish
# scripture for a Spanish-language reader, then the KJV.
def passage_translation
code = params[:translation].presence&.upcase
return Translation.find_by!(code: code) if code

current_user.default_translation ||
(Translation.find_by(code: "RV1909") if I18n.locale == :es) ||
Translation.find_by!(code: "KJV")
end

# The tellings whose verses this translation actually has.
def appearances_available_in(translation, passage)
first_refs = passage.appearances.to_h { |a| [ a.osis_refs(translation.code).first, a ] }
found = Verse.where(osis_ref: first_refs.keys).pluck(:osis_ref)
first_refs.select { |ref, _| found.include?(ref) }.values
end

def chosen_appearance
return @appearances.first if params[:in].blank?

@appearances.detect { |a| a.book.casecmp?(params[:in]) }
end

# The shared layer loads a chapter at a time. On a passage page,
# drop the notes anchored elsewhere in the chapter.
def keep_notes_on_passage
@group_notes = @group_notes.select do |note|
note.highlights.any? do |highlight|
ref = highlight.parsed_ref
ref.start_verse <= @appearance.verses.last && ref.end_verse >= @appearance.verses.first
end
end
end

def load_group
@group = Group.find(params[:group_id])
Expand Down
15 changes: 12 additions & 3 deletions app/controllers/groups_controller.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
class GroupsController < ApplicationController
before_action :authenticate_user!
before_action :load_group, only: %i[show edit update destroy leave]
before_action :load_group, only: %i[show edit update destroy leave current_passage]
before_action :ensure_group_member, only: %i[show]
before_action :ensure_group_manager, only: %i[edit update]
before_action :ensure_group_manager, only: %i[edit update current_passage]
before_action :ensure_group_owner, only: %i[destroy]

skip_before_action :authenticate_user!, only: [ :discover ]
Expand Down Expand Up @@ -38,6 +38,15 @@ def show
@reading_together = @group.reading_together_location
end

# A manager marks the passage the study is on ("we are here").
def current_passage
passage = @group.passages.detect { |candidate| candidate.slug == params[:slug] }
return head(:not_found) unless passage

@group.update!(current_passage: passage.slug)
redirect_to group_passage_path(@group, passage.slug), notice: t("groups.reading_list.moved"), status: :see_other
end

def edit
end

Expand Down Expand Up @@ -114,6 +123,6 @@ def ensure_group_manager
end

def group_params
params.require(:group).permit(:name, :description, :privacy, :focus)
params.require(:group).permit(:name, :description, :privacy, :focus, :reading_list)
end
end
22 changes: 22 additions & 0 deletions app/helpers/bible/reader_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,28 @@ def passage_neighbours(passage)
[ (list[at - 1] if at.positive?), list[at + 1] ]
end

# A passage on a study's list, read in the study's reader.
def study_passage_path(group, passage, appearance = nil)
told_in = appearance&.book&.downcase
told_in = nil if told_in == passage.appearances.first.book.downcase
group_passage_path(group, passage.slug, in: told_in, translation: params[:translation].presence)
end

# [previous, next] passage on the study's own list.
def study_passage_neighbours(group, passage)
list = group.passages
at = list.index(passage)
[ (list[at - 1] if at.positive?), list[at + 1] ]
end

# "The Parables", "Encounters", or a theme's name.
def study_reading_list_title(group)
list = group.reading_list
return Bible::Themes.find(list.delete_prefix(Group::THEME_PREFIX)).name if list.start_with?(Group::THEME_PREFIX)

t("red.#{list}.title")
end

# Whether the page dims narration. An encounter is the exception
# under the red lens: there the story is the point, so it is shown
# at full strength with his words still in red.
Expand Down
40 changes: 40 additions & 0 deletions app/models/group.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ class Group < ApplicationRecord
FOCUSES = { bible: 0, red: 1 }.freeze
enum :focus, FOCUSES, prefix: true, validate: true

# Optionally the study works through a curated list, one passage at
# a time: "parables", "encounters", "sayings", or "theme:<slug>".
# Choosing a list makes it a Highlighted in Red study.
THEME_PREFIX = "theme:".freeze
before_validation :normalize_reading_list
validate :reading_list_exists

belongs_to :owner, class_name: "User"
# delete_all (not :destroy) bypasses the at-least-one-owner callback on
# Membership — if the whole group is going away, there's nothing to
Expand Down Expand Up @@ -84,6 +91,25 @@ def starting_location
{ translation: "kjv", book: first_red.book.downcase, chapter: first_red.number }
end

# The passages on the study's list, in the order the list presents
# them. Empty for a study with no list.
def passages
return [] if reading_list.blank?

if reading_list.start_with?(THEME_PREFIX)
Bible::Themes.find(reading_list.delete_prefix(THEME_PREFIX))&.passages || []
else
Bible::Passages.in_collection(reading_list)
end
end

# Where the study is on its list: the passage a manager last marked,
# or the first one.
def current_passage_record
list = passages
list.detect { |passage| passage.slug == current_passage } || list.first
end

def self.generate_invitation_code
alphabet = ("A".."Z").to_a + ("0".."9").to_a
length = 6 + rand(3) # 6..8
Expand All @@ -92,6 +118,20 @@ def self.generate_invitation_code

private

def normalize_reading_list
self.reading_list = reading_list.presence
self.focus = :red if reading_list
end

def reading_list_exists
return if reading_list.nil?

known = Bible::Passages::COLLECTIONS.key?(reading_list) ||
(reading_list.start_with?(THEME_PREFIX) &&
Bible::Themes.find(reading_list.delete_prefix(THEME_PREFIX)).present?)
errors.add(:reading_list, :inclusion) unless known
end

def ensure_owner_membership
memberships.find_or_create_by!(user: owner) do |m|
m.role = :owner
Expand Down
13 changes: 13 additions & 0 deletions app/views/groups/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,19 @@
<p class="font-ui text-xs text-surface-600"><%= t("groups.focus.hint") %></p>
</fieldset>

<div>
<%= f.label :reading_list, t("groups.reading_list.label"), class: label_classes %>
<%= f.select :reading_list,
grouped_options_for_select(
{ t("groups.reading_list.lists_group") => Bible::Passages::COLLECTIONS.keys.map { |list| [ t("red.#{list}.title"), list ] },
t("groups.reading_list.themes_group") => Bible::Themes.all.map { |theme| [ theme.name, "#{Group::THEME_PREFIX}#{theme.slug}" ] } },
group.reading_list
),
{ include_blank: t("groups.reading_list.none") },
class: "#{input_classes} [&_option]:bg-surface-50 [&_option]:text-surface-900" %>
<p class="mt-1.5 font-ui text-xs text-surface-600"><%= t("groups.reading_list.hint") %></p>
</div>

<div class="flex items-center justify-end gap-3">
<%= link_to t("groups.cancel"), groups_path, class: cancel_classes %>
<%= f.submit class: submit_classes %>
Expand Down
61 changes: 52 additions & 9 deletions app/views/groups/bible/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,45 @@
<span aria-hidden="true">&rsaquo;</span>
<%= link_to @group.name, group_path(@group), class: "transition-colors hover:text-accent-700" %>
<span aria-hidden="true">&rsaquo;</span>
<span class="text-surface-900"><%= book_name %> <%= @chapter.number %></span>
<span class="text-surface-900"><%= @passage ? @passage.title : "#{book_name} #{@chapter.number}" %></span>
</nav>

<header class="space-y-3 border-b border-surface-200 pb-6">
<div class="flex items-center justify-between gap-3">
<p class="font-ui text-xs font-medium uppercase tracking-wider text-surface-500">
<%= @translation.name %>
</p>
<%= render "bible/reader/layer_switcher",
translation: @translation, book: @book, chapter: @chapter,
selected_url: group_bible_chapter_path(@group, translation: @translation.code.downcase, book: @book.osis_code.downcase, chapter: @chapter.number) %>
<% if @passage %>
<% if @appearances.size > 1 %>
<nav aria-label="<%= t("red.parables.told_in") %>" class="passage-tellings flex flex-wrap items-center gap-1 font-ui text-sm">
<% @appearances.each do |appearance| %>
<% current = appearance == @appearance %>
<%= link_to passage_reference(appearance), study_passage_path(@group, @passage, appearance),
aria: { current: (current ? "page" : nil) },
class: "rounded-full border px-3 py-1 transition-colors #{current ? 'border-surface-900 bg-surface-900 text-surface-50' : 'border-surface-300 text-surface-700 hover:bg-surface-100'}" %>
<% end %>
</nav>
<% end %>
<% else %>
<%= render "bible/reader/layer_switcher",
translation: @translation, book: @book, chapter: @chapter,
selected_url: group_bible_chapter_path(@group, translation: @translation.code.downcase, book: @book.osis_code.downcase, chapter: @chapter.number) %>
<% end %>
</div>
<%= render "bible/reader/red_lens_bar", translation: @translation, book: @book, chapter: @chapter, verses: @verses, study: true if red %>
<h1 class="font-ui text-4xl font-semibold leading-[1.05] tracking-[-0.025em] text-surface-900 sm:text-5xl md:text-6xl">
<span class="book-title-text"><%= book_name %> <%= @chapter.number %></span>
</h1>
<%= render "bible/reader/red_lens_bar", translation: @translation, book: @book, chapter: @chapter, verses: @verses, study: true if @lens == :red %>
<%= render "bible/reader/title", book_name: book_name %>
<% if @passage %>
<%# Where the study is. A manager can move it here. %>
<div class="study-here font-ui text-sm">
<% if @group.current_passage_record == @passage %>
<span class="rounded-full bg-accent-100 px-2.5 py-0.5 text-xs font-medium text-accent-800"><%= t("groups.reading_list.we_are_here") %></span>
<% elsif @group.manager?(current_user) %>
<%= button_to t("groups.reading_list.mark_here"), current_passage_group_path(@group, slug: @passage.slug), method: :patch,
form_class: "inline",
class: "rounded-full border border-surface-300 px-3 py-1 text-surface-700 transition-colors hover:bg-surface-100" %>
<% end %>
</div>
<% end %>
</header>

<div class="chapter-body font-reading <%= red ? "text-surface-500" : "text-surface-900" %>"
Expand All @@ -45,7 +68,27 @@
<%= render "bible/reader/toolbar" %>
<% end %>

<% if red %>
<% if @passage %>
<% previous_passage, next_passage = study_passage_neighbours(@group, @passage) %>
<nav aria-label="<%= study_reading_list_title(@group) %>" class="study-passage-nav flex items-start justify-between gap-6 border-t border-surface-200 pt-6 font-ui text-sm">
<% if previous_passage %>
<%= link_to study_passage_path(@group, previous_passage),
class: "inline-flex items-center gap-1 text-surface-600 transition-colors hover:text-accent-700" do %>
<span aria-hidden="true">&larr;</span>
<%= previous_passage.title %>
<% end %>
<% else %>
<span aria-hidden="true"></span>
<% end %>
<% if next_passage %>
<%= link_to study_passage_path(@group, next_passage),
class: "inline-flex items-center gap-1 text-right text-surface-600 transition-colors hover:text-accent-700" do %>
<%= next_passage.title %>
<span aria-hidden="true">&rarr;</span>
<% end %>
<% end %>
</nav>
<% elsif red %>
<% prev_stop, next_stop = reader_neighbours(@chapter) %>
<nav aria-label="<%= t("bible.reader.chapter_label") %>" class="study-chapter-nav flex items-center justify-between border-t border-surface-200 pt-6 font-ui text-sm">
<% if prev_stop %>
Expand Down
Loading
Loading