+
+ <% case state
+ when :empty %>
+ Help wanted! 0 of <%= slot.volunteer_capacity %>
+ <% when :partial %>
+ <%= slot.volunteer_signup_count %> of <%= slot.volunteer_capacity %>
+ <% when :full %>
+ Filled (<%= slot.volunteer_capacity %> of <%= slot.volunteer_capacity %>)
+ <% end %>
+
+
+
+ <% case state
+ when :empty %>
+ Help wanted! 0 of <%= @slot.volunteer_capacity %>
+ <% when :partial %>
+ <%= @slot.volunteer_signup_count %> of <%= @slot.volunteer_capacity %>
+ <% when :full %>
+ Filled (<%= @slot.volunteer_capacity %> of <%= @slot.volunteer_capacity %>)
+ <% end %>
+
+
+ <%= f.submit "Add to this slot", class: "btn btn-navy" %>
+
+ <% end %>
+
+<% else %>
+
+ Everyone with the volunteer role is already signed up. Promote more attendees from
+ <%= link_to "Attendees", admin_users_path, class: "text-blue underline" %>.
+
+<% end %>
diff --git a/app/views/admin/volunteers/_tabs.html.erb b/app/views/admin/volunteers/_tabs.html.erb
new file mode 100644
index 0000000..7f50359
--- /dev/null
+++ b/app/views/admin/volunteers/_tabs.html.erb
@@ -0,0 +1,6 @@
+
diff --git a/app/views/admin/volunteers/index.html.erb b/app/views/admin/volunteers/index.html.erb
new file mode 100644
index 0000000..f5b95be
--- /dev/null
+++ b/app/views/admin/volunteers/index.html.erb
@@ -0,0 +1,35 @@
+<% content_for :container_width, "max-w-6xl" %>
+
+
No more volunteer slots available — they're already on every one.
+<% end %>
diff --git a/app/views/dashboard/show.html.erb b/app/views/dashboard/show.html.erb
index d15fd5e..ace9ca1 100644
--- a/app/views/dashboard/show.html.erb
+++ b/app/views/dashboard/show.html.erb
@@ -13,11 +13,12 @@
What would you like to do?
- <% if current_user.volunteer? || current_user.admin? %>
+ <% if (current_user.volunteer? || current_user.admin?) && @empty_volunteer_count.to_i > 0 %>
<%= link_to schedule_path(kind: :volunteer), class: "action-card action-card--hero mb-6" do %>
Volunteers Needed
- We could use a hand. Pick a slot whenever you've got time.
+ We need help with <%= pluralize(@empty_volunteer_count, "thing") %>.
+ Pick a slot whenever you've got time.
+ <% case state
+ when :empty %>
+ Help wanted! 0 of <%= item.volunteer_capacity %>
+ <% when :partial %>
+ <%= item.volunteer_signup_count %> of <%= item.volunteer_capacity %> — <%= item.volunteer_seats_remaining %> still needed
+ <% when :full %>
+ Filled (<%= item.volunteer_capacity %> of <%= item.volunteer_capacity %>)
+ <% end %>
+
+ <% end %>
+
<% if rsvp_note %>
<%= rsvp_note %>
<% end %>
diff --git a/config/routes.rb b/config/routes.rb
index e5c03ff..c1c567b 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -18,6 +18,9 @@
post :sync, on: :collection
end
resources :schedule_items
+ resources :volunteers, only: %i[index show]
+ resources :volunteer_slots, only: %i[index show]
+ resources :volunteer_signups, only: %i[create destroy]
resources :embassy_questions
resources :notary_profiles
resources :embassy_applications, only: %i[index show] do
diff --git a/db/migrate/20260427032216_add_volunteer_capacity_to_schedule_items.rb b/db/migrate/20260427032216_add_volunteer_capacity_to_schedule_items.rb
new file mode 100644
index 0000000..a369df5
--- /dev/null
+++ b/db/migrate/20260427032216_add_volunteer_capacity_to_schedule_items.rb
@@ -0,0 +1,5 @@
+class AddVolunteerCapacityToScheduleItems < ActiveRecord::Migration[8.1]
+ def change
+ add_column :schedule_items, :volunteer_capacity, :integer
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 5eee889..627b795 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_025532) do
+ActiveRecord::Schema[8.1].define(version: 2026_04_27_032216) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@@ -55,6 +55,45 @@
t.index ["user_id"], name: "index_embassy_bookings_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
@@ -116,6 +155,7 @@
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"
@@ -145,6 +185,12 @@
add_foreign_key "embassy_bookings", "plan_items"
add_foreign_key "embassy_bookings", "schedule_items"
add_foreign_key "embassy_bookings", "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/volunteer_signups_controller_test.rb b/test/controllers/admin/volunteer_signups_controller_test.rb
new file mode 100644
index 0000000..03096fe
--- /dev/null
+++ b/test/controllers/admin/volunteer_signups_controller_test.rb
@@ -0,0 +1,69 @@
+require "test_helper"
+
+class Admin::VolunteerSignupsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @slot = ScheduleItem.create!(
+ day: "fri", title: "Stamp passports",
+ kind: :volunteer, is_public: true, volunteer_capacity: 2
+ )
+ end
+
+ test "attendee cannot create a signup" do
+ sign_in_as users(:attendee_one)
+ assert_no_difference -> { PlanItem.count } do
+ post admin_volunteer_signups_path,
+ params: { user_id: users(:volunteer_one).id, schedule_item_id: @slot.id }
+ end
+ assert_response :not_found
+ end
+
+ test "admin can assign a volunteer to a slot" do
+ sign_in_as users(:jeremy)
+ assert_difference -> { PlanItem.count }, 1 do
+ post admin_volunteer_signups_path,
+ params: { user_id: users(:volunteer_one).id, schedule_item_id: @slot.id }
+ end
+ assert_redirected_to admin_volunteers_path
+ end
+
+ test "admin cannot assign someone to a slot that's already full" do
+ @slot.update!(volunteer_capacity: 1)
+ @slot.plan_items.create!(user: users(:volunteer_one))
+
+ sign_in_as users(:jeremy)
+ assert_no_difference -> { PlanItem.count } do
+ post admin_volunteer_signups_path,
+ params: { user_id: users(:jeremy).id, schedule_item_id: @slot.id }
+ end
+ follow_redirect!
+ assert_match "full", response.body
+ end
+
+ test "admin cannot assign attendee through this endpoint (scoped finder)" do
+ sign_in_as users(:jeremy)
+ assert_no_difference -> { PlanItem.count } do
+ post admin_volunteer_signups_path,
+ params: { user_id: users(:attendee_one).id, schedule_item_id: @slot.id }
+ end
+ assert_response :not_found
+ end
+
+ test "admin cannot use this endpoint for non-volunteer-kind items (scoped finder)" do
+ talk = ScheduleItem.create!(day: "fri", title: "A talk", kind: :talk, is_public: true)
+ sign_in_as users(:jeremy)
+ assert_no_difference -> { PlanItem.count } do
+ post admin_volunteer_signups_path,
+ params: { user_id: users(:volunteer_one).id, schedule_item_id: talk.id }
+ end
+ assert_response :not_found
+ end
+
+ test "admin can remove a signup" do
+ plan = @slot.plan_items.create!(user: users(:volunteer_one))
+ sign_in_as users(:jeremy)
+ assert_difference -> { PlanItem.count }, -1 do
+ delete admin_volunteer_signup_path(plan)
+ end
+ assert_redirected_to admin_volunteers_path
+ end
+end
diff --git a/test/controllers/admin/volunteer_slots_controller_test.rb b/test/controllers/admin/volunteer_slots_controller_test.rb
new file mode 100644
index 0000000..1829c87
--- /dev/null
+++ b/test/controllers/admin/volunteer_slots_controller_test.rb
@@ -0,0 +1,55 @@
+require "test_helper"
+
+class Admin::VolunteerSlotsControllerTest < ActionDispatch::IntegrationTest
+ test "attendee cannot access /admin/volunteer_slots" do
+ sign_in_as users(:attendee_one)
+ get admin_volunteer_slots_path
+ assert_response :not_found
+ end
+
+ test "admin sees the slot index with capacity status" do
+ ScheduleItem.create!(
+ day: "fri", title: "Stamp passports",
+ kind: :volunteer, is_public: true, volunteer_capacity: 3
+ )
+
+ sign_in_as users(:jeremy)
+ get admin_volunteer_slots_path
+ assert_response :success
+ assert_match "Stamp passports", response.body
+ assert_match "Help wanted", response.body
+ end
+
+ test "admin show page lists signups + add form" do
+ slot = ScheduleItem.create!(
+ day: "fri", title: "Stamp passports",
+ kind: :volunteer, is_public: true, volunteer_capacity: 3
+ )
+ slot.plan_items.create!(user: users(:volunteer_one))
+
+ sign_in_as users(:jeremy)
+ get admin_volunteer_slot_path(slot)
+ assert_response :success
+ assert_match users(:volunteer_one).full_name, response.body
+ assert_match "Add a volunteer", response.body
+ end
+
+ test "admin show page returns 404 for non-volunteer-kind item" do
+ talk = ScheduleItem.create!(day: "fri", title: "A talk", kind: :talk, is_public: true)
+ sign_in_as users(:jeremy)
+ get admin_volunteer_slot_path(talk)
+ assert_response :not_found
+ end
+
+ test "admin show page hides add form when slot is full" do
+ slot = ScheduleItem.create!(
+ day: "fri", title: "Cleanup",
+ kind: :volunteer, is_public: true, volunteer_capacity: 1
+ )
+ slot.plan_items.create!(user: users(:volunteer_one))
+
+ sign_in_as users(:jeremy)
+ get admin_volunteer_slot_path(slot)
+ assert_match "This slot is full", response.body
+ end
+end
diff --git a/test/controllers/admin/volunteers_controller_test.rb b/test/controllers/admin/volunteers_controller_test.rb
new file mode 100644
index 0000000..28ab2de
--- /dev/null
+++ b/test/controllers/admin/volunteers_controller_test.rb
@@ -0,0 +1,42 @@
+require "test_helper"
+
+class Admin::VolunteersControllerTest < ActionDispatch::IntegrationTest
+ test "attendee cannot access /admin/volunteers" do
+ sign_in_as users(:attendee_one)
+ get admin_volunteers_path
+ assert_response :not_found
+ end
+
+ test "admin sees volunteer list with slot counts" do
+ slot = ScheduleItem.create!(
+ day: "fri", title: "Stamp passports",
+ kind: :volunteer, is_public: true, volunteer_capacity: 3
+ )
+ slot.plan_items.create!(user: users(:volunteer_one))
+
+ sign_in_as users(:jeremy)
+ get admin_volunteers_path
+ assert_response :success
+ assert_match users(:volunteer_one).full_name, response.body
+ end
+
+ test "admin show page renders volunteer's slots and assign form" do
+ slot = ScheduleItem.create!(
+ day: "fri", title: "Stamp passports",
+ kind: :volunteer, is_public: true, volunteer_capacity: 3
+ )
+ slot.plan_items.create!(user: users(:volunteer_one))
+
+ sign_in_as users(:jeremy)
+ get admin_volunteer_path(users(:volunteer_one))
+ assert_response :success
+ assert_match "Stamp passports", response.body
+ assert_match "Assign to a volunteer slot", response.body
+ end
+
+ test "admin show page returns 404 for an attendee" do
+ sign_in_as users(:jeremy)
+ get admin_volunteer_path(users(:attendee_one))
+ assert_response :not_found
+ end
+end
diff --git a/test/controllers/plan_items_controller_test.rb b/test/controllers/plan_items_controller_test.rb
index 1811988..3ce2b74 100644
--- a/test/controllers/plan_items_controller_test.rb
+++ b/test/controllers/plan_items_controller_test.rb
@@ -69,7 +69,8 @@ class PlanItemsControllerTest < ActionDispatch::IntegrationTest
test "attendee POST to a volunteers_only item returns 404 (visibility guard)" do
hidden = ScheduleItem.create!(
slug: "vol-only", day: "fri", title: "Vol-only briefing",
- kind: :volunteer, is_public: true, audience: "volunteers_only"
+ kind: :volunteer, is_public: true, audience: "volunteers_only",
+ volunteer_capacity: 3
)
sign_in_as users(:attendee_one)
@@ -82,7 +83,8 @@ class PlanItemsControllerTest < ActionDispatch::IntegrationTest
test "volunteer can RSVP to a volunteers_only item" do
hidden = ScheduleItem.create!(
slug: "vol-only", day: "fri", title: "Vol-only briefing",
- kind: :volunteer, is_public: true, audience: "volunteers_only"
+ kind: :volunteer, is_public: true, audience: "volunteers_only",
+ volunteer_capacity: 3
)
sign_in_as users(:volunteer_one)
diff --git a/test/integration/schedule_browsing_test.rb b/test/integration/schedule_browsing_test.rb
index 37f9313..28b57ab 100644
--- a/test/integration/schedule_browsing_test.rb
+++ b/test/integration/schedule_browsing_test.rb
@@ -109,7 +109,8 @@ class ScheduleBrowsingTest < ActionDispatch::IntegrationTest
slug: "volunteer-briefing",
day: "fri", time_label: "8:00 AM", sort_time: 800,
title: "Volunteer Briefing",
- kind: :volunteer, is_public: true, audience: "volunteers_only"
+ kind: :volunteer, is_public: true, audience: "volunteers_only",
+ volunteer_capacity: 3
)
sign_in_as users(:attendee_one)
get schedule_path
@@ -121,7 +122,8 @@ class ScheduleBrowsingTest < ActionDispatch::IntegrationTest
slug: "volunteer-briefing",
day: "fri", time_label: "8:00 AM", sort_time: 800,
title: "Volunteer Briefing",
- kind: :volunteer, is_public: true, audience: "volunteers_only"
+ kind: :volunteer, is_public: true, audience: "volunteers_only",
+ volunteer_capacity: 3
)
sign_in_as users(:volunteer_one)
get schedule_path
@@ -133,7 +135,8 @@ class ScheduleBrowsingTest < ActionDispatch::IntegrationTest
slug: "volunteer-briefing",
day: "fri", time_label: "8:00 AM", sort_time: 800,
title: "Volunteer Briefing",
- kind: :volunteer, is_public: true, audience: "volunteers_only"
+ kind: :volunteer, is_public: true, audience: "volunteers_only",
+ volunteer_capacity: 3
)
sign_in_as users(:jeremy)
get schedule_path
diff --git a/test/models/plan_item_test.rb b/test/models/plan_item_test.rb
index 1f122c0..459ed4a 100644
--- a/test/models/plan_item_test.rb
+++ b/test/models/plan_item_test.rb
@@ -32,4 +32,25 @@ def build_schedule_item
plan = PlanItem.new(user: users(:attendee_one), schedule_item: item)
assert plan.valid?
end
+
+ test "cannot create plan_item for a full volunteer slot" do
+ slot = ScheduleItem.create!(
+ day: "thu", title: "Stamp passports",
+ kind: :volunteer, is_public: true, volunteer_capacity: 1
+ )
+ PlanItem.create!(user: users(:volunteer_one), schedule_item: slot)
+
+ overflow = PlanItem.new(user: users(:jeremy), schedule_item: slot)
+ assert_not overflow.valid?
+ assert_includes overflow.errors[:base], "This volunteer slot is full"
+ end
+
+ test "non-full volunteer slot accepts a signup" do
+ slot = ScheduleItem.create!(
+ day: "thu", title: "Stamp passports",
+ kind: :volunteer, is_public: true, volunteer_capacity: 2
+ )
+ plan = PlanItem.new(user: users(:volunteer_one), schedule_item: slot)
+ assert plan.valid?
+ end
end
diff --git a/test/models/schedule_item_test.rb b/test/models/schedule_item_test.rb
index 20b4bc8..d6ac759 100644
--- a/test/models/schedule_item_test.rb
+++ b/test/models/schedule_item_test.rb
@@ -189,4 +189,72 @@ def valid_attrs(overrides = {})
))
end
end
+
+ # ----- Volunteer capacity ----------------------------------------------
+
+ def volunteer_attrs(overrides = {})
+ valid_attrs(kind: :volunteer, title: "Stamp passports", volunteer_capacity: 3).merge(overrides)
+ end
+
+ test "volunteer_capacity is required when kind is volunteer" do
+ item = ScheduleItem.new(volunteer_attrs(volunteer_capacity: nil))
+ assert_not item.valid?
+ assert_includes item.errors[:volunteer_capacity], "can't be blank"
+ end
+
+ test "volunteer_capacity not required for non-volunteer kinds" do
+ item = ScheduleItem.new(valid_attrs(kind: :activity, volunteer_capacity: nil))
+ assert item.valid?, item.errors.full_messages.inspect
+ end
+
+ test "volunteer_capacity must be a positive integer" do
+ item = ScheduleItem.new(volunteer_attrs(volunteer_capacity: 0))
+ assert_not item.valid?
+ assert_includes item.errors[:volunteer_capacity], "must be greater than 0"
+ end
+
+ test "volunteer_state returns :empty when no signups" do
+ item = ScheduleItem.create!(volunteer_attrs(volunteer_capacity: 3))
+ assert item.volunteer_empty?
+ assert_equal :empty, item.volunteer_state
+ end
+
+ test "volunteer_state returns :partial when some signups but not full" do
+ item = ScheduleItem.create!(volunteer_attrs(volunteer_capacity: 3))
+ item.plan_items.create!(user: users(:volunteer_one))
+ assert item.reload.volunteer_partial?
+ assert_equal :partial, item.volunteer_state
+ end
+
+ test "volunteer_state returns :full when capacity reached" do
+ item = ScheduleItem.create!(volunteer_attrs(volunteer_capacity: 2))
+ item.plan_items.create!(user: users(:volunteer_one))
+ item.plan_items.create!(user: users(:jeremy))
+ assert item.reload.volunteer_full?
+ assert_equal :full, item.volunteer_state
+ end
+
+ test "volunteer_state returns nil for non-volunteer kinds" do
+ item = ScheduleItem.create!(valid_attrs(kind: :talk))
+ assert_nil item.volunteer_state
+ end
+
+ test "volunteer_seats_remaining decrements with signups" do
+ item = ScheduleItem.create!(volunteer_attrs(volunteer_capacity: 3))
+ assert_equal 3, item.volunteer_seats_remaining
+ item.plan_items.create!(user: users(:volunteer_one))
+ assert_equal 2, item.reload.volunteer_seats_remaining
+ end
+
+ test "volunteer_empty scope returns only volunteer-kind items with zero signups" do
+ empty = ScheduleItem.create!(volunteer_attrs(title: "Empty"))
+ filled = ScheduleItem.create!(volunteer_attrs(title: "Filled", volunteer_capacity: 1))
+ filled.plan_items.create!(user: users(:volunteer_one))
+ other = ScheduleItem.create!(valid_attrs(kind: :talk, title: "Talk"))
+
+ result = ScheduleItem.volunteer_empty
+ assert_includes result, empty
+ assert_not_includes result, filled
+ assert_not_includes result, other
+ end
end