From 161b58fe57edb55a6e6e8d62893eca20cc201909 Mon Sep 17 00:00:00 2001 From: Katya Sarmiento <5871075+Kitkatnik@users.noreply.github.com> Date: Mon, 27 Apr 2026 00:20:53 -0400 Subject: [PATCH 1/9] Lightning talk signups: Speak flow, lineup, admin Speakers panel Adds a separate "Speak" affordance on lightning blocks (distinct from "Add to Plan") so attendees can claim a presenter slot. Slots are race-safely claimed via with_lock, capped at 10 speakers, and start times are computed by dividing the 60-minute block by the current signup count. Volunteering auto-adds the block to the user's Plan with a "Presenting" badge. Admins get a Speakers panel on the schedule item edit page with drag-to-reorder, manual add/remove, talk-detail editing, and CSV export at /admin/schedule_items/:id/lightning_talk_signups.csv. --- Gemfile | 3 + Gemfile.lock | 2 + app/assets/stylesheets/application.css | 109 +++++++++++++ .../lightning_talk_signups_controller.rb | 98 ++++++++++++ .../lightning_talk_signups_controller.rb | 49 ++++++ app/controllers/plan_controller.rb | 3 +- .../admin/lightning_talk_signups_helper.rb | 2 + app/helpers/lightning_talk_signups_helper.rb | 2 + .../controllers/sortable_controller.js | 61 +++++++ app/models/lightning_talk_signup.rb | 61 +++++++ app/models/schedule_item.rb | 6 + app/models/user.rb | 6 + .../lightning_talk_signups/_panel.html.erb | 57 +++++++ .../lightning_talk_signups/index.html.erb | 18 +++ app/views/admin/schedule_items/edit.html.erb | 4 + .../lightning_talk_signups/edit.html.erb | 50 ++++++ app/views/plan/_plan_item.html.erb | 26 ++- .../schedule/_lightning_session_item.html.erb | 105 ++++++++++++ app/views/schedule/_session_item.html.erb | 4 + config/routes.rb | 10 +- ...427032222_create_lightning_talk_signups.rb | 17 ++ db/schema.rb | 150 +++++++++++++++++- .../lightning_talk_signups_controller_test.rb | 80 ++++++++++ .../lightning_talk_signups_controller_test.rb | 62 ++++++++ test/fixtures/lightning_talk_signups.yml | 1 + test/models/lightning_talk_signup_test.rb | 119 ++++++++++++++ 26 files changed, 1094 insertions(+), 11 deletions(-) create mode 100644 app/controllers/admin/lightning_talk_signups_controller.rb create mode 100644 app/controllers/lightning_talk_signups_controller.rb create mode 100644 app/helpers/admin/lightning_talk_signups_helper.rb create mode 100644 app/helpers/lightning_talk_signups_helper.rb create mode 100644 app/javascript/controllers/sortable_controller.js create mode 100644 app/models/lightning_talk_signup.rb create mode 100644 app/views/admin/lightning_talk_signups/_panel.html.erb create mode 100644 app/views/admin/lightning_talk_signups/index.html.erb create mode 100644 app/views/lightning_talk_signups/edit.html.erb create mode 100644 app/views/schedule/_lightning_session_item.html.erb create mode 100644 db/migrate/20260427032222_create_lightning_talk_signups.rb create mode 100644 test/controllers/admin/lightning_talk_signups_controller_test.rb create mode 100644 test/controllers/lightning_talk_signups_controller_test.rb create mode 100644 test/fixtures/lightning_talk_signups.yml create mode 100644 test/models/lightning_talk_signup_test.rb diff --git a/Gemfile b/Gemfile index ff07466..330f534 100644 --- a/Gemfile +++ b/Gemfile @@ -44,6 +44,9 @@ gem "image_processing", "~> 1.2" # Tito API client for event registration lookup gem "tito_ruby" +# CSV is no longer a default gem in Ruby 3.4+; needed for admin CSV exports. +gem "csv" + # Postmark for transactional email in production gem "postmark-rails" diff --git a/Gemfile.lock b/Gemfile.lock index b08612d..d1862a7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -108,6 +108,7 @@ GEM concurrent-ruby (1.3.6) connection_pool (3.0.2) crass (1.0.6) + csv (3.3.5) date (3.5.1) debug (1.11.1) irb (~> 1.10) @@ -440,6 +441,7 @@ DEPENDENCIES bullet bundler-audit capybara + csv debug image_processing (~> 1.2) importmap-rails diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 9eb53b6..bd9c663 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -544,6 +544,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; @@ -2469,3 +2477,104 @@ 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.25rem 0; +} + +.lightning-slots__summary::-webkit-details-marker { display: none; } + +.lightning-slots__hint { + color: #8a8f96; + font-size: 0.8125rem; +} + +.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; } 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..4cb613e --- /dev/null +++ b/app/controllers/admin/lightning_talk_signups_controller.rb @@ -0,0 +1,98 @@ +require "csv" + +module Admin + class LightningTalkSignupsController < AdminController + before_action :load_schedule_item + before_action :set_signup, only: %i[update destroy] + + def index + @signups = @schedule_item.lightning_talk_signups.includes(:user) + respond_to do |format| + format.html + format.csv { send_data signups_csv(@signups), filename: csv_filename, type: "text/csv" } + end + end + + def create + user = User.find(params[:user_id]) + LightningTalkSignup.claim_next_slot!(user: user, schedule_item: @schedule_item) + redirect_to admin_schedule_item_lightning_talk_signups_path(@schedule_item), + notice: "Speaker added." + rescue LightningTalkSignup::SlotsFull + redirect_to admin_schedule_item_lightning_talk_signups_path(@schedule_item), + alert: "All speaking slots are full." + end + + def update + if @signup.update(signup_params) + redirect_to admin_schedule_item_lightning_talk_signups_path(@schedule_item), + notice: "Talk details updated." + else + redirect_to admin_schedule_item_lightning_talk_signups_path(@schedule_item), + 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_to admin_schedule_item_lightning_talk_signups_path(@schedule_item), + 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 + head :no_content + 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 signups_csv(signups) + CSV.generate do |csv| + csv << %w[position slot_time first_name last_name email talk_title talk_description slides_url] + signups.each do |s| + csv << [ + s.position, + s.slot_start_label, + s.user.first_name, + s.user.last_name, + s.user.email, + s.talk_title, + s.talk_description, + s.slides_url + ] + end + end + end + + def csv_filename + "lightning-talks-#{@schedule_item.day}-#{@schedule_item.id}.csv" + 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..e4d5c2d --- /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, 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 06f027f..b68ceec 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 } - @travel = MOCK_TRAVEL + @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/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..d99ef95 --- /dev/null +++ b/app/javascript/controllers/sortable_controller.js @@ -0,0 +1,61 @@ +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": "application/json" + }, + body: JSON.stringify({ signup_ids: ids }) + }) + } +} 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 72922b5..c872996 100644 --- a/app/models/schedule_item.rb +++ b/app/models/schedule_item.rb @@ -2,6 +2,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 enum :kind, { talk: 0, lightning: 1, embassy: 2, activity: 3 } @@ -42,6 +44,10 @@ def rsvp_count plan_items.count end + def lightning_slots_full? + lightning? && lightning_talk_signups.count >= LightningTalkSignup::MAX_SPEAKERS + end + def editable_by?(user) return false if user.nil? user.admin? || created_by_id == user.id diff --git a/app/models/user.rb b/app/models/user.rb index c6a461e..4043bb3 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 :created_schedule_items, class_name: "ScheduleItem", foreign_key: :created_by_id, @@ -52,6 +54,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/views/admin/lightning_talk_signups/_panel.html.erb b/app/views/admin/lightning_talk_signups/_panel.html.erb new file mode 100644 index 0000000..558f484 --- /dev/null +++ b/app/views/admin/lightning_talk_signups/_panel.html.erb @@ -0,0 +1,57 @@ +<% + 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 CSV", + admin_schedule_item_lightning_talk_signups_path(schedule_item, format: :csv), + 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? %> +
      + <% 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 %> +
    + <% 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, class: "flex gap-2 items-end" do |f| %> +
    + <%= label_tag :user_id, "User", class: "label" %> + <%= select_tag :user_id, + options_from_collection_for_select(available_speakers, :id, :full_name), + class: "select", required: true, prompt: "— pick a user —" %> +
    + <%= f.submit "Add", class: "btn btn-red" %> + <% end %> +
    + <% end %> +
    diff --git a/app/views/admin/lightning_talk_signups/index.html.erb b/app/views/admin/lightning_talk_signups/index.html.erb new file mode 100644 index 0000000..0fa4105 --- /dev/null +++ b/app/views/admin/lightning_talk_signups/index.html.erb @@ -0,0 +1,18 @@ +<% content_for :container_width, "max-w-6xl" %> + +
    +
    +

    Lightning Talk Speakers

    +

    + <%= link_to @schedule_item.title, edit_admin_schedule_item_path(@schedule_item) %> + · + <%= ScheduleItem::DAY_META.dig(@schedule_item.day, :label) || @schedule_item.day %>, + <%= @schedule_item.time_label %> +

    +
    + <%= link_to "Export CSV", + admin_schedule_item_lightning_talk_signups_path(@schedule_item, format: :csv), + class: "btn btn-muted" %> +
    + +<%= render "panel", schedule_item: @schedule_item %> 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/lightning_talk_signups/edit.html.erb b/app/views/lightning_talk_signups/edit.html.erb new file mode 100644 index 0000000..e3f6a1d --- /dev/null +++ b/app/views/lightning_talk_signups/edit.html.erb @@ -0,0 +1,50 @@ +<% 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, + 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, class: "btn btn-muted" %> +
    + <% end %> +
    +
    +
    diff --git a/app/views/plan/_plan_item.html.erb b/app/views/plan/_plan_item.html.erb index a0d3094..ef5c95a 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" : "" %> @@ -95,21 +96,32 @@ "> <%= 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) %> +

    + <% 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" } %> + <% unless presenting %> + <%= button_to "×", + plan_item_path(plan_item), + method: :delete, + class: "plan-item__remove", + aria: { label: "Remove from 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..17088a6 --- /dev/null +++ b/app/views/schedule/_lightning_session_item.html.erb @@ -0,0 +1,105 @@ +<% + 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 + — click to see 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 f98a031..bdd79c2 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 = { "talk" => "Talk", @@ -116,3 +119,4 @@ <% end %> +<% end %> diff --git a/config/routes.rb b/config/routes.rb index 9212761..a888157 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -17,7 +17,11 @@ 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 :embassy_questions resources :embassy_applications, only: %i[index show] resource :embassy_blank_pdfs, only: %i[new create] @@ -33,7 +37,9 @@ get "plan", to: "plan#index" resources :plan_items, only: %i[create update destroy] - resources :schedule_items, only: %i[new create edit update] + resources :schedule_items, only: %i[new create edit update] do + resource :lightning_talk_signup, only: %i[create edit update] + end # Embassy booking + application — attendee-facing mockup resources :embassy_bookings, only: %i[new create] 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 900d27a..973a5dc 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,10 +10,115 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_04_23_145241) 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" + create_table "embassy_application_answers", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "embassy_application_id", null: false + t.bigint "question_id", null: false + t.datetime "updated_at", null: false + t.jsonb "value_array", default: [], null: false + t.text "value_text" + t.index ["embassy_application_id", "question_id"], name: "index_embassy_application_answers_on_app_question_uniq", unique: true + t.index ["embassy_application_id"], name: "index_embassy_application_answers_on_embassy_application_id" + t.index ["question_id"], name: "index_embassy_application_answers_on_question_id" + end + + create_table "embassy_applications", force: :cascade do |t| + t.datetime "created_at", null: false + t.jsonb "drawn_question_ids", default: [], null: false + t.bigint "embassy_booking_id", null: false + t.bigint "notary_profile_id" + t.datetime "passport_received_at" + t.string "serial", null: false + t.string "state", default: "draft", null: false + t.datetime "submitted_at" + t.datetime "updated_at", null: false + t.index ["embassy_booking_id"], name: "index_embassy_applications_on_booking_uniq", unique: true + t.index ["notary_profile_id"], name: "index_embassy_applications_on_notary_profile_id" + t.index ["serial"], name: "index_embassy_applications_on_serial", unique: true + end + + create_table "embassy_bookings", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "mode", null: false + t.bigint "plan_item_id", null: false + t.bigint "schedule_item_id", null: false + t.string "state", default: "confirmed", null: false + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["plan_item_id"], name: "index_embassy_bookings_on_plan_item_id" + t.index ["schedule_item_id"], name: "index_embassy_bookings_on_schedule_item_id" + t.index ["user_id", "schedule_item_id"], name: "index_embassy_bookings_on_user_id_and_schedule_item_id", unique: true + 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 + t.bigint "schedule_item_id", null: false + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["meal_spot_transport_id"], name: "index_meal_spot_rsvps_on_meal_spot_transport_id" + t.index ["schedule_item_id"], name: "index_meal_spot_rsvps_on_schedule_item_id" + t.index ["user_id", "schedule_item_id"], name: "index_meal_spot_rsvps_on_user_id_and_schedule_item_id", unique: true + t.index ["user_id"], name: "index_meal_spot_rsvps_on_user_id" + end + + create_table "meal_spot_transports", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "departs_at", null: false + t.bigint "meal_spot_id", null: false + t.integer "mode", null: false + t.integer "seats_offered" + t.datetime "updated_at", null: false + t.index ["meal_spot_id", "mode"], name: "index_meal_spot_transports_on_meal_spot_id_and_mode", unique: true + t.index ["meal_spot_id"], name: "index_meal_spot_transports_on_meal_spot_id" + end + + create_table "meal_spots", force: :cascade do |t| + t.string "contact_info" + t.datetime "created_at", null: false + t.bigint "created_by_id", null: false + t.boolean "is_public", default: true, null: false + t.string "map_url" + t.string "meet_up_spot" + t.string "name", null: false + t.bigint "schedule_item_id", null: false + t.datetime "updated_at", null: false + t.index "schedule_item_id, lower((name)::text)", name: "index_meal_spots_on_schedule_item_id_and_lower_name", unique: true + t.index ["created_by_id"], name: "index_meal_spots_on_created_by_id" + t.index ["schedule_item_id", "is_public"], name: "index_meal_spots_on_schedule_item_id_and_is_public" + t.index ["schedule_item_id"], name: "index_meal_spots_on_schedule_item_id" + end + + create_table "notary_profiles", force: :cascade do |t| + t.datetime "created_at", null: false + t.text "description", null: false + t.string "external_id", null: false + t.text "followup_prompt" + t.string "status", default: "active", null: false + t.datetime "updated_at", null: false + t.index ["external_id"], name: "index_notary_profiles_on_external_id", unique: true + end + create_table "plan_items", force: :cascade do |t| t.datetime "created_at", null: false t.text "notes" @@ -25,24 +130,52 @@ t.index ["user_id"], name: "index_plan_items_on_user_id" end + create_table "questions", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "external_id", null: false + t.string "field_type", null: false + t.text "help" + t.text "label", null: false + t.integer "max_length" + t.jsonb "options", default: [], null: false + t.string "placeholder" + t.integer "position", default: 0, null: false + t.boolean "required", default: false, null: false + t.string "scope", default: "common", null: false + t.integer "section", null: false + t.string "status", default: "active", null: false + t.datetime "updated_at", null: false + t.index ["external_id"], name: "index_questions_on_external_id", unique: true + t.index ["scope", "status"], name: "index_questions_on_scope_and_status" + t.index ["section", "position"], name: "index_questions_on_section_and_position" + end + create_table "schedule_items", force: :cascade do |t| + t.string "audience", default: "everyone", null: false t.datetime "created_at", null: false t.bigint "created_by_id" t.string "day", null: false t.text "description" + t.integer "embassy_capacity" + t.string "embassy_mode" t.boolean "flexible", default: false, null: false t.string "host" + t.string "host_url" t.boolean "is_public", default: false, null: false t.integer "kind", null: false t.string "location" + t.string "map_url" t.string "slug" t.integer "sort_time" t.string "time_label" t.string "title", null: false t.datetime "updated_at", null: false + t.integer "volunteer_capacity" + t.index ["audience"], name: "index_schedule_items_on_audience" t.index ["created_by_id"], name: "index_schedule_items_on_created_by_id" t.index ["day", "sort_time"], name: "index_schedule_items_on_day_and_sort_time" t.index ["is_public"], name: "index_schedule_items_on_is_public" + t.index ["kind", "embassy_mode"], name: "index_schedule_items_on_kind_and_embassy_mode" t.index ["slug"], name: "index_schedule_items_on_slug", unique: true, where: "(slug IS NOT NULL)" end @@ -60,6 +193,21 @@ t.index ["tito_ticket_slug"], name: "index_users_on_tito_ticket_slug", unique: true end + add_foreign_key "embassy_application_answers", "embassy_applications" + add_foreign_key "embassy_application_answers", "questions" + add_foreign_key "embassy_applications", "embassy_bookings" + add_foreign_key "embassy_applications", "notary_profiles" + 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" + add_foreign_key "meal_spot_transports", "meal_spots" + add_foreign_key "meal_spots", "schedule_items" + add_foreign_key "meal_spots", "users", column: "created_by_id" add_foreign_key "plan_items", "schedule_items" add_foreign_key "plan_items", "users" add_foreign_key "schedule_items", "users", column: "created_by_id" 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..3946e1c --- /dev/null +++ b/test/controllers/admin/lightning_talk_signups_controller_test.rb @@ -0,0 +1,80 @@ +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 sees the speakers index page" 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) + assert_response :success + assert_match users(:attendee_one).full_name, @response.body + 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 "CSV export returns csv 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: :csv) + assert_response :success + assert_match "text/csv", @response.content_type + assert_match "position,slot_time,first_name", @response.body + assert_match users(:attendee_one).email, @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 From f940c132ac301ea728c91cdecea53aa089890689 Mon Sep 17 00:00:00 2001 From: Katya Sarmiento <5871075+Kitkatnik@users.noreply.github.com> Date: Mon, 27 Apr 2026 00:40:14 -0400 Subject: [PATCH 2/9] Polish lightning row, edit form, and lineup toggle - Reorder slot row so name + title + Slides + Edit my lightning talk share row 1; description wraps to row 2 (was 3 rows previously). - Replace the gray "click to see lineup" hint with a red chevron + "View lineup"/"Hide lineup" affordance that rotates on open. - Restyle the Edit my lightning talk form to match other user-facing forms (card + label/input classes + standard btn classes). - After saving talk details, redirect to /schedule with a fragment anchor so the browser scrolls back to the lightning row instead of landing at the top of the schedule. --- app/assets/stylesheets/application.css | 36 ++++++++++++++++--- .../lightning_talk_signups_controller.rb | 2 +- .../lightning_talk_signups/edit.html.erb | 2 +- .../schedule/_lightning_session_item.html.erb | 14 +++++--- 4 files changed, 43 insertions(+), 11 deletions(-) diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 7e1bae7..de5a1c0 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -3075,16 +3075,44 @@ hr { color: #525C66; cursor: pointer; list-style: none; - padding: 0.25rem 0; + padding: 0.375rem 0; + display: flex; + align-items: center; + gap: 0.5rem; } .lightning-slots__summary::-webkit-details-marker { display: none; } -.lightning-slots__hint { - color: #8a8f96; - font-size: 0.8125rem; +.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; diff --git a/app/controllers/lightning_talk_signups_controller.rb b/app/controllers/lightning_talk_signups_controller.rb index e4d5c2d..c47469c 100644 --- a/app/controllers/lightning_talk_signups_controller.rb +++ b/app/controllers/lightning_talk_signups_controller.rb @@ -27,7 +27,7 @@ def edit def update @signup = current_signup if @signup.update(signup_params) - redirect_to schedule_path, notice: "Talk details saved." + redirect_to schedule_path(anchor: helpers.dom_id(@schedule_item)), notice: "Talk details saved." else render :edit, status: :unprocessable_content end diff --git a/app/views/lightning_talk_signups/edit.html.erb b/app/views/lightning_talk_signups/edit.html.erb index e3f6a1d..4ef789b 100644 --- a/app/views/lightning_talk_signups/edit.html.erb +++ b/app/views/lightning_talk_signups/edit.html.erb @@ -42,7 +42,7 @@
    <%= f.submit "Save", class: "btn btn-red" %> - <%= link_to "Cancel", schedule_path, class: "btn btn-muted" %> + <%= link_to "Cancel", schedule_path(anchor: dom_id(@schedule_item)), class: "btn btn-muted" %>
    <% end %> diff --git a/app/views/schedule/_lightning_session_item.html.erb b/app/views/schedule/_lightning_session_item.html.erb index 17088a6..dfb0e74 100644 --- a/app/views/schedule/_lightning_session_item.html.erb +++ b/app/views/schedule/_lightning_session_item.html.erb @@ -30,8 +30,12 @@
    > - <%= signup_count %> of <%= LightningTalkSignup::MAX_SPEAKERS %> speaking slots filled - — click to see lineup + <%= signup_count %> of <%= LightningTalkSignup::MAX_SPEAKERS %> speaking slots filled + + + View lineup + Hide lineup +
      @@ -46,9 +50,6 @@ <% if signup.talk_title.present? %> “<%= signup.talk_title %>” <% end %> - <% if signup.talk_description.present? %> - <%= signup.talk_description %> - <% end %> <% if signup.slides_url.present? %> <%= link_to "Slides", signup.slides_url, class: "lightning-slot__slides", target: "_blank", rel: "noopener" %> <% end %> @@ -58,6 +59,9 @@ class: "lightning-slot__edit", data: { turbo_frame: "_top" } %> <% end %> + <% if signup.talk_description.present? %> + <%= signup.talk_description %> + <% end %> <% else %> Open slot <% end %> From 1a78c61c3fa422833fa6e99c7dade788db265140 Mon Sep 17 00:00:00 2001 From: Katya Sarmiento <5871075+Kitkatnik@users.noreply.github.com> Date: Mon, 27 Apr 2026 00:41:35 -0400 Subject: [PATCH 3/9] Remove redundant Admin Dashboard link from admin nav The "Ruby Embassy Admin" brand link in the navbar already points at admin_root_path, so the separate "Admin Dashboard" link was a duplicate destination. Removed from both the desktop nav and the mobile drawer. --- app/views/layouts/admin.html.erb | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index 573aab3..20586d7 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb @@ -22,7 +22,6 @@