+
+ <% 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
From 0b04b3bb1e752618969a741a4ed1ff3e79c1c990 Mon Sep 17 00:00:00 2001
From: Katya Sarmiento <5871075+Kitkatnik@users.noreply.github.com>
Date: Sun, 26 Apr 2026 23:37:53 -0400
Subject: [PATCH 2/2] Activate Volunteers card on admin dashboard + pad
empty-slot row
The Manage section's Volunteers card was a "Soon" placeholder; switch it
to a real link to /admin/volunteers now that the page exists.
The empty-volunteer-slot treatment on /schedule had its red border and
pink background bleeding to the container edges, so the time, title, and
button felt jammed against the colored area. Add 1.5rem horizontal
padding to schedule-item--volunteer-empty so the urgent treatment reads
as a properly inset callout.
---
app/assets/stylesheets/application.css | 2 ++
app/views/admin/dashboard/show.html.erb | 5 ++---
2 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css
index f06f5ce..216424f 100644
--- a/app/assets/stylesheets/application.css
+++ b/app/assets/stylesheets/application.css
@@ -1932,6 +1932,8 @@ hr {
.schedule-item.schedule-item--volunteer-empty {
border-left: 4px solid #b91c1c;
background: #fef2f2;
+ padding-left: 1.5rem;
+ padding-right: 1.5rem;
}
/* Admin tabs ----------------------------------------------------------- */
diff --git a/app/views/admin/dashboard/show.html.erb b/app/views/admin/dashboard/show.html.erb
index 34af9f0..ea5c260 100644
--- a/app/views/admin/dashboard/show.html.erb
+++ b/app/views/admin/dashboard/show.html.erb
@@ -32,11 +32,10 @@
Manage attendees, volunteers, and admins. Sync from Tito.
<% end %>
-
+ <%= link_to admin_volunteers_path, class: "action-card" do %>
Volunteers
Coordinate volunteer signups, shifts, and assignments.
- Soon
-
+ <% end %>
<%= link_to admin_schedule_items_path, class: "action-card" do %>