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| %> +
    1. + + <%= 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?" } %> +
    2. + <% 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 @@
    - <%= 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] %> + + <% 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