diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css
index 79aac2b..de5a1c0 100644
--- a/app/assets/stylesheets/application.css
+++ b/app/assets/stylesheets/application.css
@@ -621,6 +621,14 @@ hr {
color: #1F6B45;
}
+.item-badge--presenting {
+ background-color: #FEF9C3;
+ color: #8A6500;
+ border: 1px solid #E0C97A;
+ padding: 0.0625rem 0.4375rem;
+ font-weight: 600;
+}
+
.item-badge--private {
background-color: transparent;
color: #525C66;
@@ -3053,6 +3061,136 @@ hr {
.pdf-page--notary { page-break-before: always; }
}
+
+/* ---------- Lightning talk slots --------------------------- */
+
+.lightning-slots {
+ margin-top: 0.75rem;
+ border-top: 1px dashed #e6e8eb;
+ padding-top: 0.5rem;
+}
+
+.lightning-slots__summary {
+ font-size: 0.875rem;
+ color: #525C66;
+ cursor: pointer;
+ list-style: none;
+ padding: 0.375rem 0;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.lightning-slots__summary::-webkit-details-marker { display: none; }
+
+.lightning-slots__count { color: #525C66; }
+
+.lightning-slots__toggle {
+ margin-left: auto;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.375rem;
+ color: #C41C1C;
+ font-weight: 500;
+}
+
+.lightning-slots__chevron {
+ display: inline-block;
+ width: 0;
+ height: 0;
+ border-top: 4px solid transparent;
+ border-bottom: 4px solid transparent;
+ border-left: 6px solid #C41C1C;
+ transition: transform 0.15s ease;
+}
+
+.lightning-slots[open] .lightning-slots__chevron {
+ transform: rotate(90deg);
+}
+
+.lightning-slots__toggle-label { text-decoration: underline; }
+.lightning-slots__toggle-label--open { display: none; }
+.lightning-slots[open] .lightning-slots__toggle-label--closed { display: none; }
+.lightning-slots[open] .lightning-slots__toggle-label--open { display: inline; }
+
+.lightning-slots__list {
+ list-style: none;
+ padding: 0.5rem 0 0 0;
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+}
+
+.lightning-slot {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 0.5rem;
+ padding: 0.375rem 0.5rem;
+ border-radius: 0.375rem;
+ background-color: #FAFAF6;
+ font-size: 0.875rem;
+ line-height: 1.4;
+}
+
+.lightning-slot--open { color: #8a8f96; font-style: italic; }
+
+.lightning-slot__position { font-weight: 600; min-width: 1.75rem; color: #525C66; }
+.lightning-slot__time { color: #1F6B45; font-variant-numeric: tabular-nums; min-width: 4.5rem; }
+.lightning-slot__speaker { font-weight: 500; color: #1f242a; }
+.lightning-slot__talk-title { font-style: italic; color: #1f242a; }
+.lightning-slot__talk-description { color: #525C66; flex-basis: 100%; padding-left: 6.5rem; }
+.lightning-slot__slides { color: #C41C1C; text-decoration: underline; }
+.lightning-slot__edit { color: #C41C1C; font-size: 0.8125rem; margin-left: auto; }
+.lightning-slot__open-label { color: #8a8f96; }
+
+.schedule-item__action--lightning {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ gap: 0.375rem;
+ min-width: 130px;
+}
+
+.schedule-item__action--lightning > form,
+.schedule-item__action--lightning > .add-btn,
+.schedule-item__action--lightning > form > .add-btn {
+ width: 100%;
+}
+
+/* ---------- Admin speakers list (drag-to-reorder) ---------- */
+
+.speakers-list {
+ list-style: none;
+ padding: 0;
+ margin: 0 0 1rem 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+}
+
+.speakers-list__item {
+ display: grid;
+ grid-template-columns: auto 2.5rem 5rem 1fr 2fr auto;
+ gap: 0.75rem;
+ align-items: center;
+ padding: 0.5rem 0.75rem;
+ background-color: #FAFAF6;
+ border: 1px solid #e6e8eb;
+ border-radius: 0.375rem;
+ cursor: grab;
+}
+
+.speakers-list__item.is-dragging { opacity: 0.4; }
+.speakers-list__handle { color: #c8cdd1; cursor: grab; user-select: none; }
+.speakers-list__position { font-weight: 600; color: #525C66; }
+.speakers-list__time { color: #1F6B45; font-variant-numeric: tabular-nums; }
+.speakers-list__name { font-weight: 500; color: #1f242a; }
+.speakers-list__title { color: #525C66; font-size: 0.9375rem; }
+
+.btn-small { padding: 0.25rem 0.625rem; font-size: 0.8125rem; }
+
/* ---------- Meal-spot transport form: hide passenger seats unless driving --- */
/* Uses :has() to react to the radio's checked state — no JS needed. */
.transport-fields .seats-field {
diff --git a/app/controllers/admin/lightning_talk_signups_controller.rb b/app/controllers/admin/lightning_talk_signups_controller.rb
new file mode 100644
index 0000000..3910966
--- /dev/null
+++ b/app/controllers/admin/lightning_talk_signups_controller.rb
@@ -0,0 +1,93 @@
+module Admin
+ class LightningTalkSignupsController < AdminController
+ before_action :load_schedule_item
+ before_action :set_signup, only: %i[update destroy]
+
+ def index
+ respond_to do |format|
+ format.html { redirect_to admin_lightning_talks_path }
+ format.pdf {
+ send_data LightningTalksPdf.new(@schedule_item).render,
+ filename: pdf_filename,
+ type: "application/pdf",
+ disposition: "attachment"
+ }
+ end
+ end
+
+ def create
+ user = User.find(params[:user_id])
+ LightningTalkSignup.claim_next_slot!(user: user, schedule_item: @schedule_item)
+ redirect_back fallback_location: admin_lightning_talks_path,
+ notice: "Speaker added."
+ rescue LightningTalkSignup::SlotsFull
+ redirect_back fallback_location: admin_lightning_talks_path,
+ alert: "All speaking slots are full."
+ end
+
+ def update
+ if @signup.update(signup_params)
+ redirect_back fallback_location: admin_lightning_talks_path,
+ notice: "Talk details updated."
+ else
+ redirect_back fallback_location: admin_lightning_talks_path,
+ alert: @signup.errors.full_messages.to_sentence
+ end
+ end
+
+ def destroy
+ removed_position = @signup.position
+ LightningTalkSignup.transaction do
+ @signup.destroy!
+ @schedule_item.lightning_talk_signups
+ .where("position > ?", removed_position)
+ .order(:position)
+ .each { |s| s.update_columns(position: s.position - 1) }
+ end
+ redirect_back fallback_location: admin_lightning_talks_path,
+ notice: "Speaker removed."
+ end
+
+ def reorder
+ ids = Array(params[:signup_ids]).map(&:to_i)
+ LightningTalkSignup.transaction do
+ signups = @schedule_item.lightning_talk_signups.where(id: ids).index_by(&:id)
+ # Two-pass renumber to avoid colliding with the unique [schedule_item_id, position] index.
+ signups.each_value { |s| s.update_columns(position: s.position + LightningTalkSignup::MAX_SPEAKERS) }
+ ids.each_with_index do |id, index|
+ signups[id]&.update_columns(position: index + 1)
+ end
+ end
+
+ respond_to do |format|
+ format.json { head :no_content }
+ format.turbo_stream {
+ signups = @schedule_item.lightning_talk_signups.includes(:user).ordered
+ render turbo_stream: turbo_stream.replace(
+ "lightning-speakers-list",
+ partial: "admin/lightning_talk_signups/speakers_list",
+ locals: { signups: signups, schedule_item: @schedule_item }
+ )
+ }
+ end
+ end
+
+ private
+
+ def load_schedule_item
+ @schedule_item = ScheduleItem.find(params[:schedule_item_id])
+ end
+
+ def set_signup
+ @signup = @schedule_item.lightning_talk_signups.find(params[:id])
+ end
+
+ def signup_params
+ params.require(:lightning_talk_signup).permit(:talk_title, :talk_description, :slides_url)
+ end
+
+ def pdf_filename
+ "lightning-talks-#{@schedule_item.day}-#{@schedule_item.id}.pdf"
+ end
+ end
+end
diff --git a/app/controllers/admin/lightning_talks_controller.rb b/app/controllers/admin/lightning_talks_controller.rb
new file mode 100644
index 0000000..3784650
--- /dev/null
+++ b/app/controllers/admin/lightning_talks_controller.rb
@@ -0,0 +1,7 @@
+module Admin
+ class LightningTalksController < AdminController
+ def index
+ @schedule_item = ScheduleItem.lightning.ordered.first
+ end
+ end
+end
diff --git a/app/controllers/lightning_talk_signups_controller.rb b/app/controllers/lightning_talk_signups_controller.rb
new file mode 100644
index 0000000..c47469c
--- /dev/null
+++ b/app/controllers/lightning_talk_signups_controller.rb
@@ -0,0 +1,49 @@
+class LightningTalkSignupsController < ApplicationController
+ before_action :load_schedule_item
+
+ def create
+ @signup = LightningTalkSignup.claim_next_slot!(
+ user: current_user,
+ schedule_item: @schedule_item
+ )
+ respond_to do |format|
+ format.turbo_stream {
+ render turbo_stream: turbo_stream.replace(
+ helpers.dom_id(@schedule_item),
+ partial: "schedule/session_item",
+ locals: { item: @schedule_item, planned: true }
+ )
+ }
+ format.html { redirect_back fallback_location: schedule_path, notice: "You're signed up to give a lightning talk." }
+ end
+ rescue LightningTalkSignup::SlotsFull
+ redirect_back fallback_location: schedule_path, alert: "All speaking slots are full."
+ end
+
+ def edit
+ @signup = current_signup
+ end
+
+ def update
+ @signup = current_signup
+ if @signup.update(signup_params)
+ redirect_to schedule_path(anchor: helpers.dom_id(@schedule_item)), notice: "Talk details saved."
+ else
+ render :edit, status: :unprocessable_content
+ end
+ end
+
+ private
+
+ def load_schedule_item
+ @schedule_item = ScheduleItem.find(params[:schedule_item_id])
+ end
+
+ def current_signup
+ current_user.lightning_talk_signups.find_by!(schedule_item_id: @schedule_item.id)
+ end
+
+ def signup_params
+ params.require(:lightning_talk_signup).permit(:talk_title, :talk_description, :slides_url)
+ end
+end
diff --git a/app/controllers/plan_controller.rb b/app/controllers/plan_controller.rb
index 9fed34c..13a3f7c 100644
--- a/app/controllers/plan_controller.rb
+++ b/app/controllers/plan_controller.rb
@@ -11,6 +11,7 @@ def index
.sort_by { |pi| [ ScheduleItem::DAY_META.keys.index(pi.schedule_item.day) || 99, pi.schedule_item.sort_time.to_i ] }
.group_by { |pi| pi.schedule_item.day }
+ @speaking_ids = current_user.lightning_talk_signups.pluck(:schedule_item_id).to_set
# @travel = MOCK_TRAVEL
end
end
diff --git a/app/helpers/admin/lightning_talk_signups_helper.rb b/app/helpers/admin/lightning_talk_signups_helper.rb
new file mode 100644
index 0000000..294fc1a
--- /dev/null
+++ b/app/helpers/admin/lightning_talk_signups_helper.rb
@@ -0,0 +1,2 @@
+module Admin::LightningTalkSignupsHelper
+end
diff --git a/app/helpers/admin/lightning_talks_helper.rb b/app/helpers/admin/lightning_talks_helper.rb
new file mode 100644
index 0000000..92d35d5
--- /dev/null
+++ b/app/helpers/admin/lightning_talks_helper.rb
@@ -0,0 +1,2 @@
+module Admin::LightningTalksHelper
+end
diff --git a/app/helpers/lightning_talk_signups_helper.rb b/app/helpers/lightning_talk_signups_helper.rb
new file mode 100644
index 0000000..0980032
--- /dev/null
+++ b/app/helpers/lightning_talk_signups_helper.rb
@@ -0,0 +1,2 @@
+module LightningTalkSignupsHelper
+end
diff --git a/app/javascript/controllers/sortable_controller.js b/app/javascript/controllers/sortable_controller.js
new file mode 100644
index 0000000..ca560a4
--- /dev/null
+++ b/app/javascript/controllers/sortable_controller.js
@@ -0,0 +1,63 @@
+import { Controller } from "@hotwired/stimulus"
+
+// Drag-to-reorder. Each child
needs draggable="true" and data-id="...".
+// On drop, PATCHes the new ordered ids array to data-sortable-url-value.
+export default class extends Controller {
+ static values = { url: String }
+
+ connect() {
+ this.draggedItem = null
+ this.element.addEventListener("dragstart", this.onDragStart)
+ this.element.addEventListener("dragover", this.onDragOver)
+ this.element.addEventListener("drop", this.onDrop)
+ this.element.addEventListener("dragend", this.onDragEnd)
+ }
+
+ disconnect() {
+ this.element.removeEventListener("dragstart", this.onDragStart)
+ this.element.removeEventListener("dragover", this.onDragOver)
+ this.element.removeEventListener("drop", this.onDrop)
+ this.element.removeEventListener("dragend", this.onDragEnd)
+ }
+
+ onDragStart = (e) => {
+ const li = e.target.closest("[data-id]")
+ if (!li) return
+ this.draggedItem = li
+ li.classList.add("is-dragging")
+ e.dataTransfer.effectAllowed = "move"
+ }
+
+ onDragOver = (e) => {
+ e.preventDefault()
+ const target = e.target.closest("[data-id]")
+ if (!target || target === this.draggedItem) return
+ const rect = target.getBoundingClientRect()
+ const before = (e.clientY - rect.top) < rect.height / 2
+ target.parentNode.insertBefore(this.draggedItem, before ? target : target.nextSibling)
+ }
+
+ onDrop = (e) => { e.preventDefault() }
+
+ onDragEnd = () => {
+ if (this.draggedItem) this.draggedItem.classList.remove("is-dragging")
+ this.draggedItem = null
+ this.persist()
+ }
+
+ persist() {
+ const ids = Array.from(this.element.querySelectorAll("[data-id]")).map(el => el.dataset.id)
+ const csrf = document.querySelector('meta[name="csrf-token"]')?.content
+ fetch(this.urlValue, {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ "X-CSRF-Token": csrf,
+ "Accept": "text/vnd.turbo-stream.html"
+ },
+ body: JSON.stringify({ signup_ids: ids })
+ })
+ .then(r => r.text())
+ .then(html => window.Turbo?.renderStreamMessage(html))
+ }
+}
diff --git a/app/models/lightning_talk_signup.rb b/app/models/lightning_talk_signup.rb
new file mode 100644
index 0000000..618c12e
--- /dev/null
+++ b/app/models/lightning_talk_signup.rb
@@ -0,0 +1,61 @@
+class LightningTalkSignup < ApplicationRecord
+ MAX_SPEAKERS = 10
+ BLOCK_DURATION_MINUTES = 60
+
+ class SlotsFull < StandardError; end
+
+ belongs_to :user
+ belongs_to :schedule_item
+
+ scope :ordered, -> { order(:position) }
+
+ validates :user_id, uniqueness: { scope: :schedule_item_id }
+ validate :schedule_item_must_be_lightning
+ validate :slot_must_be_available, on: :create
+
+ after_create_commit :ensure_plan_item
+
+ def self.claim_next_slot!(user:, schedule_item:)
+ schedule_item.with_lock do
+ raise SlotsFull if schedule_item.lightning_talk_signups.count >= MAX_SPEAKERS
+
+ next_position = (schedule_item.lightning_talk_signups.maximum(:position) || 0) + 1
+ create!(user: user, schedule_item: schedule_item, position: next_position)
+ end
+ end
+
+ def editable_by?(actor)
+ return false if actor.nil?
+ actor.admin? || actor.id == user_id
+ end
+
+ def slot_minutes
+ BLOCK_DURATION_MINUTES.to_f / [ schedule_item.lightning_talk_signups.count, 1 ].max
+ end
+
+ def slot_start_label
+ return schedule_item.time_label if schedule_item.sort_time.blank?
+
+ base = (schedule_item.sort_time / 100) * 60 + (schedule_item.sort_time % 100)
+ offset = ((position - 1) * slot_minutes).round
+ total = base + offset
+ Time.new(2000, 1, 1, (total / 60) % 24, total % 60).strftime("%-l:%M %p")
+ end
+
+ private
+
+ def schedule_item_must_be_lightning
+ errors.add(:schedule_item, "must be a lightning talk") unless schedule_item&.lightning?
+ end
+
+ def slot_must_be_available
+ return unless schedule_item&.lightning?
+ if schedule_item.lightning_talk_signups.count >= MAX_SPEAKERS
+ errors.add(:base, "All speaking slots are full")
+ end
+ end
+
+ def ensure_plan_item
+ PlanItem.find_or_create_by!(user: user, schedule_item: schedule_item)
+ end
+end
diff --git a/app/models/schedule_item.rb b/app/models/schedule_item.rb
index 2bb9e05..063ea00 100644
--- a/app/models/schedule_item.rb
+++ b/app/models/schedule_item.rb
@@ -4,6 +4,8 @@ class ScheduleItem < ApplicationRecord
belongs_to :created_by, class_name: "User", optional: true
has_many :plan_items, dependent: :destroy
has_many :attendees, through: :plan_items, source: :user
+ has_many :lightning_talk_signups, -> { ordered }, dependent: :destroy
+ has_many :speakers, through: :lightning_talk_signups, source: :user
has_many :embassy_bookings, dependent: :destroy
has_many :meal_spots, dependent: :destroy
@@ -67,6 +69,10 @@ def rsvp_count
plan_items.count
end
+ def lightning_slots_full?
+ lightning? && lightning_talk_signups.count >= LightningTalkSignup::MAX_SPEAKERS
+ end
+
def seats_taken
embassy_bookings.active.count
end
diff --git a/app/models/user.rb b/app/models/user.rb
index 0541ce0..c073039 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -3,6 +3,8 @@ class User < ApplicationRecord
has_many :plan_items, dependent: :destroy
has_many :planned_schedule_items, through: :plan_items, source: :schedule_item
+ has_many :lightning_talk_signups, dependent: :destroy
+ has_many :speaking_at, through: :lightning_talk_signups, source: :schedule_item
has_many :embassy_bookings, dependent: :destroy
has_many :embassy_applications, through: :embassy_bookings
has_many :meal_spot_rsvps, dependent: :destroy
@@ -55,6 +57,10 @@ def full_name
[ first_name, last_name ].compact.join(" ").presence || email
end
+ def speaking_at?(schedule_item)
+ lightning_talk_signups.exists?(schedule_item_id: schedule_item.id)
+ end
+
private
# When a user gets linked to a Tito ticket, snapshot the current event's
diff --git a/app/services/lightning_talks_pdf.rb b/app/services/lightning_talks_pdf.rb
new file mode 100644
index 0000000..d4d46ce
--- /dev/null
+++ b/app/services/lightning_talks_pdf.rb
@@ -0,0 +1,60 @@
+require "prawn"
+require "prawn/table"
+
+Prawn::Fonts::AFM.hide_m17n_warning = true
+
+# A printable lineup for the day-of admin: title, day/time, and a
+# numbered speaker list with slot times and talk titles.
+class LightningTalksPdf
+ PAGE_SIZE = "LETTER".freeze
+ MARGIN = 50
+
+ def initialize(schedule_item)
+ @schedule_item = schedule_item
+ @signups = schedule_item.lightning_talk_signups.includes(:user).ordered
+ end
+
+ def render
+ pdf = Prawn::Document.new(page_size: PAGE_SIZE, margin: MARGIN)
+ pdf.font "Helvetica"
+
+ pdf.font_size 20
+ pdf.text "Lightning Talks Lineup", style: :bold
+
+ pdf.move_down 4
+ pdf.font_size 11
+ pdf.fill_color "555555"
+ day_label = ScheduleItem::DAY_META.dig(@schedule_item.day, :label) || @schedule_item.day
+ pdf.text "#{day_label} · #{@schedule_item.time_label} · #{@signups.size} of #{LightningTalkSignup::MAX_SPEAKERS} speakers"
+ pdf.fill_color "000000"
+
+ pdf.move_down 18
+
+ if @signups.empty?
+ pdf.font_size 11
+ pdf.text "No speakers signed up yet.", style: :italic, color: "777777"
+ else
+ data = [ [ "#", "Time", "Speaker", "Talk title" ] ]
+ @signups.each do |signup|
+ data << [
+ signup.position.to_s,
+ signup.slot_start_label,
+ signup.user.full_name,
+ signup.talk_title.to_s
+ ]
+ end
+
+ pdf.table(data, header: true, width: pdf.bounds.width) do
+ row(0).font_style = :bold
+ row(0).background_color = "EEEEEE"
+ cells.borders = [ :bottom ]
+ cells.padding = [ 6, 8 ]
+ column(0).width = 30
+ column(1).width = 70
+ column(2).width = 160
+ end
+ end
+
+ pdf.render
+ end
+end
diff --git a/app/views/admin/dashboard/show.html.erb b/app/views/admin/dashboard/show.html.erb
index ea5c260..7b35fcd 100644
--- a/app/views/admin/dashboard/show.html.erb
+++ b/app/views/admin/dashboard/show.html.erb
@@ -42,6 +42,11 @@
Full CRUD over the conference agenda — all schedule items, by kind.
<% end %>
+ <%= link_to admin_lightning_talks_path, class: "action-card" do %>
+ Lightning Talks
+ Manage the lineup — add, remove, reorder speakers.
+ <% end %>
+
<%= link_to admin_embassy_applications_path, class: "action-card" do %>
Embassy Applications
Review adjudicated applications by serial. Print individual copies for the Attaché.
diff --git a/app/views/admin/lightning_talk_signups/_panel.html.erb b/app/views/admin/lightning_talk_signups/_panel.html.erb
new file mode 100644
index 0000000..ccf8d56
--- /dev/null
+++ b/app/views/admin/lightning_talk_signups/_panel.html.erb
@@ -0,0 +1,40 @@
+<%
+ signups = schedule_item.lightning_talk_signups.includes(:user)
+ available_speakers = User.where.not(id: signups.pluck(:user_id)).order(:first_name, :last_name, :email)
+%>
+
+
+
+
Speakers
+ <%= link_to "Export PDF",
+ admin_schedule_item_lightning_talk_signups_path(schedule_item, format: :pdf),
+ class: "btn btn-muted" %>
+
+
+
+ <%= signups.count %> of <%= LightningTalkSignup::MAX_SPEAKERS %> slots filled.
+ Each slot is currently <%= ((LightningTalkSignup::BLOCK_DURATION_MINUTES.to_f / [signups.count, 1].max).round(1)) %> minutes.
+ Drag rows to reorder.
+
+
+ <% if signups.any? %>
+ <%= render "admin/lightning_talk_signups/speakers_list", signups: signups, schedule_item: schedule_item %>
+ <% else %>
+ No speakers signed up yet.
+ <% end %>
+
+ <% if signups.count < LightningTalkSignup::MAX_SPEAKERS %>
+
+
Add a speaker
+ <%= form_with url: admin_schedule_item_lightning_talk_signups_path(schedule_item), method: :post do %>
+ <%= label_tag :user_id, "User", class: "label" %>
+
+ <%= select_tag :user_id,
+ options_from_collection_for_select(available_speakers, :id, :full_name),
+ class: "select flex-1", required: true, prompt: "— pick a user —" %>
+ <%= button_tag "Add", type: :submit, class: "btn btn-red" %>
+
+ <% end %>
+
+ <% end %>
+
diff --git a/app/views/admin/lightning_talk_signups/_speakers_list.html.erb b/app/views/admin/lightning_talk_signups/_speakers_list.html.erb
new file mode 100644
index 0000000..c0e5824
--- /dev/null
+++ b/app/views/admin/lightning_talk_signups/_speakers_list.html.erb
@@ -0,0 +1,19 @@
+
+ <% signups.each do |signup| %>
+ -
+ ⋮⋮
+ <%= signup.position %>.
+ <%= signup.slot_start_label %>
+ <%= signup.user.full_name %>
+ <%= signup.talk_title.presence || "—" %>
+ <%= button_to "Remove",
+ admin_schedule_item_lightning_talk_signup_path(schedule_item, signup),
+ method: :delete,
+ class: "btn btn-muted btn-small",
+ data: { turbo_confirm: "Remove #{signup.user.full_name} from the lineup?" } %>
+
+ <% end %>
+
diff --git a/app/views/admin/lightning_talks/index.html.erb b/app/views/admin/lightning_talks/index.html.erb
new file mode 100644
index 0000000..5aa948b
--- /dev/null
+++ b/app/views/admin/lightning_talks/index.html.erb
@@ -0,0 +1,17 @@
+<% content_for :container_width, "max-w-6xl" %>
+
+Lightning Talks
+
+<% if @schedule_item %>
+
+ <%= ScheduleItem::DAY_META.dig(@schedule_item.day, :label) || @schedule_item.day %>
+ · <%= @schedule_item.time_label %> · <%= @schedule_item.title %>
+
+
+ <%= render "admin/lightning_talk_signups/panel", schedule_item: @schedule_item %>
+<% else %>
+
+ No lightning talk block scheduled yet. Add one from
+ <%= link_to "Schedule", admin_schedule_items_path, class: "text-blue underline" %>.
+
+<% end %>
diff --git a/app/views/admin/schedule_items/edit.html.erb b/app/views/admin/schedule_items/edit.html.erb
index b086d86..b195444 100644
--- a/app/views/admin/schedule_items/edit.html.erb
+++ b/app/views/admin/schedule_items/edit.html.erb
@@ -1,3 +1,7 @@
Edit schedule item
<%= render "form", schedule_item: @schedule_item %>
+
+<% if @schedule_item.lightning? && @schedule_item.persisted? %>
+ <%= render "admin/lightning_talk_signups/panel", schedule_item: @schedule_item %>
+<% end %>
diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb
index 573aab3..265cefe 100644
--- a/app/views/layouts/admin.html.erb
+++ b/app/views/layouts/admin.html.erb
@@ -22,10 +22,10 @@
- <%= link_to "Admin Dashboard", admin_root_path %>
<%= link_to "Attendees", admin_users_path %>
<%= link_to "Volunteers", admin_volunteers_path %>
<%= link_to "Schedule", admin_schedule_items_path %>
+ <%= link_to "Lightning Talks", admin_lightning_talks_path %>
<%= link_to "Embassy Applications", admin_embassy_applications_path %>
<%= link_to "Back to User Dashboard", dashboard_path %>
diff --git a/app/views/lightning_talk_signups/edit.html.erb b/app/views/lightning_talk_signups/edit.html.erb
new file mode 100644
index 0000000..6b911cb
--- /dev/null
+++ b/app/views/lightning_talk_signups/edit.html.erb
@@ -0,0 +1,51 @@
+<% content_for(:title, "Edit my lightning talk · Ruby Embassy") %>
+
+
+
+
+ Edit my lightning talk
+
+
+
+ All fields are optional. If you fill them in, they'll show next to your name on the public schedule.
+
+
+ <% if @signup.errors.any? %>
+
+
+ <% @signup.errors.full_messages.each do |msg| %>
+ - <%= msg %>
+ <% end %>
+
+
+ <% end %>
+
+
+ <%= form_with model: @signup,
+ url: schedule_item_lightning_talk_signup_path(@schedule_item),
+ method: :patch,
+ data: { turbo: false },
+ class: "space-y-4" do |f| %>
+
+ <%= f.label :talk_title, "Talk title", class: "label" %>
+ <%= f.text_field :talk_title, class: "input", placeholder: "e.g., Why I love Sidekiq" %>
+
+
+
+ <%= f.label :talk_description, "One-line description", class: "label" %>
+ <%= f.text_area :talk_description, rows: 3, class: "input", placeholder: "Quick pitch — what attendees will learn" %>
+
+
+
+ <%= f.label :slides_url, "Slides URL", class: "label" %>
+ <%= f.url_field :slides_url, class: "input", placeholder: "https://..." %>
+
+
+
+ <%= f.submit "Save", class: "btn btn-red" %>
+ <%= link_to "Cancel", schedule_path(anchor: dom_id(@schedule_item)), class: "btn btn-muted" %>
+
+ <% end %>
+
+
+
diff --git a/app/views/plan/_plan_item.html.erb b/app/views/plan/_plan_item.html.erb
index 2920ee9..dfb96c9 100644
--- a/app/views/plan/_plan_item.html.erb
+++ b/app/views/plan/_plan_item.html.erb
@@ -1,6 +1,7 @@
<%
- item = plan_item.schedule_item
- custom = item.created_by_id == current_user.id
+ item = plan_item.schedule_item
+ custom = item.created_by_id == current_user.id
+ presenting = item.lightning? && @speaking_ids&.include?(item.id)
custom_class = custom ? "plan-item--custom" : ""
%>
@@ -111,22 +112,35 @@
">
<%= item.is_public? ? "Public" : "Private" %>
+ <% if presenting %>
+
Presenting
+ <% end %>
<% if custom %>
Custom
<% end %>
+ <% if presenting %>
+
+ <%= link_to "Edit my lightning talk",
+ edit_schedule_item_lightning_talk_signup_path(item),
+ data: { turbo_frame: "_top" } %>
+
+ <% end %>
+
<% if plan_item.notes.present? %>
<%= plan_item.notes %>
<% end %>
- <%= button_to "×",
- plan_item_path(plan_item),
- method: :delete,
- class: "plan-item__remove",
- aria: { label: "Remove from plan" },
- data: { turbo_confirm: "Remove '#{item.title}' from your plan?" } %>
+ <% unless presenting %>
+ <%= button_to "×",
+ plan_item_path(plan_item),
+ method: :delete,
+ class: "plan-item__remove",
+ aria: { label: "Remove from plan" },
+ data: { turbo_confirm: "Remove '#{item.title}' from your plan?" } %>
+ <% end %>
<% end %>
<% end %>
diff --git a/app/views/schedule/_lightning_session_item.html.erb b/app/views/schedule/_lightning_session_item.html.erb
new file mode 100644
index 0000000..dfb0e74
--- /dev/null
+++ b/app/views/schedule/_lightning_session_item.html.erb
@@ -0,0 +1,109 @@
+<%
+ signups = item.lightning_talk_signups.includes(:user).to_a
+ signup_count = signups.length
+ filled_by_pos = signups.index_by(&:position)
+ full = item.lightning_slots_full?
+ user_signup = signups.find { |s| s.user_id == current_user.id }
+ has_plan_item = current_user.plan_items.exists?(schedule_item_id: item.id)
+%>
+
+<%= turbo_frame_tag dom_id(item) do %>
+
+
+ <%= item.time_label %>
+
+
+
+
<%= item.title %>
+
+ <% if item.location.present? %>
+
<%= item.location %>
+ <% end %>
+
+ <% if item.description.present? %>
+
<%= simple_format(item.description, {}, wrapper_tag: "span") %>
+ <% end %>
+
+
+ Lightning
+
+
+
>
+
+ <%= signup_count %> of <%= LightningTalkSignup::MAX_SPEAKERS %> speaking slots filled
+
+
+ View lineup
+ Hide lineup
+
+
+
+
+ <% (1..LightningTalkSignup::MAX_SPEAKERS).each do |position| %>
+ <% signup = filled_by_pos[position] %>
+ -
+ <%= position %>.
+
+ <% if signup %>
+ <%= signup.slot_start_label %>
+ <%= signup.user.full_name %>
+ <% if signup.talk_title.present? %>
+ “<%= signup.talk_title %>”
+ <% end %>
+ <% if signup.slides_url.present? %>
+ <%= link_to "Slides", signup.slides_url, class: "lightning-slot__slides", target: "_blank", rel: "noopener" %>
+ <% end %>
+ <% if signup.user_id == current_user.id %>
+ <%= link_to "Edit my lightning talk",
+ edit_schedule_item_lightning_talk_signup_path(item),
+ class: "lightning-slot__edit",
+ data: { turbo_frame: "_top" } %>
+ <% end %>
+ <% if signup.talk_description.present? %>
+ <%= signup.talk_description %>
+ <% end %>
+ <% else %>
+ Open slot
+ <% end %>
+
+ <% end %>
+
+
+
+
+
+ <% if user_signup %>
+ ✓ Presenting
+ <% elsif full %>
+
+ <% else %>
+ <%= button_to "Speak",
+ schedule_item_lightning_talk_signup_path(item),
+ method: :post,
+ class: "add-btn",
+ data: { turbo_frame: dom_id(item) },
+ aria: { pressed: false } %>
+ <% end %>
+
+ <% unless user_signup %>
+ <% if has_plan_item %>
+ <% existing_plan = current_user.plan_items.find_by(schedule_item: item) %>
+ <%= button_to "✓ Added to Plan",
+ plan_item_path(existing_plan),
+ method: :delete,
+ class: "add-btn add-btn--added",
+ data: { turbo_frame: dom_id(item) },
+ aria: { pressed: true } %>
+ <% else %>
+ <%= button_to "+ Add to Plan",
+ plan_items_path,
+ method: :post,
+ params: { schedule_item_id: item.id },
+ class: "add-btn",
+ data: { turbo_frame: dom_id(item) },
+ aria: { pressed: false } %>
+ <% end %>
+ <% end %>
+
+
+<% end %>
diff --git a/app/views/schedule/_session_item.html.erb b/app/views/schedule/_session_item.html.erb
index 2efeeed..336a410 100644
--- a/app/views/schedule/_session_item.html.erb
+++ b/app/views/schedule/_session_item.html.erb
@@ -1,3 +1,6 @@
+<% if item.lightning? %>
+ <%= render "schedule/lightning_session_item", item: item %>
+<% else %>
<%
kind_label = kind_display_label(item.kind)
flexible_classes = item.flexible? ? "schedule-item--flexible" : ""
@@ -139,3 +142,4 @@
<% end %>
+<% end %>
diff --git a/config/routes.rb b/config/routes.rb
index ec45314..a0cd16e 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -17,7 +17,12 @@
resources :users do
post :sync, on: :collection
end
- resources :schedule_items
+ resources :schedule_items do
+ resources :lightning_talk_signups, only: %i[index create update destroy] do
+ collection { patch :reorder }
+ end
+ end
+ resources :lightning_talks, only: %i[index]
resources :volunteers, only: %i[index show]
resources :volunteer_slots, only: %i[index show]
resources :volunteer_signups, only: %i[create destroy]
@@ -46,6 +51,7 @@
resources :plan_items, only: %i[create update destroy]
resources :schedule_items, only: %i[new create edit update] do
+ resource :lightning_talk_signup, only: %i[create edit update]
resources :meal_spots, only: %i[index new create edit update] do
resources :rsvps, only: %i[create destroy], controller: "meal_spot_rsvps"
end
diff --git a/db/migrate/20260427032222_create_lightning_talk_signups.rb b/db/migrate/20260427032222_create_lightning_talk_signups.rb
new file mode 100644
index 0000000..ee67021
--- /dev/null
+++ b/db/migrate/20260427032222_create_lightning_talk_signups.rb
@@ -0,0 +1,17 @@
+class CreateLightningTalkSignups < ActiveRecord::Migration[8.1]
+ def change
+ create_table :lightning_talk_signups do |t|
+ t.references :user, null: false, foreign_key: true
+ t.references :schedule_item, null: false, foreign_key: true
+ t.string :talk_title
+ t.text :talk_description
+ t.string :slides_url
+ t.integer :position, null: false
+
+ t.timestamps
+ end
+
+ add_index :lightning_talk_signups, [ :user_id, :schedule_item_id ], unique: true
+ add_index :lightning_talk_signups, [ :schedule_item_id, :position ], unique: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 627b795..973a5dc 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_04_27_032216) do
+ActiveRecord::Schema[8.1].define(version: 2026_04_27_032222) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@@ -55,6 +55,21 @@
t.index ["user_id"], name: "index_embassy_bookings_on_user_id"
end
+ create_table "lightning_talk_signups", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.integer "position", null: false
+ t.bigint "schedule_item_id", null: false
+ t.string "slides_url"
+ t.text "talk_description"
+ t.string "talk_title"
+ t.datetime "updated_at", null: false
+ t.bigint "user_id", null: false
+ t.index ["schedule_item_id", "position"], name: "index_lightning_talk_signups_on_schedule_item_id_and_position", unique: true
+ t.index ["schedule_item_id"], name: "index_lightning_talk_signups_on_schedule_item_id"
+ t.index ["user_id", "schedule_item_id"], name: "index_lightning_talk_signups_on_user_id_and_schedule_item_id", unique: true
+ t.index ["user_id"], name: "index_lightning_talk_signups_on_user_id"
+ end
+
create_table "meal_spot_rsvps", force: :cascade do |t|
t.datetime "created_at", null: false
t.bigint "meal_spot_transport_id", null: false
@@ -185,6 +200,8 @@
add_foreign_key "embassy_bookings", "plan_items"
add_foreign_key "embassy_bookings", "schedule_items"
add_foreign_key "embassy_bookings", "users"
+ add_foreign_key "lightning_talk_signups", "schedule_items"
+ add_foreign_key "lightning_talk_signups", "users"
add_foreign_key "meal_spot_rsvps", "meal_spot_transports"
add_foreign_key "meal_spot_rsvps", "schedule_items"
add_foreign_key "meal_spot_rsvps", "users"
diff --git a/test/controllers/admin/lightning_talk_signups_controller_test.rb b/test/controllers/admin/lightning_talk_signups_controller_test.rb
new file mode 100644
index 0000000..3992790
--- /dev/null
+++ b/test/controllers/admin/lightning_talk_signups_controller_test.rb
@@ -0,0 +1,94 @@
+require "test_helper"
+
+class Admin::LightningTalkSignupsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @admin = users(:jeremy)
+ @item = ScheduleItem.create!(
+ day: "fri", title: "Lightning Talks", kind: :lightning,
+ sort_time: 1400, time_label: "2:00 PM", is_public: true
+ )
+ end
+
+ test "non-admin gets 404 from index" do
+ sign_in_as users(:attendee_one)
+ get admin_schedule_item_lightning_talk_signups_path(@item)
+ assert_response :not_found
+ end
+
+ test "admin HTML index redirects to top-level Lightning Talks page" do
+ sign_in_as @admin
+ get admin_schedule_item_lightning_talk_signups_path(@item)
+ assert_redirected_to admin_lightning_talks_path
+ end
+
+ test "admin can add a speaker" do
+ sign_in_as @admin
+ assert_difference -> { LightningTalkSignup.count }, 1 do
+ post admin_schedule_item_lightning_talk_signups_path(@item),
+ params: { user_id: users(:attendee_one).id }
+ end
+ end
+
+ test "admin can update talk details for any speaker" do
+ sign_in_as @admin
+ signup = LightningTalkSignup.claim_next_slot!(user: users(:attendee_one), schedule_item: @item)
+ patch admin_schedule_item_lightning_talk_signup_path(@item, signup),
+ params: { lightning_talk_signup: { talk_title: "Admin-edited" } }
+ assert_equal "Admin-edited", signup.reload.talk_title
+ end
+
+ test "admin destroy renumbers subsequent positions" do
+ sign_in_as @admin
+ s1 = LightningTalkSignup.claim_next_slot!(user: users(:attendee_one), schedule_item: @item)
+ s2 = LightningTalkSignup.claim_next_slot!(user: users(:volunteer_one), schedule_item: @item)
+ s3 = LightningTalkSignup.claim_next_slot!(user: users(:katya), schedule_item: @item)
+
+ delete admin_schedule_item_lightning_talk_signup_path(@item, s2)
+
+ assert_nil LightningTalkSignup.find_by(id: s2.id)
+ assert_equal 1, s1.reload.position
+ assert_equal 2, s3.reload.position
+ end
+
+ test "admin reorder updates positions" do
+ sign_in_as @admin
+ s1 = LightningTalkSignup.claim_next_slot!(user: users(:attendee_one), schedule_item: @item)
+ s2 = LightningTalkSignup.claim_next_slot!(user: users(:volunteer_one), schedule_item: @item)
+ s3 = LightningTalkSignup.claim_next_slot!(user: users(:katya), schedule_item: @item)
+
+ patch reorder_admin_schedule_item_lightning_talk_signups_path(@item),
+ params: { signup_ids: [ s3.id, s1.id, s2.id ] },
+ as: :json
+
+ assert_response :no_content
+ assert_equal 1, s3.reload.position
+ assert_equal 2, s1.reload.position
+ assert_equal 3, s2.reload.position
+ end
+
+ test "admin reorder via turbo_stream returns updated speakers list with new times" do
+ sign_in_as @admin
+ s1 = LightningTalkSignup.claim_next_slot!(user: users(:attendee_one), schedule_item: @item)
+ s2 = LightningTalkSignup.claim_next_slot!(user: users(:volunteer_one), schedule_item: @item)
+
+ patch reorder_admin_schedule_item_lightning_talk_signups_path(@item),
+ params: { signup_ids: [ s2.id, s1.id ] },
+ headers: { "Accept" => "text/vnd.turbo-stream.html" }
+
+ assert_response :success
+ assert_match "text/vnd.turbo-stream.html", @response.content_type
+ assert_match %r{}, @response.body
+ # Speaker that moved to position 1 now shows the start time (2:00 PM)
+ assert_match users(:volunteer_one).full_name, @response.body
+ assert_match "2:00 PM", @response.body
+ end
+
+ test "PDF export returns pdf content type" do
+ sign_in_as @admin
+ LightningTalkSignup.claim_next_slot!(user: users(:attendee_one), schedule_item: @item)
+ get admin_schedule_item_lightning_talk_signups_path(@item, format: :pdf)
+ assert_response :success
+ assert_match "application/pdf", @response.content_type
+ assert @response.body.start_with?("%PDF-"), "expected PDF signature"
+ end
+end
diff --git a/test/controllers/admin/lightning_talks_controller_test.rb b/test/controllers/admin/lightning_talks_controller_test.rb
new file mode 100644
index 0000000..f3b2793
--- /dev/null
+++ b/test/controllers/admin/lightning_talks_controller_test.rb
@@ -0,0 +1,35 @@
+require "test_helper"
+
+class Admin::LightningTalksControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @admin = users(:jeremy)
+ @item = ScheduleItem.create!(
+ day: "fri", title: "Lightning Talks", kind: :lightning,
+ sort_time: 1400, time_label: "2:00 PM", is_public: true
+ )
+ end
+
+ test "non-admin gets 404" do
+ sign_in_as users(:attendee_one)
+ get admin_lightning_talks_path
+ assert_response :not_found
+ end
+
+ test "admin sees the lineup directly with the speakers panel" do
+ sign_in_as @admin
+ LightningTalkSignup.claim_next_slot!(user: users(:attendee_one), schedule_item: @item)
+ get admin_lightning_talks_path
+ assert_response :success
+ assert_match @item.title, @response.body
+ assert_match users(:attendee_one).full_name, @response.body
+ assert_match "Speakers", @response.body
+ end
+
+ test "admin sees empty state when no lightning block exists" do
+ @item.destroy
+ sign_in_as @admin
+ get admin_lightning_talks_path
+ assert_response :success
+ assert_match "No lightning talk block", @response.body
+ end
+end
diff --git a/test/controllers/lightning_talk_signups_controller_test.rb b/test/controllers/lightning_talk_signups_controller_test.rb
new file mode 100644
index 0000000..e3c4f5c
--- /dev/null
+++ b/test/controllers/lightning_talk_signups_controller_test.rb
@@ -0,0 +1,62 @@
+require "test_helper"
+
+class LightningTalkSignupsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @item = ScheduleItem.create!(
+ day: "fri", title: "Lightning Talks", kind: :lightning,
+ sort_time: 1400, time_label: "2:00 PM", is_public: true
+ )
+ end
+
+ test "anonymous POST redirects to sign in" do
+ post schedule_item_lightning_talk_signup_path(@item)
+ assert_redirected_to new_session_path
+ end
+
+ test "signed-in user can claim a slot" do
+ sign_in_as users(:attendee_one)
+ assert_difference -> { LightningTalkSignup.count }, 1 do
+ post schedule_item_lightning_talk_signup_path(@item)
+ end
+ signup = LightningTalkSignup.last
+ assert_equal users(:attendee_one), signup.user
+ assert_equal 1, signup.position
+ end
+
+ test "claiming a slot auto-creates a PlanItem" do
+ sign_in_as users(:attendee_one)
+ assert_difference -> { PlanItem.count }, 1 do
+ post schedule_item_lightning_talk_signup_path(@item)
+ end
+ end
+
+ test "POST when full does not create and shows alert" do
+ LightningTalkSignup::MAX_SPEAKERS.times do |i|
+ user = User.create!(email: "filler-#{i}@example.com", role: :attendee)
+ LightningTalkSignup.claim_next_slot!(user: user, schedule_item: @item)
+ end
+ sign_in_as users(:attendee_one)
+ assert_no_difference -> { LightningTalkSignup.count } do
+ post schedule_item_lightning_talk_signup_path(@item)
+ end
+ assert_match(/full/i, flash[:alert].to_s)
+ end
+
+ test "speaker can edit own talk details" do
+ sign_in_as users(:attendee_one)
+ signup = LightningTalkSignup.claim_next_slot!(user: users(:attendee_one), schedule_item: @item)
+ patch schedule_item_lightning_talk_signup_path(@item),
+ params: { lightning_talk_signup: { talk_title: "Hello", talk_description: "About a thing", slides_url: "https://example.com/slides" } }
+ signup.reload
+ assert_equal "Hello", signup.talk_title
+ assert_equal "About a thing", signup.talk_description
+ assert_equal "https://example.com/slides", signup.slides_url
+ end
+
+ test "user without a signup gets 404 when editing" do
+ sign_in_as users(:attendee_one)
+ LightningTalkSignup.claim_next_slot!(user: users(:volunteer_one), schedule_item: @item)
+ get edit_schedule_item_lightning_talk_signup_path(@item)
+ assert_response :not_found
+ end
+end
diff --git a/test/fixtures/lightning_talk_signups.yml b/test/fixtures/lightning_talk_signups.yml
new file mode 100644
index 0000000..67a1d4f
--- /dev/null
+++ b/test/fixtures/lightning_talk_signups.yml
@@ -0,0 +1 @@
+# LightningTalkSignup fixtures. Keep empty; tests build signups inline via claim_next_slot!.
diff --git a/test/models/lightning_talk_signup_test.rb b/test/models/lightning_talk_signup_test.rb
new file mode 100644
index 0000000..001b2d3
--- /dev/null
+++ b/test/models/lightning_talk_signup_test.rb
@@ -0,0 +1,119 @@
+require "test_helper"
+
+class LightningTalkSignupTest < ActiveSupport::TestCase
+ def lightning_item(overrides = {})
+ ScheduleItem.create!({
+ day: "fri",
+ title: "Lightning Talks",
+ kind: :lightning,
+ sort_time: 1400,
+ time_label: "2:00 PM",
+ is_public: true
+ }.merge(overrides))
+ end
+
+ def non_lightning_item
+ ScheduleItem.create!(day: "fri", title: "Regular Talk", kind: :talk, is_public: true)
+ end
+
+ test "rejects signup for non-lightning schedule item" do
+ item = non_lightning_item
+ signup = LightningTalkSignup.new(user: users(:attendee_one), schedule_item: item, position: 1)
+ assert_not signup.valid?
+ assert_includes signup.errors[:schedule_item], "must be a lightning talk"
+ end
+
+ test "uniqueness: same user cannot sign up twice" do
+ item = lightning_item
+ LightningTalkSignup.claim_next_slot!(user: users(:attendee_one), schedule_item: item)
+ duplicate = LightningTalkSignup.new(user: users(:attendee_one), schedule_item: item, position: 2)
+ assert_not duplicate.valid?
+ assert_includes duplicate.errors[:user_id], "has already been taken"
+ end
+
+ test "claim_next_slot! assigns positions 1, 2, 3 in creation order" do
+ item = lightning_item
+ s1 = LightningTalkSignup.claim_next_slot!(user: users(:attendee_one), schedule_item: item)
+ s2 = LightningTalkSignup.claim_next_slot!(user: users(:volunteer_one), schedule_item: item)
+ s3 = LightningTalkSignup.claim_next_slot!(user: users(:jeremy), schedule_item: item)
+ assert_equal [ 1, 2, 3 ], [ s1.position, s2.position, s3.position ]
+ end
+
+ test "claim_next_slot! raises SlotsFull when at MAX_SPEAKERS" do
+ item = lightning_item
+ LightningTalkSignup::MAX_SPEAKERS.times do |i|
+ user = User.create!(email: "filler-#{i}@example.com", role: :attendee)
+ LightningTalkSignup.claim_next_slot!(user: user, schedule_item: item)
+ end
+
+ overflow_user = User.create!(email: "overflow@example.com", role: :attendee)
+ assert_raises(LightningTalkSignup::SlotsFull) do
+ LightningTalkSignup.claim_next_slot!(user: overflow_user, schedule_item: item)
+ end
+ end
+
+ test "slot_start_label divides 60 minutes evenly across 5 signups" do
+ item = lightning_item(sort_time: 1400, time_label: "2:00 PM")
+ signups = 5.times.map do |i|
+ user = User.create!(email: "s#{i}@example.com", role: :attendee)
+ LightningTalkSignup.claim_next_slot!(user: user, schedule_item: item)
+ end
+ assert_equal "2:00 PM", signups[0].slot_start_label
+ assert_equal "2:12 PM", signups[1].slot_start_label
+ assert_equal "2:24 PM", signups[2].slot_start_label
+ assert_equal "2:36 PM", signups[3].slot_start_label
+ assert_equal "2:48 PM", signups[4].slot_start_label
+ end
+
+ test "slot_start_label uses 6-minute slots when 10 signups are filled" do
+ item = lightning_item(sort_time: 1400, time_label: "2:00 PM")
+ signups = LightningTalkSignup::MAX_SPEAKERS.times.map do |i|
+ user = User.create!(email: "ten#{i}@example.com", role: :attendee)
+ LightningTalkSignup.claim_next_slot!(user: user, schedule_item: item)
+ end
+ assert_equal "2:00 PM", signups[0].slot_start_label
+ assert_equal "2:06 PM", signups[1].slot_start_label
+ assert_equal "2:54 PM", signups[9].slot_start_label
+ end
+
+ test "creating a signup creates a matching PlanItem" do
+ item = lightning_item
+ user = users(:attendee_one)
+ assert_difference -> { PlanItem.where(user: user, schedule_item: item).count }, 1 do
+ LightningTalkSignup.claim_next_slot!(user: user, schedule_item: item)
+ end
+ end
+
+ test "creating a signup is idempotent with existing PlanItem" do
+ item = lightning_item
+ user = users(:attendee_one)
+ PlanItem.create!(user: user, schedule_item: item)
+ assert_no_difference -> { PlanItem.count } do
+ LightningTalkSignup.claim_next_slot!(user: user, schedule_item: item)
+ end
+ end
+
+ test "editable_by? admin can edit any signup" do
+ item = lightning_item
+ signup = LightningTalkSignup.claim_next_slot!(user: users(:attendee_one), schedule_item: item)
+ assert signup.editable_by?(users(:jeremy))
+ end
+
+ test "editable_by? speaker can edit own signup" do
+ item = lightning_item
+ signup = LightningTalkSignup.claim_next_slot!(user: users(:attendee_one), schedule_item: item)
+ assert signup.editable_by?(users(:attendee_one))
+ end
+
+ test "editable_by? other attendee cannot edit" do
+ item = lightning_item
+ signup = LightningTalkSignup.claim_next_slot!(user: users(:attendee_one), schedule_item: item)
+ assert_not signup.editable_by?(users(:volunteer_one))
+ end
+
+ test "editable_by? nil actor returns false" do
+ item = lightning_item
+ signup = LightningTalkSignup.claim_next_slot!(user: users(:attendee_one), schedule_item: item)
+ assert_not signup.editable_by?(nil)
+ end
+end