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