diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css
index 17158ae..1642d91 100644
--- a/app/assets/stylesheets/application.css
+++ b/app/assets/stylesheets/application.css
@@ -520,12 +520,12 @@ hr {
color: #ffffff;
}
-.item-badge--social {
+.item-badge--meal {
background-color: #FEF3C7;
color: #8A5A00;
}
-.item-badge--logistics {
+.item-badge--reception {
background-color: #EEF1F4;
color: #525C66;
}
@@ -545,6 +545,16 @@ hr {
color: #1E5A8A;
}
+.item-badge--community {
+ background-color: #E6F4EC;
+ color: #1F6B45;
+}
+
+.item-badge--volunteer {
+ background-color: #FFEDD5;
+ color: #9A3412;
+}
+
.item-badge--custom {
background-color: transparent;
color: #C41C1C;
diff --git a/app/models/schedule_item.rb b/app/models/schedule_item.rb
index 698010c..6156573 100644
--- a/app/models/schedule_item.rb
+++ b/app/models/schedule_item.rb
@@ -6,7 +6,10 @@ class ScheduleItem < ApplicationRecord
has_many :attendees, through: :plan_items, source: :user
has_many :embassy_bookings, dependent: :destroy
- enum :kind, { talk: 0, lightning: 1, embassy: 2, activity: 3 }
+ enum :kind, {
+ talk: 0, lightning: 1, embassy: 2, activity: 3,
+ reception: 4, meal: 5, community: 6, volunteer: 7
+ }
validates :title, presence: true
validates :day, presence: true
diff --git a/app/views/admin/dashboard/show.html.erb b/app/views/admin/dashboard/show.html.erb
index 25c745e..70deda8 100644
--- a/app/views/admin/dashboard/show.html.erb
+++ b/app/views/admin/dashboard/show.html.erb
@@ -38,6 +38,11 @@
Soon
+ <%= link_to admin_schedule_items_path, class: "action-card" do %>
+
Schedule
+ Full CRUD over the conference agenda — all schedule items, by kind.
+ <% end %>
+
<%= link_to admin_embassy_applications_path, class: "action-card" do %>
Embassy Applications
Review adjudicated applications by serial. Print individual copies for the Attaché.
@@ -53,11 +58,6 @@
Print N blank randomized applications for walk-ins and stragglers.
<% end %>
- <%= link_to admin_schedule_items_path, class: "action-card" do %>
- Schedule Items
- Full CRUD over the conference agenda — talks, lightning, embassy, activities.
- <% end %>
-
<%= link_to "/admin/jobs", class: "action-card" do %>
Background Jobs
Inspect background jobs and the Solid Queue dashboard.
diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb
index 099c030..974528d 100644
--- a/app/views/layouts/admin.html.erb
+++ b/app/views/layouts/admin.html.erb
@@ -27,6 +27,7 @@
Volunteers
Soon
+ <%= link_to "Schedule", admin_schedule_items_path %>
<%= link_to "Embassy Applications", admin_embassy_applications_path %>
<%= link_to "User Dashboard", dashboard_path %>
diff --git a/app/views/schedule/_session_item.html.erb b/app/views/schedule/_session_item.html.erb
index 43ed359..da8cc22 100644
--- a/app/views/schedule/_session_item.html.erb
+++ b/app/views/schedule/_session_item.html.erb
@@ -3,7 +3,11 @@
"talk" => "Talk",
"lightning" => "Lightning",
"embassy" => "Embassy",
- "activity" => "Activity"
+ "activity" => "Activity",
+ "reception" => "Reception",
+ "meal" => "Meal",
+ "community" => "Community",
+ "volunteer" => "Volunteer"
}[item.kind]
flexible_classes = item.flexible? ? "schedule-item--flexible" : ""
@@ -12,24 +16,28 @@
button_label =
if planned
- if item.talk?
- "✓ Added"
- elsif item.embassy?
+ if item.embassy?
"✓ Booked"
- else
+ elsif item.activity?
"✓ RSVP'd"
+ elsif item.volunteer?
+ "✓ Volunteering"
+ else
+ "✓ Added"
end
else
- if item.talk?
- "+ Add to plan"
- elsif item.embassy?
+ if item.embassy?
"Schedule Appointment"
- else
+ elsif item.activity?
"+ RSVP"
+ elsif item.volunteer?
+ "+ Volunteer"
+ else
+ "+ Add to plan"
end
end
- rsvp_note = (!item.talk? && !item.embassy? && item.rsvp_count > 0) ? "#{item.rsvp_count} going" : nil
+ rsvp_note = (item.activity? && item.rsvp_count > 0) ? "#{item.rsvp_count} going" : nil
%>
<%= turbo_frame_tag dom_id(item) do %>
diff --git a/config/schedule.yml b/config/schedule.yml
index 1717342..9dbcde0 100644
--- a/config/schedule.yml
+++ b/config/schedule.yml
@@ -1,7 +1,8 @@
---
# Blue Ridge Ruby 2025 conference schedule.
-# Item kinds: talk, lightning, embassy, activity
-# `flexible: true` renders the item with a dashed/TBD treatment (Saturday activities).
+# Item kinds: talk, lightning, embassy, activity, reception, meal, community, volunteer
+# `flexible: true` renders the item with a dashed/TBD treatment.
+# Mirrors https://blueridgeruby.com/schedule — keep in sync if the public schedule changes.
:days:
- :anchor: wed
@@ -13,8 +14,9 @@
:time: "7:00 PM"
:sort_time: 1900
:title: "Pre-Conference Meetup"
- :type: activity
+ :type: community
:location: "Wicked Weed Brew Pub"
+ :description: "We'll probably try to find a spot for all of us in the beer garden downstairs in the back."
- :anchor: thu
:date: "April 30"
@@ -24,25 +26,35 @@
- :id: thu-registration
:time: "8:00 AM"
:sort_time: 800
- :title: "Registration & Coffee"
- :type: embassy
+ :title: "Registration, Coffee, Light Snacks"
+ :type: reception
- :id: thu-welcome
:time: "9:00 AM"
:sort_time: 900
- :title: "Welcome"
- :type: embassy
+ :title: "Welcome, Day 1"
+ :type: reception
- :id: thu-talk-1
:time: "9:30 AM"
:sort_time: 930
:host: "John Athayde"
:title: "Learning from Permaculture: Sustainable Software Development"
:type: talk
+ - :id: thu-break-1
+ :time: "10:00 AM"
+ :sort_time: 1000
+ :title: "Break"
+ :type: reception
- :id: thu-talk-2
:time: "10:30 AM"
:sort_time: 1030
:host: "Joël Quenneville"
:title: "State is the First Decision You Never Made"
:type: talk
+ - :id: thu-break-2
+ :time: "11:00 AM"
+ :sort_time: 1100
+ :title: "Break"
+ :type: reception
- :id: thu-talk-3
:time: "11:30 AM"
:sort_time: 1130
@@ -53,29 +65,52 @@
:time: "12:00 PM"
:sort_time: 1200
:title: "Open Lunch"
- :type: activity
+ :type: meal
+ :description: "(Coordinate on Slack)"
- :id: thu-mystery
:time: "2:00 PM"
:sort_time: 1400
- :title: "Mystery Activity"
- :type: activity
+ :title: "Mystery Activity 👀"
+ :type: community
+ - :id: thu-break-3
+ :time: "2:30 PM"
+ :sort_time: 1430
+ :title: "Break"
+ :type: reception
- :id: thu-talk-4
:time: "3:00 PM"
:sort_time: 1500
:host: "Annie Kiley"
:title: "How To Finish What You Start: Lessons From Actually Shipping a Big Refactor"
:type: talk
+ - :id: thu-break-4
+ :time: "3:30 PM"
+ :sort_time: 1530
+ :title: "Break"
+ :type: reception
- :id: thu-talk-5
:time: "4:00 PM"
:sort_time: 1600
:host: "Kevin Murphy"
:title: "InstiLLment of Successful Practices in an Agentic World"
:type: talk
+ - :id: thu-closing
+ :time: "4:30 PM"
+ :sort_time: 1630
+ :title: "Closing, Day 1"
+ :type: reception
- :id: thu-dinner
- :time: "Evening"
- :sort_time: 1800
- :title: "Open Dinner & Roundtable"
- :type: activity
+ :time: "5:00 PM"
+ :sort_time: 1700
+ :title: "Open Dinner"
+ :type: meal
+ :description: "(Coordinate on Slack)"
+ - :id: thu-roundtable
+ :time: "7:00 PM"
+ :sort_time: 1900
+ :title: "Roundtable Discussion"
+ :type: community
+ :location: "YMI Cultural Center"
- :anchor: fri
:date: "May 1"
@@ -85,82 +120,109 @@
- :id: fri-coffee
:time: "8:00 AM"
:sort_time: 800
- :title: "Coffee"
- :type: embassy
+ :title: "Coffee, Light Snacks"
+ :type: reception
+ - :id: fri-welcome
+ :time: "9:00 AM"
+ :sort_time: 900
+ :title: "Welcome, Day 2"
+ :type: reception
- :id: fri-talk-1
:time: "9:30 AM"
:sort_time: 930
:host: "Brooke Kuhlmann"
:title: "Terminus: A Hanami + htmx web application for e-ink devices"
:type: talk
+ - :id: fri-break-1
+ :time: "10:00 AM"
+ :sort_time: 1000
+ :title: "Break"
+ :type: reception
- :id: fri-talk-2
:time: "10:30 AM"
:sort_time: 1030
:host: "Rachael Wright-Munn"
:title: "Your First Open-Source Contribution"
:type: talk
+ - :id: fri-break-2
+ :time: "11:00 AM"
+ :sort_time: 1100
+ :title: "Break"
+ :type: reception
- :id: fri-talk-3
:time: "11:30 AM"
:sort_time: 1130
:host: "David Paluy"
:title: "LLM Telemetry as a First-Class Rails Concern"
:type: talk
+ - :id: fri-lunch
+ :time: "12:00 PM"
+ :sort_time: 1200
+ :title: "Open Lunch"
+ :type: meal
+ :description: "(Coordinate on Slack)"
- :id: fri-lightning
:time: "2:00 PM"
:sort_time: 1400
:title: "Lightning Talks"
:type: lightning
+ :description: "8–10 Total"
+ - :id: fri-break-3
+ :time: "3:00 PM"
+ :sort_time: 1500
+ :title: "Break"
+ :type: reception
- :id: fri-talk-4
:time: "3:30 PM"
:sort_time: 1530
:host: "Christine Seeman"
:title: "Optimize Your Mindset (Without Overclocking)"
:type: talk
+ - :id: fri-break-4
+ :time: "4:00 PM"
+ :sort_time: 1600
+ :title: "Break"
+ :type: reception
- :id: fri-talk-5
:time: "4:30 PM"
:sort_time: 1630
:host: "Thomas Cannon"
:title: "5 ways to invest in yourself for the long haul"
:type: talk
+ - :id: fri-closing
+ :time: "5:00 PM"
+ :sort_time: 1700
+ :title: "Closing, Day 2"
+ :type: reception
+ - :id: fri-dinner
+ :time: "5:30 PM"
+ :sort_time: 1730
+ :title: "Open Dinner"
+ :type: meal
+ :description: "(Coordinate on Slack)"
- :id: fri-afterparty
- :time: "Evening"
- :sort_time: 1900
+ :time: "8:00 PM"
+ :sort_time: 2000
:title: "Afterparty"
- :type: activity
+ :type: community
:location: "Burial Beer Co. South Slope"
- :anchor: sat
:date: "May 2"
:label: "Saturday"
- :subtitle: "Activities & Ruby Embassy"
+ :subtitle: "Hack Day & Ruby Embassy"
:items:
- - :id: sat-morning
- :time: "Morning"
+ - :id: sat-hackday
+ :time: "9:00 AM"
:sort_time: 900
- :title: "Morning Activities"
- :type: activity
- :flexible: true
- - :id: sat-embassy-am
- :time: "Morning Block"
- :sort_time: 1000
- :title: "Ruby Embassy Appointments"
- :type: embassy
- :flexible: true
- - :id: sat-afternoon
- :time: "Afternoon"
- :sort_time: 1300
- :title: "Afternoon Activities"
- :type: activity
- :flexible: true
- - :id: sat-embassy-pm
- :time: "Afternoon Block"
- :sort_time: 1400
- :title: "Ruby Embassy Appointments"
- :type: embassy
- :flexible: true
- - :id: sat-evening
- :time: "Evening"
- :sort_time: 1800
- :title: "Evening Activities"
+ :title: "Hack Day"
+ :type: community
+ :location: "YMI Cultural Center"
+ :description: "Hack on projects, or just come and hang out! (Ends at 4:00 PM)"
+ - :id: sat-dinner
+ :time: "5:00 PM"
+ :sort_time: 1700
+ :title: "Informal Dinner"
:type: activity
- :flexible: true
+ :location: "Asheville Pizza South Slope"
+ :description: "For those still in town, let's get dinner one last time!"
diff --git a/db/seeds.rb b/db/seeds.rb
index 9dfbd34..b1fa0af 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -16,31 +16,11 @@
user.save!
end
-# 2. Canonical schedule from config/schedule.yml. Upsert by slug so re-runs
-# never duplicate. After seeding, admins edit via /admin/schedule_items;
-# the YAML is reference data.
-schedule_data = YAML.load_file(
- Rails.root.join("config/schedule.yml"),
- permitted_classes: [ Symbol ]
-)
-
-schedule_data[:days].each do |day|
- day[:items].each do |item|
- record = ScheduleItem.find_or_initialize_by(slug: item[:id])
- record.day = day[:anchor]
- record.time_label = item[:time]
- record.sort_time = item[:sort_time]
- record.title = item[:title]
- record.host = item[:host]
- record.location = item[:location]
- record.description = item[:description]
- record.kind = item[:type]
- record.flexible = item[:flexible] || false
- record.is_public = true
- record.created_by = nil
- record.save!
- end
-end
+# 2. Schedule items — extracted to a standalone file so it can be run
+# independently in production to re-sync the schedule without re-running
+# admin-user seeds:
+# rails runner db/seeds/schedule.rb
+load Rails.root.join("db/seeds/schedule.rb").to_s
# 3. Embassy Question Bank + Notary Pool. Loaded from the curated source
# file; idempotent upsert by external_id. Admins can edit/archive/add
diff --git a/db/seeds/embassy_questions.rb b/db/seeds/embassy_questions.rb
index 5560208..8674694 100644
--- a/db/seeds/embassy_questions.rb
+++ b/db/seeds/embassy_questions.rb
@@ -227,17 +227,26 @@ def normalize(raw)
# === Notary Pool =========================================================
# Each notary asks ONE follow-up question. (The original seed had multi-prompt
# notaries; the schema now stores a single canonical prompt per notary.)
+ #
+ # external_ids are stable. New entries get the next available N## — never
+ # renumber existing ones, since prior applications reference them.
+ # Removed entries (e.g., N03 — "Harbors resentment toward Ruby on Rails") are
+ # archived from the seed; admins archive existing DB rows via /admin/embassy_questions.
NOTARY_POOL = [
{ external_id: "N01", description: "Uses a different language than Ruby",
followup_prompt: "What does the notary like about it?" },
{ external_id: "N02", description: "Has attended three (3) or more Ruby conferences",
followup_prompt: "Which is the notary's favorite?" },
- { external_id: "N03", description: "Harbors documented resentment toward Ruby on Rails",
- followup_prompt: "State the notary's grievance." },
{ external_id: "N04", description: "Has deployed to production on today's date",
followup_prompt: "Describe what the notary shipped." },
{ external_id: "N05", description: "Actively uses Hotwire",
followup_prompt: "State what the notary loves about Hotwire." },
+ { external_id: "N19", description: "Prefers React over Hotwire",
+ followup_prompt: "Summarize the notary's reasoning." },
+ { external_id: "N20", description: "Actively uses Phlex",
+ followup_prompt: "State the notary's reason for adoption." },
+ { external_id: "N21", description: "Prefers spaces over tabs",
+ followup_prompt: "State the notary's justification." },
{ external_id: "N06", description: "Prefers tabs over spaces",
followup_prompt: "State the notary's justification." },
{ external_id: "N07", description: "Has formally rage-quit JavaScript at least once",
@@ -250,6 +259,8 @@ def normalize(raw)
followup_prompt: "Assess the notary's well-being." },
{ external_id: "N11", description: "Prefers monoliths to microservices",
followup_prompt: "State the notary's justification." },
+ { external_id: "N22", description: "Prefers microservices to monoliths",
+ followup_prompt: "State the notary's justification." },
{ external_id: "N12", description: "Is new to the Ruby programming language",
followup_prompt: "State what the notary is currently learning." },
{ external_id: "N13", description: "Maintains an interesting side project",
@@ -263,6 +274,34 @@ def normalize(raw)
{ external_id: "N17", description: "Possesses the funniest documented bug story",
followup_prompt: "Record the account in full." },
{ external_id: "N18", description: "Sustained the worst production incident in recent memory",
- followup_prompt: "Record the account in full." }
+ followup_prompt: "Record the account in full." },
+ { external_id: "N23", description: "Uses vim",
+ followup_prompt: "Record the notary's exit strategy." },
+ { external_id: "N24", description: "Has been employed by the smallest startup in attendance (a head count of two (2) or greater)",
+ followup_prompt: "State the head count." },
+ { external_id: "N25", description: "Has been employed by the largest company in attendance",
+ followup_prompt: "State the head count." },
+ { external_id: "N26", description: "Has traveled the farthest in attendance to this conference",
+ followup_prompt: "State the city of origin." },
+ { external_id: "N27", description: "Has traveled the shortest distance in attendance to this conference",
+ followup_prompt: "Name the notary's favorite local spot." },
+ { external_id: "N28", description: "Has operated, in production, a database known to no other party in attendance",
+ followup_prompt: "Name the database." },
+ { external_id: "N29", description: "Maintains the oldest GitHub account in attendance",
+ followup_prompt: "State the year of registration." },
+ { external_id: "N30", description: "Has attended more conferences in the past twelve (12) months than anyone else in attendance",
+ followup_prompt: "State the count." },
+ { external_id: "N31", description: "Is in attendance at their first technical conference of any kind",
+ followup_prompt: "Record the notary's first impressions." },
+ { external_id: "N32", description: "Has appeared as a guest on more podcasts than anyone else in attendance",
+ followup_prompt: "Name the most recent appearance." },
+ { external_id: "N33", description: "Hosts a podcast on a recurring basis",
+ followup_prompt: "State the title of the podcast." },
+ { external_id: "N34", description: "Has previously attended a Blue Ridge Ruby conference",
+ followup_prompt: "State the year(s) of prior attendance." },
+ { external_id: "N35", description: "Is, at the time of signing, in attendance at the current Blue Ridge Ruby",
+ followup_prompt: "Render a likeness of the notary in the space provided." },
+ { external_id: "N36", description: "Maintains the newest GitHub account in attendance",
+ followup_prompt: "State the year of registration." }
].freeze
end
diff --git a/db/seeds/schedule.rb b/db/seeds/schedule.rb
new file mode 100644
index 0000000..d197dfa
--- /dev/null
+++ b/db/seeds/schedule.rb
@@ -0,0 +1,33 @@
+# Schedule seed — runnable independently in production:
+# rails runner db/seeds/schedule.rb
+#
+# Idempotent: upserts ScheduleItem records by slug, then removes obsolete
+# slugs from prior schedule versions. Safe to run repeatedly.
+
+schedule_data = YAML.load_file(
+ Rails.root.join("config/schedule.yml"),
+ permitted_classes: [ Symbol ]
+)
+
+schedule_data[:days].each do |day|
+ day[:items].each do |item|
+ record = ScheduleItem.find_or_initialize_by(slug: item[:id])
+ record.day = day[:anchor]
+ record.time_label = item[:time]
+ record.sort_time = item[:sort_time]
+ record.title = item[:title]
+ record.host = item[:host]
+ record.location = item[:location]
+ record.description = item[:description]
+ record.kind = item[:type]
+ record.flexible = item[:flexible] || false
+ record.is_public = true
+ record.created_by = nil
+ record.save!
+ end
+end
+
+# Remove slugs that existed in earlier schedule versions but are no longer
+# part of the canonical schedule. Cascades to plan_items via dependent: :destroy.
+obsolete_slugs = %w[sat-morning sat-afternoon sat-evening sat-embassy-am sat-embassy-pm]
+ScheduleItem.where(slug: obsolete_slugs).destroy_all
diff --git a/lib/tasks/db_seed_content.rake b/lib/tasks/db_seed_content.rake
new file mode 100644
index 0000000..a7e775f
--- /dev/null
+++ b/lib/tasks/db_seed_content.rake
@@ -0,0 +1,15 @@
+namespace :db do
+ namespace :seed do
+ desc "Seed content (schedule items + embassy questions/notary pool); skips admin users"
+ task content: :environment do
+ load Rails.root.join("db/seeds/schedule.rb").to_s
+
+ require Rails.root.join("db/seeds/embassy_questions").to_s
+ EmbassyQuestionsSeed.import!
+
+ puts "Seeded: #{ScheduleItem.count} schedule items · " \
+ "#{Question.count} questions · " \
+ "#{NotaryProfile.count} notary profiles"
+ end
+ end
+end
diff --git a/test/models/schedule_item_test.rb b/test/models/schedule_item_test.rb
index 71676b0..370bcfe 100644
--- a/test/models/schedule_item_test.rb
+++ b/test/models/schedule_item_test.rb
@@ -28,12 +28,16 @@ def valid_attrs(overrides = {})
assert_includes item.errors[:kind], "can't be blank"
end
- test "kind enum exposes talk/lightning/embassy/activity" do
- assert_equal %w[talk lightning embassy activity], ScheduleItem.kinds.keys
+ test "kind enum exposes all 8 kinds with stable integer values" do
+ assert_equal %w[talk lightning embassy activity reception meal community volunteer], ScheduleItem.kinds.keys
assert_equal 0, ScheduleItem.kinds["talk"]
assert_equal 1, ScheduleItem.kinds["lightning"]
assert_equal 2, ScheduleItem.kinds["embassy"]
assert_equal 3, ScheduleItem.kinds["activity"]
+ assert_equal 4, ScheduleItem.kinds["reception"]
+ assert_equal 5, ScheduleItem.kinds["meal"]
+ assert_equal 6, ScheduleItem.kinds["community"]
+ assert_equal 7, ScheduleItem.kinds["volunteer"]
end
test "is_public defaults to false" do
diff --git a/test/models/seed_test.rb b/test/models/seed_test.rb
index c6c42f0..5bee301 100644
--- a/test/models/seed_test.rb
+++ b/test/models/seed_test.rb
@@ -45,19 +45,37 @@ class SeedTest < ActiveSupport::TestCase
assert talk.talk?
end
- test "seeded logistics (registration/welcome/coffee) are kind: embassy" do
+ test "seeded reception items (registration/welcome/breaks/coffee/closing) are kind: reception" do
Rails.application.load_seed
- %w[thu-registration thu-welcome fri-coffee].each do |slug|
+ %w[thu-registration thu-welcome thu-break-1 thu-closing fri-coffee fri-welcome fri-closing].each do |slug|
item = ScheduleItem.find_by(slug: slug)
- assert_equal "embassy", item.kind, "#{slug} should be kind: embassy"
+ assert_equal "reception", item.kind, "#{slug} should be kind: reception"
end
end
- test "seeded social items are kind: activity" do
+ test "seeded meal items (lunches and dinners) are kind: meal" do
Rails.application.load_seed
- %w[wed-meetup thu-lunch thu-dinner fri-afterparty sat-evening].each do |slug|
+ %w[thu-lunch thu-dinner fri-lunch fri-dinner].each do |slug|
+ item = ScheduleItem.find_by(slug: slug)
+ assert_equal "meal", item.kind, "#{slug} should be kind: meal"
+ end
+ end
+
+ test "seeded community items (in-event participatory + offsite social) are kind: community" do
+ Rails.application.load_seed
+
+ %w[wed-meetup thu-mystery thu-roundtable fri-afterparty sat-hackday].each do |slug|
+ item = ScheduleItem.find_by(slug: slug)
+ assert_equal "community", item.kind, "#{slug} should be kind: community"
+ end
+ end
+
+ test "seeded activity items (external with capacity concerns) are kind: activity" do
+ Rails.application.load_seed
+
+ %w[sat-dinner].each do |slug|
item = ScheduleItem.find_by(slug: slug)
assert_equal "activity", item.kind, "#{slug} should be kind: activity"
end