From 7c8ec09c394a660c3d99922e189120eed48eb173 Mon Sep 17 00:00:00 2001 From: Katya Sarmiento <5871075+Kitkatnik@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:16:27 -0400 Subject: [PATCH 01/29] Add Tito-backed magic link auth (ashevillagers pattern) Port the ashevillagers auth flow: users enter their email, we check our DB first, fall back to Tito API, auto-create a user from their ticket, then email a 30-day magic link. No passwords. Single User model with three roles (attendee, volunteer, admin); admins are seeded in db/seeds.rb and never overwritten by Tito sync. Admin UI at /admin for user CRUD, bulk Tito sync, and DB-backed Configuration management for Tito credentials and email_from. Ruby bumped to 3.4.4 for Rails 8.1.3 compatibility. --- .ruby-version | 2 +- .tool-versions | 1 + Gemfile | 6 + Gemfile.lock | 18 +++ .../admin/configurations_controller.rb | 46 ++++++ app/controllers/admin/users_controller.rb | 86 +++++++++++ app/controllers/admin_controller.rb | 5 + app/controllers/application_controller.rb | 32 +++- app/controllers/dashboard_controller.rb | 6 + app/controllers/sessions_controller.rb | 50 +++++++ app/mailers/application_mailer.rb | 6 +- app/mailers/user_mailer.rb | 9 ++ app/models/configuration.rb | 77 ++++++++++ app/models/configuration/configurable.rb | 54 +++++++ app/models/user.rb | 46 ++++++ app/services/tito_lookup_service.rb | 26 ++++ app/views/admin/configurations/_form.html.erb | 35 +++++ app/views/admin/configurations/edit.html.erb | 4 + app/views/admin/configurations/index.html.erb | 42 ++++++ app/views/admin/configurations/new.html.erb | 4 + app/views/admin/users/_form.html.erb | 40 +++++ app/views/admin/users/edit.html.erb | 4 + app/views/admin/users/index.html.erb | 50 +++++++ app/views/admin/users/new.html.erb | 4 + app/views/dashboard/show.html.erb | 32 ++++ app/views/layouts/admin.html.erb | 44 ++++++ app/views/sessions/callback.html.erb | 16 ++ app/views/sessions/new.html.erb | 29 ++++ app/views/sessions/not_registered.html.erb | 20 +++ app/views/user_mailer/login_link.html.erb | 9 ++ app/views/user_mailer/login_link.text.erb | 9 ++ config/environments/production.rb | 18 +-- config/routes.rb | 30 ++-- db/cable_schema.rb | 23 ++- db/cache_schema.rb | 25 +++- .../20260417140714_create_configurations.rb | 12 ++ db/migrate/20260417140731_create_users.rb | 18 +++ db/queue_schema.rb | 139 ++++++++++-------- db/schema.rb | 38 +++++ db/seeds.rb | 22 +-- 40 files changed, 1032 insertions(+), 105 deletions(-) create mode 100644 .tool-versions create mode 100644 app/controllers/admin/configurations_controller.rb create mode 100644 app/controllers/admin/users_controller.rb create mode 100644 app/controllers/admin_controller.rb create mode 100644 app/controllers/dashboard_controller.rb create mode 100644 app/controllers/sessions_controller.rb create mode 100644 app/mailers/user_mailer.rb create mode 100644 app/models/configuration.rb create mode 100644 app/models/configuration/configurable.rb create mode 100644 app/models/user.rb create mode 100644 app/services/tito_lookup_service.rb create mode 100644 app/views/admin/configurations/_form.html.erb create mode 100644 app/views/admin/configurations/edit.html.erb create mode 100644 app/views/admin/configurations/index.html.erb create mode 100644 app/views/admin/configurations/new.html.erb create mode 100644 app/views/admin/users/_form.html.erb create mode 100644 app/views/admin/users/edit.html.erb create mode 100644 app/views/admin/users/index.html.erb create mode 100644 app/views/admin/users/new.html.erb create mode 100644 app/views/dashboard/show.html.erb create mode 100644 app/views/layouts/admin.html.erb create mode 100644 app/views/sessions/callback.html.erb create mode 100644 app/views/sessions/new.html.erb create mode 100644 app/views/sessions/not_registered.html.erb create mode 100644 app/views/user_mailer/login_link.html.erb create mode 100644 app/views/user_mailer/login_link.text.erb create mode 100644 db/migrate/20260417140714_create_configurations.rb create mode 100644 db/migrate/20260417140731_create_users.rb create mode 100644 db/schema.rb diff --git a/.ruby-version b/.ruby-version index f13c6f4..e3cc07a 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -ruby-3.3.5 +ruby-3.4.4 diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..ca745c6 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +ruby 3.4.4 diff --git a/Gemfile b/Gemfile index 46a09c2..ff07466 100644 --- a/Gemfile +++ b/Gemfile @@ -41,6 +41,12 @@ gem "thruster", require: false # Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] gem "image_processing", "~> 1.2" +# Tito API client for event registration lookup +gem "tito_ruby" + +# Postmark for transactional email in production +gem "postmark-rails" + group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" diff --git a/Gemfile.lock b/Gemfile.lock index 989711e..eb0a50f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -120,6 +120,12 @@ GEM erubi (1.13.1) et-orbi (1.4.0) tzinfo + faraday (2.14.1) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-net_http (3.4.2) + net-http (~> 0.5) ffi (1.17.4-aarch64-linux-gnu) ffi (1.17.4-aarch64-linux-musl) ffi (1.17.4-arm-linux-gnu) @@ -200,6 +206,8 @@ GEM stimulus-rails turbo-rails msgpack (1.8.0) + net-http (0.9.1) + uri (>= 0.11.1) net-imap (0.6.3) date net-protocol @@ -243,6 +251,11 @@ GEM pg (1.6.3-x86_64-darwin) pg (1.6.3-x86_64-linux) pg (1.6.3-x86_64-linux-musl) + postmark (1.25.1) + json + postmark-rails (0.22.1) + actionmailer (>= 3.0.0) + postmark (>= 1.21.3, < 2.0) pp (0.6.3) prettyprint prettyprint (0.2.0) @@ -382,6 +395,9 @@ GEM thruster (0.1.20-x86_64-darwin) thruster (0.1.20-x86_64-linux) timeout (0.6.1) + tito_ruby (0.3.0) + activemodel (>= 7.1, < 9.0) + faraday (~> 2.0) tsort (0.2.0) turbo-rails (2.0.23) actionpack (>= 7.1.0) @@ -433,6 +449,7 @@ DEPENDENCIES letter_opener mission_control-jobs pg (~> 1.1) + postmark-rails propshaft puma (>= 5.0) rack-mini-profiler @@ -444,6 +461,7 @@ DEPENDENCIES solid_queue stimulus-rails thruster + tito_ruby turbo-rails tzinfo-data web-console diff --git a/app/controllers/admin/configurations_controller.rb b/app/controllers/admin/configurations_controller.rb new file mode 100644 index 0000000..f4a09ec --- /dev/null +++ b/app/controllers/admin/configurations_controller.rb @@ -0,0 +1,46 @@ +module Admin + class ConfigurationsController < AdminController + def index + @configurations = Configuration.all_and_expected + end + + def new + @configuration = Configuration.new(name: params[:name]) + end + + def create + @configuration = Configuration.new(configuration_params) + + if @configuration.save + redirect_to admin_configurations_path, notice: "Configuration added." + else + render :new, status: :unprocessable_entity + end + end + + def edit + @configuration = Configuration.find(params[:id]) + end + + def update + @configuration = Configuration.find(params[:id]) + + if @configuration.update(configuration_params) + redirect_to admin_configurations_path, notice: "Configuration updated." + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + Configuration.find(params[:id]).destroy + redirect_to admin_configurations_path, notice: "Configuration removed." + end + + private + + def configuration_params + params.require(:configuration).permit(:name, :value) + end + end +end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb new file mode 100644 index 0000000..a1fd688 --- /dev/null +++ b/app/controllers/admin/users_controller.rb @@ -0,0 +1,86 @@ +module Admin + class UsersController < AdminController + def index + @users = User.order(:last_name, :first_name) + end + + def new + @user = User.new + end + + def create + @user = User.new(user_params) + + if @user.save(context: :interactive) + redirect_to admin_users_path, notice: "User added." + else + render :new, status: :unprocessable_entity + end + end + + def edit + @user = User.find(params[:id]) + end + + def update + @user = User.find(params[:id]) + @user.assign_attributes(user_params) + + if @user.save(context: :interactive) + redirect_to admin_users_path, notice: "User updated." + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + User.find(params[:id]).destroy + redirect_to admin_users_path, notice: "User removed." + end + + def sync + users = User.all.to_a + slugs = users.each_with_object({}) { |u, h| h[u.tito_ticket_slug] = u if u.tito_ticket_slug.present? } + emails = users.each_with_object({}) { |u, h| (h[u.email.downcase] ||= u) if u.email.present? && u.tito_ticket_slug.blank? } + + already = 0 + connected = 0 + added = 0 + + User.tito_client.tickets.where(state: %w[complete]).each do |ticket| + if slugs[ticket.slug] + already += 1 + elsif (user = emails[ticket.email.to_s.downcase]) + user.update!( + tito_ticket_slug: ticket.slug, + first_name: ticket.first_name, + last_name: ticket.last_name + ) + connected += 1 + else + User.create!( + tito_ticket_slug: ticket.slug, + first_name: ticket.first_name, + last_name: ticket.last_name, + email: ticket.email, + role: :attendee + ) + added += 1 + end + end + + redirect_to admin_users_path, + notice: "Sync complete: #{already} already linked, #{connected} connected, #{added} added." + rescue StandardError => e + Rails.logger.error("Tito sync error: #{e.class}: #{e.message}") + redirect_to admin_users_path, + alert: "Sync failed: #{e.message}. Check your Tito configuration." + end + + private + + def user_params + params.require(:user).permit(:first_name, :last_name, :email, :role) + end + end +end diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb new file mode 100644 index 0000000..ffcf86f --- /dev/null +++ b/app/controllers/admin_controller.rb @@ -0,0 +1,5 @@ +class AdminController < ApplicationController + before_action :require_admin! + + layout "admin" +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c353756..ce08ffb 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,7 +1,33 @@ class ApplicationController < ActionController::Base - # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. allow_browser versions: :modern - - # Changes to the importmap will invalidate the etag for HTML responses stale_when_importmap_changes + + helper_method :current_user, :admin? + + private + + def current_user + return @current_user if defined?(@current_user) + @current_user = session[:user_id] && User.find_by(id: session[:user_id]) + end + + def admin? + current_user&.admin? + end + + def authenticate_user! + unless current_user + session[:return_to] = request.url if request.get? + redirect_to new_session_path, alert: "Please sign in." + end + end + + def require_admin! + authenticate_user! + return if performed? + + unless admin? + redirect_to dashboard_path, alert: "Admins only." + end + end end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb new file mode 100644 index 0000000..9c36ed0 --- /dev/null +++ b/app/controllers/dashboard_controller.rb @@ -0,0 +1,6 @@ +class DashboardController < ApplicationController + before_action :authenticate_user! + + def show + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 0000000..d3cf636 --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -0,0 +1,50 @@ +class SessionsController < ApplicationController + REGISTER_URL = "https://blueridgeruby.com/#register".freeze + + def new + end + + def create + @email = params[:email].to_s.strip.downcase + user = User.where("LOWER(email) = ?", @email).first + + if user.nil? + result = TitoLookupService.new.find_or_create_from_tito(@email) + case result.status + when :found + user = result.user + when :not_found + @register_url = REGISTER_URL + render :not_registered, status: :not_found + return + when :api_error + flash.now[:alert] = "Something went wrong checking your registration. Please try again in a few minutes." + render :new, status: :service_unavailable + return + end + end + + UserMailer.login_link(user).deliver_later + redirect_to new_session_path, notice: "Check your email for a login link." + end + + def callback + @token = params[:token] + + if request.post? + user = User.find_by_token_for(:login, @token) + + if user + session[:user_id] = user.id + redirect_to(session.delete(:return_to) || dashboard_path, notice: "Signed in.") + else + redirect_to new_session_path, alert: "Invalid or expired login link." + end + end + end + + def destroy + session.delete(:user_id) + redirect_to root_path, notice: "Signed out." + end +end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index 3c34c81..1b81d33 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -1,4 +1,8 @@ class ApplicationMailer < ActionMailer::Base - default from: "from@example.com" + include Configuration::Configurable + + configure_with from: :email_from + default from: from + layout "mailer" end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb new file mode 100644 index 0000000..76b4272 --- /dev/null +++ b/app/mailers/user_mailer.rb @@ -0,0 +1,9 @@ +class UserMailer < ApplicationMailer + def login_link(user) + @user = user + @token = user.generate_token_for(:login) + @login_url = callback_session_url(token: @token) + + mail to: user.email, subject: "Your Ruby Embassy login link" + end +end diff --git a/app/models/configuration.rb b/app/models/configuration.rb new file mode 100644 index 0000000..70b5627 --- /dev/null +++ b/app/models/configuration.rb @@ -0,0 +1,77 @@ +class Configuration < ApplicationRecord + SECRET_SEGMENTS = /(?:^|_)(key|secret|token)(?:_|$)/i + + validates :name, presence: true, uniqueness: true + after_commit :apply_callbacks + + def self.expect(*names, &block) + names = names.map(&:to_s) + expected_names.merge(names) + case [names, block] + in [name], nil then self[name] + in Array, nil then values_at(*names) + else + callback = -> { block.call(*values_at(*names)) } + @callbacks ||= Hash.new { |h, k| h[k] = [] } + names.each do |name| + @callbacks[name] << callback + end + callback.call + end + end + + def self.expected_names = @expected_names ||= Set.new + + def self.callbacks = @callbacks.dup + + def self.all_and_expected + existing = order(:name).to_a + existing_names = existing.map(&:name).to_set + missing = (expected_names - existing_names).sort.map { |name| new(name: name) } + (existing + missing).sort_by(&:name) + end + # -- Hash-like class interface -- + + def self.[](name) = find_by(name: name)&.value + + def self.fetch(name, *args, &block) + name = name.to_s + where(name:).pluck(:name, :value).to_h.fetch(name, *args, &block) + end + + def self.values_at(*names) + names = names.map(&:to_s) + where(name: names).pluck(:name, :value).to_h.values_at(*names) + end + + def self.fetch_values(*names, &block) + names = names.map(&:to_s) + where(name: names).pluck(:name, :value).to_h.fetch_values(*names, &block) + end + + def self.each_pair(&block) + return to_enum(:each_pair) unless block + find_each { |config| block.call(config.name, config.value) } + end + + def self.to_h + pluck(:name, :value).to_h + end + + def self.to_hash = to_h + + # -- Instance methods -- + + def secret? = SECRET_SEGMENTS.match?(name) + + def display_value + return value unless secret? && value.present? && value.length > 4 + + masked = "#{"*" * (value.length - 4)}#{value.last(4)}" + (masked.length > 40) ? "…#{masked.last(39)}" : masked + end + + private + + def apply_callbacks = self.class.callbacks&.[](name)&.each(&:call) +end diff --git a/app/models/configuration/configurable.rb b/app/models/configuration/configurable.rb new file mode 100644 index 0000000..5183c22 --- /dev/null +++ b/app/models/configuration/configurable.rb @@ -0,0 +1,54 @@ +module Configuration::Configurable + extend ActiveSupport::Concern + + class_methods do + def configure_with(*names, instance_methods: true, **mappings) + method_map = {} + names.each { |name| method_map[name.to_sym] = name.to_sym } + mappings.each { |method_name, config_key| method_map[method_name.to_sym] = config_key.to_sym } + + config_keys = method_map.values + attr_names = method_map.keys + + Configuration.expected_names.merge(config_keys.map(&:to_s)) + + current_config = Class.new(ActiveSupport::CurrentAttributes) do + @current_instances_key = :"#{object_id}_current_configuration" + attribute(*attr_names, :_loaded) + end + + const_set(:CurrentConfiguration, current_config) + + current_config.define_singleton_method(:ensure_loaded) do + inst = current_config.instance + return if inst._loaded + + values = Configuration.values_at(*config_keys) + attr_names.each_with_index do |attr, i| + inst.send(:"#{attr}=", values[i]) + end + inst._loaded = true + end + + method_map.each_key do |method_name| + define_singleton_method(method_name) do + current_config.ensure_loaded + current_config.instance.send(method_name) + end + end + + if instance_methods + method_map.each_key do |method_name| + define_method(method_name) do + current_config.ensure_loaded + current_config.instance.send(method_name) + end + end + end + + define_singleton_method(:reload_configuration!) do + current_config.reset + end + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 0000000..e3086a5 --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,46 @@ +class User < ApplicationRecord + include Configuration::Configurable + + configure_with :tito_account_slug, :tito_event_slug, :tito_api_token, instance_methods: false + + enum :role, { attendee: 0, volunteer: 1, admin: 2 } + + before_save :memorialize_tito_config + + generates_token_for :login, expires_in: 30.days + + validates :email, presence: true, uniqueness: true + validates :first_name, presence: true, on: :interactive + validates :last_name, presence: true, on: :interactive + + normalizes :first_name, :last_name, :tito_ticket_slug, with: ->(v) { v.presence } + normalizes :email, with: ->(e) { e.strip.downcase.presence } + + def tito_event_slug = attributes["tito_event_slug"] || self.class.tito_event_slug + def tito_account_slug = attributes["tito_account_slug"] || self.class.tito_account_slug + def tito_api_token = self.class.tito_api_token + + def self.tito_client + Tito::Admin::Client.new(token: tito_api_token, account: tito_account_slug, event: tito_event_slug) + end + + def tito_client + Tito::Admin::Client.new(token: tito_api_token, account: tito_account_slug, event: tito_event_slug) + end + + def admin_ticket_url + tito_ticket_slug && "https://dashboard.tito.io/#{tito_account_slug}/#{tito_event_slug}/tickets/#{tito_ticket_slug}" + end + + def full_name + [first_name, last_name].compact.join(" ").presence || email + end + + private + + def memorialize_tito_config + return unless tito_ticket_slug.present? + self.tito_account_slug = tito_account_slug + self.tito_event_slug = tito_event_slug + end +end diff --git a/app/services/tito_lookup_service.rb b/app/services/tito_lookup_service.rb new file mode 100644 index 0000000..6fb499f --- /dev/null +++ b/app/services/tito_lookup_service.rb @@ -0,0 +1,26 @@ +class TitoLookupService + Result = Data.define(:status, :user) + + def find_or_create_from_tito(email) + normalized = email.to_s.strip.downcase + return Result.new(status: :not_found, user: nil) if normalized.blank? + + ticket = User.tito_client.tickets + .where(state: %w[complete]) + .find { |t| t.email.to_s.downcase == normalized } + + return Result.new(status: :not_found, user: nil) unless ticket + + user = User.create!( + email: ticket.email, + first_name: ticket.first_name, + last_name: ticket.last_name, + tito_ticket_slug: ticket.slug, + role: :attendee + ) + Result.new(status: :found, user: user) + rescue StandardError => e + Rails.logger.error("TitoLookupService error for #{email.inspect}: #{e.class}: #{e.message}") + Result.new(status: :api_error, user: nil) + end +end diff --git a/app/views/admin/configurations/_form.html.erb b/app/views/admin/configurations/_form.html.erb new file mode 100644 index 0000000..b237d44 --- /dev/null +++ b/app/views/admin/configurations/_form.html.erb @@ -0,0 +1,35 @@ +<% if configuration.errors.any? %> +
+ +
+<% end %> + +<%= form_with model: configuration, + url: configuration.persisted? ? admin_configuration_path(configuration) : admin_configurations_path, + class: "space-y-4" do |f| %> +
+ + <%= f.text_field :name, required: true, class: "w-full border rounded px-3 py-2" %> +
+ +
+ + <% if configuration.persisted? && configuration.secret? %> + <%= f.text_field :value, required: true, value: "", + placeholder: "Enter new value", + class: "w-full border rounded px-3 py-2" %> + <% else %> + <%= f.text_field :value, required: true, class: "w-full border rounded px-3 py-2" %> + <% end %> +
+ +
+ <%= f.submit class: "bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700 cursor-pointer" %> + <%= link_to "Cancel", admin_configurations_path, + class: "bg-gray-200 text-gray-800 py-2 px-4 rounded hover:bg-gray-300" %> +
+<% end %> diff --git a/app/views/admin/configurations/edit.html.erb b/app/views/admin/configurations/edit.html.erb new file mode 100644 index 0000000..8e0c080 --- /dev/null +++ b/app/views/admin/configurations/edit.html.erb @@ -0,0 +1,4 @@ +
+

Edit Configuration

+ <%= render "form", configuration: @configuration %> +
diff --git a/app/views/admin/configurations/index.html.erb b/app/views/admin/configurations/index.html.erb new file mode 100644 index 0000000..7ecb757 --- /dev/null +++ b/app/views/admin/configurations/index.html.erb @@ -0,0 +1,42 @@ +
+

Configurations

+ <%= link_to "Add configuration", new_admin_configuration_path, + class: "bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700" %> +
+ +

+ Expected keys: tito_api_token, tito_account_slug, tito_event_slug, email_from +

+ + + + + + + + + + + <% @configurations.each do |configuration| %> + + + <% if configuration.persisted? %> + + + <% else %> + + + <% end %> + + <% end %> + +
NameValue 
<%= configuration.name %><%= configuration.display_value %> + <%= link_to "Edit", edit_admin_configuration_path(configuration), + class: "text-indigo-600 hover:underline" %> + <%= button_to "Delete", admin_configuration_path(configuration), method: :delete, + data: { turbo_confirm: "Remove #{configuration.name}?" }, + class: "text-red-600 hover:underline bg-transparent border-0 cursor-pointer inline" %> + not set + <%= link_to "Create", new_admin_configuration_path(name: configuration.name), + class: "text-indigo-600 hover:underline" %> +
diff --git a/app/views/admin/configurations/new.html.erb b/app/views/admin/configurations/new.html.erb new file mode 100644 index 0000000..d7d730d --- /dev/null +++ b/app/views/admin/configurations/new.html.erb @@ -0,0 +1,4 @@ +
+

Add Configuration

+ <%= render "form", configuration: @configuration %> +
diff --git a/app/views/admin/users/_form.html.erb b/app/views/admin/users/_form.html.erb new file mode 100644 index 0000000..1a829aa --- /dev/null +++ b/app/views/admin/users/_form.html.erb @@ -0,0 +1,40 @@ +<% if user.errors.any? %> +
+ +
+<% end %> + +<%= form_with model: user, + url: user.persisted? ? admin_user_path(user) : admin_users_path, + class: "space-y-4" do |f| %> +
+ + <%= f.text_field :first_name, required: true, class: "w-full border rounded px-3 py-2" %> +
+ +
+ + <%= f.text_field :last_name, required: true, class: "w-full border rounded px-3 py-2" %> +
+ +
+ + <%= f.email_field :email, required: true, class: "w-full border rounded px-3 py-2" %> +
+ +
+ + <%= f.select :role, User.roles.keys.map { |r| [r.humanize, r] }, + {}, class: "w-full border rounded px-3 py-2" %> +
+ +
+ <%= f.submit class: "bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700 cursor-pointer" %> + <%= link_to "Cancel", admin_users_path, + class: "bg-gray-200 text-gray-800 py-2 px-4 rounded hover:bg-gray-300" %> +
+<% end %> diff --git a/app/views/admin/users/edit.html.erb b/app/views/admin/users/edit.html.erb new file mode 100644 index 0000000..258f76b --- /dev/null +++ b/app/views/admin/users/edit.html.erb @@ -0,0 +1,4 @@ +
+

Edit User

+ <%= render "form", user: @user %> +
diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb new file mode 100644 index 0000000..a141926 --- /dev/null +++ b/app/views/admin/users/index.html.erb @@ -0,0 +1,50 @@ +
+

Users

+
+ <%= button_to "Sync from Tito", sync_admin_users_path, method: :post, + class: "bg-green-600 text-white py-2 px-4 rounded hover:bg-green-700 cursor-pointer" %> + <%= link_to "Add user", new_admin_user_path, + class: "bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700" %> +
+
+ + + + + + + + + + + + + <% @users.each do |user| %> + + + + + + + + <% end %> + +
NameEmailRoleTito
<%= user.full_name %><%= user.email %> + + <%= user.role.humanize %> + + + <% if user.admin_ticket_url %> + <%= link_to "Ticket", user.admin_ticket_url, + target: "_blank", rel: "noopener noreferrer", + class: "text-indigo-600 hover:underline" %> + <% end %> + + <%= link_to "Edit", edit_admin_user_path(user), class: "text-indigo-600 hover:underline" %> + <%= button_to "Delete", admin_user_path(user), method: :delete, + data: { turbo_confirm: "Remove #{user.full_name}?" }, + class: "text-red-600 hover:underline bg-transparent border-0 cursor-pointer inline" %> +
diff --git a/app/views/admin/users/new.html.erb b/app/views/admin/users/new.html.erb new file mode 100644 index 0000000..58f78d1 --- /dev/null +++ b/app/views/admin/users/new.html.erb @@ -0,0 +1,4 @@ +
+

Add User

+ <%= render "form", user: @user %> +
diff --git a/app/views/dashboard/show.html.erb b/app/views/dashboard/show.html.erb new file mode 100644 index 0000000..b617920 --- /dev/null +++ b/app/views/dashboard/show.html.erb @@ -0,0 +1,32 @@ +
+ <% if flash[:notice] %> +
<%= flash[:notice] %>
+ <% end %> + +

Welcome to Ruby Embassy

+

You're signed in.

+ +
+
+
Name
+
<%= current_user.full_name %>
+
+
+
Email
+
<%= current_user.email %>
+
+
+
Role
+
<%= current_user.role.humanize %>
+
+
+ +
+ <% if current_user.admin? %> + <%= link_to "Admin", admin_users_path, + class: "bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700" %> + <% end %> + <%= button_to "Sign out", session_path, method: :delete, + class: "bg-gray-200 text-gray-800 py-2 px-4 rounded hover:bg-gray-300 cursor-pointer" %> +
+
diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb new file mode 100644 index 0000000..1434529 --- /dev/null +++ b/app/views/layouts/admin.html.erb @@ -0,0 +1,44 @@ + + + + <%= content_for(:title) || "Admin — Ruby Embassy" %> + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= yield :head %> + + + + + <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + + + + + +
+ <% if flash[:notice] %> +
<%= flash[:notice] %>
+ <% end %> + <% if flash[:alert] %> +
<%= flash[:alert] %>
+ <% end %> + +
+ <%= yield %> +
+
+ + diff --git a/app/views/sessions/callback.html.erb b/app/views/sessions/callback.html.erb new file mode 100644 index 0000000..a3a051c --- /dev/null +++ b/app/views/sessions/callback.html.erb @@ -0,0 +1,16 @@ +
+

Signing you in…

+ + + <%= form_with url: callback_session_path, method: :post, id: "login-callback", class: "inline" do |f| %> + <%= f.hidden_field :token, value: @token %> + + <% end %> +
+ + diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb new file mode 100644 index 0000000..ce9cc1e --- /dev/null +++ b/app/views/sessions/new.html.erb @@ -0,0 +1,29 @@ +
+

Sign In

+

Enter your email and we'll send you a login link.

+ + <% if flash[:notice] %> +
<%= flash[:notice] %>
+ <% end %> + <% if flash[:alert] %> +
<%= flash[:alert] %>
+ <% end %> + + <%= form_with url: session_path, method: :post, class: "space-y-4" do |f| %> +
+ Email address + <%= f.email_field :email, required: true, autofocus: true, + value: params[:email], + class: "w-full border rounded px-3 py-2" %> +
+ + <%= f.submit "Send login link", + class: "w-full bg-[#C0392B] text-white py-2 px-4 rounded font-medium hover:bg-[#a5321f] cursor-pointer" %> + <% end %> + +

+ Don't have a ticket yet? + <%= link_to "Register for Blue Ridge Ruby", "https://blueridgeruby.com/#register", + class: "text-[#C0392B] hover:underline" %> +

+
diff --git a/app/views/sessions/not_registered.html.erb b/app/views/sessions/not_registered.html.erb new file mode 100644 index 0000000..5067357 --- /dev/null +++ b/app/views/sessions/not_registered.html.erb @@ -0,0 +1,20 @@ +
+

No Ticket Found

+ +

+ We couldn't find a Blue Ridge Ruby ticket for + <%= @email %>. +

+ +

+ You need a ticket to sign in. Register on the Blue Ridge Ruby website to get started. +

+ + <%= link_to "Register for Blue Ridge Ruby", @register_url, + class: "inline-block bg-[#C0392B] text-white py-3 px-6 rounded font-medium hover:bg-[#a5321f]" %> + +

+ Typed the wrong email? + <%= link_to "Try again", new_session_path, class: "text-[#C0392B] hover:underline" %> +

+
diff --git a/app/views/user_mailer/login_link.html.erb b/app/views/user_mailer/login_link.html.erb new file mode 100644 index 0000000..7ad70c6 --- /dev/null +++ b/app/views/user_mailer/login_link.html.erb @@ -0,0 +1,9 @@ +

Sign in to Ruby Embassy

+ +

Hi <%= @user.first_name.presence || "there" %>,

+ +

Click the link below to sign in to Ruby Embassy:

+ +

<%= link_to "Sign in", @login_url %>

+ +

This link will expire in 30 days. If you didn't request this, you can safely ignore this email.

diff --git a/app/views/user_mailer/login_link.text.erb b/app/views/user_mailer/login_link.text.erb new file mode 100644 index 0000000..3f16f7d --- /dev/null +++ b/app/views/user_mailer/login_link.text.erb @@ -0,0 +1,9 @@ +Sign in to Ruby Embassy + +Hi <%= @user.first_name.presence || "there" %>, + +Click the link below to sign in to Ruby Embassy: + +<%= @login_url %> + +This link will expire in 30 days. If you didn't request this, you can safely ignore this email. diff --git a/config/environments/production.rb b/config/environments/production.rb index f5763e0..4d70e91 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -58,16 +58,14 @@ # config.action_mailer.raise_delivery_errors = false # Set host to be used by links generated in mailer templates. - config.action_mailer.default_url_options = { host: "example.com" } - - # Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit. - # config.action_mailer.smtp_settings = { - # user_name: Rails.application.credentials.dig(:smtp, :user_name), - # password: Rails.application.credentials.dig(:smtp, :password), - # address: "smtp.example.com", - # port: 587, - # authentication: :plain - # } + config.action_mailer.default_url_options = { + host: ENV.fetch("APP_HOST", "activities.blueridgeruby.com"), + protocol: "https" + } + + # Send email via Postmark (matches ashevillagers stack) + config.action_mailer.delivery_method = :postmark + config.action_mailer.postmark_settings = { api_token: ENV["POSTMARK_API_TOKEN"] } # Enable locale fallbacks for I18n (makes lookups for any locale fall back to # the I18n.default_locale when a translation cannot be found). diff --git a/config/routes.rb b/config/routes.rb index d6b0621..b6d81e5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,15 +1,25 @@ Rails.application.routes.draw do - # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html - - # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. - # Can be used by load balancers and uptime monitors to verify that the app is live. + # Health check get "up" => "rails/health#show", as: :rails_health_check - mount MissionControl::Jobs::Engine, at: "/jobs" - # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb) - # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest - # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker + # Public — magic-link auth + resource :session, only: %i[new create destroy], controller: "sessions" do + get :callback, on: :collection + post :callback, on: :collection + end + + # Authenticated user dashboard + get "dashboard", to: "dashboard#show", as: :dashboard + root "dashboard#show" + + # Admin area + namespace :admin do + resources :users do + post :sync, on: :collection + end + resources :configurations + end - # Defines the root path route ("/") - # root "posts#index" + # Background jobs dashboard (admin-only mount inside /admin/) + mount MissionControl::Jobs::Engine, at: "/admin/jobs" end diff --git a/db/cable_schema.rb b/db/cable_schema.rb index 2366660..593da41 100644 --- a/db/cable_schema.rb +++ b/db/cable_schema.rb @@ -1,9 +1,24 @@ -ActiveRecord::Schema[7.1].define(version: 1) do +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 1) do + # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" + create_table "solid_cable_messages", force: :cascade do |t| - t.binary "channel", limit: 1024, null: false - t.binary "payload", limit: 536870912, null: false + t.binary "channel", null: false + t.bigint "channel_hash", null: false t.datetime "created_at", null: false - t.integer "channel_hash", limit: 8, null: false + t.binary "payload", null: false t.index ["channel"], name: "index_solid_cable_messages_on_channel" t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash" t.index ["created_at"], name: "index_solid_cable_messages_on_created_at" diff --git a/db/cache_schema.rb b/db/cache_schema.rb index 81a410d..96be0dc 100644 --- a/db/cache_schema.rb +++ b/db/cache_schema.rb @@ -1,10 +1,25 @@ -ActiveRecord::Schema[7.2].define(version: 1) do +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 1) do + # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" + create_table "solid_cache_entries", force: :cascade do |t| - t.binary "key", limit: 1024, null: false - t.binary "value", limit: 536870912, null: false + t.integer "byte_size", null: false t.datetime "created_at", null: false - t.integer "key_hash", limit: 8, null: false - t.integer "byte_size", limit: 4, null: false + t.binary "key", null: false + t.bigint "key_hash", null: false + t.binary "value", null: false t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size" t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size" t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true diff --git a/db/migrate/20260417140714_create_configurations.rb b/db/migrate/20260417140714_create_configurations.rb new file mode 100644 index 0000000..5e6f415 --- /dev/null +++ b/db/migrate/20260417140714_create_configurations.rb @@ -0,0 +1,12 @@ +class CreateConfigurations < ActiveRecord::Migration[8.1] + def change + create_table :configurations do |t| + t.string :name, null: false + t.string :value + + t.timestamps + end + + add_index :configurations, :name, unique: true + end +end diff --git a/db/migrate/20260417140731_create_users.rb b/db/migrate/20260417140731_create_users.rb new file mode 100644 index 0000000..d716a38 --- /dev/null +++ b/db/migrate/20260417140731_create_users.rb @@ -0,0 +1,18 @@ +class CreateUsers < ActiveRecord::Migration[8.1] + def change + create_table :users do |t| + t.string :email, null: false + t.string :first_name + t.string :last_name + t.integer :role, default: 0, null: false + t.string :tito_account_slug + t.string :tito_event_slug + t.string :tito_ticket_slug + + t.timestamps + end + + add_index :users, :email, unique: true + add_index :users, :tito_ticket_slug, unique: true + end +end diff --git a/db/queue_schema.rb b/db/queue_schema.rb index 85194b6..2181653 100644 --- a/db/queue_schema.rb +++ b/db/queue_schema.rb @@ -1,123 +1,138 @@ -ActiveRecord::Schema[7.1].define(version: 1) do +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 1) do + # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" + create_table "solid_queue_blocked_executions", force: :cascade do |t| - t.bigint "job_id", null: false - t.string "queue_name", null: false - t.integer "priority", default: 0, null: false t.string "concurrency_key", null: false - t.datetime "expires_at", null: false t.datetime "created_at", null: false - t.index [ "concurrency_key", "priority", "job_id" ], name: "index_solid_queue_blocked_executions_for_release" - t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance" - t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true + t.datetime "expires_at", null: false + t.bigint "job_id", null: false + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.index ["concurrency_key", "priority", "job_id"], name: "index_solid_queue_blocked_executions_for_release" + t.index ["expires_at", "concurrency_key"], name: "index_solid_queue_blocked_executions_for_maintenance" + t.index ["job_id"], name: "index_solid_queue_blocked_executions_on_job_id", unique: true end create_table "solid_queue_claimed_executions", force: :cascade do |t| + t.datetime "created_at", null: false t.bigint "job_id", null: false t.bigint "process_id" - t.datetime "created_at", null: false - t.index [ "job_id" ], name: "index_solid_queue_claimed_executions_on_job_id", unique: true - t.index [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" + t.index ["job_id"], name: "index_solid_queue_claimed_executions_on_job_id", unique: true + t.index ["process_id", "job_id"], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" end create_table "solid_queue_failed_executions", force: :cascade do |t| - t.bigint "job_id", null: false - t.text "error" t.datetime "created_at", null: false - t.index [ "job_id" ], name: "index_solid_queue_failed_executions_on_job_id", unique: true + t.text "error" + t.bigint "job_id", null: false + t.index ["job_id"], name: "index_solid_queue_failed_executions_on_job_id", unique: true end create_table "solid_queue_jobs", force: :cascade do |t| - t.string "queue_name", null: false - t.string "class_name", null: false - t.text "arguments" - t.integer "priority", default: 0, null: false t.string "active_job_id" - t.datetime "scheduled_at" - t.datetime "finished_at" + t.text "arguments" + t.string "class_name", null: false t.string "concurrency_key" t.datetime "created_at", null: false + t.datetime "finished_at" + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.datetime "scheduled_at" t.datetime "updated_at", null: false - t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id" - t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name" - t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at" - t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering" - t.index [ "scheduled_at", "finished_at" ], name: "index_solid_queue_jobs_for_alerting" + t.index ["active_job_id"], name: "index_solid_queue_jobs_on_active_job_id" + t.index ["class_name"], name: "index_solid_queue_jobs_on_class_name" + t.index ["finished_at"], name: "index_solid_queue_jobs_on_finished_at" + t.index ["queue_name", "finished_at"], name: "index_solid_queue_jobs_for_filtering" + t.index ["scheduled_at", "finished_at"], name: "index_solid_queue_jobs_for_alerting" end create_table "solid_queue_pauses", force: :cascade do |t| - t.string "queue_name", null: false t.datetime "created_at", null: false - t.index [ "queue_name" ], name: "index_solid_queue_pauses_on_queue_name", unique: true + t.string "queue_name", null: false + t.index ["queue_name"], name: "index_solid_queue_pauses_on_queue_name", unique: true end create_table "solid_queue_processes", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "hostname" t.string "kind", null: false t.datetime "last_heartbeat_at", null: false - t.bigint "supervisor_id" - t.integer "pid", null: false - t.string "hostname" t.text "metadata" - t.datetime "created_at", null: false t.string "name", null: false - t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at" - t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true - t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_supervisor_id" + t.integer "pid", null: false + t.bigint "supervisor_id" + t.index ["last_heartbeat_at"], name: "index_solid_queue_processes_on_last_heartbeat_at" + t.index ["name", "supervisor_id"], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true + t.index ["supervisor_id"], name: "index_solid_queue_processes_on_supervisor_id" end create_table "solid_queue_ready_executions", force: :cascade do |t| + t.datetime "created_at", null: false t.bigint "job_id", null: false - t.string "queue_name", null: false t.integer "priority", default: 0, null: false - t.datetime "created_at", null: false - t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true - t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all" - t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue" + t.string "queue_name", null: false + t.index ["job_id"], name: "index_solid_queue_ready_executions_on_job_id", unique: true + t.index ["priority", "job_id"], name: "index_solid_queue_poll_all" + t.index ["queue_name", "priority", "job_id"], name: "index_solid_queue_poll_by_queue" end create_table "solid_queue_recurring_executions", force: :cascade do |t| + t.datetime "created_at", null: false t.bigint "job_id", null: false - t.string "task_key", null: false t.datetime "run_at", null: false - t.datetime "created_at", null: false - t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true - t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true + t.string "task_key", null: false + t.index ["job_id"], name: "index_solid_queue_recurring_executions_on_job_id", unique: true + t.index ["task_key", "run_at"], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true end create_table "solid_queue_recurring_tasks", force: :cascade do |t| - t.string "key", null: false - t.string "schedule", null: false - t.string "command", limit: 2048 - t.string "class_name" t.text "arguments" - t.string "queue_name" + t.string "class_name" + t.string "command", limit: 2048 + t.datetime "created_at", null: false + t.text "description" + t.string "key", null: false t.integer "priority", default: 0 + t.string "queue_name" + t.string "schedule", null: false t.boolean "static", default: true, null: false - t.text "description" - t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true - t.index [ "static" ], name: "index_solid_queue_recurring_tasks_on_static" + t.index ["key"], name: "index_solid_queue_recurring_tasks_on_key", unique: true + t.index ["static"], name: "index_solid_queue_recurring_tasks_on_static" end create_table "solid_queue_scheduled_executions", force: :cascade do |t| + t.datetime "created_at", null: false t.bigint "job_id", null: false - t.string "queue_name", null: false t.integer "priority", default: 0, null: false + t.string "queue_name", null: false t.datetime "scheduled_at", null: false - t.datetime "created_at", null: false - t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true - t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all" + t.index ["job_id"], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true + t.index ["scheduled_at", "priority", "job_id"], name: "index_solid_queue_dispatch_all" end create_table "solid_queue_semaphores", force: :cascade do |t| - t.string "key", null: false - t.integer "value", default: 1, null: false - t.datetime "expires_at", null: false t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.string "key", null: false t.datetime "updated_at", null: false - t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at" - t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value" - t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true + t.integer "value", default: 1, null: false + t.index ["expires_at"], name: "index_solid_queue_semaphores_on_expires_at" + t.index ["key", "value"], name: "index_solid_queue_semaphores_on_key_and_value" + t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true end add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 0000000..c3e29e0 --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,38 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 2026_04_17_140731) do + # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" + + create_table "configurations", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "name", null: false + t.datetime "updated_at", null: false + t.string "value" + t.index ["name"], name: "index_configurations_on_name", unique: true + end + + create_table "users", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "email", null: false + t.string "first_name" + t.string "last_name" + t.integer "role", default: 0, null: false + t.string "tito_account_slug" + t.string "tito_event_slug" + t.string "tito_ticket_slug" + t.datetime "updated_at", null: false + t.index ["email"], name: "index_users_on_email", unique: true + t.index ["tito_ticket_slug"], name: "index_users_on_tito_ticket_slug", unique: true + end +end diff --git a/db/seeds.rb b/db/seeds.rb index 4fbd6ed..082dc42 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,9 +1,13 @@ -# This file should ensure the existence of records required to run the application in every environment (production, -# development, test). The code here should be idempotent so that it can be executed at any point in every environment. -# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). -# -# Example: -# -# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name| -# MovieGenre.find_or_create_by!(name: genre_name) -# end +# Seeds are idempotent - safe to run repeatedly. + +admin_users = [ + { email: "jeremy@blueridgeruby.com", first_name: "Jeremy", last_name: "Smith" } +] + +admin_users.each do |attrs| + User.find_or_create_by!(email: attrs[:email]) do |u| + u.first_name = attrs[:first_name] + u.last_name = attrs[:last_name] + u.role = :admin + end +end From 3a5a4ff7373d501b4e733c9a341293660e7d6df1 Mon Sep 17 00:00:00 2001 From: Katya Sarmiento <5871075+Kitkatnik@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:00:22 -0400 Subject: [PATCH 02/29] Apply Blue Ridge Ruby branding to auth pages Pull the template branch's navbar/footer/ridge-bg layout, logo, colfax font, and the blueridgeruby.com class-name system. Restyle all session views (new, callback, not_registered) and the dashboard to use the brand palette (navy text, red gradient CTAs, ridge background). Give the admin area a matching navy top bar and brand-consistent forms, tables, and role badges. Extend application.css with form/card/button/ alert/table/badge utilities on top of the template's base. Also add test/system/ scaffolding so bin/rails test:system runs cleanly and a brakeman.ignore entry for the admin-only role mass-assignment (guarded by require_admin! on every action). --- app/assets/images/logo.svg | 1 + app/assets/stylesheets/application.css | 577 +++++++++++++++++- .../controllers/toggle_controller.js | 31 + app/models/configuration.rb | 4 +- app/models/user.rb | 2 +- app/views/admin/configurations/_form.html.erb | 50 +- app/views/admin/configurations/edit.html.erb | 2 +- app/views/admin/configurations/index.html.erb | 37 +- app/views/admin/configurations/new.html.erb | 2 +- app/views/admin/users/_form.html.erb | 58 +- app/views/admin/users/edit.html.erb | 2 +- app/views/admin/users/index.html.erb | 49 +- app/views/admin/users/new.html.erb | 2 +- app/views/dashboard/show.html.erb | 62 +- app/views/layouts/admin.html.erb | 34 +- app/views/layouts/application.html.erb | 19 +- app/views/sessions/callback.html.erb | 30 +- app/views/sessions/new.html.erb | 63 +- app/views/sessions/not_registered.html.erb | 40 +- app/views/shared/_footer.html.erb | 58 ++ app/views/shared/_navbar.html.erb | 38 ++ config/brakeman.ignore | 26 + public/images/mountains.jpg | Bin 0 -> 119396 bytes public/images/ridge.jpg | Bin 0 -> 55959 bytes public/images/ruby.svg | 1 + test/application_system_test_case.rb | 5 + test/system/.keep | 0 27 files changed, 973 insertions(+), 220 deletions(-) create mode 100644 app/assets/images/logo.svg create mode 100644 app/javascript/controllers/toggle_controller.js create mode 100644 app/views/shared/_footer.html.erb create mode 100644 app/views/shared/_navbar.html.erb create mode 100644 config/brakeman.ignore create mode 100644 public/images/mountains.jpg create mode 100644 public/images/ridge.jpg create mode 100644 public/images/ruby.svg create mode 100644 test/application_system_test_case.rb create mode 100644 test/system/.keep diff --git a/app/assets/images/logo.svg b/app/assets/images/logo.svg new file mode 100644 index 0000000..702eb89 --- /dev/null +++ b/app/assets/images/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index fe93333..2b6763f 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -1,10 +1,567 @@ -/* - * This is a manifest file that'll be compiled into application.css. - * - * With Propshaft, assets are served efficiently without preprocessing steps. You can still include - * application-wide styles in this file, but keep in mind that CSS precedence will follow the standard - * cascading order, meaning styles declared later in the document or manifest will override earlier ones, - * depending on specificity. - * - * Consider organizing styles into separate files for maintainability. - */ +/* ============================================================ + * Ruby Embassy — plain CSS that mirrors the blueridgeruby.com + * styling. The HTML uses the same Tailwind-style class names + * the real site uses, so the markup is copy-paste compatible. + * Only the specific utilities used on that page are defined. + * ============================================================ */ + + +/* ---------- Reset & base ---------------------------------- */ + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; + border: 0 solid; +} + +html { + font-family: 'colfax-web', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + color: #404040; + line-height: 1.5; + -webkit-text-size-adjust: 100%; +} + +body { min-height: 100vh; } + +img, svg { display: block; max-width: 100%; } + +a { + color: inherit; + text-decoration: inherit; + transition: opacity 0.15s ease; +} + +a:hover { opacity: 0.85; } + +button { + font: inherit; + color: inherit; + background: transparent; + cursor: pointer; +} + +ul, ol { list-style: none; } + +h1, h2, h3, h4, h5, h6 { + font-size: inherit; + font-weight: inherit; +} + +p { line-height: 1.5; } + +sup { font-size: 0.75em; vertical-align: super; } + +code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.9em; + background-color: rgba(38, 114, 181, 0.08); + color: #0C2866; + padding: 0.125rem 0.375rem; + border-radius: 0.125rem; +} + + +/* ---------- Screen-reader only ---------------------------- */ + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; +} + + +/* ---------- Sizing ---------------------------------------- */ + +.min-h-screen { min-height: 100vh; } + +.max-w-10xl { max-width: 100rem; } +.max-w-5xl { max-width: 64rem; } +.max-w-2xl { max-width: 42rem; } + +.h-5 { height: 1.25rem; } +.w-5 { width: 1.25rem; } +.h-6 { height: 1.5rem; } +.w-6 { width: 1.5rem; } + + +/* ---------- Margin & padding ------------------------------ */ + +.mx-auto { margin-left: auto; margin-right: auto; } + +.mr-0\.5 { margin-right: 0.125rem; } +.mt-2 { margin-top: 0.5rem; } +.mt-4 { margin-top: 1rem; } +.mb-4 { margin-bottom: 1rem; } +.mb-12 { margin-bottom: 3rem; } + +.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; } +.px-4 { padding-left: 1rem; padding-right: 1rem; } +.px-8 { padding-left: 2rem; padding-right: 2rem; } + +.py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; } +.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; } +.py-4 { padding-top: 1rem; padding-bottom: 1rem; } +.py-10 { padding-top: 2.5rem; padding-bottom: 2.5rem; } +.pb-32 { padding-bottom: 8rem; } + +.p-2 { padding: 0.5rem; } + + +/* ---------- Display & flex -------------------------------- */ + +.block { display: block; } +.inline-flex { display: inline-flex; } +.flex { display: flex; } +.hidden { display: none; } + +.flex-col { flex-direction: column; } + +.items-center { align-items: center; } +.justify-between { justify-content: space-between; } +.justify-center { justify-content: center; } + +.shrink-0 { flex-shrink: 0; } + +.gap-x-3 { column-gap: 0.75rem; } +.gap-x-4 { column-gap: 1rem; } +.gap-y-2 { row-gap: 0.5rem; } + + +/* ---------- Typography ------------------------------------ */ + +.text-lg { font-size: 1.125rem; line-height: 1.5; } +.text-2xl { font-size: 1.5rem; line-height: 2rem; } +.text-3xl { font-size: 1.875rem; line-height: 2.25rem; } + +/* Arbitrary sizes used on the real site */ +.text-hero { font-size: 2.375rem; } /* 38px — main page title */ +.text-section { font-size: 1.75rem; } /* 28px — section heading */ + +.text-center { text-align: center; } +.text-right { text-align: right; } + +.font-medium { font-weight: 500; } + +.leading-tight { line-height: 1.25; } +.leading-relaxed { line-height: 1.625; } + +.underline { text-decoration: underline; } + + +/* ---------- Colors (from the real site) ------------------- */ + +.text-blue { color: #2672B5; } +.text-navy { color: #0C2866; } +.text-gray { color: #404040; } +.text-white { color: #ffffff; } + +/* Red register button — exact gradient from the real site */ +.bg-red-gradient { + background: linear-gradient(to top, #C41C1C, #DD423E); +} + + +/* ---------- Borders, shadows, cursors --------------------- */ + +.rounded-sm { border-radius: 0.125rem; } +.shadow-sm { box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); } +.cursor-pointer { cursor: pointer; } + +.focus\:outline-hidden:focus { outline: none; } + + +/* ---------- List utilities -------------------------------- */ + +.list-disc { + list-style: disc; + padding-left: 1.5rem; +} + + +/* ---------- Spacing between children (space-y-*) ---------- */ + +.space-y-8 > * + * { margin-top: 2rem; } +.space-y-2 > * + * { margin-top: 0.5rem; } + + +/* ---------- Page backgrounds (from the real site) --------- */ + +.bg-ridge { + background-color: #ffffff; + background-image: url("/images/ridge.jpg"); + background-size: 100% auto; + background-repeat: no-repeat; + background-position: 50% 100%; +} + +.bg-mountains { + background-color: #ffffff; + background-image: + linear-gradient(to top, transparent, #ffffff), + linear-gradient(to bottom, transparent, #0C2866), + url("/images/mountains.jpg"), + linear-gradient(to bottom, #ffffff 0% 50%, #0C2866 50% 100%); + background-repeat: no-repeat; + background-size: 100% 100px, 100% 100px, 1800px auto, 100% 100%; + background-position: 50% 0%, 50% 100%, 50% 50%, 50% 0%; +} + +@media (min-width: 550px) { + .bg-mountains { + background-position: 50% 0%, 50% 100%, 50% 100%, 50% 0%; + } +} + + +/* ---------- Content styles for
inside prose ---------- */ + +hr { + border: 0; + border-top: 1px solid #d9dee1; +} + + +/* ============================================================ + * Responsive utilities + * sm: 640px md: 768px lg: 1024px + * ============================================================ */ + +@media (min-width: 640px) { + .sm\:text-3xl { font-size: 1.875rem; line-height: 2.25rem; } +} + +@media (min-width: 768px) { + .md\:block { display: block; } + .md\:flex-row { flex-direction: row; } + .md\:justify-between { justify-content: space-between; } + .md\:items-center { align-items: center; } + .md\:text-right { text-align: right; } + .md\:text-hero { font-size: 2.375rem; } + .md\:leading-tight { line-height: 1.25; } +} + +@media (min-width: 1024px) { + .lg\:block { display: block; } + .lg\:hidden { display: none; } +} + + +/* ============================================================ + * App-specific additions (extends the template utilities for + * forms, cards, buttons, and tables used in auth + admin UIs). + * ============================================================ */ + + +/* ---------- Additional sizing/spacing --------------------- */ + +.max-w-md { max-width: 28rem; } +.max-w-xl { max-width: 36rem; } +.max-w-4xl { max-width: 56rem; } + +.w-full { width: 100%; } + +.mt-1 { margin-top: 0.25rem; } +.mt-3 { margin-top: 0.75rem; } +.mt-6 { margin-top: 1.5rem; } +.mt-8 { margin-top: 2rem; } + +.mb-1 { margin-bottom: 0.25rem; } +.mb-2 { margin-bottom: 0.5rem; } +.mb-6 { margin-bottom: 1.5rem; } +.mb-8 { margin-bottom: 2rem; } + +.ml-2 { margin-left: 0.5rem; } + +.p-4 { padding: 1rem; } +.p-6 { padding: 1.5rem; } +.p-8 { padding: 2rem; } + +.px-6 { padding-left: 1.5rem; padding-right: 1.5rem; } +.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; } +.py-6 { padding-top: 1.5rem; padding-bottom: 1.5rem; } +.py-16 { padding-top: 4rem; padding-bottom: 4rem; } + +.pt-4 { padding-top: 1rem; } + + +/* ---------- Typography extras ----------------------------- */ + +.text-sm { font-size: 0.875rem; line-height: 1.25rem; } +.text-xs { font-size: 0.75rem; line-height: 1rem; } +.text-xl { font-size: 1.25rem; line-height: 1.75rem; } + +.font-bold { font-weight: 700; } +.font-semibold { font-weight: 600; } + +.uppercase { text-transform: uppercase; } +.tracking-wide { letter-spacing: 0.025em; } +.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + + +/* ---------- Colors (palette extensions) ------------------- */ + +.text-blue-dark { color: #1d5a94; } +.text-red { color: #C41C1C; } +.text-muted { color: #6b7280; } + +.bg-white { background-color: #ffffff; } +.bg-gray-50 { background-color: #f9fafb; } +.bg-gray-100 { background-color: #f3f4f6; } +.bg-gray-200 { background-color: #e5e7eb; } +.bg-navy { background-color: #0C2866; } +.bg-blue { background-color: #2672B5; } +.bg-green-50 { background-color: #f0fdf4; } +.bg-red-50 { background-color: #fef2f2; } + +.text-green-dark { color: #166534; } +.text-red-dark { color: #991b1b; } + +.border-gray { border: 1px solid #d9dee1; } +.border-navy { border: 1px solid #0C2866; } +.border-t-gray { border-top: 1px solid #e5e7eb; } + + +/* ---------- Layout extras --------------------------------- */ + +.grid { display: grid; } +.inline-block { display: inline-block; } +.gap-3 { gap: 0.75rem; } +.gap-4 { gap: 1rem; } +.space-y-4 > * + * { margin-top: 1rem; } +.space-y-6 > * + * { margin-top: 1.5rem; } + + +/* ---------- Borders & shadows ----------------------------- */ + +.rounded { border-radius: 0.25rem; } +.rounded-md { border-radius: 0.375rem; } +.rounded-lg { border-radius: 0.5rem; } +.rounded-full { border-radius: 9999px; } +.shadow { box-shadow: 0 1px 3px 0 rgba(0,0,0,.1), 0 1px 2px -1px rgba(0,0,0,.1); } + + +/* ---------- Card ------------------------------------------ */ + +.card { + background: #ffffff; + border-radius: 0.5rem; + box-shadow: 0 1px 3px 0 rgba(0,0,0,0.1), 0 1px 2px -1px rgba(0,0,0,0.1); + padding: 2rem; +} + +.card-compact { padding: 1.5rem; } + + +/* ---------- Form controls --------------------------------- */ + +.label { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: #404040; + margin-bottom: 0.375rem; +} + +.input, +.select { + width: 100%; + border: 1px solid #d1d5db; + border-radius: 0.375rem; + padding: 0.5rem 0.75rem; + font-family: inherit; + font-size: 1rem; + color: #111827; + background-color: #ffffff; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.input:focus, +.select:focus { + outline: none; + border-color: #2672B5; + box-shadow: 0 0 0 3px rgba(38, 114, 181, 0.15); +} + +.input::placeholder { color: #9ca3af; } + + +/* ---------- Buttons --------------------------------------- */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.625rem 1.25rem; + font-weight: 500; + font-size: 1rem; + border-radius: 0.25rem; + box-shadow: 0 1px 2px 0 rgba(0,0,0,0.05); + cursor: pointer; + transition: opacity 0.15s ease; + border: 0; +} + +.btn:hover { opacity: 0.9; } + +.btn-red { + background: linear-gradient(to top, #C41C1C, #DD423E); + color: #ffffff; +} + +.btn-navy { + background: #0C2866; + color: #ffffff; +} + +.btn-ghost { + background: transparent; + color: #2672B5; + box-shadow: none; +} + +.btn-ghost:hover { + background: rgba(38, 114, 181, 0.08); + opacity: 1; +} + +.btn-muted { + background: #e5e7eb; + color: #374151; +} + +.btn-full { width: 100%; } + + +/* ---------- Alerts/flash ---------------------------------- */ + +.alert { + padding: 0.75rem 1rem; + border-radius: 0.375rem; + margin-bottom: 1rem; + font-size: 0.9375rem; +} + +.alert-notice { + background: #f0fdf4; + color: #166534; + border: 1px solid #bbf7d0; +} + +.alert-error { + background: #fef2f2; + color: #991b1b; + border: 1px solid #fecaca; +} + + +/* ---------- Tables ---------------------------------------- */ + +.table { + width: 100%; + background: #ffffff; + border-radius: 0.5rem; + box-shadow: 0 1px 3px 0 rgba(0,0,0,0.08); + overflow: hidden; + border-collapse: separate; + border-spacing: 0; +} + +.table th { + text-align: left; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #6b7280; + background: #f9fafb; + padding: 0.75rem 1rem; + border-bottom: 1px solid #e5e7eb; +} + +.table td { + padding: 0.75rem 1rem; + border-top: 1px solid #f3f4f6; + vertical-align: middle; +} + +.table tr:first-child td { border-top: 0; } + + +/* ---------- Role/status badges ---------------------------- */ + +.badge { + display: inline-block; + padding: 0.125rem 0.5rem; + font-size: 0.75rem; + font-weight: 600; + border-radius: 9999px; + text-transform: capitalize; +} + +.badge-admin { + background: rgba(196, 28, 28, 0.1); + color: #991b1b; +} + +.badge-volunteer { + background: rgba(38, 114, 181, 0.12); + color: #1d5a94; +} + +.badge-attendee { + background: #f3f4f6; + color: #374151; +} + + +/* ---------- Admin nav ------------------------------------- */ + +.admin-nav { + background: #0C2866; + color: #ffffff; + padding: 0.875rem 2rem; + display: flex; + align-items: center; + gap: 1.5rem; +} + +.admin-nav a { color: #ffffff; } +.admin-nav a:hover { text-decoration: underline; } +.admin-nav .ml-auto { margin-left: auto; } + +.admin-nav-btn { + background: rgba(255, 255, 255, 0.12); + color: #ffffff; + border: 0; + padding: 0.375rem 0.875rem; + border-radius: 0.25rem; + cursor: pointer; + font-size: 0.875rem; +} + +.admin-nav-btn:hover { background: rgba(255, 255, 255, 0.2); } + + +/* ---------- Section heading helper ------------------------ */ + +.page-title { + font-size: 1.875rem; + font-weight: 500; + color: #0C2866; + line-height: 1.25; + margin-bottom: 0.5rem; +} + +.page-subtitle { + color: #6b7280; + font-size: 1rem; + margin-bottom: 2rem; +} + diff --git a/app/javascript/controllers/toggle_controller.js b/app/javascript/controllers/toggle_controller.js new file mode 100644 index 0000000..253b413 --- /dev/null +++ b/app/javascript/controllers/toggle_controller.js @@ -0,0 +1,31 @@ +import { Controller } from "@hotwired/stimulus" + +/* + * Generic toggle controller — matches the Bridgetown pattern. + * + * Usage: + *
+ * + * + *
+ * + * - #toggle flips the configured class on the toggleable target. + * - #hide re-applies the class (closes the panel). It's bound to + * click@window so clicks outside close the menu; the event is + * ignored if the click came from inside this controller's element + * (so tapping the button doesn't immediately close what it opened). + */ +export default class extends Controller { + static targets = ["toggleable"] + static values = { toggleClass: { type: String, default: "hidden" } } + + toggle(event) { + event?.stopPropagation() + this.toggleableTarget.classList.toggle(this.toggleClassValue) + } + + hide(event) { + if (event && this.element.contains(event.target)) return + this.toggleableTarget.classList.add(this.toggleClassValue) + } +} diff --git a/app/models/configuration.rb b/app/models/configuration.rb index 70b5627..665d785 100644 --- a/app/models/configuration.rb +++ b/app/models/configuration.rb @@ -7,8 +7,8 @@ class Configuration < ApplicationRecord def self.expect(*names, &block) names = names.map(&:to_s) expected_names.merge(names) - case [names, block] - in [name], nil then self[name] + case [ names, block ] + in [ name ], nil then self[name] in Array, nil then values_at(*names) else callback = -> { block.call(*values_at(*names)) } diff --git a/app/models/user.rb b/app/models/user.rb index e3086a5..114f6e6 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -33,7 +33,7 @@ def admin_ticket_url end def full_name - [first_name, last_name].compact.join(" ").presence || email + [ first_name, last_name ].compact.join(" ").presence || email end private diff --git a/app/views/admin/configurations/_form.html.erb b/app/views/admin/configurations/_form.html.erb index b237d44..7353b8a 100644 --- a/app/views/admin/configurations/_form.html.erb +++ b/app/views/admin/configurations/_form.html.erb @@ -1,6 +1,6 @@ <% if configuration.errors.any? %> -
-
diff --git a/app/views/schedule/_day.html.erb b/app/views/schedule/_day.html.erb index 52f2ff5..79765e8 100644 --- a/app/views/schedule/_day.html.erb +++ b/app/views/schedule/_day.html.erb @@ -1,15 +1,15 @@ -
+
-

<%= day[:label] %>

- <%= day[:date] %> - <% if day[:subtitle].present? %> - <%= day[:subtitle] %> +

<%= meta[:label] %>

+ <%= meta[:date] %> + <% if meta[:subtitle].present? %> + <%= meta[:subtitle] %> <% end %>
- <% day[:items].each do |item| %> - <%= render "session_item", item: item %> + <% items.each do |item| %> + <%= render "session_item", item: item, planned: planned_ids.include?(item.id) %> <% end %>
diff --git a/app/views/schedule/_session_item.html.erb b/app/views/schedule/_session_item.html.erb index 8be6078..96aae69 100644 --- a/app/views/schedule/_session_item.html.erb +++ b/app/views/schedule/_session_item.html.erb @@ -1,49 +1,75 @@ <% - type = item[:type].to_s - type_label = { - "talk" => "Talk", - "social" => "Social", - "logistics" => "Logistics", - "activity" => "Activity", - "lightning" => "Lightning" - }[type] - flexible_classes = item[:flexible] ? "schedule-item--flexible" : "" + kind_label = { + "talk" => "Talk", + "lightning" => "Lightning", + "embassy" => "Embassy", + "activity" => "Activity" + }[item.kind] + flexible_classes = item.flexible? ? "schedule-item--flexible" : "" + + existing_plan = current_user.plan_items.find_by(schedule_item: item) if planned + + button_label = + if planned + item.talk? ? "✓ Added" : "✓ RSVP'd" + else + item.talk? ? "+ Add to plan" : "+ RSVP" + end + + rsvp_note = (!item.talk? && item.rsvp_count > 0) ? "#{item.rsvp_count} going" : nil %> -
-
- <%= item[:time] %> -
+<%= turbo_frame_tag dom_id(item) do %> +
+
+ <%= item.time_label %> +
-
-

<%= item[:title] %>

+
+ <% if item.talk? && item.host.present? %> +

<%= item.host %>

+

“<%= item.title %>”

+ <% else %> +

<%= item.title %>

+ <% if item.host.present? %> +

<%= item.host %>

+ <% end %> + <% end %> - <% if item[:talk_title].present? %> -

“<%= item[:talk_title] %>”

- <% end %> + <% if item.location.present? %> +

<%= item.location %>

+ <% end %> - <% if item[:location].present? %> -

<%= item[:location] %>

- <% end %> +
+ <% if kind_label.present? %> + <%= kind_label %> + <% end %> + <% if item.flexible? %> + TBD + <% end %> +
+
-
- <% if type_label.present? %> - <%= type_label %> +
+ <% if planned && existing_plan %> + <%= button_to button_label, + 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 button_label, + plan_items_path, + method: :post, + params: { schedule_item_id: item.id }, + class: "add-btn", + data: { turbo_frame: dom_id(item) }, + aria: { pressed: false } %> <% end %> - <% if item[:flexible] %> - TBD + <% if rsvp_note %> +

<%= rsvp_note %>

<% end %>
-
- - <% if item[:added] %> - - <% else %> - - <% end %> -
+
+<% end %> diff --git a/app/views/schedule/index.html.erb b/app/views/schedule/index.html.erb index 53f12cf..b7faac1 100644 --- a/app/views/schedule/index.html.erb +++ b/app/views/schedule/index.html.erb @@ -7,17 +7,20 @@

- Browse the full conference agenda. Tap Add on any session to save it to your Plan. + Browse the full conference agenda. Tap Add to plan on any talk or RSVP on any activity to save it to your Plan.

- <% @days.each do |day| %> - <%= render "day", day: day %> + <% ScheduleItem::DAY_META.each do |day_key, meta| %> + <% items = @items_by_day[day_key] %> + <% next if items.blank? %> + <%= render "day", day_key: day_key, meta: meta, items: items, planned_ids: @planned_ids %> <% end %>
diff --git a/app/views/schedule_items/_form.html.erb b/app/views/schedule_items/_form.html.erb new file mode 100644 index 0000000..52ad2d5 --- /dev/null +++ b/app/views/schedule_items/_form.html.erb @@ -0,0 +1,57 @@ +<%= form_with model: schedule_item, local: true do |f| %> + <% if schedule_item.errors.any? %> +
+

<%= pluralize(schedule_item.errors.count, "error") %> prevented saving:

+
    + <% schedule_item.errors.full_messages.each do |msg| %> +
  • <%= msg %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= f.label :title %> + <%= f.text_field :title, required: true %> +
+ +
+ <%= f.label :day %> + <%= f.select :day, ScheduleItem::DAY_META.map { |k, m| [ m[:label], k ] } %> +
+ +
+ <%= f.label :time_label, "Time (display label, e.g., '6:30 PM')" %> + <%= f.text_field :time_label %> +
+ +
+ <%= f.label :sort_time, "Sort time (integer, e.g., 1830 for 6:30 PM)" %> + <%= f.number_field :sort_time %> +
+ +
+ <%= f.label :host, "Host (optional)" %> + <%= f.text_field :host %> +
+ +
+ <%= f.label :location, "Location (optional)" %> + <%= f.text_field :location %> +
+ +
+ <%= f.label :description, "Description (optional)" %> + <%= f.text_area :description, rows: 3 %> +
+ +
+ <%= f.check_box :is_public %> + <%= f.label :is_public, "Share with all attendees — they can RSVP" %> +
+ +
+ <%= f.submit %> + <%= link_to "Cancel", plan_path %> +
+<% end %> diff --git a/app/views/schedule_items/edit.html.erb b/app/views/schedule_items/edit.html.erb new file mode 100644 index 0000000..74c5bd8 --- /dev/null +++ b/app/views/schedule_items/edit.html.erb @@ -0,0 +1,11 @@ +<% content_for(:title, "Edit activity · Ruby Embassy") %> + +
+
+

+ Edit your activity +

+ + <%= render "form", schedule_item: @schedule_item %> +
+
diff --git a/app/views/schedule_items/new.html.erb b/app/views/schedule_items/new.html.erb new file mode 100644 index 0000000..90aec6a --- /dev/null +++ b/app/views/schedule_items/new.html.erb @@ -0,0 +1,15 @@ +<% content_for(:title, "Propose an activity · Ruby Embassy") %> + +
+
+

+ Propose an activity +

+ +

+ Add a dinner, hike, or meetup. Keep it private (just you) or share it with all attendees so they can RSVP. +

+ + <%= render "form", schedule_item: @schedule_item %> +
+
diff --git a/config/initializers/mission_control_jobs.rb b/config/initializers/mission_control_jobs.rb new file mode 100644 index 0000000..2d90ada --- /dev/null +++ b/config/initializers/mission_control_jobs.rb @@ -0,0 +1,4 @@ +Rails.application.config.to_prepare do + MissionControl::Jobs.base_controller_class = "AdminController" + MissionControl::Jobs.http_basic_auth_enabled = false +end diff --git a/config/routes.rb b/config/routes.rb index fddc97b..1f98b2d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -13,9 +13,11 @@ # Admin area namespace :admin do + root "dashboard#show" resources :users do post :sync, on: :collection end + resources :schedule_items end # Background jobs dashboard (admin-only mount inside /admin/) @@ -26,4 +28,7 @@ get "schedule", to: "schedule#index" get "plan", to: "plan#index" + + resources :plan_items, only: %i[create update destroy] + resources :schedule_items, only: %i[new create edit update] end diff --git a/config/schedule.yml b/config/schedule.yml index b789987..1717342 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -1,8 +1,6 @@ --- # Blue Ridge Ruby 2025 conference schedule. -# Item types: talk, social, logistics, activity, lightning -# `added: true` is a design-only flag that marks items as "already in the user's plan" -# so the static mockup can show both default and added button states without JS. +# Item kinds: talk, lightning, embassy, activity # `flexible: true` renders the item with a dashed/TBD treatment (Saturday activities). :days: @@ -15,9 +13,8 @@ :time: "7:00 PM" :sort_time: 1900 :title: "Pre-Conference Meetup" - :type: social + :type: activity :location: "Wicked Weed Brew Pub" - :added: true - :anchor: thu :date: "April 30" @@ -28,37 +25,35 @@ :time: "8:00 AM" :sort_time: 800 :title: "Registration & Coffee" - :type: logistics + :type: embassy - :id: thu-welcome :time: "9:00 AM" :sort_time: 900 :title: "Welcome" - :type: logistics + :type: embassy - :id: thu-talk-1 :time: "9:30 AM" :sort_time: 930 - :title: "John Athayde" - :talk_title: "Learning from Permaculture: Sustainable Software Development" + :host: "John Athayde" + :title: "Learning from Permaculture: Sustainable Software Development" :type: talk - :added: true - :id: thu-talk-2 :time: "10:30 AM" :sort_time: 1030 - :title: "Joël Quenneville" - :talk_title: "State is the First Decision You Never Made" + :host: "Joël Quenneville" + :title: "State is the First Decision You Never Made" :type: talk - :added: true - :id: thu-talk-3 :time: "11:30 AM" :sort_time: 1130 - :title: "Ifat Ribon" - :talk_title: "Yes, &…: Ruby's Secret Talent for Improvisation" + :host: "Ifat Ribon" + :title: "Yes, &…: Ruby's Secret Talent for Improvisation" :type: talk - :id: thu-lunch :time: "12:00 PM" :sort_time: 1200 :title: "Open Lunch" - :type: social + :type: activity - :id: thu-mystery :time: "2:00 PM" :sort_time: 1400 @@ -67,20 +62,20 @@ - :id: thu-talk-4 :time: "3:00 PM" :sort_time: 1500 - :title: "Annie Kiley" - :talk_title: "How To Finish What You Start: Lessons From Actually Shipping a Big Refactor" + :host: "Annie Kiley" + :title: "How To Finish What You Start: Lessons From Actually Shipping a Big Refactor" :type: talk - :id: thu-talk-5 :time: "4:00 PM" :sort_time: 1600 - :title: "Kevin Murphy" - :talk_title: "InstiLLment of Successful Practices in an Agentic World" + :host: "Kevin Murphy" + :title: "InstiLLment of Successful Practices in an Agentic World" :type: talk - :id: thu-dinner :time: "Evening" :sort_time: 1800 :title: "Open Dinner & Roundtable" - :type: social + :type: activity - :anchor: fri :date: "May 1" @@ -91,51 +86,48 @@ :time: "8:00 AM" :sort_time: 800 :title: "Coffee" - :type: logistics + :type: embassy - :id: fri-talk-1 :time: "9:30 AM" :sort_time: 930 - :title: "Brooke Kuhlmann" - :talk_title: "Terminus: A Hanami + htmx web application for e-ink devices" + :host: "Brooke Kuhlmann" + :title: "Terminus: A Hanami + htmx web application for e-ink devices" :type: talk - :added: true - :id: fri-talk-2 :time: "10:30 AM" :sort_time: 1030 - :title: "Rachael Wright-Munn" - :talk_title: "Your First Open-Source Contribution" + :host: "Rachael Wright-Munn" + :title: "Your First Open-Source Contribution" :type: talk - :id: fri-talk-3 :time: "11:30 AM" :sort_time: 1130 - :title: "David Paluy" - :talk_title: "LLM Telemetry as a First-Class Rails Concern" + :host: "David Paluy" + :title: "LLM Telemetry as a First-Class Rails Concern" :type: talk - :id: fri-lightning :time: "2:00 PM" :sort_time: 1400 :title: "Lightning Talks" :type: lightning - :added: true - :id: fri-talk-4 :time: "3:30 PM" :sort_time: 1530 - :title: "Christine Seeman" - :talk_title: "Optimize Your Mindset (Without Overclocking)" + :host: "Christine Seeman" + :title: "Optimize Your Mindset (Without Overclocking)" :type: talk - :id: fri-talk-5 :time: "4:30 PM" :sort_time: 1630 - :title: "Thomas Cannon" - :talk_title: "5 ways to invest in yourself for the long haul" + :host: "Thomas Cannon" + :title: "5 ways to invest in yourself for the long haul" :type: talk - :id: fri-afterparty :time: "Evening" :sort_time: 1900 :title: "Afterparty" - :type: social + :type: activity :location: "Burial Beer Co. South Slope" - :added: true - :anchor: sat :date: "May 2" @@ -152,7 +144,7 @@ :time: "Morning Block" :sort_time: 1000 :title: "Ruby Embassy Appointments" - :type: activity + :type: embassy :flexible: true - :id: sat-afternoon :time: "Afternoon" @@ -164,11 +156,11 @@ :time: "Afternoon Block" :sort_time: 1400 :title: "Ruby Embassy Appointments" - :type: activity + :type: embassy :flexible: true - :id: sat-evening :time: "Evening" :sort_time: 1800 :title: "Evening Activities" - :type: social + :type: activity :flexible: true diff --git a/db/migrate/20260423145132_create_schedule_items.rb b/db/migrate/20260423145132_create_schedule_items.rb new file mode 100644 index 0000000..98b3536 --- /dev/null +++ b/db/migrate/20260423145132_create_schedule_items.rb @@ -0,0 +1,24 @@ +class CreateScheduleItems < ActiveRecord::Migration[8.1] + def change + create_table :schedule_items do |t| + t.string :slug + t.string :day, null: false + t.string :time_label + t.integer :sort_time + t.string :title, null: false + t.string :host + t.string :location + t.text :description + t.integer :kind, null: false + t.boolean :flexible, null: false, default: false + t.references :created_by, foreign_key: { to_table: :users }, null: true + t.boolean :is_public, null: false, default: false + + t.timestamps + end + + add_index :schedule_items, :slug, unique: true, where: "slug IS NOT NULL" + add_index :schedule_items, [ :day, :sort_time ] + add_index :schedule_items, :is_public + end +end diff --git a/db/migrate/20260423145241_create_plan_items.rb b/db/migrate/20260423145241_create_plan_items.rb new file mode 100644 index 0000000..273ecc5 --- /dev/null +++ b/db/migrate/20260423145241_create_plan_items.rb @@ -0,0 +1,13 @@ +class CreatePlanItems < ActiveRecord::Migration[8.1] + def change + create_table :plan_items do |t| + t.references :user, null: false, foreign_key: true + t.references :schedule_item, null: false, foreign_key: true + t.text :notes + + t.timestamps + end + + add_index :plan_items, [ :user_id, :schedule_item_id ], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index a910623..900d27a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,10 +10,42 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_04_22_215023) do +ActiveRecord::Schema[8.1].define(version: 2026_04_23_145241) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" + create_table "plan_items", force: :cascade do |t| + t.datetime "created_at", null: false + t.text "notes" + t.bigint "schedule_item_id", null: false + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["schedule_item_id"], name: "index_plan_items_on_schedule_item_id" + t.index ["user_id", "schedule_item_id"], name: "index_plan_items_on_user_id_and_schedule_item_id", unique: true + t.index ["user_id"], name: "index_plan_items_on_user_id" + end + + create_table "schedule_items", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "created_by_id" + t.string "day", null: false + t.text "description" + t.boolean "flexible", default: false, null: false + t.string "host" + t.boolean "is_public", default: false, null: false + t.integer "kind", null: false + t.string "location" + t.string "slug" + t.integer "sort_time" + t.string "time_label" + t.string "title", null: false + t.datetime "updated_at", null: false + 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 ["slug"], name: "index_schedule_items_on_slug", unique: true, where: "(slug IS NOT NULL)" + end + create_table "users", force: :cascade do |t| t.datetime "created_at", null: false t.string "email", null: false @@ -27,4 +59,8 @@ t.index ["email"], name: "index_users_on_email", unique: true t.index ["tito_ticket_slug"], name: "index_users_on_tito_ticket_slug", unique: true end + + add_foreign_key "plan_items", "schedule_items" + add_foreign_key "plan_items", "users" + add_foreign_key "schedule_items", "users", column: "created_by_id" end diff --git a/db/seeds.rb b/db/seeds.rb index 082dc42..8e32d7b 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,13 +1,43 @@ -# Seeds are idempotent - safe to run repeatedly. +# Seeds are idempotent — safe to run repeatedly, including in production. +# 1. Admin users. find_or_initialize_by + explicit save! ensures an existing +# row gets updated to role: :admin (e.g., if Jeremy already exists in prod +# but with a different role, this corrects it). admin_users = [ - { email: "jeremy@blueridgeruby.com", first_name: "Jeremy", last_name: "Smith" } + { email: "jeremy@blueridgeruby.com", first_name: "Jeremy", last_name: "Smith" }, + { email: "katyasarmientodev@gmail.com", first_name: "Katya", last_name: "Sarmiento" } ] admin_users.each do |attrs| - User.find_or_create_by!(email: attrs[:email]) do |u| - u.first_name = attrs[:first_name] - u.last_name = attrs[:last_name] - u.role = :admin + user = User.find_or_initialize_by(email: attrs[:email]) + user.first_name ||= attrs[:first_name] + user.last_name ||= attrs[:last_name] + user.role = :admin + 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 diff --git a/test/controllers/admin/dashboard_controller_test.rb b/test/controllers/admin/dashboard_controller_test.rb new file mode 100644 index 0000000..486c4af --- /dev/null +++ b/test/controllers/admin/dashboard_controller_test.rb @@ -0,0 +1,40 @@ +require "test_helper" + +class Admin::DashboardControllerTest < ActionDispatch::IntegrationTest + test "anonymous GET /admin returns 404" do + get admin_root_path + assert_response :not_found + end + + test "attendee GET /admin returns 404" do + sign_in_as users(:attendee_one) + get admin_root_path + assert_response :not_found + end + + test "volunteer GET /admin returns 404" do + sign_in_as users(:volunteer_one) + get admin_root_path + assert_response :not_found + end + + test "admin GET /admin returns 200 and shows section links" do + sign_in_as users(:jeremy) + get admin_root_path + assert_response :success + assert_select "a[href=?]", admin_users_path + assert_select "a[href=?]", admin_schedule_items_path + assert_select "a[href=?]", "/admin/jobs" + end + + test "dashboard shows counts" do + # Seed a handful of items so counts are non-zero + ScheduleItem.create!(day: "thu", title: "Count test", kind: :activity, is_public: true) + + sign_in_as users(:jeremy) + get admin_root_path + + assert_match(/#{User.count}/, response.body) + assert_match(/#{ScheduleItem.count}/, response.body) + end +end diff --git a/test/controllers/admin/schedule_items_controller_test.rb b/test/controllers/admin/schedule_items_controller_test.rb new file mode 100644 index 0000000..4c21345 --- /dev/null +++ b/test/controllers/admin/schedule_items_controller_test.rb @@ -0,0 +1,82 @@ +require "test_helper" + +class Admin::ScheduleItemsControllerTest < ActionDispatch::IntegrationTest + def valid_form_params(overrides = {}) + { + schedule_item: { + day: "fri", + time_label: "9:00 AM", + sort_time: 900, + title: "Admin-created Talk", + host: "Someone", + kind: "talk", + flexible: false, + is_public: true + }.merge(overrides) + } + end + + test "attendee GET /admin/schedule_items returns 404" do + sign_in_as users(:attendee_one) + get admin_schedule_items_path + assert_response :not_found + end + + test "volunteer GET /admin/schedule_items returns 404" do + sign_in_as users(:volunteer_one) + get admin_schedule_items_path + assert_response :not_found + end + + test "admin GET /admin/schedule_items returns 200" do + sign_in_as users(:jeremy) + get admin_schedule_items_path + assert_response :success + end + + test "admin can create an item of kind: talk" do + sign_in_as users(:jeremy) + assert_difference -> { ScheduleItem.count }, 1 do + post admin_schedule_items_path, params: valid_form_params + end + assert_equal "talk", ScheduleItem.last.kind + end + + test "admin can create an item of kind: embassy" do + sign_in_as users(:jeremy) + post admin_schedule_items_path, params: valid_form_params(kind: "embassy", title: "Registration") + assert_equal "embassy", ScheduleItem.find_by(title: "Registration").kind + end + + test "admin can edit any item's kind" do + item = ScheduleItem.create!(day: "thu", title: "Original", kind: :activity, is_public: true) + sign_in_as users(:jeremy) + + patch admin_schedule_item_path(item), params: valid_form_params(kind: "talk", title: "Changed") + item.reload + assert_equal "talk", item.kind + assert_equal "Changed", item.title + end + + test "admin can delete items (including user-created ones)" do + user_item = users(:attendee_one).created_schedule_items.create!( + day: "sat", title: "Attendee's activity", kind: :activity, is_public: true + ) + sign_in_as users(:jeremy) + + assert_difference -> { ScheduleItem.count }, -1 do + delete admin_schedule_item_path(user_item) + end + end + + test "delete cascades associated plan_items" do + item = ScheduleItem.create!(day: "thu", title: "Cascade test", kind: :activity, is_public: true) + users(:attendee_one).plan_items.create!(schedule_item: item) + users(:volunteer_one).plan_items.create!(schedule_item: item) + + sign_in_as users(:jeremy) + assert_difference -> { PlanItem.count }, -2 do + delete admin_schedule_item_path(item) + end + end +end diff --git a/test/controllers/auth_gating_test.rb b/test/controllers/auth_gating_test.rb new file mode 100644 index 0000000..e9f24df --- /dev/null +++ b/test/controllers/auth_gating_test.rb @@ -0,0 +1,80 @@ +require "test_helper" + +class AuthGatingTest < ActionDispatch::IntegrationTest + test "anonymous GET /schedule redirects to sign-in" do + get schedule_path + assert_redirected_to new_session_path + end + + test "anonymous GET /plan redirects to sign-in" do + get plan_path + assert_redirected_to new_session_path + end + + test "anonymous GET /dashboard redirects to sign-in" do + get dashboard_path + assert_redirected_to new_session_path + end + + test "anonymous GET / redirects to sign-in" do + get root_path + assert_redirected_to new_session_path + end + + test "anonymous GET /admin/users returns 404" do + get admin_users_path + assert_response :not_found + end + + test "attendee GET /admin/users returns 404" do + sign_in_as users(:attendee_one) + get admin_users_path + assert_response :not_found + end + + test "volunteer GET /admin/users returns 404" do + sign_in_as users(:volunteer_one) + get admin_users_path + assert_response :not_found + end + + test "admin GET /admin/users returns 200" do + sign_in_as users(:jeremy) + get admin_users_path + assert_response :success + end + + test "signed-in attendee GET / redirects to /schedule" do + sign_in_as users(:attendee_one) + get root_path + assert_redirected_to schedule_path + end + + test "signed-in attendee can view /schedule" do + sign_in_as users(:attendee_one) + get schedule_path + assert_response :success + end + + test "sign-in page is reachable without authentication" do + get new_session_path + assert_response :success + end + + test "anonymous GET /admin/jobs returns 404" do + get "/admin/jobs" + assert_response :not_found + end + + test "attendee GET /admin/jobs returns 404" do + sign_in_as users(:attendee_one) + get "/admin/jobs" + assert_response :not_found + end + + test "admin GET /admin/jobs is reachable (200 or redirect)" do + sign_in_as users(:jeremy) + get "/admin/jobs" + assert_includes [ 200, 302 ], response.status + end +end diff --git a/test/controllers/plan_items_controller_test.rb b/test/controllers/plan_items_controller_test.rb new file mode 100644 index 0000000..a25e0bc --- /dev/null +++ b/test/controllers/plan_items_controller_test.rb @@ -0,0 +1,68 @@ +require "test_helper" + +class PlanItemsControllerTest < ActionDispatch::IntegrationTest + setup do + @item = ScheduleItem.create!( + slug: "test-talk", + day: "thu", + title: "Test Talk", + kind: :talk, + is_public: true + ) + end + + test "anonymous POST /plan_items redirects to sign-in" do + post plan_items_path, params: { schedule_item_id: @item.id } + assert_redirected_to new_session_path + end + + test "signed-in attendee POST creates a plan_item" do + sign_in_as users(:attendee_one) + assert_difference -> { users(:attendee_one).plan_items.count }, 1 do + post plan_items_path, params: { schedule_item_id: @item.id } + end + end + + test "POST with duplicate schedule_item does not double-create" do + sign_in_as users(:attendee_one) + post plan_items_path, params: { schedule_item_id: @item.id } + assert_no_difference -> { users(:attendee_one).plan_items.count } do + post plan_items_path, params: { schedule_item_id: @item.id } + end + end + + test "DELETE removes caller's own plan_item" do + sign_in_as users(:attendee_one) + plan = users(:attendee_one).plan_items.create!(schedule_item: @item) + assert_difference -> { PlanItem.count }, -1 do + delete plan_item_path(plan) + end + end + + test "DELETE on another user's plan_item returns 404" do + sign_in_as users(:attendee_one) + other_plan = users(:volunteer_one).plan_items.create!(schedule_item: @item) + + assert_no_difference -> { PlanItem.count } do + delete plan_item_path(other_plan) + end + assert_response :not_found + end + + test "PATCH updates notes on own plan_item" do + sign_in_as users(:attendee_one) + plan = users(:attendee_one).plan_items.create!(schedule_item: @item) + + patch plan_item_path(plan), params: { plan_item: { notes: "Sit near the front" } } + assert_equal "Sit near the front", plan.reload.notes + end + + test "PATCH on another user's plan_item returns 404" do + sign_in_as users(:attendee_one) + other_plan = users(:volunteer_one).plan_items.create!(schedule_item: @item) + + patch plan_item_path(other_plan), params: { plan_item: { notes: "injected" } } + assert_response :not_found + assert_not_equal "injected", other_plan.reload.notes + end +end diff --git a/test/controllers/schedule_items_controller_test.rb b/test/controllers/schedule_items_controller_test.rb new file mode 100644 index 0000000..32624ea --- /dev/null +++ b/test/controllers/schedule_items_controller_test.rb @@ -0,0 +1,112 @@ +require "test_helper" + +class ScheduleItemsControllerTest < ActionDispatch::IntegrationTest + def valid_form_params(overrides = {}) + { + schedule_item: { + day: "sat", + time_label: "6:00 PM", + sort_time: 1800, + title: "Dinner with crew", + location: "Tupelo Honey", + description: "Bring appetite", + flexible: false, + is_public: false + }.merge(overrides) + } + end + + test "anonymous GET /schedule_items/new redirects to sign-in" do + get new_schedule_item_path + assert_redirected_to new_session_path + end + + test "attendee can create a private item" do + sign_in_as users(:attendee_one) + assert_difference -> { ScheduleItem.count }, 1 do + post schedule_items_path, params: valid_form_params + end + item = ScheduleItem.last + assert_equal "activity", item.kind + assert_equal false, item.is_public + assert_equal users(:attendee_one), item.created_by + end + + test "creating a private item auto-plans it for creator" do + sign_in_as users(:attendee_one) + assert_difference -> { PlanItem.count }, 1 do + post schedule_items_path, params: valid_form_params(is_public: false) + end + end + + test "creating a public item does not auto-plan" do + sign_in_as users(:attendee_one) + assert_no_difference -> { PlanItem.count } do + post schedule_items_path, params: valid_form_params(is_public: true) + end + end + + test "attendee cannot set kind to talk via form tampering" do + sign_in_as users(:attendee_one) + post schedule_items_path, params: valid_form_params(kind: "talk") + assert_equal "activity", ScheduleItem.last.kind + end + + test "attendee cannot set kind to embassy or lightning" do + sign_in_as users(:attendee_one) + + post schedule_items_path, params: valid_form_params(kind: "embassy", title: "Sneaky") + assert_equal "activity", ScheduleItem.find_by(title: "Sneaky").kind + + post schedule_items_path, params: valid_form_params(kind: "lightning", title: "Sneakier") + assert_equal "activity", ScheduleItem.find_by(title: "Sneakier").kind + end + + test "attendee can update their own item but kind stays activity" do + sign_in_as users(:attendee_one) + item = users(:attendee_one).created_schedule_items.create!( + day: "thu", title: "Mine", kind: :activity, is_public: true + ) + + patch schedule_item_path(item), params: valid_form_params(title: "Updated", kind: "talk") + item.reload + assert_equal "Updated", item.title + assert_equal "activity", item.kind + end + + test "attendee gets 404 editing another user's item" do + other_item = users(:volunteer_one).created_schedule_items.create!( + day: "thu", title: "Not yours", kind: :activity, is_public: true + ) + + sign_in_as users(:attendee_one) + get edit_schedule_item_path(other_item) + assert_response :not_found + + patch schedule_item_path(other_item), params: valid_form_params(title: "Hack") + assert_response :not_found + assert_equal "Not yours", other_item.reload.title + end + + test "DELETE route does not exist" do + sign_in_as users(:attendee_one) + item = users(:attendee_one).created_schedule_items.create!( + day: "thu", title: "Mine", kind: :activity, is_public: true + ) + + delete "/schedule_items/#{item.id}" + # Route isn't defined; Rails returns 404 Not Found (no DELETE route matches). + assert_equal 404, response.status + assert ScheduleItem.exists?(item.id), "item should not be destroyed" + end + + test "created_by is forced to current_user, ignoring any param" do + sign_in_as users(:attendee_one) + post schedule_items_path, params: valid_form_params( + title: "Impersonation attempt", + created_by_id: users(:volunteer_one).id + ) + item = ScheduleItem.find_by(title: "Impersonation attempt") + assert_equal users(:attendee_one), item.created_by + end +end diff --git a/test/fixtures/plan_items.yml b/test/fixtures/plan_items.yml new file mode 100644 index 0000000..a655afe --- /dev/null +++ b/test/fixtures/plan_items.yml @@ -0,0 +1 @@ +# PlanItem fixtures. Keep minimal; tests build via associations inline. diff --git a/test/fixtures/schedule_items.yml b/test/fixtures/schedule_items.yml new file mode 100644 index 0000000..61b598e --- /dev/null +++ b/test/fixtures/schedule_items.yml @@ -0,0 +1 @@ +# ScheduleItem fixtures. Keep minimal; most tests build items inline via valid_attrs. diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml new file mode 100644 index 0000000..f363076 --- /dev/null +++ b/test/fixtures/users.yml @@ -0,0 +1,23 @@ +attendee_one: + email: alice@example.com + first_name: Alice + last_name: Attendee + role: 0 + +volunteer_one: + email: vic@example.com + first_name: Vic + last_name: Volunteer + role: 1 + +jeremy: + email: jeremy@blueridgeruby.com + first_name: Jeremy + last_name: Smith + role: 2 + +katya: + email: katyasarmientodev@gmail.com + first_name: Katya + last_name: Sarmiento + role: 2 diff --git a/test/integration/plan_test.rb b/test/integration/plan_test.rb new file mode 100644 index 0000000..129049a --- /dev/null +++ b/test/integration/plan_test.rb @@ -0,0 +1,81 @@ +require "test_helper" + +class PlanTest < ActionDispatch::IntegrationTest + setup do + @talk = ScheduleItem.create!( + slug: "thu-plan-talk", + day: "thu", + time_label: "10:00 AM", + sort_time: 1000, + title: "Planned Talk", + host: "Jane", + kind: :talk, + is_public: true + ) + @activity = ScheduleItem.create!( + slug: "sat-plan-act", + day: "sat", + time_label: "2:00 PM", + sort_time: 1400, + title: "Planned Activity", + kind: :activity, + is_public: true + ) + end + + test "anonymous GET /plan redirects to sign-in" do + get plan_path + assert_redirected_to new_session_path + end + + test "/plan shows only current_user's plan_items" do + alice = users(:attendee_one) + vic = users(:volunteer_one) + alice.plan_items.create!(schedule_item: @talk) + vic.plan_items.create!(schedule_item: @activity, notes: "Vic's secret note") + + sign_in_as alice + get plan_path + + assert_response :success + assert_match "Planned Talk", response.body + assert_no_match "Planned Activity", response.body + assert_no_match "Vic's secret note", response.body + end + + test "/plan orders items by day then sort_time" do + alice = users(:attendee_one) + alice.plan_items.create!(schedule_item: @activity) # Saturday + alice.plan_items.create!(schedule_item: @talk) # Thursday + + sign_in_as alice + get plan_path + + thu_idx = response.body.index("Planned Talk") + sat_idx = response.body.index("Planned Activity") + assert thu_idx && sat_idx + assert thu_idx < sat_idx, "Thursday item should appear before Saturday item" + end + + test "/plan shows notes for plan_items that have them" do + alice = users(:attendee_one) + alice.plan_items.create!(schedule_item: @talk, notes: "Sit near the front") + + sign_in_as alice + get plan_path + + assert_match "Sit near the front", response.body + end + + test "/plan shows remove button for each plan_item" do + alice = users(:attendee_one) + plan = alice.plan_items.create!(schedule_item: @talk) + + sign_in_as alice + get plan_path + + assert_select "form[action=?][method=?]", plan_item_path(plan), "post" do + assert_select "input[name=_method][value=delete]" + end + end +end diff --git a/test/integration/schedule_browsing_test.rb b/test/integration/schedule_browsing_test.rb new file mode 100644 index 0000000..373aa5d --- /dev/null +++ b/test/integration/schedule_browsing_test.rb @@ -0,0 +1,90 @@ +require "test_helper" + +class ScheduleBrowsingTest < ActionDispatch::IntegrationTest + setup do + @talk = ScheduleItem.create!( + slug: "test-talk", + day: "thu", + time_label: "10:00 AM", + sort_time: 1000, + title: "A Talk About Tests", + host: "Jane Speaker", + kind: :talk, + is_public: true + ) + @activity = ScheduleItem.create!( + slug: "test-activity", + day: "sat", + time_label: "TBD", + sort_time: 1000, + title: "Group Bike Ride", + kind: :activity, + is_public: true, + flexible: true + ) + @embassy = ScheduleItem.create!( + slug: "test-embassy", + day: "thu", + time_label: "9:00 AM", + sort_time: 900, + title: "Welcome", + kind: :embassy, + is_public: true + ) + @private_item = ScheduleItem.create!( + slug: "test-private", + day: "thu", + time_label: "7:00 PM", + sort_time: 1900, + title: "Secret Dinner", + kind: :activity, + is_public: false, + created_by: users(:attendee_one) + ) + end + + test "signed-in attendee sees public items on /schedule" do + sign_in_as users(:attendee_one) + get schedule_path + + assert_response :success + assert_select "h1", text: /Schedule/i + assert_match "A Talk About Tests", response.body + assert_match "Jane Speaker", response.body + assert_match "Group Bike Ride", response.body + assert_match "Welcome", response.body + end + + test "private items do not appear on /schedule even for creator" do + sign_in_as users(:attendee_one) + get schedule_path + + assert_no_match "Secret Dinner", response.body + end + + test "talks show 'Add to plan' button label" do + sign_in_as users(:attendee_one) + get schedule_path + + assert_match(/Add to plan/i, response.body) + end + + test "non-talks show 'RSVP' button label" do + sign_in_as users(:attendee_one) + get schedule_path + + assert_match(/RSVP/i, response.body) + end + + test "days are rendered in conference order (wed, thu, fri, sat)" do + sign_in_as users(:attendee_one) + get schedule_path + + body = response.body + thu_index = body.index("Thursday") + sat_index = body.index("Saturday") + assert thu_index, "Thursday header should render" + assert sat_index, "Saturday header should render" + assert thu_index < sat_index, "Thursday should render before Saturday" + end +end diff --git a/test/models/plan_item_test.rb b/test/models/plan_item_test.rb new file mode 100644 index 0000000..1f122c0 --- /dev/null +++ b/test/models/plan_item_test.rb @@ -0,0 +1,35 @@ +require "test_helper" + +class PlanItemTest < ActiveSupport::TestCase + def build_schedule_item + ScheduleItem.create!(day: "thu", title: "Some Item", kind: :activity, is_public: true) + end + + test "belongs_to user and schedule_item" do + item = build_schedule_item + plan = PlanItem.create!(user: users(:attendee_one), schedule_item: item) + assert_equal users(:attendee_one), plan.user + assert_equal item, plan.schedule_item + end + + test "a user cannot have two plan_items for the same schedule_item" do + item = build_schedule_item + PlanItem.create!(user: users(:attendee_one), schedule_item: item) + duplicate = PlanItem.new(user: users(:attendee_one), schedule_item: item) + assert_not duplicate.valid? + assert_includes duplicate.errors[:user_id], "already has this item on their plan" + end + + test "different users can both have the same schedule_item on their plans" do + item = build_schedule_item + PlanItem.create!(user: users(:attendee_one), schedule_item: item) + other = PlanItem.new(user: users(:volunteer_one), schedule_item: item) + assert other.valid? + end + + test "notes are optional" do + item = build_schedule_item + plan = PlanItem.new(user: users(:attendee_one), schedule_item: item) + assert plan.valid? + end +end diff --git a/test/models/schedule_item_test.rb b/test/models/schedule_item_test.rb new file mode 100644 index 0000000..d751ff8 --- /dev/null +++ b/test/models/schedule_item_test.rb @@ -0,0 +1,124 @@ +require "test_helper" + +class ScheduleItemTest < ActiveSupport::TestCase + def valid_attrs(overrides = {}) + { + day: "thu", + title: "Test Item", + kind: :activity, + is_public: true + }.merge(overrides) + end + + test "requires title" do + item = ScheduleItem.new(valid_attrs(title: nil)) + assert_not item.valid? + assert_includes item.errors[:title], "can't be blank" + end + + test "requires day" do + item = ScheduleItem.new(valid_attrs(day: nil)) + assert_not item.valid? + assert_includes item.errors[:day], "can't be blank" + end + + test "requires kind" do + item = ScheduleItem.new(valid_attrs.except(:kind)) + assert_not item.valid? + 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 + assert_equal 0, ScheduleItem.kinds["talk"] + assert_equal 1, ScheduleItem.kinds["lightning"] + assert_equal 2, ScheduleItem.kinds["embassy"] + assert_equal 3, ScheduleItem.kinds["activity"] + end + + test "is_public defaults to false" do + item = ScheduleItem.new(valid_attrs.except(:is_public)) + assert_equal false, item.is_public + end + + test "flexible defaults to false" do + item = ScheduleItem.new(valid_attrs) + assert_equal false, item.flexible + end + + test "talk? returns true only for talk kind" do + assert ScheduleItem.new(valid_attrs(kind: :talk)).talk? + assert_not ScheduleItem.new(valid_attrs(kind: :activity)).talk? + assert_not ScheduleItem.new(valid_attrs(kind: :lightning)).talk? + assert_not ScheduleItem.new(valid_attrs(kind: :embassy)).talk? + end + + test "rsvp_count counts plan_items" do + item = ScheduleItem.create!(valid_attrs) + assert_equal 0, item.rsvp_count + item.plan_items.create!(user: users(:attendee_one)) + item.plan_items.create!(user: users(:volunteer_one)) + assert_equal 2, item.reload.rsvp_count + end + + test "editable_by? admin can edit any item" do + item = ScheduleItem.create!(valid_attrs(created_by: users(:attendee_one))) + assert item.editable_by?(users(:jeremy)) + end + + test "editable_by? creator can edit own item" do + item = ScheduleItem.create!(valid_attrs(created_by: users(:attendee_one))) + assert item.editable_by?(users(:attendee_one)) + end + + test "editable_by? non-creator non-admin cannot edit" do + item = ScheduleItem.create!(valid_attrs(created_by: users(:attendee_one))) + assert_not item.editable_by?(users(:volunteer_one)) + end + + test "public_items scope returns only items with is_public: true" do + public_item = ScheduleItem.create!(valid_attrs(title: "Public", is_public: true)) + private_item = ScheduleItem.create!(valid_attrs(title: "Private", is_public: false)) + assert_includes ScheduleItem.public_items, public_item + assert_not_includes ScheduleItem.public_items, private_item + end + + test "ordered scope orders by day then sort_time" do + wed_am = ScheduleItem.create!(valid_attrs(day: "wed", sort_time: 900, title: "Wed AM")) + wed_pm = ScheduleItem.create!(valid_attrs(day: "wed", sort_time: 1800, title: "Wed PM")) + thu_am = ScheduleItem.create!(valid_attrs(day: "thu", sort_time: 900, title: "Thu AM")) + ordered = ScheduleItem.where(id: [ wed_am.id, wed_pm.id, thu_am.id ]).ordered + assert_equal [ wed_am, wed_pm, thu_am ], ordered.to_a + end + + test "after_create auto-plans private items for creator" do + assert_difference -> { PlanItem.count }, 1 do + ScheduleItem.create!(valid_attrs( + title: "Private Dinner", + is_public: false, + created_by: users(:attendee_one) + )) + end + assert_equal users(:attendee_one), PlanItem.last.user + end + + test "after_create does not auto-plan public items" do + assert_no_difference -> { PlanItem.count } do + ScheduleItem.create!(valid_attrs( + title: "Public Activity", + is_public: true, + created_by: users(:attendee_one) + )) + end + end + + test "after_create does not auto-plan seeded items (no creator)" do + assert_no_difference -> { PlanItem.count } do + ScheduleItem.create!(valid_attrs( + title: "Seeded Private-Like", + is_public: false, + created_by: nil + )) + end + end +end diff --git a/test/models/seed_test.rb b/test/models/seed_test.rb new file mode 100644 index 0000000..c6c42f0 --- /dev/null +++ b/test/models/seed_test.rb @@ -0,0 +1,65 @@ +require "test_helper" + +class SeedTest < ActiveSupport::TestCase + self.use_transactional_tests = false + + setup do + User.where(email: [ "jeremy@blueridgeruby.com", "katyasarmientodev@gmail.com" ]).destroy_all + ScheduleItem.where.not(id: nil).destroy_all + end + + teardown do + User.where(email: [ "jeremy@blueridgeruby.com", "katyasarmientodev@gmail.com" ]).destroy_all + ScheduleItem.where.not(id: nil).destroy_all + end + + test "seed makes jeremy and katya admins, idempotently" do + 2.times { Rails.application.load_seed } + + assert User.find_by(email: "jeremy@blueridgeruby.com").admin? + assert User.find_by(email: "katyasarmientodev@gmail.com").admin? + end + + test "seed upserts every YAML row as a public ScheduleItem" do + Rails.application.load_seed + + yaml_count = YAML.load_file(Rails.root.join("config/schedule.yml"), permitted_classes: [ Symbol ])[:days] + .sum { |d| d[:items].size } + assert_equal yaml_count, ScheduleItem.count + assert_equal yaml_count, ScheduleItem.public_items.count + end + + test "seed is idempotent for schedule_items" do + Rails.application.load_seed + count_after_first = ScheduleItem.count + Rails.application.load_seed + assert_equal count_after_first, ScheduleItem.count + end + + test "seeded talks carry title (topic) and host (speaker)" do + Rails.application.load_seed + + talk = ScheduleItem.find_by(slug: "thu-talk-1") + assert_equal "John Athayde", talk.host + assert talk.title.start_with?("Learning from Permaculture"), "talk title should be the topic, not the speaker" + assert talk.talk? + end + + test "seeded logistics (registration/welcome/coffee) are kind: embassy" do + Rails.application.load_seed + + %w[thu-registration thu-welcome fri-coffee].each do |slug| + item = ScheduleItem.find_by(slug: slug) + assert_equal "embassy", item.kind, "#{slug} should be kind: embassy" + end + end + + test "seeded social items are kind: activity" do + Rails.application.load_seed + + %w[wed-meetup thu-lunch thu-dinner fri-afterparty sat-evening].each do |slug| + item = ScheduleItem.find_by(slug: slug) + assert_equal "activity", item.kind, "#{slug} should be kind: activity" + end + end +end diff --git a/test/models/user_test.rb b/test/models/user_test.rb new file mode 100644 index 0000000..9c031bf --- /dev/null +++ b/test/models/user_test.rb @@ -0,0 +1,37 @@ +require "test_helper" + +class UserTest < ActiveSupport::TestCase + test "destroying user destroys their plan_items" do + user = users(:attendee_one) + item = ScheduleItem.create!(day: "thu", title: "Destroy test", kind: :activity, is_public: true) + user.plan_items.create!(schedule_item: item) + + assert_difference -> { PlanItem.count }, -1 do + user.destroy + end + end + + test "destroying user nullifies created_schedule_items (items survive)" do + creator = users(:attendee_one) + item = ScheduleItem.create!( + day: "thu", + title: "Creator-owned", + kind: :activity, + is_public: true, + created_by: creator + ) + + creator.destroy + + assert ScheduleItem.exists?(item.id), "schedule_item should survive creator deletion" + assert_nil item.reload.created_by_id + end + + test "planned_schedule_items returns items on user's plan" do + user = users(:attendee_one) + item = ScheduleItem.create!(day: "fri", title: "Planned", kind: :talk, is_public: true) + user.plan_items.create!(schedule_item: item) + + assert_includes user.planned_schedule_items, item + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 0c22470..eb9468c 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -13,3 +13,13 @@ class TestCase # Add more helper methods to be used by all tests here... end end + +module SignInTestHelper + def sign_in_as(user) + post callback_session_url(token: user.generate_token_for(:login)) + end +end + +class ActionDispatch::IntegrationTest + include SignInTestHelper +end From 7bbf3a20632cc36cb5585e58e84eef9d5342f02f Mon Sep 17 00:00:00 2001 From: Katya Sarmiento <5871075+Kitkatnik@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:50:27 -0400 Subject: [PATCH 10/29] Fix /plan X button: remove plan_item row via Turbo Stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The destroy action only emitted a turbo_stream.replace targeting the schedule_item frame — which doesn't exist on /plan. Result: the DB row was destroyed, but the row stayed on the page and the X appeared dead. Emit two stream actions instead: remove the plan_item frame (makes /plan row disappear) and replace the schedule_item frame (resets /schedule button to "+ Add to plan" / "+ RSVP"). Turbo no-ops targets that don't exist in the current DOM, so one action works for both pages. Also drop the form turbo_frame: "_top" override on the X button so the form submits normally and accepts the turbo_stream response, instead of escaping to a full-page nav. --- app/controllers/plan_items_controller.rb | 22 ++++++++++++++----- app/views/plan/_plan_item.html.erb | 1 - .../controllers/plan_items_controller_test.rb | 13 +++++++++++ 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/app/controllers/plan_items_controller.rb b/app/controllers/plan_items_controller.rb index ac288c5..6af3943 100644 --- a/app/controllers/plan_items_controller.rb +++ b/app/controllers/plan_items_controller.rb @@ -32,16 +32,26 @@ def update end def destroy - schedule_item = @plan_item.schedule_item + schedule_item = @plan_item.schedule_item + plan_item_dom_id = helpers.dom_id(@plan_item) @plan_item.destroy 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: false } - ) + # Emit both actions. Turbo applies stream actions globally; targets + # that don't exist in the current DOM are silent no-ops. So this + # single response handles both pages: + # - on /plan, the plan_item frame is removed (row disappears) + # - on /schedule, the session_item frame is swapped back to the + # "+ Add to plan" / "+ RSVP" state with updated count + render turbo_stream: [ + turbo_stream.remove(plan_item_dom_id), + turbo_stream.replace( + helpers.dom_id(schedule_item), + partial: "schedule/session_item", + locals: { item: schedule_item, planned: false } + ) + ] } format.html { redirect_back fallback_location: plan_path } end diff --git a/app/views/plan/_plan_item.html.erb b/app/views/plan/_plan_item.html.erb index 9c62d23..5cc035c 100644 --- a/app/views/plan/_plan_item.html.erb +++ b/app/views/plan/_plan_item.html.erb @@ -38,7 +38,6 @@ plan_item_path(plan_item), method: :delete, class: "plan-item__remove", - form: { data: { turbo_frame: "_top" } }, aria: { label: "Remove from plan" } %> <% end %> diff --git a/test/controllers/plan_items_controller_test.rb b/test/controllers/plan_items_controller_test.rb index a25e0bc..42f4293 100644 --- a/test/controllers/plan_items_controller_test.rb +++ b/test/controllers/plan_items_controller_test.rb @@ -65,4 +65,17 @@ class PlanItemsControllerTest < ActionDispatch::IntegrationTest assert_response :not_found assert_not_equal "injected", other_plan.reload.notes end + + test "turbo_stream DELETE response removes plan_item frame AND updates schedule_item frame" do + sign_in_as users(:attendee_one) + plan = users(:attendee_one).plan_items.create!(schedule_item: @item) + + delete plan_item_path(plan), headers: { "Accept" => "text/vnd.turbo-stream.html" } + assert_response :success + + # /plan uses this to make the row disappear. + assert_match %r{}, response.body + # /schedule uses this to revert the button to "+ Add to plan" / "+ RSVP". + assert_match %r{}, response.body + end end From 023607bb7dba4aca2739f5896b74f5cdf33e67f2 Mon Sep 17 00:00:00 2001 From: Katya Sarmiento <5871075+Kitkatnik@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:44:18 -0400 Subject: [PATCH 11/29] Inline custom-block form via Turbo Frame; auto-plan public items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On /plan, "+ Add custom block" now expands into the day's section inline instead of navigating away. On submit, the new block appears in its day's list and the frame collapses back to the "+ Add" link — no page reload, no redirect. - plan/_day.html.erb: items container extracted to items_for_day partial with stable id="plan_items_"; the "+ Add custom block" link now lives in a turbo_frame_tag "new_schedule_item_". - schedule_items/new.html.erb: form wrapped in a matching frame so Turbo extracts just the form on a framed GET; direct visits still render the full page. - ScheduleItemsController#create: on turbo_stream, replaces the day's plan_items container (incorporating the new item + retiring the "Nothing planned yet" empty-state) AND resets the form frame back to the link. html path still redirects for non-Turbo clients. - ScheduleItem model: auto_plan_for_creator now fires for public items too, not just private — propose a group hike, you're obviously going. - PlanItem: added :for_day scope used by the controller to re-fetch the day's items with one sorted query. - Tests flipped accordingly; new turbo_stream assertions on create. --- app/controllers/schedule_items_controller.rb | 24 ++++++++++++++++++- app/models/plan_item.rb | 6 +++++ app/models/schedule_item.rb | 5 +++- app/views/plan/_day.html.erb | 12 ++-------- app/views/plan/_items_for_day.html.erb | 9 +++++++ app/views/schedule_items/_new_link.html.erb | 5 ++++ app/views/schedule_items/new.html.erb | 7 +++++- .../schedule_items_controller_test.rb | 15 ++++++++++-- test/models/schedule_item_test.rb | 9 +++---- 9 files changed, 73 insertions(+), 19 deletions(-) create mode 100644 app/views/plan/_items_for_day.html.erb create mode 100644 app/views/schedule_items/_new_link.html.erb diff --git a/app/controllers/schedule_items_controller.rb b/app/controllers/schedule_items_controller.rb index 94a13f4..c592c27 100644 --- a/app/controllers/schedule_items_controller.rb +++ b/app/controllers/schedule_items_controller.rb @@ -15,7 +15,29 @@ def create ) if @schedule_item.save - redirect_to plan_path, notice: "Item added to your plan." + day_key = @schedule_item.day + plan_items = current_user.plan_items.includes(:schedule_item).for_day(day_key) + + respond_to do |format| + format.turbo_stream { + render turbo_stream: [ + # Replace the day's plan_items container — the new custom block + # slots in; the "Nothing planned yet" placeholder disappears. + turbo_stream.replace( + "plan_items_#{day_key}", + partial: "plan/items_for_day", + locals: { day_key: day_key, plan_items: plan_items } + ), + # Collapse the form back to the "+ Add custom block" link. + turbo_stream.replace( + "new_schedule_item_#{day_key}", + partial: "schedule_items/new_link", + locals: { day_key: day_key } + ) + ] + } + format.html { redirect_to plan_path, notice: "Item added to your plan." } + end else render :new, status: :unprocessable_content end diff --git a/app/models/plan_item.rb b/app/models/plan_item.rb index b48dd34..e1df64e 100644 --- a/app/models/plan_item.rb +++ b/app/models/plan_item.rb @@ -6,4 +6,10 @@ class PlanItem < ApplicationRecord scope: :schedule_item_id, message: "already has this item on their plan" } + + scope :for_day, ->(day_key) { + joins(:schedule_item) + .where(schedule_items: { day: day_key }) + .order("schedule_items.sort_time") + } end diff --git a/app/models/schedule_item.rb b/app/models/schedule_item.rb index 90c475b..543f6f7 100644 --- a/app/models/schedule_item.rb +++ b/app/models/schedule_item.rb @@ -24,7 +24,10 @@ class ScheduleItem < ApplicationRecord ) } - after_create :auto_plan_for_creator, if: -> { !is_public? && created_by_id.present? } + # Creators always get auto-added to their own plan — whether the item is + # private (only they see it) or public (others can RSVP). The rationale: + # if you propose a group hike, you're obviously going to it. + after_create :auto_plan_for_creator, if: -> { created_by_id.present? } def rsvp_count plan_items.count diff --git a/app/views/plan/_day.html.erb b/app/views/plan/_day.html.erb index 1977565..e116e29 100644 --- a/app/views/plan/_day.html.erb +++ b/app/views/plan/_day.html.erb @@ -4,15 +4,7 @@ <%= meta[:date] %> - <% if plan_items.any? %> -
- <% plan_items.each do |plan_item| %> - <%= render "plan_item", plan_item: plan_item %> - <% end %> -
- <% else %> -

Nothing planned yet.

- <% end %> + <%= render "items_for_day", day_key: day_key, plan_items: plan_items %> - <%= link_to "+ Add custom block", new_schedule_item_path(day: day_key), class: "custom-block-btn" %> + <%= render "schedule_items/new_link", day_key: day_key %> diff --git a/app/views/plan/_items_for_day.html.erb b/app/views/plan/_items_for_day.html.erb new file mode 100644 index 0000000..a8bffe2 --- /dev/null +++ b/app/views/plan/_items_for_day.html.erb @@ -0,0 +1,9 @@ +
+ <% if plan_items.any? %> + <% plan_items.each do |plan_item| %> + <%= render "plan/plan_item", plan_item: plan_item %> + <% end %> + <% else %> +

Nothing planned yet.

+ <% end %> +
diff --git a/app/views/schedule_items/_new_link.html.erb b/app/views/schedule_items/_new_link.html.erb new file mode 100644 index 0000000..c00bd0c --- /dev/null +++ b/app/views/schedule_items/_new_link.html.erb @@ -0,0 +1,5 @@ +<%= turbo_frame_tag "new_schedule_item_#{day_key}" do %> + <%= link_to "+ Add custom block", + new_schedule_item_path(day: day_key), + class: "custom-block-btn" %> +<% end %> diff --git a/app/views/schedule_items/new.html.erb b/app/views/schedule_items/new.html.erb index 90aec6a..5aa4920 100644 --- a/app/views/schedule_items/new.html.erb +++ b/app/views/schedule_items/new.html.erb @@ -10,6 +10,11 @@ Add a dinner, hike, or meetup. Keep it private (just you) or share it with all attendees so they can RSVP.

- <%= render "form", schedule_item: @schedule_item %> + <%# Shared turbo_frame id with /plan's "+ Add custom block" link — + on /plan this fragment is extracted into that frame (form + expands inline). On direct visit, the surrounding page renders. %> + <%= turbo_frame_tag "new_schedule_item_#{@schedule_item.day}" do %> + <%= render "form", schedule_item: @schedule_item %> + <% end %> diff --git a/test/controllers/schedule_items_controller_test.rb b/test/controllers/schedule_items_controller_test.rb index 32624ea..7b17722 100644 --- a/test/controllers/schedule_items_controller_test.rb +++ b/test/controllers/schedule_items_controller_test.rb @@ -39,13 +39,24 @@ def valid_form_params(overrides = {}) end end - test "creating a public item does not auto-plan" do + test "creating a public item also auto-plans for creator" do sign_in_as users(:attendee_one) - assert_no_difference -> { PlanItem.count } do + assert_difference -> { PlanItem.count }, 1 do post schedule_items_path, params: valid_form_params(is_public: true) end end + test "Turbo Stream create appends item to day container and resets the form frame" do + sign_in_as users(:attendee_one) + post schedule_items_path, + params: valid_form_params(day: "sat", title: "Turbo Dinner", is_public: false), + headers: { "Accept" => "text/vnd.turbo-stream.html" } + assert_response :success + assert_match %r{}, response.body + assert_match %r{}, response.body + assert_match "Turbo Dinner", response.body + end + test "attendee cannot set kind to talk via form tampering" do sign_in_as users(:attendee_one) post schedule_items_path, params: valid_form_params(kind: "talk") diff --git a/test/models/schedule_item_test.rb b/test/models/schedule_item_test.rb index d751ff8..25455a1 100644 --- a/test/models/schedule_item_test.rb +++ b/test/models/schedule_item_test.rb @@ -102,21 +102,22 @@ def valid_attrs(overrides = {}) assert_equal users(:attendee_one), PlanItem.last.user end - test "after_create does not auto-plan public items" do - assert_no_difference -> { PlanItem.count } do + test "after_create auto-plans public items for creator too" do + assert_difference -> { PlanItem.count }, 1 do ScheduleItem.create!(valid_attrs( title: "Public Activity", is_public: true, created_by: users(:attendee_one) )) end + assert_equal users(:attendee_one), PlanItem.last.user end test "after_create does not auto-plan seeded items (no creator)" do assert_no_difference -> { PlanItem.count } do ScheduleItem.create!(valid_attrs( - title: "Seeded Private-Like", - is_public: false, + title: "Seeded-Like", + is_public: true, created_by: nil )) end From 5736a8b72599c3c6aabd8df88d4ddcf489d81620 Mon Sep 17 00:00:00 2001 From: Katya Sarmiento <5871075+Kitkatnik@users.noreply.github.com> Date: Thu, 23 Apr 2026 21:43:34 -0400 Subject: [PATCH 12/29] Reskin custom-block form; auto-derive host/sort_time; add Sunday MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline custom-block form on /plan (and its admin counterpart) now uses the site's real design tokens — .card + .label + .input + .btn — matching admin/users/_form.html.erb. The old invented .form-row / .form-actions classes rendered fields without width or border. Simplifications for attendees (admin form unchanged): - Host and Sort order fields removed from the user-facing form. Controller auto-sets host to the creator's full_name and derives sort_time from the time label ("6:30 PM" -> 1830, "8:00 AM" -> 800, unparseable -> 0). :host and :sort_time also dropped from strong params so form tampering can't override them. - Day field removed from the user-facing form. The containing plan section already shows the day + date, and the "+ Add custom block" link on each day carries its day_key into the new action. The form emits a hidden_field :day so create/edit still submit the value. - Title + Location + Time now share a single row via a new .field-grid-3 helper (2fr / 1fr / 1fr at >=640px, stacked below). Mirrors the existing .travel-grid pattern. Sunday (May 3) added as a possible day across the board — DAY_META, the ordered scope's CASE WHEN, and therefore every day selector and both /schedule and /plan views. Useful for attendees leaving later who want to plan a Sunday brunch or airport meetup. Test suite grew from 85 to 91 examples; all green. --- app/assets/stylesheets/application.css | 12 ++ app/controllers/schedule_items_controller.rb | 30 ++++- app/models/schedule_item.rb | 13 +- app/views/admin/schedule_items/_form.html.erb | 125 +++++++++--------- app/views/schedule_items/_form.html.erb | 98 +++++++------- .../schedule_items_controller_test.rb | 28 ++++ test/models/schedule_item_test.rb | 12 ++ 7 files changed, 197 insertions(+), 121 deletions(-) diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index c52c4d3..09eb14d 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -616,6 +616,18 @@ hr { } } +.field-grid-3 { + display: grid; + grid-template-columns: 1fr; + gap: 1rem; +} + +@media (min-width: 640px) { + .field-grid-3 { + grid-template-columns: 2fr 1fr 1fr; + } +} + .travel-row { display: flex; flex-direction: column; diff --git a/app/controllers/schedule_items_controller.rb b/app/controllers/schedule_items_controller.rb index c592c27..c8eb1a1 100644 --- a/app/controllers/schedule_items_controller.rb +++ b/app/controllers/schedule_items_controller.rb @@ -11,7 +11,7 @@ def new def create @schedule_item = current_user.created_schedule_items.build( - schedule_item_params.merge(kind: :activity) + schedule_item_params.merge(auto_attrs) ) if @schedule_item.save @@ -47,7 +47,7 @@ def edit end def update - if @schedule_item.update(schedule_item_params) + if @schedule_item.update(schedule_item_params.merge(auto_attrs)) redirect_to plan_path, notice: "Item updated." else render :edit, status: :unprocessable_content @@ -60,13 +60,29 @@ def set_schedule_item @schedule_item = current_user.created_schedule_items.find(params[:id]) end - # :kind and :created_by_id are deliberately absent here — the controller - # hardcodes kind: :activity on create, never changes it on update, and - # always scopes to current_user.created_schedule_items. + # Permitted user-settable fields. Deliberately absent: + # :kind — forced to :activity in auto_attrs + # :host — forced to current_user.full_name in auto_attrs + # :sort_time — derived from :time_label in auto_attrs + # :created_by_id — forced by association scope (current_user.created_schedule_items) def schedule_item_params params.require(:schedule_item).permit( - :day, :time_label, :sort_time, :title, :host, - :location, :description, :flexible, :is_public + :day, :time_label, :title, :location, :description, :flexible, :is_public ) end + + def auto_attrs + { + kind: :activity, + host: current_user.full_name, + sort_time: derive_sort_time(params.dig(:schedule_item, :time_label)) + } + end + + # "6:30 PM" -> 1830, "8:00 AM" -> 800, "whenever" -> 0. + def derive_sort_time(time_label) + return 0 if time_label.blank? + parsed = Time.parse(time_label) rescue nil + parsed ? (parsed.hour * 100 + parsed.min) : 0 + end end diff --git a/app/models/schedule_item.rb b/app/models/schedule_item.rb index 543f6f7..72922b5 100644 --- a/app/models/schedule_item.rb +++ b/app/models/schedule_item.rb @@ -13,13 +13,22 @@ class ScheduleItem < ApplicationRecord "wed" => { label: "Wednesday", date: "April 29", subtitle: "Pre-Conference" }, "thu" => { label: "Thursday", date: "April 30", subtitle: "Conference Day 1" }, "fri" => { label: "Friday", date: "May 1", subtitle: "Conference Day 2" }, - "sat" => { label: "Saturday", date: "May 2", subtitle: "Activities & Ruby Embassy" } + "sat" => { label: "Saturday", date: "May 2", subtitle: "Activities & Ruby Embassy" }, + "sun" => { label: "Sunday", date: "May 3", subtitle: "Departures" } }.freeze scope :public_items, -> { where(is_public: true) } scope :ordered, -> { order( - Arel.sql("CASE day WHEN 'wed' THEN 1 WHEN 'thu' THEN 2 WHEN 'fri' THEN 3 WHEN 'sat' THEN 4 ELSE 5 END"), + Arel.sql( + "CASE day " \ + "WHEN 'wed' THEN 1 " \ + "WHEN 'thu' THEN 2 " \ + "WHEN 'fri' THEN 3 " \ + "WHEN 'sat' THEN 4 " \ + "WHEN 'sun' THEN 5 " \ + "ELSE 6 END" + ), :sort_time ) } diff --git a/app/views/admin/schedule_items/_form.html.erb b/app/views/admin/schedule_items/_form.html.erb index 8293190..30e596f 100644 --- a/app/views/admin/schedule_items/_form.html.erb +++ b/app/views/admin/schedule_items/_form.html.erb @@ -1,72 +1,77 @@ -<%= form_with model: [ :admin, schedule_item ], local: true do |f| %> - <% if schedule_item.errors.any? %> -
-

<%= pluralize(schedule_item.errors.count, "error") %> prevented saving:

-
    - <% schedule_item.errors.full_messages.each do |msg| %> -
  • <%= msg %>
  • - <% end %> -
-
- <% end %> - -
- <%= f.label :title %> - <%= f.text_field :title, required: true %> +<% if schedule_item.errors.any? %> +
+
    + <% schedule_item.errors.full_messages.each do |msg| %> +
  • <%= msg %>
  • + <% end %> +
+<% end %> -
- <%= f.label :kind %> - <%= f.select :kind, ScheduleItem.kinds.keys.map { |k| [ k.humanize, k ] } %> -
+
+ <%= form_with model: [ :admin, schedule_item ], class: "space-y-4" do |f| %> +
+ <%= f.label :title, class: "label" %> + <%= f.text_field :title, required: true, class: "input" %> +
-
- <%= f.label :day %> - <%= f.select :day, ScheduleItem::DAY_META.map { |k, m| [ m[:label], k ] } %> -
+
+ <%= f.label :kind, class: "label" %> + <%= f.select :kind, + ScheduleItem.kinds.keys.map { |k| [ k.humanize, k ] }, + {}, class: "select" %> +
-
- <%= f.label :time_label, "Time label (e.g., '9:30 AM', 'TBD')" %> - <%= f.text_field :time_label %> -
+
+ <%= f.label :day, class: "label" %> + <%= f.select :day, + ScheduleItem::DAY_META.map { |k, m| [ m[:label], k ] }, + {}, class: "select" %> +
-
- <%= f.label :sort_time, "Sort time (integer, e.g., 930)" %> - <%= f.number_field :sort_time %> -
+
+ <%= f.label :time_label, "Time label", class: "label" %> + <%= f.text_field :time_label, class: "input", placeholder: "e.g., 9:30 AM, TBD" %> +
-
- <%= f.label :host, "Host / speaker (optional)" %> - <%= f.text_field :host %> -
+
+ <%= f.label :sort_time, "Sort order", class: "label" %> + <%= f.number_field :sort_time, class: "input", placeholder: "e.g., 930" %> +
-
- <%= f.label :location %> - <%= f.text_field :location %> -
+
+ <%= f.label :host, "Host / speaker", class: "label" %> + <%= f.text_field :host, class: "input" %> +
-
- <%= f.label :description %> - <%= f.text_area :description, rows: 3 %> -
+
+ <%= f.label :location, class: "label" %> + <%= f.text_field :location, class: "input" %> +
-
- <%= f.label :slug, "Slug (optional, used for seeded items; leave blank for admin-created)" %> - <%= f.text_field :slug %> -
+
+ <%= f.label :description, class: "label" %> + <%= f.text_area :description, rows: 3, class: "input" %> +
-
- <%= f.check_box :flexible %> - <%= f.label :flexible, "Flexible / TBD (Saturday-style dashed treatment)" %> -
+
+ <%= f.label :slug, "Slug (optional)", class: "label" %> + <%= f.text_field :slug, class: "input", placeholder: "leave blank for admin-created items" %> +
-
- <%= f.check_box :is_public %> - <%= f.label :is_public, "Public (appears on /schedule for all attendees)" %> -
+
+ <%= f.check_box :flexible %> + <%= f.label :flexible, "Flexible / TBD (Saturday dashed treatment)" %> +
-
- <%= f.submit class: "btn btn-red" %> - <%= link_to "Cancel", admin_schedule_items_path, class: "text-blue underline ml-4" %> -
-<% end %> +
+ <%= f.check_box :is_public %> + <%= f.label :is_public, "Public (appears on /schedule for all attendees)" %> +
+ +
+ <%= f.submit class: "btn btn-navy" %> + <%= link_to "Cancel", admin_schedule_items_path, class: "btn btn-muted" %> +
+ <% end %> +
diff --git a/app/views/schedule_items/_form.html.erb b/app/views/schedule_items/_form.html.erb index 52ad2d5..79805fe 100644 --- a/app/views/schedule_items/_form.html.erb +++ b/app/views/schedule_items/_form.html.erb @@ -1,57 +1,51 @@ -<%= form_with model: schedule_item, local: true do |f| %> - <% if schedule_item.errors.any? %> -
-

<%= pluralize(schedule_item.errors.count, "error") %> prevented saving:

-
    - <% schedule_item.errors.full_messages.each do |msg| %> -
  • <%= msg %>
  • - <% end %> -
-
- <% end %> - -
- <%= f.label :title %> - <%= f.text_field :title, required: true %> -
- -
- <%= f.label :day %> - <%= f.select :day, ScheduleItem::DAY_META.map { |k, m| [ m[:label], k ] } %> -
- -
- <%= f.label :time_label, "Time (display label, e.g., '6:30 PM')" %> - <%= f.text_field :time_label %> -
- -
- <%= f.label :sort_time, "Sort time (integer, e.g., 1830 for 6:30 PM)" %> - <%= f.number_field :sort_time %> -
- -
- <%= f.label :host, "Host (optional)" %> - <%= f.text_field :host %> +<% if schedule_item.errors.any? %> +
+
    + <% schedule_item.errors.full_messages.each do |msg| %> +
  • <%= msg %>
  • + <% end %> +
+<% end %> -
- <%= f.label :location, "Location (optional)" %> - <%= f.text_field :location %> -
+
+ <%= form_with model: schedule_item, class: "space-y-4" do |f| %> + <%# Day is implied by the plan section this form was opened from — + ScheduleItemsController#new seeds @schedule_item.day from params[:day]. + We render it as a hidden field so admins-browsing-as-attendees still + submit the intended day without an extra dropdown. %> + <%= f.hidden_field :day %> + +
+
+ <%= f.label :title, class: "label" %> + <%= f.text_field :title, required: true, class: "input", placeholder: "e.g., Dinner with crew" %> +
+ +
+ <%= f.label :location, "Location", class: "label" %> + <%= f.text_field :location, class: "input", placeholder: "e.g., Tupelo Honey" %> +
+ +
+ <%= f.label :time_label, "Time", class: "label" %> + <%= f.text_field :time_label, class: "input", placeholder: "e.g., 6:30 PM" %> +
+
-
- <%= f.label :description, "Description (optional)" %> - <%= f.text_area :description, rows: 3 %> -
+
+ <%= f.label :description, "Description (optional)", class: "label" %> + <%= f.text_area :description, rows: 3, class: "input" %> +
-
- <%= f.check_box :is_public %> - <%= f.label :is_public, "Share with all attendees — they can RSVP" %> -
+
+ <%= f.check_box :is_public %> + <%= f.label :is_public, "Share with all attendees — they can RSVP" %> +
-
- <%= f.submit %> - <%= link_to "Cancel", plan_path %> -
-<% end %> +
+ <%= f.submit (schedule_item.new_record? ? "Add to my plan" : "Save changes"), class: "btn btn-red" %> + <%= link_to "Cancel", plan_path, class: "btn btn-muted", data: { turbo_frame: "_top" } %> +
+ <% end %> +
diff --git a/test/controllers/schedule_items_controller_test.rb b/test/controllers/schedule_items_controller_test.rb index 7b17722..e5dbe22 100644 --- a/test/controllers/schedule_items_controller_test.rb +++ b/test/controllers/schedule_items_controller_test.rb @@ -120,4 +120,32 @@ def valid_form_params(overrides = {}) item = ScheduleItem.find_by(title: "Impersonation attempt") assert_equal users(:attendee_one), item.created_by end + + test "host is auto-set to the creator's full_name, ignoring any submitted host param" do + sign_in_as users(:attendee_one) + post schedule_items_path, params: valid_form_params(host: "Some Other Speaker", title: "Host test") + assert_equal users(:attendee_one).full_name, ScheduleItem.find_by(title: "Host test").host + end + + test "sort_time is derived from time_label, ignoring any submitted sort_time param" do + sign_in_as users(:attendee_one) + post schedule_items_path, params: valid_form_params( + time_label: "6:30 PM", + sort_time: 99999, + title: "Sort test" + ) + assert_equal 1830, ScheduleItem.find_by(title: "Sort test").sort_time + end + + test "sort_time derivation handles morning times" do + sign_in_as users(:attendee_one) + post schedule_items_path, params: valid_form_params(time_label: "8:00 AM", title: "Morning test") + assert_equal 800, ScheduleItem.find_by(title: "Morning test").sort_time + end + + test "sort_time falls back to 0 for unparseable time_label" do + sign_in_as users(:attendee_one) + post schedule_items_path, params: valid_form_params(time_label: "whenever", title: "Garbage time") + assert_equal 0, ScheduleItem.find_by(title: "Garbage time").sort_time + end end diff --git a/test/models/schedule_item_test.rb b/test/models/schedule_item_test.rb index 25455a1..71676b0 100644 --- a/test/models/schedule_item_test.rb +++ b/test/models/schedule_item_test.rb @@ -91,6 +91,18 @@ def valid_attrs(overrides = {}) assert_equal [ wed_am, wed_pm, thu_am ], ordered.to_a end + test "ordered scope places Sunday after Saturday" do + sat = ScheduleItem.create!(valid_attrs(day: "sat", sort_time: 1800, title: "Sat PM")) + sun = ScheduleItem.create!(valid_attrs(day: "sun", sort_time: 900, title: "Sun AM brunch")) + ordered = ScheduleItem.where(id: [ sat.id, sun.id ]).ordered + assert_equal [ sat, sun ], ordered.to_a + end + + test "DAY_META includes Sunday" do + assert ScheduleItem::DAY_META.key?("sun"), "Sunday should be a listed day" + assert_equal "Sunday", ScheduleItem::DAY_META["sun"][:label] + end + test "after_create auto-plans private items for creator" do assert_difference -> { PlanItem.count }, 1 do ScheduleItem.create!(valid_attrs( From 66649e5f1c3b4f72c42463fa3c3130a9ce768a9f Mon Sep 17 00:00:00 2001 From: Katya Sarmiento <5871075+Kitkatnik@users.noreply.github.com> Date: Thu, 23 Apr 2026 21:51:51 -0400 Subject: [PATCH 13/29] Add light-blue Embassy pill to match the other type badges The Embassy kind rendered with only the base .item-badge rules (no background) because no .item-badge--embassy color recipe existed. Added a pale sky blue (#DCEBF5) background with a darker brand blue (#1E5A8A) text tone, matching the pill weight of Activity and Lightning while staying on-brand with the Ruby Embassy palette. --- app/assets/stylesheets/application.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 09eb14d..d07e9de 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -518,6 +518,11 @@ hr { color: #8A6500; } +.item-badge--embassy { + background-color: #DCEBF5; + color: #1E5A8A; +} + .item-badge--custom { background-color: transparent; color: #C41C1C; From 90aa3c63ecd25eeea47c8ef7e6eb93011aff1156 Mon Sep 17 00:00:00 2001 From: Katya Sarmiento <5871075+Kitkatnik@users.noreply.github.com> Date: Thu, 23 Apr 2026 21:52:34 -0400 Subject: [PATCH 14/29] Host/speaker is a user dropdown in the admin form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admins used to free-text the host of a schedule item — easy to mistype. Now it's a select populated from User.full_name (ordered by last name), which keeps the field in sync with whoever actually has an account. Seed talks ship with external speaker names like "John Athayde" who don't have User records. To preserve that data when an admin edits those talks, the select keeps the current host value as a sticky top option when it doesn't match any existing user. No schema change. --- app/views/admin/schedule_items/_form.html.erb | 15 +++++++++++- .../admin/schedule_items_controller_test.rb | 23 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/app/views/admin/schedule_items/_form.html.erb b/app/views/admin/schedule_items/_form.html.erb index 30e596f..e21a8c0 100644 --- a/app/views/admin/schedule_items/_form.html.erb +++ b/app/views/admin/schedule_items/_form.html.erb @@ -41,7 +41,20 @@
<%= f.label :host, "Host / speaker", class: "label" %> - <%= f.text_field :host, class: "input" %> + <% + # Pull every existing user as a host option. Preserve the current + # host value as a sticky top option if it doesn't match a user — + # so seeded external speakers (e.g., "John Athayde") aren't lost + # when an admin edits the talk. + existing_names = User.order(:last_name, :first_name).map(&:full_name).uniq + current_host = schedule_item.host.presence + sticky_options = current_host && !existing_names.include?(current_host) ? [ current_host ] : [] + all_options = (sticky_options + existing_names).map { |name| [ name, name ] } + %> + <%= f.select :host, + options_for_select(all_options, current_host), + { include_blank: "— None —" }, + class: "select" %>
diff --git a/test/controllers/admin/schedule_items_controller_test.rb b/test/controllers/admin/schedule_items_controller_test.rb index 4c21345..60aafad 100644 --- a/test/controllers/admin/schedule_items_controller_test.rb +++ b/test/controllers/admin/schedule_items_controller_test.rb @@ -79,4 +79,27 @@ def valid_form_params(overrides = {}) delete admin_schedule_item_path(item) end end + + test "admin edit form renders host as a select with existing user names" do + item = ScheduleItem.create!(day: "thu", title: "Test talk", kind: :talk, is_public: true) + sign_in_as users(:jeremy) + get edit_admin_schedule_item_path(item) + + assert_select "select[name='schedule_item[host]']" do + # Every existing user's full_name should be an option. + User.all.each do |u| + assert_select "option", text: u.full_name + end + end + end + + test "admin edit form preserves an external speaker host value as a sticky option" do + item = ScheduleItem.create!(day: "thu", title: "Keynote", host: "John Athayde", kind: :talk, is_public: true) + sign_in_as users(:jeremy) + get edit_admin_schedule_item_path(item) + + assert_select "select[name='schedule_item[host]']" do + assert_select "option[selected='selected']", text: "John Athayde" + end + end end From 8716babd1607500015814b2ab0f704474b7ddccc Mon Sep 17 00:00:00 2001 From: Katya Sarmiento <5871075+Kitkatnik@users.noreply.github.com> Date: Thu, 23 Apr 2026 21:56:13 -0400 Subject: [PATCH 15/29] Render schedule-item descriptions on /schedule, /plan, and admin index The description column was already on ScheduleItem and in the admin form, but nothing surfaced it. Now it renders: - /schedule: paragraph between location and the badges row, styled as .schedule-item__description (0.9375rem, dark gray, 1.5 line-height). - /plan: paragraph in the same relative position inside each plan_item card, styled as .plan-item__description. - /admin/schedule_items index: secondary gray line under title/host in the Title column, matching the existing host-under-title pattern. Descriptions are run through simple_format to preserve line breaks from the textarea without allowing raw HTML (wrapper_tag: "span" keeps the surrounding

semantically clean). --- app/assets/stylesheets/application.css | 14 ++++++++++++++ app/views/admin/schedule_items/index.html.erb | 3 +++ app/views/plan/_plan_item.html.erb | 4 ++++ app/views/schedule/_session_item.html.erb | 4 ++++ .../admin/schedule_items_controller_test.rb | 13 +++++++++++++ test/integration/plan_test.rb | 18 ++++++++++++++++++ test/integration/schedule_browsing_test.rb | 16 ++++++++++++++++ 7 files changed, 72 insertions(+) diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index d07e9de..c5b7d81 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -472,6 +472,13 @@ hr { color: #7a8189; } +.schedule-item__description { + font-size: 0.9375rem; + color: #404040; + line-height: 1.5; + margin-top: 0.125rem; +} + .schedule-item__badges { display: flex; gap: 0.375rem; @@ -750,6 +757,13 @@ hr { color: #7a8189; } +.plan-item__description { + font-size: 0.875rem; + color: #404040; + line-height: 1.5; + margin-top: 0.125rem; +} + .plan-item__notes { display: inline-flex; align-items: center; diff --git a/app/views/admin/schedule_items/index.html.erb b/app/views/admin/schedule_items/index.html.erb index 3e5d569..4dd964e 100644 --- a/app/views/admin/schedule_items/index.html.erb +++ b/app/views/admin/schedule_items/index.html.erb @@ -25,6 +25,9 @@ <% if item.host.present? %>

<%= item.host %>
<% end %> + <% if item.description.present? %> +
<%= item.description %>
+ <% end %> <%= item.kind.humanize %> <%= item.is_public? ? "Public" : "Private" %> diff --git a/app/views/plan/_plan_item.html.erb b/app/views/plan/_plan_item.html.erb index 5cc035c..c6d4b12 100644 --- a/app/views/plan/_plan_item.html.erb +++ b/app/views/plan/_plan_item.html.erb @@ -23,6 +23,10 @@

<%= item.location %>

<% end %> + <% if item.description.present? %> +

<%= simple_format(item.description, {}, wrapper_tag: "span") %>

+ <% end %> + <% if custom %>
Custom
<% end %> diff --git a/app/views/schedule/_session_item.html.erb b/app/views/schedule/_session_item.html.erb index 96aae69..e41c268 100644 --- a/app/views/schedule/_session_item.html.erb +++ b/app/views/schedule/_session_item.html.erb @@ -40,6 +40,10 @@

<%= item.location %>

<% end %> + <% if item.description.present? %> +

<%= simple_format(item.description, {}, wrapper_tag: "span") %>

+ <% end %> +
<% if kind_label.present? %> <%= kind_label %> diff --git a/test/controllers/admin/schedule_items_controller_test.rb b/test/controllers/admin/schedule_items_controller_test.rb index 60aafad..f8fe800 100644 --- a/test/controllers/admin/schedule_items_controller_test.rb +++ b/test/controllers/admin/schedule_items_controller_test.rb @@ -93,6 +93,19 @@ def valid_form_params(overrides = {}) end end + test "admin index shows item descriptions when present" do + ScheduleItem.create!( + day: "thu", + title: "Admin desc", + description: "Short admin-visible description.", + kind: :activity, + is_public: true + ) + sign_in_as users(:jeremy) + get admin_schedule_items_path + assert_match "Short admin-visible description.", response.body + end + test "admin edit form preserves an external speaker host value as a sticky option" do item = ScheduleItem.create!(day: "thu", title: "Keynote", host: "John Athayde", kind: :talk, is_public: true) sign_in_as users(:jeremy) diff --git a/test/integration/plan_test.rb b/test/integration/plan_test.rb index 129049a..c1f540a 100644 --- a/test/integration/plan_test.rb +++ b/test/integration/plan_test.rb @@ -67,6 +67,24 @@ class PlanTest < ActionDispatch::IntegrationTest assert_match "Sit near the front", response.body end + test "/plan shows item descriptions when present" do + alice = users(:attendee_one) + item = ScheduleItem.create!( + day: "fri", + time_label: "2:00 PM", + sort_time: 1400, + title: "Planned Item With Description", + description: "Notes about this session go here.", + kind: :activity, + is_public: true + ) + alice.plan_items.create!(schedule_item: item) + + sign_in_as alice + get plan_path + assert_match "Notes about this session go here.", response.body + end + test "/plan shows remove button for each plan_item" do alice = users(:attendee_one) plan = alice.plan_items.create!(schedule_item: @talk) diff --git a/test/integration/schedule_browsing_test.rb b/test/integration/schedule_browsing_test.rb index 373aa5d..5e75d6a 100644 --- a/test/integration/schedule_browsing_test.rb +++ b/test/integration/schedule_browsing_test.rb @@ -76,6 +76,22 @@ class ScheduleBrowsingTest < ActionDispatch::IntegrationTest assert_match(/RSVP/i, response.body) end + test "descriptions render on schedule items when present" do + ScheduleItem.create!( + slug: "desc-test", + day: "thu", + time_label: "10:00 AM", + sort_time: 1000, + title: "Item with details", + description: "Bring a notebook and questions for the speaker.", + kind: :talk, + is_public: true + ) + sign_in_as users(:attendee_one) + get schedule_path + assert_match "Bring a notebook and questions for the speaker.", response.body + end + test "days are rendered in conference order (wed, thu, fri, sat)" do sign_in_as users(:attendee_one) get schedule_path From bd60551c8b126ff371c70e879ba3bd85f14ca36b Mon Sep 17 00:00:00 2001 From: Katya Sarmiento <5871075+Kitkatnik@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:12:58 -0400 Subject: [PATCH 16/29] Wider admin schedule table with Info column and button-styled actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename Title column to Info and fold location + description into the same cell under the title. A single glance now answers "what, where, about what?" without widening the row count. - Swap "Created by" (noisy for admins) for Host (the actionable name an admin typically cares about when auditing the agenda). - Style Edit and Delete as real buttons (.btn .btn-muted / .btn-red) instead of text links. Delete still submits via button_to + Turbo confirm; the reskin is CSS-only — action/URL/handler unchanged. - Give the admin layout an opt-in wider container via content_for :container_width so the Schedule Items table can breathe at max-w-6xl (72rem) without affecting narrower pages like the dashboard and user forms. --- app/assets/stylesheets/application.css | 1 + app/views/admin/schedule_items/index.html.erb | 25 +++++++++++-------- app/views/layouts/admin.html.erb | 2 +- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index c5b7d81..fab8780 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -263,6 +263,7 @@ hr { .max-w-xl { max-width: 36rem; } .max-w-3xl { max-width: 48rem; } .max-w-4xl { max-width: 56rem; } +.max-w-6xl { max-width: 72rem; } .w-full { width: 100%; } diff --git a/app/views/admin/schedule_items/index.html.erb b/app/views/admin/schedule_items/index.html.erb index 4dd964e..ebbd9ae 100644 --- a/app/views/admin/schedule_items/index.html.erb +++ b/app/views/admin/schedule_items/index.html.erb @@ -1,3 +1,5 @@ +<% content_for :container_width, "max-w-6xl" %> +

Schedule Items

<%= link_to "Add item", new_admin_schedule_item_path, class: "btn btn-red" %> @@ -8,10 +10,10 @@ Day Time - Title + Info Kind Public? - Created by + Host @@ -21,9 +23,9 @@ <%= ScheduleItem::DAY_META.dig(item.day, :label) || item.day %> <%= item.time_label %> - <%= item.title %> - <% if item.host.present? %> -
<%= item.host %>
+
<%= item.title %>
+ <% if item.location.present? %> +
<%= item.location %>
<% end %> <% if item.description.present? %>
<%= item.description %>
@@ -31,12 +33,15 @@ <%= item.kind.humanize %> <%= item.is_public? ? "Public" : "Private" %> - <%= item.created_by&.full_name || "— seeded —" %> + <%= item.host.presence || "—" %> - <%= link_to "Edit", edit_admin_schedule_item_path(item), class: "text-blue underline" %> - <%= button_to "Delete", admin_schedule_item_path(item), method: :delete, - data: { turbo_confirm: "Delete '#{item.title}'? RSVPs will also be removed." }, - class: "btn-ghost ml-2", style: "display:inline;color:#C41C1C;padding:0;box-shadow:none" %> +
+ <%= link_to "Edit", edit_admin_schedule_item_path(item), class: "btn btn-muted" %> + <%= button_to "Delete", admin_schedule_item_path(item), + method: :delete, + data: { turbo_confirm: "Delete '#{item.title}'? RSVPs will also be removed." }, + class: "btn btn-red" %> +
<% end %> diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index a62fe70..47d6a1d 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb @@ -29,7 +29,7 @@
-
+
<% if flash[:notice] %>
<%= flash[:notice] %>
<% end %> From acf56889b16b82da68f8954ee3cb271d3915d614 Mon Sep 17 00:00:00 2001 From: Katya Sarmiento <5871075+Kitkatnik@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:26:26 -0400 Subject: [PATCH 17/29] Unify kind-badge styling across /schedule, /plan, and admin index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three pages now share the same .item-badge-- pills: - admin index had been using .badge badge-, which are role badges for User (admin/volunteer/attendee) with no per-kind rules, so Kind cells rendered as naked .badge. Swapped to .item-badge. - /plan plan_item cards gained a kind badge (Talk / Lightning / Embassy / Activity) sitting in a .schedule-item__badges row next to the existing Custom outline badge when applicable. No new CSS — the per-kind color recipes (.item-badge--talk etc.) already existed for /schedule and are now shared. --- app/views/admin/schedule_items/index.html.erb | 2 +- app/views/plan/_plan_item.html.erb | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/views/admin/schedule_items/index.html.erb b/app/views/admin/schedule_items/index.html.erb index ebbd9ae..130bf51 100644 --- a/app/views/admin/schedule_items/index.html.erb +++ b/app/views/admin/schedule_items/index.html.erb @@ -31,7 +31,7 @@
<%= item.description %>
<% end %> - <%= item.kind.humanize %> + <%= item.kind.humanize %> <%= item.is_public? ? "Public" : "Private" %> <%= item.host.presence || "—" %> diff --git a/app/views/plan/_plan_item.html.erb b/app/views/plan/_plan_item.html.erb index c6d4b12..c6ffc13 100644 --- a/app/views/plan/_plan_item.html.erb +++ b/app/views/plan/_plan_item.html.erb @@ -27,9 +27,12 @@

<%= simple_format(item.description, {}, wrapper_tag: "span") %>

<% end %> - <% if custom %> -
Custom
- <% end %> +
+ <%= item.kind.humanize %> + <% if custom %> + Custom + <% end %> +
<% if plan_item.notes.present? %>

<%= plan_item.notes %>

From 33d6721b61d820ac39db3990d5326ca3889938ad Mon Sep 17 00:00:00 2001 From: Katya Sarmiento <5871075+Kitkatnik@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:27:49 -0400 Subject: [PATCH 18/29] Remove nonfunctional "+ Add a note" prompt on /plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prompt hinted at a click-to-edit-notes flow that isn't wired up yet. Showing a dead affordance is worse than no affordance — removing it until notes UI lands. Existing notes still render; the update endpoint and schema are untouched. --- app/views/plan/_plan_item.html.erb | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/views/plan/_plan_item.html.erb b/app/views/plan/_plan_item.html.erb index c6ffc13..1989e44 100644 --- a/app/views/plan/_plan_item.html.erb +++ b/app/views/plan/_plan_item.html.erb @@ -36,8 +36,6 @@ <% if plan_item.notes.present? %>

<%= plan_item.notes %>

- <% else %> -

+ Add a note

<% end %>
From c3fe8ea490909df4348ff7d7f9097b61b779b69d Mon Sep 17 00:00:00 2001 From: Katya Sarmiento <5871075+Kitkatnik@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:31:22 -0400 Subject: [PATCH 19/29] Users admin: match schedule-items table style; add View (show) page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Users index gets the same treatment as Schedule Items: wider container (max-w-6xl via content_for), real button classes on Edit and Delete (.btn .btn-muted / .btn-red), flex wrapper so the three actions align nicely. - New "View" button linking to /admin/users/:id — a full profile page with the user's info card (role badge, Tito ticket link if linked), followed by everything they have on their plan grouped by day, with kind pills on each row. Lets an admin see who's RSVP'd to what without fishing through the schedule items table. - Sync from Tito button stays; it's still the only way Tito ticket slugs get linked to existing users — see the Tito note below. Note on the Tito column (not changed): the column shows a link to the user's Tito admin dashboard ticket, but only after Sync from Tito is clicked AND the TITO_API_TOKEN / TITO_ACCOUNT_SLUG / TITO_EVENT_SLUG envs are set. Without credentials locally, the column stays "—" for every user, including the seeded admins who have no Tito ticket. --- app/controllers/admin/users_controller.rb | 12 +++ app/views/admin/users/index.html.erb | 20 +++-- app/views/admin/users/show.html.erb | 77 +++++++++++++++++++ .../admin/users_controller_test.rb | 43 +++++++++++ 4 files changed, 146 insertions(+), 6 deletions(-) create mode 100644 app/views/admin/users/show.html.erb create mode 100644 test/controllers/admin/users_controller_test.rb diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index a1fd688..f3f9490 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -4,6 +4,18 @@ def index @users = User.order(:last_name, :first_name) end + def show + @user = User.find(params[:id]) + @plan_items = @user.plan_items + .includes(:schedule_item) + .sort_by { |pi| + [ + ScheduleItem::DAY_META.keys.index(pi.schedule_item.day) || 99, + pi.schedule_item.sort_time.to_i + ] + } + end + def new @user = User.new end diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb index 9900b96..12342b8 100644 --- a/app/views/admin/users/index.html.erb +++ b/app/views/admin/users/index.html.erb @@ -1,3 +1,5 @@ +<% content_for :container_width, "max-w-6xl" %> +

Users

@@ -19,23 +21,29 @@ <% @users.each do |user| %> - <%= user.full_name %> + <%= user.full_name %> <%= user.email %> <%= user.role.humanize %> <% if user.admin_ticket_url %> - <%= link_to "Ticket", user.admin_ticket_url, + <%= link_to "View ticket \u2192", user.admin_ticket_url, target: "_blank", rel: "noopener noreferrer", class: "text-blue underline" %> + <% else %> + <% end %> - <%= link_to "Edit", edit_admin_user_path(user), class: "text-blue underline" %> - <%= button_to "Delete", admin_user_path(user), method: :delete, - data: { turbo_confirm: "Remove #{user.full_name}?" }, - class: "btn-ghost ml-2", style: "display:inline;color:#C41C1C;padding:0;box-shadow:none" %> +
+ <%= link_to "View", admin_user_path(user), class: "btn btn-muted" %> + <%= link_to "Edit", edit_admin_user_path(user), class: "btn btn-muted" %> + <%= button_to "Delete", admin_user_path(user), + method: :delete, + data: { turbo_confirm: "Remove #{user.full_name}?" }, + class: "btn btn-red" %> +
<% end %> diff --git a/app/views/admin/users/show.html.erb b/app/views/admin/users/show.html.erb new file mode 100644 index 0000000..115d9ce --- /dev/null +++ b/app/views/admin/users/show.html.erb @@ -0,0 +1,77 @@ +<% content_for :container_width, "max-w-6xl" %> + +
+ <%= link_to "\u2190 All users", admin_users_path, class: "text-blue underline" %> +
+ +
+
+

<%= @user.full_name %>

+

<%= @user.email %>

+
+
+ <%= link_to "Edit", edit_admin_user_path(@user), class: "btn btn-muted" %> + <%= button_to "Delete", admin_user_path(@user), method: :delete, + data: { turbo_confirm: "Remove #{@user.full_name}?" }, + class: "btn btn-red" %> +
+
+ +
+
+
+ Role + <%= @user.role.humanize %> +
+
+ Tito ticket + <% if @user.admin_ticket_url %> + <%= link_to "View in Tito \u2192", @user.admin_ticket_url, + target: "_blank", rel: "noopener noreferrer", + class: "text-blue underline" %> + (<%= @user.tito_ticket_slug %>) + <% else %> + Not linked + <% end %> +
+
+
+ +

On their plan

+ +<% if @plan_items.any? %> + <% @plan_items.group_by { |pi| pi.schedule_item.day }.each do |day_key, items| %> +
+

+ <%= ScheduleItem::DAY_META.dig(day_key, :label) || day_key %> + (<%= ScheduleItem::DAY_META.dig(day_key, :date) %>) +

+
    + <% items.each do |pi| %> + <% si = pi.schedule_item %> +
  • +
    +
    +
    <%= si.title %>
    + <% if si.host.present? %> +
    <%= si.host %>
    + <% end %> + <% if si.time_label.present? || si.location.present? %> +
    + <%= si.time_label %><%= " · #{si.location}" if si.location.present? %> +
    + <% end %> +
    + <%= si.kind.humanize %> +
    + <% if pi.notes.present? %> +

    Notes: <%= pi.notes %>

    + <% end %> +
  • + <% end %> +
+
+ <% end %> +<% else %> +

Nothing planned yet.

+<% end %> diff --git a/test/controllers/admin/users_controller_test.rb b/test/controllers/admin/users_controller_test.rb new file mode 100644 index 0000000..ced6ac6 --- /dev/null +++ b/test/controllers/admin/users_controller_test.rb @@ -0,0 +1,43 @@ +require "test_helper" + +class Admin::UsersControllerTest < ActionDispatch::IntegrationTest + test "attendee GET /admin/users/:id returns 404" do + sign_in_as users(:attendee_one) + get admin_user_path(users(:volunteer_one)) + assert_response :not_found + end + + test "admin GET /admin/users/:id returns 200" do + sign_in_as users(:jeremy) + get admin_user_path(users(:attendee_one)) + assert_response :success + assert_match users(:attendee_one).full_name, response.body + assert_match users(:attendee_one).email, response.body + end + + test "admin show page lists the user's plan items" do + alice = users(:attendee_one) + item = ScheduleItem.create!( + day: "fri", + title: "Alice planned talk", + kind: :talk, + is_public: true, + time_label: "10:00 AM", + sort_time: 1000 + ) + alice.plan_items.create!(schedule_item: item) + + sign_in_as users(:jeremy) + get admin_user_path(alice) + assert_match "Alice planned talk", response.body + end + + test "admin users index links to each user's show page" do + sign_in_as users(:jeremy) + get admin_users_path + + User.all.each do |u| + assert_select "a[href=?]", admin_user_path(u) + end + end +end From ddd8fa0a187c8519526ddabc7012a74a4c2a21d5 Mon Sep 17 00:00:00 2001 From: Katya Sarmiento <5871075+Kitkatnik@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:33:18 -0400 Subject: [PATCH 20/29] Add missing .items-start utility so admin user show badges don't stretch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user show page used class="flex items-start" for the row holding a plan item's details + its kind badge, but .items-start was never defined. Flex falls back to align-items: stretch, which vertically stretched each .item-badge background to fill the multi-line row — making "ACTIVITY" and "TALK" pills look like tall blocks. Adding .items-start { align-items: flex-start } pins badges to the top at their natural compact height. --- app/assets/stylesheets/application.css | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index fab8780..6d3dad4 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -123,6 +123,7 @@ code { .flex-col { flex-direction: column; } .items-center { align-items: center; } +.items-start { align-items: flex-start; } .justify-between { justify-content: space-between; } .justify-center { justify-content: center; } From dda6697fd50002290f702aae7039b1a403252ad0 Mon Sep 17 00:00:00 2001 From: Katya Sarmiento <5871075+Kitkatnik@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:37:10 -0400 Subject: [PATCH 21/29] Admin user show: split into Info / Embassy / Hosting / On their plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single "On their plan" list conflated three different things an admin actually wants to see at a glance: their embassy appointment (often the most time-sensitive for an attendee), the events they're hosting (so admins know who's running what), and the rest of their itinerary. Sections: 1. Info — existing role badge + Tito ticket link 2. Embassy appointment — plan_items with schedule_item.kind == embassy 3. Hosting — every ScheduleItem whose host string matches the user's full_name. Catches admin-dropdown assignments and user-created custom blocks (the user-facing controller auto-sets host to the creator's full_name at create time). 4. On their plan — remaining plan_items grouped by day, same pattern as before but embassy items are removed (they're in section 2). Factored the repeated item card markup into _item_card.html.erb so all three lists share styling. Each section shows a graceful empty state ("No embassy appointment yet", "Not hosting any events", "Nothing else planned") when nothing matches. --- app/controllers/admin/users_controller.rb | 25 +++++--- app/views/admin/users/_item_card.html.erb | 23 ++++++++ app/views/admin/users/show.html.erb | 57 ++++++++++-------- .../admin/users_controller_test.rb | 58 +++++++++++++++++++ 4 files changed, 130 insertions(+), 33 deletions(-) create mode 100644 app/views/admin/users/_item_card.html.erb diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index f3f9490..b0e6e7f 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -6,14 +6,23 @@ def index def show @user = User.find(params[:id]) - @plan_items = @user.plan_items - .includes(:schedule_item) - .sort_by { |pi| - [ - ScheduleItem::DAY_META.keys.index(pi.schedule_item.day) || 99, - pi.schedule_item.sort_time.to_i - ] - } + + all_plan_items = @user.plan_items + .includes(:schedule_item) + .sort_by { |pi| + [ + ScheduleItem::DAY_META.keys.index(pi.schedule_item.day) || 99, + pi.schedule_item.sort_time.to_i + ] + } + @embassy_plan_items = all_plan_items.select { |pi| pi.schedule_item.embassy? } + @other_plan_items = all_plan_items - @embassy_plan_items + + # "Hosting" = any schedule_item whose host string matches this user's + # full_name. Catches both admin-dropdown assignments and user-created + # custom blocks (the user-facing controller auto-sets host to + # current_user.full_name on create). + @hosted_items = ScheduleItem.where(host: @user.full_name).ordered end def new diff --git a/app/views/admin/users/_item_card.html.erb b/app/views/admin/users/_item_card.html.erb new file mode 100644 index 0000000..e30d283 --- /dev/null +++ b/app/views/admin/users/_item_card.html.erb @@ -0,0 +1,23 @@ +<% + si = schedule_item + notes = local_assigns[:notes] +%> +
  • +
    +
    +
    <%= si.title %>
    + <% if si.host.present? %> +
    <%= si.host %>
    + <% end %> + <% if si.time_label.present? || si.location.present? %> +
    + <%= ScheduleItem::DAY_META.dig(si.day, :label) || si.day %><%= " · #{si.time_label}" if si.time_label.present? %><%= " · #{si.location}" if si.location.present? %> +
    + <% end %> +
    + <%= si.kind.humanize %> +
    + <% if notes.present? %> +

    Notes: <%= notes %>

    + <% end %> +
  • diff --git a/app/views/admin/users/show.html.erb b/app/views/admin/users/show.html.erb index 115d9ce..c065511 100644 --- a/app/views/admin/users/show.html.erb +++ b/app/views/admin/users/show.html.erb @@ -17,7 +17,9 @@
    -
    +<%# 1. Info %> +

    Info

    +
    Role @@ -37,10 +39,34 @@
    -

    On their plan

    +<%# 2. Embassy %> +

    Embassy appointment

    +<% if @embassy_plan_items.any? %> +
      + <% @embassy_plan_items.each do |pi| %> + <%= render "item_card", schedule_item: pi.schedule_item, notes: pi.notes %> + <% end %> +
    +<% else %> +

    No embassy appointment yet.

    +<% end %> -<% if @plan_items.any? %> - <% @plan_items.group_by { |pi| pi.schedule_item.day }.each do |day_key, items| %> +<%# 3. Hosting %> +

    Hosting

    +<% if @hosted_items.any? %> +
      + <% @hosted_items.each do |si| %> + <%= render "item_card", schedule_item: si, notes: nil %> + <% end %> +
    +<% else %> +

    Not hosting any events.

    +<% end %> + +<%# 4. On their plan %> +

    On their plan

    +<% if @other_plan_items.any? %> + <% @other_plan_items.group_by { |pi| pi.schedule_item.day }.each do |day_key, items| %>

    <%= ScheduleItem::DAY_META.dig(day_key, :label) || day_key %> @@ -48,30 +74,11 @@

      <% items.each do |pi| %> - <% si = pi.schedule_item %> -
    • -
      -
      -
      <%= si.title %>
      - <% if si.host.present? %> -
      <%= si.host %>
      - <% end %> - <% if si.time_label.present? || si.location.present? %> -
      - <%= si.time_label %><%= " · #{si.location}" if si.location.present? %> -
      - <% end %> -
      - <%= si.kind.humanize %> -
      - <% if pi.notes.present? %> -

      Notes: <%= pi.notes %>

      - <% end %> -
    • + <%= render "item_card", schedule_item: pi.schedule_item, notes: pi.notes %> <% end %>
    <% end %> <% else %> -

    Nothing planned yet.

    +

    Nothing else planned.

    <% end %> diff --git a/test/controllers/admin/users_controller_test.rb b/test/controllers/admin/users_controller_test.rb index ced6ac6..81890fa 100644 --- a/test/controllers/admin/users_controller_test.rb +++ b/test/controllers/admin/users_controller_test.rb @@ -40,4 +40,62 @@ class Admin::UsersControllerTest < ActionDispatch::IntegrationTest assert_select "a[href=?]", admin_user_path(u) end end + + test "show page lists events the user is hosting under Hosting" do + alice = users(:attendee_one) + ScheduleItem.create!( + day: "fri", + title: "Alice hosted session", + host: alice.full_name, + kind: :talk, + is_public: true, + time_label: "2:00 PM", + sort_time: 1400 + ) + + sign_in_as users(:jeremy) + get admin_user_path(alice) + assert_response :success + assert_match "Hosting", response.body + assert_match "Alice hosted session", response.body + end + + test "show page surfaces an embassy plan item under its own section" do + alice = users(:attendee_one) + embassy = ScheduleItem.create!( + day: "sat", + title: "Alice embassy slot", + kind: :embassy, + is_public: true, + time_label: "10:00 AM", + sort_time: 1000, + flexible: true + ) + alice.plan_items.create!(schedule_item: embassy) + + sign_in_as users(:jeremy) + get admin_user_path(alice) + assert_match "Embassy appointment", response.body + assert_match "Alice embassy slot", response.body + end + + test "embassy plan items do not appear in the generic plan section" do + alice = users(:attendee_one) + embassy = ScheduleItem.create!( + day: "sat", + title: "Only embassy item", + kind: :embassy, + is_public: true, + time_label: "10:00 AM", + sort_time: 1000 + ) + alice.plan_items.create!(schedule_item: embassy) + + sign_in_as users(:jeremy) + get admin_user_path(alice) + # The embassy header and item render once in the Embassy section, + # and the catch-all "On their plan" section should show the empty + # state since this user has no non-embassy plan items. + assert_match "Nothing else planned", response.body + end end From 57ab0f21f45d2e8aace1c9e1cdde36f3da183dbc Mon Sep 17 00:00:00 2001 From: Katya Sarmiento <5871075+Kitkatnik@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:43:24 -0400 Subject: [PATCH 22/29] Wrap "On their plan" in a subtle panel with label-style day markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The section's nested h3 day headers (same navy styling as the h2 section title) were visually competing with peer section headers (Info / Embassy / Hosting / On their plan), making the plan feel like more parallel sections rather than one grouped block. Now the plan section renders inside a light-gray .panel with small uppercase gray .panel-day-label markers for each day. Typography hierarchy reads: navy h2 = "section" > uppercase-small-gray label = "sub-divider". The white item cards stand out against the gray panel background, reinforcing that everything inside belongs together. Only the plan section gets this treatment — Info / Embassy / Hosting are short flat lists that read fine without an envelope. --- app/assets/stylesheets/application.css | 28 +++++++++++++++++++++++++ app/views/admin/users/show.html.erb | 29 ++++++++++++++------------ 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 6d3dad4..58614fc 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -879,6 +879,34 @@ hr { .card-compact { padding: 1.5rem; } +/* Subtle panel that groups a long list of cards as one visual unit. + Used on the admin user show page around "On their plan". */ +.panel { + background: #f3f4f6; + border: 1px solid #e5e7eb; + border-radius: 0.5rem; + padding: 1.5rem; +} + +/* Sub-heading label inside a .panel — reads as a divider, not a + competing section header. */ +.panel-day-label { + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #6b7280; + margin-bottom: 0.5rem; +} + +.panel-day-label .panel-day-date { + margin-left: 0.5rem; + font-weight: 500; + letter-spacing: 0; + text-transform: none; + color: #9ca3af; +} + /* ---------- Form controls --------------------------------- */ diff --git a/app/views/admin/users/show.html.erb b/app/views/admin/users/show.html.erb index c065511..a5383b7 100644 --- a/app/views/admin/users/show.html.erb +++ b/app/views/admin/users/show.html.erb @@ -66,19 +66,22 @@ <%# 4. On their plan %>

    On their plan

    <% if @other_plan_items.any? %> - <% @other_plan_items.group_by { |pi| pi.schedule_item.day }.each do |day_key, items| %> -
    -

    - <%= ScheduleItem::DAY_META.dig(day_key, :label) || day_key %> - (<%= ScheduleItem::DAY_META.dig(day_key, :date) %>) -

    -
      - <% items.each do |pi| %> - <%= render "item_card", schedule_item: pi.schedule_item, notes: pi.notes %> - <% end %> -
    -
    - <% end %> +
    + <% @other_plan_items.group_by { |pi| pi.schedule_item.day } + .each_with_index do |(day_key, items), i| %> +
    +
    + <%= ScheduleItem::DAY_META.dig(day_key, :label) || day_key %> + <%= ScheduleItem::DAY_META.dig(day_key, :date) %> +
    +
      + <% items.each do |pi| %> + <%= render "item_card", schedule_item: pi.schedule_item, notes: pi.notes %> + <% end %> +
    +
    + <% end %> +
    <% else %>

    Nothing else planned.

    <% end %> From a3580a9ef5a6d0c015d39d0b2ec9cd335f7bf0ee Mon Sep 17 00:00:00 2001 From: Katya Sarmiento <5871075+Kitkatnik@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:46:26 -0400 Subject: [PATCH 23/29] Rename Embassy appointment section to Embassy; widen section spacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Embassy" leaves room for the section to grow when application / waitlist content lands; the appointment list is now the first (and currently only) entry inside it rather than the section title. Bumped spacing on the show page so sections read as distinct: - h2 header to its content: mb-3 -> mb-4 (1rem) - end of one section to the next h2: mb-8 -> mb-12 (3rem) 3:1 ratio applies Gestalt proximity — tight between related items, wide between groups — so structure reads without adding dividers. --- app/views/admin/users/show.html.erb | 18 +++++++++--------- .../controllers/admin/users_controller_test.rb | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/views/admin/users/show.html.erb b/app/views/admin/users/show.html.erb index a5383b7..8c02f4c 100644 --- a/app/views/admin/users/show.html.erb +++ b/app/views/admin/users/show.html.erb @@ -18,8 +18,8 @@
    <%# 1. Info %> -

    Info

    -
    +

    Info

    +
    Role @@ -40,31 +40,31 @@
    <%# 2. Embassy %> -

    Embassy appointment

    +

    Embassy

    <% if @embassy_plan_items.any? %> -
      +
        <% @embassy_plan_items.each do |pi| %> <%= render "item_card", schedule_item: pi.schedule_item, notes: pi.notes %> <% end %>
      <% else %> -

      No embassy appointment yet.

      +

      No embassy appointment yet.

      <% end %> <%# 3. Hosting %> -

      Hosting

      +

      Hosting

      <% if @hosted_items.any? %> -
        +
          <% @hosted_items.each do |si| %> <%= render "item_card", schedule_item: si, notes: nil %> <% end %>
        <% else %> -

        Not hosting any events.

        +

        Not hosting any events.

        <% end %> <%# 4. On their plan %> -

        On their plan

        +

        On their plan

        <% if @other_plan_items.any? %>
        <% @other_plan_items.group_by { |pi| pi.schedule_item.day } diff --git a/test/controllers/admin/users_controller_test.rb b/test/controllers/admin/users_controller_test.rb index 81890fa..6e32022 100644 --- a/test/controllers/admin/users_controller_test.rb +++ b/test/controllers/admin/users_controller_test.rb @@ -75,7 +75,7 @@ class Admin::UsersControllerTest < ActionDispatch::IntegrationTest sign_in_as users(:jeremy) get admin_user_path(alice) - assert_match "Embassy appointment", response.body + assert_select "h2", text: "Embassy" assert_match "Alice embassy slot", response.body end From 4d3b98b5dc88985959b2bbf459403d9a0d22f202 Mon Sep 17 00:00:00 2001 From: Katya Sarmiento <5871075+Kitkatnik@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:48:15 -0400 Subject: [PATCH 24/29] Brakeman: ignore Weak LinkToHref warning on admin user show page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin::UsersController#show renders the user's Tito ticket link via link_to("View in Tito", @user.admin_ticket_url). Brakeman Weak-flags any model attribute interpolated as an href, but admin_ticket_url is built as a fixed https://dashboard.tito.io/... string with a Tito-issued slug (alphanumeric/dashes) — scheme can't be hijacked. The view is also behind require_admin!, so only admins reach it. Added to config/brakeman.ignore with a note documenting why, matching the existing ignore entry style for Admin::UsersController#user_params. --- config/brakeman.ignore | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 15186f3..35f2c49 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -20,6 +20,26 @@ "confidence": "Medium", "cwe_id": [915], "note": "Admin-only controller (require_admin! before_action guards every action). Admins are explicitly allowed to change user roles via the admin UI." + }, + { + "warning_type": "Cross-Site Scripting", + "warning_code": 84, + "fingerprint": "1be79af9d4786778c794c0e4a562801408c947ab7279759a0f7ca517d10eff32", + "check_name": "LinkToHref", + "message": "Potentially unsafe model attribute in `link_to` href", + "file": "app/views/admin/users/show.html.erb", + "line": 31, + "link": "https://brakemanscanner.org/docs/warning_types/link_to_href", + "code": "link_to(\"View in Tito \\u2192\", User.find(params[:id]).admin_ticket_url, :target => \"_blank\", :rel => \"noopener noreferrer\", :class => \"text-blue underline\")", + "render_path": [{"type":"controller","class":"Admin::UsersController","method":"show","line":27,"file":"app/controllers/admin/users_controller.rb","rendered":{"name":"admin/users/show","file":"app/views/admin/users/show.html.erb"}}], + "location": { + "type": "template", + "template": "admin/users/show" + }, + "user_input": "User.find(params[:id]).admin_ticket_url", + "confidence": "Weak", + "cwe_id": [79], + "note": "admin_ticket_url is built by the User model as a hard-coded https://dashboard.tito.io/... string with a Tito-issued slug. The slug is alphanumeric/dashes from the Tito API; the scheme is fixed. /admin is behind require_admin! so only admins reach this view. Low risk." } ], "brakeman_version": "8.0.4" From 401cb0aad8f28f325778546e7cb79a83f956f0db Mon Sep 17 00:00:00 2001 From: Katya Sarmiento <5871075+Kitkatnik@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:12:02 -0400 Subject: [PATCH 25/29] Add Ruby Embassy booking, application, and admin mockup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attendee flow: schedule page shows embassy blocks with mode label and capacity; booking confirm page with Turbo Frame mode picker; long-scroll application form with 3 sections; post-submit ceremony page; My Plan embassy card states (stamping/pending/submitted/expired). Admin flow: question bank CRUD with section scope badges; submitted- application list + detail view showing the per-application draw; batch- generate blank forms page. Section 2 is internally a random pool (draws 4 of 8) but its title, instructions, and rendering are neutral so attendees don't see the mechanic. Admin surfaces expose full scope. Fake data lives in app/services/fake_embassy.rb — one module, one swap when the real backend lands. --- app/assets/stylesheets/application.css | 1059 +++++++++++++++++ .../admin/embassy_applications_controller.rb | 10 + .../admin/embassy_blank_pdfs_controller.rb | 12 + .../admin/embassy_questions_controller.rb | 30 + .../embassy_applications_controller.rb | 31 + .../embassy_bookings_controller.rb | 22 + app/services/fake_embassy.rb | 289 +++++ app/views/admin/dashboard/show.html.erb | 20 +- .../admin/embassy_applications/index.html.erb | 42 + .../admin/embassy_applications/show.html.erb | 39 + .../admin/embassy_blank_pdfs/create.html.erb | 45 + .../admin/embassy_blank_pdfs/new.html.erb | 99 ++ .../admin/embassy_questions/_form.html.erb | 70 ++ .../embassy_questions/_question_row.html.erb | 22 + .../admin/embassy_questions/edit.html.erb | 10 + .../admin/embassy_questions/index.html.erb | 90 ++ .../admin/embassy_questions/new.html.erb | 8 + app/views/admin/schedule_items/_form.html.erb | 32 + .../_appointment_card.html.erb | 24 + .../embassy_applications/_expired.html.erb | 17 + app/views/embassy_applications/_pdf.html.erb | 92 ++ .../embassy_applications/_question.html.erb | 54 + .../embassy_applications/_section.html.erb | 20 + app/views/embassy_applications/new.html.erb | 51 + app/views/embassy_applications/show.html.erb | 75 ++ app/views/embassy_bookings/_confirm.html.erb | 50 + .../embassy_bookings/_mode_picker.html.erb | 34 + app/views/embassy_bookings/create.html.erb | 51 + app/views/embassy_bookings/new.html.erb | 39 + app/views/layouts/admin.html.erb | 4 + app/views/layouts/application.html.erb | 4 + app/views/plan/_plan_item.html.erb | 130 +- app/views/schedule/_session_item.html.erb | 47 +- config/routes.rb | 7 + .../embassy_applications_controller_test.rb | 7 + .../embassy_blank_pdfs_controller_test.rb | 7 + .../embassy_questions_controller_test.rb | 7 + .../embassy_applications_controller_test.rb | 7 + .../embassy_bookings_controller_test.rb | 7 + 39 files changed, 2626 insertions(+), 38 deletions(-) create mode 100644 app/controllers/admin/embassy_applications_controller.rb create mode 100644 app/controllers/admin/embassy_blank_pdfs_controller.rb create mode 100644 app/controllers/admin/embassy_questions_controller.rb create mode 100644 app/controllers/embassy_applications_controller.rb create mode 100644 app/controllers/embassy_bookings_controller.rb create mode 100644 app/services/fake_embassy.rb create mode 100644 app/views/admin/embassy_applications/index.html.erb create mode 100644 app/views/admin/embassy_applications/show.html.erb create mode 100644 app/views/admin/embassy_blank_pdfs/create.html.erb create mode 100644 app/views/admin/embassy_blank_pdfs/new.html.erb create mode 100644 app/views/admin/embassy_questions/_form.html.erb create mode 100644 app/views/admin/embassy_questions/_question_row.html.erb create mode 100644 app/views/admin/embassy_questions/edit.html.erb create mode 100644 app/views/admin/embassy_questions/index.html.erb create mode 100644 app/views/admin/embassy_questions/new.html.erb create mode 100644 app/views/embassy_applications/_appointment_card.html.erb create mode 100644 app/views/embassy_applications/_expired.html.erb create mode 100644 app/views/embassy_applications/_pdf.html.erb create mode 100644 app/views/embassy_applications/_question.html.erb create mode 100644 app/views/embassy_applications/_section.html.erb create mode 100644 app/views/embassy_applications/new.html.erb create mode 100644 app/views/embassy_applications/show.html.erb create mode 100644 app/views/embassy_bookings/_confirm.html.erb create mode 100644 app/views/embassy_bookings/_mode_picker.html.erb create mode 100644 app/views/embassy_bookings/create.html.erb create mode 100644 app/views/embassy_bookings/new.html.erb create mode 100644 test/controllers/admin/embassy_applications_controller_test.rb create mode 100644 test/controllers/admin/embassy_blank_pdfs_controller_test.rb create mode 100644 test/controllers/admin/embassy_questions_controller_test.rb create mode 100644 test/controllers/embassy_applications_controller_test.rb create mode 100644 test/controllers/embassy_bookings_controller_test.rb diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 58614fc..62837c6 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -1210,3 +1210,1062 @@ hr { background-color: #eef1f4; border-radius: 999px; } + +/* ============================================================ + EMBASSY (mockup) + ============================================================ */ + +.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.flex-1 { flex: 1 1 auto; } + +/* Embassy page wrappers ------------------------------------------------ */ + +.embassy-doc { + max-width: 48rem; + margin: 0 auto; + padding: 2rem 1.5rem 4rem; + font-family: "colfax-web", "Inter", system-ui, sans-serif; + color: #0C2866; + position: relative; +} + +.embassy-doc__header { + text-align: center; + margin-bottom: 2.25rem; + padding-bottom: 1.25rem; + border-bottom: 3px double #0C2866; +} + +.embassy-doc__title { + font-family: "Playfair Display", Georgia, serif; + font-weight: 900; + font-size: 2.75rem; + letter-spacing: 0.02em; + line-height: 1.05; + color: #0C2866; + margin: 0.25rem 0; +} + +.embassy-doc__subtitle { + font-size: 1.0625rem; + color: #404040; + font-style: italic; + margin-top: 0.25rem; +} + +.embassy-form-code { + display: inline-block; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.75rem; + color: #7a8189; + letter-spacing: 0.04em; + margin-bottom: 0.5rem; + text-transform: none; +} + +/* Section headers (form + ceremony) ------------------------------------ */ + +.embassy-section-header { + font-family: "Playfair Display", Georgia, serif; + font-weight: 700; + font-size: 1.375rem; + color: #0C2866; + text-transform: uppercase; + letter-spacing: 0.08em; + margin: 0 0 0.75rem; + padding-bottom: 0.375rem; + border-bottom: 3px double #0C2866; +} + +.embassy-section-subhead { + font-family: "Playfair Display", Georgia, serif; + font-weight: 700; + font-size: 1rem; + color: #0C2866; + text-transform: uppercase; + letter-spacing: 0.06em; + margin: 0 0 0.5rem; +} + +/* Definition list used on booking confirm + confirmation pages --------- */ + +.embassy-dl { + display: grid; + grid-template-columns: minmax(8rem, 11rem) 1fr; + gap: 0.5rem 1rem; + margin: 0; + padding: 0; +} + +.embassy-dl dt { + font-size: 0.8125rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #7a8189; + font-weight: 600; +} + +.embassy-dl dd { + font-size: 0.9375rem; + color: #0C2866; + margin: 0; +} + +/* Booking mode picker -------------------------------------------------- */ + +.embassy-mode-grid { + display: grid; + grid-template-columns: 1fr; + gap: 1rem; +} +@media (min-width: 720px) { + .embassy-mode-grid { grid-template-columns: 1fr 1fr; } +} + +.embassy-mode-card { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 1.25rem; + border: 2px solid #d9dee1; + border-radius: 0.5rem; + background: #ffffff; + text-decoration: none; + color: inherit; + transition: all 0.15s ease; +} + +.embassy-mode-card:hover { + border-color: #C41C1C; + background: #fef7f7; + box-shadow: 0 2px 6px rgba(196, 28, 28, 0.08); +} + +.embassy-mode-card__title { + font-family: "Playfair Display", Georgia, serif; + font-weight: 700; + font-size: 1.25rem; + color: #0C2866; + margin: 0; +} + +.embassy-mode-card__desc { + font-size: 0.875rem; + color: #404040; + line-height: 1.45; + margin: 0; +} + +.embassy-mode-card__cta { + margin-top: 0.5rem; + align-self: flex-end; + font-size: 0.875rem; + font-weight: 600; + color: #C41C1C; +} + +/* Schedule-item embassy label / capacity ------------------------------- */ + +.embassy-mode-label { + font-size: 0.8125rem; + color: #7a8189; + font-style: italic; + margin: 0.125rem 0 0; + letter-spacing: 0.01em; +} + +.embassy-capacity { + font-size: 0.75rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + color: #7a8189; + margin: 0.375rem 0 0; + text-align: right; + letter-spacing: 0.04em; +} + +.add-btn--disabled { + background: #f3f4f6; + color: #9ca3af; + border: 1px solid #e5e7eb; + padding: 0.5rem 1rem; + border-radius: 0.25rem; + font-size: 0.875rem; + font-weight: 500; + cursor: not-allowed; +} + +/* Stamping-only confirmation page -------------------------------------- */ + +.embassy-stamping-confirmation { + text-align: center; + padding: 2.5rem 2rem; + position: relative; +} + +.embassy-confirmation-seal { + display: inline-block; + font-family: "Playfair Display", Georgia, serif; + font-weight: 900; + font-size: 1.125rem; + letter-spacing: 0.2em; + color: #C41C1C; + border: 3px double #C41C1C; + padding: 0.5rem 1.25rem; + margin-bottom: 1.5rem; + transform: rotate(-4deg); + text-transform: uppercase; +} + +.embassy-confirmation-seal--inline { + transform: none; + font-size: 0.875rem; + padding: 0.25rem 0.75rem; + letter-spacing: 0.15em; +} + +.embassy-confirmation-detail { + font-size: 1rem; + color: #404040; + margin: 0.5rem 0; +} + +.embassy-confirmation-time { + font-family: "Playfair Display", Georgia, serif; + font-size: 1.75rem; + font-weight: 700; + color: #0C2866; + margin: 0.75rem 0 0.25rem; +} + +.embassy-confirmation-separator { + color: #7a8189; + font-weight: 400; + margin: 0 0.375rem; +} + +.embassy-confirmation-location { + font-size: 0.9375rem; + color: #404040; + margin: 0; +} + +.embassy-etiquette { text-align: left; } +.embassy-etiquette ul { list-style: disc; padding-left: 1.5rem; font-size: 0.875rem; color: #404040; } +.embassy-etiquette li { margin: 0.25rem 0; } + +/* Appointment card (shared) -------------------------------------------- */ + +.embassy-appointment-card { + position: relative; + padding: 1.5rem 1.25rem 1.25rem; + background: #ffffff; + border-top: 4px double #C41C1C; + border-left: 1px solid #d9dee1; + border-right: 1px solid #d9dee1; + border-bottom: 1px solid #d9dee1; + border-radius: 0 0 0.375rem 0.375rem; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); +} + +.embassy-appointment-card__serial { + position: absolute; + top: 0.625rem; + right: 0.875rem; + font-size: 0.6875rem; + color: #7a8189; + letter-spacing: 0.08em; +} + +.embassy-appointment-card__eyebrow { + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.18em; + color: #C41C1C; + font-weight: 600; + margin: 0 0 0.25rem; +} + +.embassy-appointment-card__when { + font-family: "Playfair Display", Georgia, serif; + font-size: 1.5rem; + font-weight: 700; + color: #0C2866; + margin: 0 0 0.125rem; + line-height: 1.15; +} + +.embassy-appointment-card__dot { + color: #7a8189; + font-weight: 400; + margin: 0 0.375rem; +} + +.embassy-appointment-card__where { + font-size: 0.9375rem; + color: #404040; + margin: 0 0 0.75rem; +} + +.embassy-appointment-card__dl { + display: grid; + grid-template-columns: minmax(5rem, 7rem) 1fr; + gap: 0.25rem 0.75rem; + margin: 0; + padding-top: 0.75rem; + border-top: 1px solid #eef1f4; +} + +.embassy-appointment-card__dl dt { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #7a8189; +} + +.embassy-appointment-card__dl dd { + font-size: 0.875rem; + color: #0C2866; + margin: 0; +} + +.embassy-appointment-card--submitted { border-top-color: #0C2866; } +.embassy-appointment-card--stamping { border-top-color: #2672B5; } +.embassy-appointment-card--pending { border-top-color: #C41C1C; border-top-style: dashed; } +.embassy-appointment-card--expired { border-top-color: #7a8189; border-top-style: dashed; opacity: 0.9; } + +/* Countdown pill ------------------------------------------------------- */ + +.embassy-countdown { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.875rem; + background: #fef3c7; + color: #8A5A00; + border-radius: 999px; + font-size: 0.8125rem; + font-weight: 500; + margin: 0.5rem 0; +} + +/* Application form canvas (and PDF canvas) ----------------------------- */ + +.application-canvas { + max-width: 48rem; + margin: 0 auto; + padding: 2rem 1.5rem 4rem; + background: #ffffff; + color: #0C2866; + font-family: "colfax-web", "Inter", system-ui, sans-serif; + position: relative; +} + +.application-canvas__header { + text-align: center; + padding-bottom: 1.25rem; + margin-bottom: 1.75rem; + border-bottom: 3px double #0C2866; + position: relative; +} + +.application-canvas__title { + font-family: "Playfair Display", Georgia, serif; + font-weight: 900; + font-size: 2.25rem; + letter-spacing: 0.03em; + margin: 0.25rem 0; + color: #0C2866; +} + +.application-canvas__subtitle { + font-size: 1rem; + color: #404040; + font-style: italic; + margin: 0.25rem 0 0.75rem; +} + +.application-canvas__instructions { + font-size: 0.875rem; + color: #525C66; + max-width: 36rem; + margin: 1rem auto 0; + line-height: 1.5; + font-style: italic; +} + +.application-canvas__footer { + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 3px double #0C2866; + position: relative; + text-align: center; +} + +.application-canvas__serial { + position: absolute; + right: 0; + top: 1.5rem; + font-size: 0.8125rem; + color: #7a8189; + letter-spacing: 0.08em; +} + +.application-canvas__actions { + display: inline-flex; + gap: 0.75rem; + flex-wrap: wrap; + justify-content: center; +} + +.application-canvas__footer-note { + font-size: 0.75rem; + font-style: italic; + margin-top: 1rem; + max-width: 30rem; + margin-left: auto; + margin-right: auto; + line-height: 1.5; +} + +.btn-lg { + padding: 0.75rem 1.75rem; + font-size: 1rem; +} + +/* Form section --------------------------------------------------------- */ + +.embassy-section { + margin: 2rem 0; + padding: 1.25rem 1.25rem 1rem; + border: 1px solid #d9dee1; + border-top: 3px solid #0C2866; + border-radius: 0 0 0.375rem 0.375rem; + background: #ffffff; +} + +.embassy-section__header { + display: flex; + align-items: baseline; + gap: 0.75rem; + padding-bottom: 0.5rem; + margin-bottom: 0.75rem; + border-bottom: 1px solid #eef1f4; +} + +.embassy-section__number { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.75rem; + letter-spacing: 0.1em; + color: #7a8189; + text-transform: uppercase; +} + +.embassy-section__title { + font-family: "Playfair Display", Georgia, serif; + font-weight: 700; + font-size: 1.375rem; + color: #0C2866; + margin: 0; + letter-spacing: 0.015em; +} + +.embassy-section__instructions { + font-size: 0.8125rem; + color: #525C66; + font-style: italic; + margin: 0 0 1rem; +} + +.embassy-section__questions { + display: flex; + flex-direction: column; + gap: 1rem; +} + +/* Question ------------------------------------------------------------- */ + +.embassy-question__label { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: #0C2866; + margin-bottom: 0.25rem; + text-transform: uppercase; + letter-spacing: 0.04em; + line-height: 1.4; +} + +.embassy-question__number { + display: inline-block; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + color: #7a8189; + margin-right: 0.375rem; + font-size: 0.8125rem; +} + +.embassy-question__required { + color: #C41C1C; + font-weight: 700; + margin-left: 0.125rem; +} + +.embassy-question__help { + font-size: 0.75rem; + color: #7a8189; + margin: 0 0 0.375rem; + font-style: italic; +} + +.embassy-question__input { + width: 100%; + padding: 0.5rem 0.625rem; + font-size: 0.9375rem; + border: 1px solid #d1d5db; + border-radius: 0.25rem; + background: #ffffff; + color: #0C2866; + font-family: inherit; +} + +.embassy-question__input:focus { + outline: 2px solid #2672B5; + outline-offset: -1px; + border-color: #2672B5; + box-shadow: 0 0 0 3px rgba(38, 114, 181, 0.15); +} + +.embassy-question__input--long { min-height: 4.5rem; resize: vertical; } +.embassy-question__input--date { max-width: 14rem; } + +.embassy-question__checkbox { + display: inline-flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border: 1px solid #d1d5db; + border-radius: 0.25rem; + background: #f9fafb; + font-size: 0.875rem; + color: #404040; +} + +.embassy-question__checkbox input { margin-top: 0.25rem; } + +.embassy-serial { + font-size: 0.875rem; + letter-spacing: 0.1em; +} + +/* Ceremony page -------------------------------------------------------- */ + +.embassy-ceremony { display: flex; flex-direction: column; gap: 2rem; } + +.embassy-ceremony__callout { + text-align: center; + padding: 1.5rem; + background: #fef7f7; + border: 1px solid #fecaca; + border-radius: 0.375rem; +} + +.embassy-ceremony__serial-label { + font-size: 0.75rem; + letter-spacing: 0.2em; + color: #7a8189; + margin: 0 0 0.375rem; +} + +.embassy-ceremony__serial-value { + font-size: 2.25rem; + font-weight: 700; + color: #C41C1C; + margin: 0; + letter-spacing: 0.08em; +} + +.embassy-ceremony__serial-note { + font-size: 0.8125rem; + color: #525C66; + margin: 0.5rem 0 0; +} + +.embassy-ceremony__download { text-align: center; } + +.embassy-ceremony__preview { + padding: 1rem; + background: #f9fafb; + border: 1px dashed #d9dee1; + border-radius: 0.375rem; +} + +.embassy-ceremony__preview .application-canvas { + padding: 1.5rem; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + border: 1px solid #d9dee1; +} + +.embassy-ceremony__back { display: flex; gap: 0.75rem; justify-content: center; } + +/* Expired state -------------------------------------------------------- */ + +.embassy-expired { + text-align: center; + padding: 1.75rem 1.5rem; + background: #f9fafb; + border-left: 3px dashed #7a8189; +} + +.embassy-expired__stamp { + display: inline-block; + font-family: "Playfair Display", Georgia, serif; + font-weight: 900; + font-size: 0.875rem; + letter-spacing: 0.25em; + color: #7a8189; + border: 2px double #7a8189; + padding: 0.375rem 0.875rem; + margin-bottom: 1rem; + transform: rotate(-3deg); +} + +.embassy-expired__title { + font-family: "Playfair Display", Georgia, serif; + font-weight: 700; + font-size: 1.25rem; + color: #0C2866; + margin: 0 0 0.5rem; +} + +.embassy-expired__body { + font-size: 0.9375rem; + color: #525C66; + max-width: 32rem; + margin: 0 auto 1rem; + line-height: 1.5; +} + +/* Plan-item embassy variant -------------------------------------------- */ + +.plan-item--embassy { + display: flex; + gap: 1rem; + padding: 0; + margin-bottom: 0.875rem; + background: #ffffff; + border: 1px solid #d9dee1; + border-radius: 0.375rem; + overflow: hidden; + transition: box-shadow 0.15s ease; +} + +.plan-item--embassy:hover { + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); +} + +.plan-item__embassy-body { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + +.plan-item__embassy-body .embassy-appointment-card { + border: none; + border-top: 4px double #C41C1C; + border-radius: 0; + box-shadow: none; + padding: 1.25rem 1.25rem 1rem; +} + +.plan-item__embassy-action { + padding: 0.875rem 1.25rem 1rem; + border-top: 1px solid #eef1f4; + background: #fbfcfd; + display: flex; + flex-direction: column; + gap: 0.625rem; + align-items: flex-start; +} + +.plan-item__embassy-action .btn { align-self: flex-start; } + +.plan-item__embassy-note { + font-size: 0.875rem; + color: #525C66; + margin: 0; + line-height: 1.4; +} + +.plan-item--embassy-expired .plan-item__embassy-body .embassy-appointment-card { + border-top-style: dashed; + border-top-color: #7a8189; +} + +.plan-item--embassy-submitted .plan-item__embassy-body .embassy-appointment-card { + border-top-color: #0C2866; +} + +.plan-item--embassy-stamping .plan-item__embassy-body .embassy-appointment-card { + border-top-color: #2672B5; +} + +/* Admin question bank sections ---------------------------------------- */ + +.embassy-bank-section { + margin: 2rem 0 1rem; +} + +.embassy-bank-section__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.625rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid #eef1f4; +} + +.embassy-bank-section__title { + font-family: "Playfair Display", Georgia, serif; + font-weight: 700; + font-size: 1.125rem; + color: #0C2866; + margin: 0; + letter-spacing: 0.02em; +} + +.embassy-bank-section__hint { + font-size: 0.8125rem; + color: #7a8189; + margin: 0.125rem 0 0; + font-style: italic; +} + +.badge-scope { + display: inline-block; + padding: 0.25rem 0.625rem; + font-size: 0.75rem; + font-weight: 500; + letter-spacing: 0.03em; + border-radius: 999px; + white-space: nowrap; +} + +.badge-scope--common { + background: #EEF1F4; + color: #525C66; +} + +.badge-scope--random { + background: #fef3c7; + color: #8A5A00; + border: 1px solid #f6d97a; +} + +/* Admin question bank badges (extras for new types) -------------------- */ + +.badge-short { background: #EEF1F4; color: #525C66; } +.badge-long { background: #EEF1F4; color: #525C66; } +.badge-select { background: #E0ECFF; color: #2672B5; } +.badge-checkbox { background: #fef3c7; color: #8A5A00; } +.badge-date { background: #F3E8FF; color: #7E22CE; } + +/* Admin schedule-items form disclosure --------------------------------- */ + +.embassy-form-extras { + margin-top: 1rem; + padding: 0.875rem 1rem; + background: #fefcf7; + border-left: 3px solid #C41C1C; + border-radius: 0.25rem; +} + +.embassy-form-extras summary { + cursor: pointer; + font-size: 0.875rem; + color: #0C2866; +} + +.embassy-form-extras__body { + margin-top: 0.875rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +/* Admin serial list ---------------------------------------------------- */ + +.embassy-serial-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr)); + gap: 0.375rem; + list-style: none; + padding: 0; + margin: 0; +} + +.embassy-serial-list li { + padding: 0.375rem 0.5rem; + background: #f9fafb; + border: 1px solid #eef1f4; + border-radius: 0.25rem; + font-size: 0.8125rem; + text-align: center; + letter-spacing: 0.06em; + color: #0C2866; +} + +/* PDF page (screen preview + print) ------------------------------------ */ + +.application-canvas--pdf { + background: #ffffff; + border: 1px solid #d9dee1; + padding: 0; + margin: 0 auto; + max-width: 48rem; +} + +.application-canvas--blank { margin-top: 1rem; } + +.pdf-page { + padding: 2.5rem 2.75rem 2rem; + color: #000000; + font-family: "colfax-web", "Inter", system-ui, sans-serif; + min-height: 55rem; + position: relative; +} + +.pdf-page__header { + padding-bottom: 1rem; + border-bottom: 2px double #000000; + margin-bottom: 1.5rem; +} + +.pdf-page__header-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.75rem; +} + +.pdf-page__form-code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.6875rem; + line-height: 1.3; + color: #000000; + letter-spacing: 0.04em; +} + +.pdf-page__title { + font-family: "Playfair Display", Georgia, serif; + font-weight: 900; + font-size: 1.875rem; + letter-spacing: 0.15em; + margin: 0; + text-align: center; + color: #000000; +} + +.pdf-page__subtitle { + font-size: 0.9375rem; + font-style: italic; + text-align: center; + margin: 0.25rem 0 0.75rem; + color: #000000; +} + +.pdf-page__meta { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.5rem 1rem; + margin: 0.5rem 0 0; +} + +.pdf-page__meta dt { + font-size: 0.625rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: #000000; + margin: 0; +} + +.pdf-page__meta dd { + font-size: 0.8125rem; + color: #000000; + margin: 0; + border-bottom: 1px solid #000000; + padding-bottom: 0.125rem; +} + +.embassy-stamp { + width: 72px; + height: 72px; + color: #000000; + flex-shrink: 0; +} + +.pdf-section { + margin-bottom: 1.25rem; + padding: 0.75rem 0.875rem; + border: 1px solid #000000; + page-break-inside: avoid; +} + +.pdf-section__title { + font-family: "Playfair Display", Georgia, serif; + font-weight: 700; + font-size: 1rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: #000000; + margin: 0 0 0.625rem; + padding-bottom: 0.25rem; + border-bottom: 1px solid #000000; +} + +.pdf-q { + display: flex; + align-items: flex-start; + gap: 0.375rem; + padding: 0.375rem 0; + border-bottom: 1px dotted #000000; +} + +.pdf-q:last-child { border-bottom: none; } + +.pdf-q__id { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.75rem; + font-weight: 700; + color: #000000; + min-width: 1.75rem; +} + +.pdf-q__body { flex: 1; min-width: 0; } + +.pdf-q__label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + margin: 0 0 0.125rem; + color: #000000; +} + +.pdf-q__answer { + font-size: 0.875rem; + color: #000000; + min-height: 1.25rem; + border-bottom: 1px solid #000000; + padding: 0.125rem 0.25rem; +} + +.pdf-q__answer--blank { min-height: 1.5rem; border-bottom: 1px solid #000000; } +.pdf-q__answer--long { display: block; min-height: 2.5rem; line-height: 1.4; } + +.pdf-signature { + margin: 1.5rem 0 1rem; + padding-top: 0.75rem; + border-top: 2px solid #000000; +} + +.pdf-signature__row { + display: grid; + grid-template-columns: 3fr 1fr; + gap: 1rem; + margin-bottom: 0.25rem; +} + +.pdf-signature__line { + border-bottom: 1px solid #000000; + min-height: 1.75rem; + padding: 0 0.5rem; + display: flex; + align-items: flex-end; +} + +.pdf-signature__signature { + font-family: Georgia, "Times New Roman", serif; + font-style: italic; + font-size: 1rem; + color: #000000; +} + +.pdf-signature__date { + border-bottom: 1px solid #000000; + min-height: 1.75rem; + padding: 0 0.5rem; + display: flex; + align-items: flex-end; +} + +.pdf-signature__datevalue { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.8125rem; +} + +.pdf-signature__captions { + display: grid; + grid-template-columns: 3fr 1fr; + gap: 1rem; + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #000000; + padding: 0 0.5rem; +} + +.pdf-page__footer { + position: absolute; + bottom: 1.25rem; + left: 2.75rem; + right: 2.75rem; + display: flex; + justify-content: space-between; + align-items: baseline; + padding-top: 0.5rem; + border-top: 1px solid #000000; + font-size: 0.6875rem; + color: #000000; + letter-spacing: 0.05em; +} + +.pdf-page__serial { + font-size: 0.8125rem; + letter-spacing: 0.1em; + font-weight: 700; +} + +/* Print media: strip nav/chrome so Print Preview shows only the PDF ---- */ + +@media print { + body { background: #ffffff !important; } + .admin-nav, nav, header, footer, + .embassy-ceremony__callout, + .embassy-ceremony__download, + .embassy-ceremony__etiquette, + .embassy-ceremony__back, + .btn, .alert { display: none !important; } + .application-canvas, + .application-canvas--pdf { + border: none !important; + box-shadow: none !important; + max-width: 100% !important; + padding: 0 !important; + } + .pdf-page { + page-break-after: always; + padding: 1rem 1.25rem; + min-height: auto; + } + .embassy-ceremony__preview { background: none; border: none; padding: 0; } + .embassy-ceremony__preview .application-canvas { + padding: 0 !important; + box-shadow: none !important; + border: none !important; + } +} + diff --git a/app/controllers/admin/embassy_applications_controller.rb b/app/controllers/admin/embassy_applications_controller.rb new file mode 100644 index 0000000..d641d95 --- /dev/null +++ b/app/controllers/admin/embassy_applications_controller.rb @@ -0,0 +1,10 @@ +class Admin::EmbassyApplicationsController < AdminController + def index + @applications = FakeEmbassy.submitted_applications + end + + def show + @application = FakeEmbassy.find_submitted_application(params[:id]) + @sections = FakeEmbassy.sample_questions + end +end diff --git a/app/controllers/admin/embassy_blank_pdfs_controller.rb b/app/controllers/admin/embassy_blank_pdfs_controller.rb new file mode 100644 index 0000000..67e8020 --- /dev/null +++ b/app/controllers/admin/embassy_blank_pdfs_controller.rb @@ -0,0 +1,12 @@ +class Admin::EmbassyBlankPdfsController < AdminController + def new + @default_count = 12 + @preview_sections = FakeEmbassy.sample_questions + @preview_serial = "RE-0427-A" + end + + def create + @count = (params[:count].presence || 12).to_i.clamp(1, 100) + @serials = (0...@count).map { |i| FakeEmbassy.serial_for(i) } + end +end diff --git a/app/controllers/admin/embassy_questions_controller.rb b/app/controllers/admin/embassy_questions_controller.rb new file mode 100644 index 0000000..8946414 --- /dev/null +++ b/app/controllers/admin/embassy_questions_controller.rb @@ -0,0 +1,30 @@ +class Admin::EmbassyQuestionsController < AdminController + def index + @questions = FakeEmbassy.question_bank + @sections = FakeEmbassy.sample_questions.map { |s| [ s[:number], s[:title] ] } + end + + def new + @question = { id: "", section: 1, label: "", type: :short, + required: false, help: "", status: "active" } + end + + def create + redirect_to admin_embassy_questions_path, + notice: "Question added to the bank." + end + + def edit + @question = FakeEmbassy.find_question(params[:id]) + end + + def update + redirect_to admin_embassy_questions_path, + notice: "Question updated." + end + + def destroy + redirect_to admin_embassy_questions_path, + notice: "Question archived." + end +end diff --git a/app/controllers/embassy_applications_controller.rb b/app/controllers/embassy_applications_controller.rb new file mode 100644 index 0000000..aa9ce53 --- /dev/null +++ b/app/controllers/embassy_applications_controller.rb @@ -0,0 +1,31 @@ +class EmbassyApplicationsController < ApplicationController + def new + @plan_item = current_user.plan_items.find_by(id: params[:plan_item_id]) + @schedule_item = @plan_item&.schedule_item + @sections = FakeEmbassy.sample_questions + @serial = FakeEmbassy.serial_for(@plan_item&.id || 1) + @minutes_left = FakeEmbassy.reservation_minutes_left(@plan_item&.id || 1) + end + + def create + plan_item_id = params[:plan_item_id] || 1 + redirect_to embassy_application_path(FakeEmbassy.serial_for(plan_item_id)) + end + + def show + @serial = params[:id] + @sections = FakeEmbassy.sample_questions + @schedule_item = ScheduleItem.embassy.first || ScheduleItem.first + @plan_item = current_user.plan_items.joins(:schedule_item) + .where(schedule_items: { kind: ScheduleItem.kinds[:embassy] }) + .first + end + + def edit + redirect_to new_embassy_application_path(plan_item_id: params[:id]) + end + + def update + redirect_to embassy_application_path(params[:id]) + end +end diff --git a/app/controllers/embassy_bookings_controller.rb b/app/controllers/embassy_bookings_controller.rb new file mode 100644 index 0000000..2494454 --- /dev/null +++ b/app/controllers/embassy_bookings_controller.rb @@ -0,0 +1,22 @@ +class EmbassyBookingsController < ApplicationController + def new + @schedule_item = ScheduleItem.find(params[:schedule_item_id]) + @block_mode = FakeEmbassy.mode_for(@schedule_item.id) + @chosen_mode = params[:mode].presence || (@block_mode == "both" ? nil : @block_mode) + @capacity = FakeEmbassy.capacity_for(@schedule_item.id) + @seats_taken = FakeEmbassy.seats_taken_for(@schedule_item.id) + end + + def create + @schedule_item = ScheduleItem.find(params[:schedule_item_id]) + mode = params[:mode].presence || "new_passport" + @plan_item = current_user.plan_items.find_or_create_by!(schedule_item: @schedule_item) + + if mode == "stamping" + @chosen_mode = "stamping" + render :create + else + redirect_to new_embassy_application_path(plan_item_id: @plan_item.id) + end + end +end diff --git a/app/services/fake_embassy.rb b/app/services/fake_embassy.rb new file mode 100644 index 0000000..d49ff52 --- /dev/null +++ b/app/services/fake_embassy.rb @@ -0,0 +1,289 @@ +module FakeEmbassy + module_function + + MODES = %w[new_passport stamping both].freeze + + def mode_for(schedule_item_id) + MODES[schedule_item_id.to_i % MODES.length] + end + + def mode_label(mode) + { + "new_passport" => "New passport", + "stamping" => "Stamping", + "both" => "New passport or Stamping" + }[mode] || "—" + end + + def mode_short(mode) + { "new_passport" => "New passport", "stamping" => "Stamping", "both" => "New or Stamp" }[mode] + end + + def capacity_for(schedule_item_id) + [ 6, 8, 10, 12 ][schedule_item_id.to_i % 4] + end + + def seats_taken_for(schedule_item_id) + [ 2, 3, 5, 7 ][schedule_item_id.to_i % 4].clamp(0, capacity_for(schedule_item_id)) + end + + def seats_remaining(schedule_item_id) + capacity_for(schedule_item_id) - seats_taken_for(schedule_item_id) + end + + def full?(schedule_item_id) + seats_remaining(schedule_item_id) <= 0 + end + + STATES = %w[stamping pending submitted expired].freeze + + def appointment_state_for(plan_item_id) + STATES[plan_item_id.to_i % STATES.length] + end + + def serial_for(id) + letter = ("A".."Z").to_a[id.to_i % 26] + "RE-0427-#{letter}" + end + + def reservation_minutes_left(plan_item_id) + [ 47, 32, 18, 3 ][plan_item_id.to_i % 4] + end + + # === Section 1 · Declaration of Ruby-ness (COMMON) ======================== + # Identity, background, and the signature relationship question. Every + # applicant answers these. The Matz question lives here deliberately so + # every Ruby Embassy passport is stamped with a personal answer. + SECTION_1_QUESTIONS = [ + { id: "1a", label: "Given Name (as it appears on your Tito badge)", + type: :short, required: true, placeholder: "e.g., Katya" }, + { id: "1b", label: "GitHub handle", + type: :short, required: false, help: "Your @handle on GitHub." }, + { id: "1c", label: "Preferred pronouns", + type: :short, required: false }, + { id: "1d", label: "First Ruby release you remember using", + type: :select, required: true, + options: [ "1.8.x", "1.9.x", "2.0–2.6", "2.7–3.0", "3.1+", "I refuse to answer" ] }, + { id: "1e", label: "Ruby release you most miss", + type: :short, required: false, placeholder: "e.g., 1.8.7, for sentimental reasons" }, + { id: "1f", label: "Describe, in one paragraph, your relationship to Yukihiro Matsumoto", + type: :long, required: true, help: "Literal or metaphorical answers both accepted." } + ].freeze + + # === Section 2 · Supplementary Declarations (RANDOM POOL, draws 4 of 8) === + # Internally a random pool that draws a different subset per application. + # Users must not know this: the section title, instructions, and rendering + # should all look like any other section of a standard embassy form. + # Admins manage the pool via the Question Bank. + SECTION_2_POOL = [ + { id: "2a", label: "How many keyboards do you currently own?", + type: :select, required: false, + options: [ "0", "1", "2–3", "4–6", "More than I am willing to admit" ] }, + { id: "2b", label: "Preferred test runner", + type: :select, required: false, + options: [ "RSpec", "Minitest", "Both, depending on mood", "Neither, I test in production" ] }, + { id: "2c", label: "Most embarrassing production incident (brief)", + type: :long, required: false, help: "Names will be redacted. Scars will not." }, + { id: "2d", label: "Worst Rails upgrade story, in three sentences", + type: :long, required: false }, + { id: "2e", label: "If Ruby were a beverage, what beverage would it be?", + type: :short, required: false, placeholder: "e.g., a warm amaro, a slow espresso" }, + { id: "2f", label: "Have you ever written method_missing? If so, how did it feel?", + type: :long, required: false, + help: "Responses involving \"transcendent\" or \"regret\" will be evaluated equally." }, + { id: "2g", label: "Name one gem that does not spark joy", + type: :short, required: false }, + { id: "2h", label: "Pair programming: describe your emotional relationship to the practice", + type: :long, required: false } + ].freeze + + # Deterministic draw for the stable mockup preview. + SECTION_2_DRAWN_IDS = %w[2a 2b 2e 2f].freeze + SECTION_2_DRAWS = 4 + + # === Section 3 · Affidavit of Attendance (COMMON) ========================= + SECTION_3_QUESTIONS = [ + { id: "3a", label: "I affirm I will respect the Embassy's Ruby-only policy for the duration of my visit", + type: :checkbox, required: true }, + { id: "3b", label: "Date of arrival in Asheville", + type: :date, required: true }, + { id: "3c", label: "Are you currently in possession of unreleased gems of unknown provenance?", + type: :select, required: true, + options: [ "No", "Yes", "I plead the fifth" ] }, + { id: "3d", label: "Signature (type your full legal name)", + type: :short, required: true, + help: "This carries the same legal weight as a stamped declaration, which is to say: very little." } + ].freeze + + def sample_questions + [ + { number: 1, + title: "Declaration of Ruby-ness", + instructions: "Print clearly. This section establishes your eligibility under Embassy Ordinance §1.9.", + scope: "common", + questions: SECTION_1_QUESTIONS }, + + # NOTE: title + instructions here are deliberately neutral — users + # must not know this section is randomized. Admin surfaces (question + # bank, applications detail) expose the scope/draws metadata. + { number: 2, + title: "Supplementary Declarations", + instructions: "Please respond to the following to the best of your ability. Answers are retained for statistical and adjudication purposes.", + scope: "random_pool", + draws: SECTION_2_DRAWS, + pool_size: SECTION_2_POOL.length, + questions: SECTION_2_POOL.select { |q| SECTION_2_DRAWN_IDS.include?(q[:id]) } }, + + { number: 3, + title: "Affidavit of Attendance", + instructions: "Falsified answers may result in revocation of Ruby Embassy privileges for up to three business gems.", + scope: "common", + questions: SECTION_3_QUESTIONS } + ] + end + + FILLED_ANSWERS = { + # Section 1 — common + "1a" => "Katya", + "1b" => "@kitkatnik", + "1c" => "she/her", + "1d" => "2.7–3.0", + "1e" => "1.8.7 — the heart wants what it wants", + "1f" => "We've never spoken, but I feel his presence every time I write a block. Once, while writing a method_missing, I felt we achieved mutual understanding.", + # Section 2 — random pool (values for all pool members; only drawn ones render) + "2a" => "2–3", + "2b" => "Minitest", + "2c" => "Rolled a migration, rolled the wrong direction, rolled my chair into a wall.", + "2d" => "We upgraded 4.2 to 5.0. We upgraded 5.0 to 5.1. We stopped upgrading.", + "2e" => "A warm amaro, drunk slowly on a porch.", + "2f" => "Yes. Transcendent, then regretful, then transcendent again.", + "2g" => "The one that shall not be named.", + "2h" => "Pair programming is the one time my keyboard is not mine.", + # Section 3 — common + "3a" => true, + "3b" => "2026-04-29", + "3c" => "I plead the fifth", + "3d" => "Katya Sarmiento" + }.freeze + + def filled_answer(question_id) + FILLED_ANSWERS[question_id] + end + + def submitted_applications + [ + { serial: "RE-0427-A", attendee_name: "Katya Sarmiento", attendee_email: "software@adhdcoder.com", + slot: "Sat May 2 · 2:00 PM", submitted_at: "Apr 30 · 3:14 PM", + drawn_ids: %w[2a 2b 2e 2f] }, + { serial: "RE-0427-B", attendee_name: "John Athayde", attendee_email: "john@example.com", + slot: "Sat May 2 · 2:00 PM", submitted_at: "Apr 30 · 4:02 PM", + drawn_ids: %w[2c 2d 2f 2h] }, + { serial: "RE-0427-C", attendee_name: "Aaron Patterson", attendee_email: "aaron@example.com", + slot: "Sat May 2 · 2:15 PM", submitted_at: "May 1 · 9:11 AM", + drawn_ids: %w[2a 2d 2e 2g] }, + { serial: "RE-0427-D", attendee_name: "Sandi Metz", attendee_email: "sandi@example.com", + slot: "Sat May 2 · 2:15 PM", submitted_at: "May 1 · 10:48 AM", + drawn_ids: %w[2b 2c 2f 2h] }, + { serial: "RE-0427-E", attendee_name: "Avdi Grimm", attendee_email: "avdi@example.com", + slot: "Sat May 2 · 2:30 PM", submitted_at: "May 1 · 11:02 AM", + drawn_ids: %w[2a 2c 2d 2g] }, + { serial: "RE-0427-F", attendee_name: "Mislav Marohnić", attendee_email: "mislav@example.com", + slot: "Sat May 2 · 2:30 PM", submitted_at: "May 1 · 12:19 PM", + drawn_ids: %w[2a 2e 2f 2h] }, + { serial: "RE-0427-G", attendee_name: "Penelope Phippen", attendee_email: "penelope@example.com", + slot: "Sat May 2 · 2:45 PM", submitted_at: "May 1 · 1:45 PM", + drawn_ids: %w[2b 2c 2d 2g] } + ] + end + + def find_submitted_application(serial) + submitted_applications.find { |a| a[:serial] == serial } || + { serial: serial, attendee_name: "Unknown", attendee_email: "—", + slot: "—", submitted_at: "—", drawn_ids: SECTION_2_DRAWN_IDS.dup } + end + + # Sections + their questions rendered for a SPECIFIC submitted application. + # Admin detail view uses this to show the exact random draw that attendee + # received, instead of the default mockup draw. + def sections_for(application) + drawn = application[:drawn_ids] || SECTION_2_DRAWN_IDS + sample_questions.map do |section| + next section unless section[:scope] == "random_pool" + section.merge(questions: SECTION_2_POOL.select { |q| drawn.include?(q[:id]) }) + end + end + + # Flat bank for the admin Question Bank index. Includes ALL random-pool + # members (not just drawn) so admins can manage the full set. + def question_bank + bank = [] + SECTION_1_QUESTIONS.each do |q| + bank << q.merge( + section: 1, section_title: "Declaration of Ruby-ness", + section_scope: "common", status: "active", + usage_count: deterministic_usage(q[:id]) + ) + end + SECTION_2_POOL.each do |q| + bank << q.merge( + section: 2, section_title: "Supplementary Declarations", + section_scope: "random_pool", status: "active", + usage_count: deterministic_usage(q[:id]) + ) + end + SECTION_3_QUESTIONS.each do |q| + bank << q.merge( + section: 3, section_title: "Affidavit of Attendance", + section_scope: "common", status: "active", + usage_count: deterministic_usage(q[:id]) + ) + end + bank << { + id: "X1", section: 0, section_title: "Archived", + section_scope: "common", + label: "Preferred blend of Ruby Roast coffee (retired question)", + type: :select, required: false, usage_count: 12, status: "archived" + } + bank + end + + # Metadata per section — used on admin surfaces to expose the scope. + SECTION_META = { + 1 => { title: "Declaration of Ruby-ness", scope: "common", + hint: "Every applicant fills these out." }, + 2 => { title: "Supplementary Declarations", scope: "random_pool", + hint: "Internally a random pool — draws 4 of 8 per application. Users see only a numbered section.", + draws: 4 }, + 3 => { title: "Affidavit of Attendance", scope: "common", + hint: "Every applicant fills these out." } + }.freeze + + def section_meta + SECTION_META + end + + def find_question(question_id) + question_bank.find { |q| q[:id] == question_id } || question_bank.first + end + + def embassy_etiquette + [ + "Please arrive 5 minutes before your appointment time.", + "Bring your printed application. Unprinted applications will be filled out on-site — paper only, please.", + "Stamping appointments require an existing Ruby passport. No passport, no stamp.", + "Photography inside the Embassy Office is permitted, but do not photograph the Stamping Apparatus.", + "The Embassy Office is located in the Main Hall. Look for the ruby-red awning." + ] + end + + def embassy_hours + [ + "Saturday, May 2 · 1:00 PM – 5:00 PM", + "Sunday, May 3 · 9:00 AM – 12:00 PM (stamping only)" + ] + end + + def deterministic_usage(id) + 40 + (id.to_s.bytes.sum % 25) + end +end diff --git a/app/views/admin/dashboard/show.html.erb b/app/views/admin/dashboard/show.html.erb index 2e057a3..a729d5b 100644 --- a/app/views/admin/dashboard/show.html.erb +++ b/app/views/admin/dashboard/show.html.erb @@ -28,7 +28,7 @@

        Manage

        -
        +
        <%= link_to admin_users_path, class: "nav-card" do %> @@ -44,3 +44,21 @@ <% end %>
        + +

        Ruby Embassy

        +
        + <%= link_to admin_embassy_questions_path, class: "nav-card" do %> + + + <% end %> + + <%= link_to admin_embassy_applications_path, class: "nav-card" do %> + + + <% end %> + + <%= link_to new_admin_embassy_blank_pdfs_path, class: "nav-card" do %> + + + <% end %> +
        diff --git a/app/views/admin/embassy_applications/index.html.erb b/app/views/admin/embassy_applications/index.html.erb new file mode 100644 index 0000000..9590354 --- /dev/null +++ b/app/views/admin/embassy_applications/index.html.erb @@ -0,0 +1,42 @@ +<% content_for :title, "Submitted Applications — Embassy Admin" %> + +
        +
        +

        Submitted Applications

        +

        + <%= @applications.size %> applications · Click a serial to view the full adjudicated form. +

        +
        + <%= link_to "Generate Blank Forms", + new_admin_embassy_blank_pdfs_path, + class: "btn btn-red" %> +
        + + + + + + + + + + + + + <% @applications.each do |app| %> + + + + + + + + <% end %> + +
        SerialAttendeeAppointmentSubmitted
        <%= app[:serial] %> +
        <%= app[:attendee_name] %>
        +
        <%= app[:attendee_email] %>
        +
        <%= app[:slot] %><%= app[:submitted_at] %> + <%= link_to "View", admin_embassy_application_path(app[:serial]), + class: "text-blue underline" %> +
        diff --git a/app/views/admin/embassy_applications/show.html.erb b/app/views/admin/embassy_applications/show.html.erb new file mode 100644 index 0000000..14914b8 --- /dev/null +++ b/app/views/admin/embassy_applications/show.html.erb @@ -0,0 +1,39 @@ +<% content_for :title, "Application #{@application[:serial]} — Embassy Admin" %> + +
        +
        +

        + Application <%= @application[:serial] %> +

        +

        + <%= @application[:attendee_name] %> · + <%= @application[:slot] %> · + submitted <%= @application[:submitted_at] %> +

        +
        +
        + + <%= link_to "← Back", admin_embassy_applications_path, class: "btn btn-muted" %> +
        +
        + +<% drawn_ids = @application[:drawn_ids] %> +<% if drawn_ids %> +
        + Random pool draw for this application: + <%= drawn_ids.join(", ") %> + (from the <%= FakeEmbassy::SECTION_2_POOL.length %>-question Section 2 pool). + The rendered application below shows exactly what this attendee saw. +
        +<% end %> + +
        + <%= render "embassy_applications/pdf", + serial: @application[:serial], + schedule_item: ScheduleItem.embassy.first || ScheduleItem.first, + sections: FakeEmbassy.sections_for(@application), + applicant_name: @application[:attendee_name], + submitted_at: @application[:submitted_at] %> +
        diff --git a/app/views/admin/embassy_blank_pdfs/create.html.erb b/app/views/admin/embassy_blank_pdfs/create.html.erb new file mode 100644 index 0000000..3b674a4 --- /dev/null +++ b/app/views/admin/embassy_blank_pdfs/create.html.erb @@ -0,0 +1,45 @@ +<% content_for :title, "Blank Applications Generated — Embassy Admin" %> + +

        Blank Applications Ready

        +

        + <%= @count %> blank applications were generated. Each has a unique serial + and a unique random draw of questions. Download the combined PDF below, + print it, and bring to the Embassy for walk-ins. +

        + +
        +
        +
        READY
        +
        +

        + ruby-embassy-blanks-<%= Date.current.strftime("%Y%m%d") %>.pdf +

        +

        + <%= @count %> forms · <%= @count * 2 %> pages · ~<%= (@count * 0.05).round(1) %> MB +

        +
        + + Download PDF + +
        +
        + +

        Generated serials

        +

        Use these to reconcile paper forms with Embassy records.

        + +
        +
          + <% @serials.each do |s| %> +
        • <%= s %>
        • + <% end %> +
        +
        + +
        + <%= link_to "Generate Another Batch", + new_admin_embassy_blank_pdfs_path, + class: "btn btn-navy" %> + <%= link_to "Back to Applications", + admin_embassy_applications_path, + class: "btn btn-muted" %> +
        diff --git a/app/views/admin/embassy_blank_pdfs/new.html.erb b/app/views/admin/embassy_blank_pdfs/new.html.erb new file mode 100644 index 0000000..bc4578c --- /dev/null +++ b/app/views/admin/embassy_blank_pdfs/new.html.erb @@ -0,0 +1,99 @@ +<% content_for :title, "Generate Blank Applications — Embassy Admin" %> + +

        Generate Blank Applications

        +

        + Print blank, randomized applications for on-site walk-ins. Each generated form + contains a unique, randomly-drawn set of questions from the active bank. + Forms are serialized sequentially (RE-0427-A, RE-0427-B, …) so paper can be + reconciled with Embassy records later. +

        + +
        + <%= form_with url: admin_embassy_blank_pdfs_path, method: :post, local: true, class: "space-y-4" do |f| %> +
        + + +

        Minimum 1, maximum 100 per batch.

        +
        + +
        + + <%= link_to "Cancel", admin_embassy_applications_path, class: "btn btn-muted" %> +
        + <% end %> +
        + +

        Preview · one blank form

        +

        + A randomly-generated blank with serial <%= @preview_serial %>. + The real batch will contain a unique set of questions per form. +

        + +
        +
        +
        +
        +
        + Form RUBY-1.0-MATZ
        + Rev. 04/2026
        + BLANK · Walk-in Copy +
        + +
        +

        RUBY EMBASSY

        +

        Application for Issuance of New Ruby Passport

        +
        + + <% @preview_sections.each do |section| %> +
        +

        + Section <%= section[:number].to_s.rjust(2, "0") %>. <%= section[:title] %> +

        + <% section[:questions].each do |q| %> +
        + <%= q[:id] %>. +
        +

        <%= q[:label] %>

        +
        +
        +
        + <% end %> +
        + <% end %> + +
        +
        +
        +
        +
        +
        + Signature of Applicant + Date +
        +
        + +
        + <%= @preview_serial %> + Page 1 of 2 · Bring this form to the Embassy +
        +
        +
        diff --git a/app/views/admin/embassy_questions/_form.html.erb b/app/views/admin/embassy_questions/_form.html.erb new file mode 100644 index 0000000..99d72e6 --- /dev/null +++ b/app/views/admin/embassy_questions/_form.html.erb @@ -0,0 +1,70 @@ +<%# locals: question:, url:, method: :post %> +
        + <%= form_with url: url, method: method, local: true, scope: :question, class: "space-y-4" do |f| %> +
        + + > +

        + Used on printed forms (1a, 1b, 2c, etc.). Once assigned, do not change. +

        +
        + +
        + + +

        + Section scope (common vs random pool) is set per section. Questions + added to a random-pool section become part of that section's draw. +

        +
        + +
        + + +
        + +
        + + +
        + +
        + + +
        + +
        + + +
        + +
        + > + +
        + +
        + + <%= link_to "Cancel", admin_embassy_questions_path, class: "btn btn-muted" %> +
        + <% end %> +
        diff --git a/app/views/admin/embassy_questions/_question_row.html.erb b/app/views/admin/embassy_questions/_question_row.html.erb new file mode 100644 index 0000000..074c165 --- /dev/null +++ b/app/views/admin/embassy_questions/_question_row.html.erb @@ -0,0 +1,22 @@ +<%# locals: question: %> +<%= turbo_frame_tag "question_#{question[:id]}", target: "_top" do %> + + <%= question[:id] %> + <%= question[:label] %> + <%= question[:type].to_s.humanize %> + <%= question[:required] ? "Yes" : "—" %> + + <%= question[:usage_count] %> + uses + + + <%= link_to "Edit", edit_admin_embassy_question_path(question[:id]), + class: "text-blue underline" %> + <%= button_to "Archive", admin_embassy_question_path(question[:id]), + method: :delete, + data: { turbo_confirm: "Archive question #{question[:id]}? It will stop appearing in new randomizations." }, + class: "btn-ghost ml-2", + style: "display:inline;color:#C41C1C;padding:0;box-shadow:none" %> + + +<% end %> diff --git a/app/views/admin/embassy_questions/edit.html.erb b/app/views/admin/embassy_questions/edit.html.erb new file mode 100644 index 0000000..b4b9246 --- /dev/null +++ b/app/views/admin/embassy_questions/edit.html.erb @@ -0,0 +1,10 @@ +<% content_for :title, "Edit Question #{@question[:id]} — Embassy Admin" %> + +

        + Edit Question <%= @question[:id] %> +

        + +<%= render "form", + question: @question, + url: admin_embassy_question_path(@question[:id]), + method: :patch %> diff --git a/app/views/admin/embassy_questions/index.html.erb b/app/views/admin/embassy_questions/index.html.erb new file mode 100644 index 0000000..18104c8 --- /dev/null +++ b/app/views/admin/embassy_questions/index.html.erb @@ -0,0 +1,90 @@ +<% content_for :title, "Question Bank — Embassy Admin" %> + +
        +
        +

        Question Bank

        +

        + <%= @questions.count { |q| q[:status] == "active" } %> active · + <%= @questions.count { |q| q[:status] == "archived" } %> archived · + Random-pool sections draw a unique subset per application. +

        +
        + <%= link_to "+ Add Question", new_admin_embassy_question_path, class: "btn btn-red" %> +
        + +<% FakeEmbassy.section_meta.each do |number, meta| %> + <% rows = @questions.select { |q| q[:section] == number && q[:status] == "active" } %> + <% next if rows.empty? %> + +
        +
        +
        +

        + Section <%= number.to_s.rjust(2, "0") %>. <%= meta[:title] %> +

        +

        <%= meta[:hint] %>

        +
        +
        + <% if meta[:scope] == "random_pool" %> + + Random pool · draws <%= meta[:draws] %> of <%= rows.size %> + + <% else %> + + Common · shown on every application + + <% end %> +
        +
        + + + + + + + + + + + + + + <% rows.each do |q| %> + <%= render "question_row", question: q %> + <% end %> + +
        IDQuestionTypeRequiredUsage
        +
        +<% end %> + +<% archived = @questions.select { |q| q[:status] == "archived" } %> +<% if archived.any? %> +
        +
        +
        +

        Archived

        +

        + Excluded from randomization. Useful for records, retired seasonal questions, + or ones pulled for adjudication reasons. +

        +
        +
        + + + + + + + + + + + + + <% archived.each do |q| %> + <%= render "question_row", question: q %> + <% end %> + +
        IDQuestionTypeRequiredUsage
        +
        +<% end %> diff --git a/app/views/admin/embassy_questions/new.html.erb b/app/views/admin/embassy_questions/new.html.erb new file mode 100644 index 0000000..44af084 --- /dev/null +++ b/app/views/admin/embassy_questions/new.html.erb @@ -0,0 +1,8 @@ +<% content_for :title, "New Question — Embassy Admin" %> + +

        Add Question to the Bank

        + +<%= render "form", + question: @question, + url: admin_embassy_questions_path, + method: :post %> diff --git a/app/views/admin/schedule_items/_form.html.erb b/app/views/admin/schedule_items/_form.html.erb index e21a8c0..8c40cae 100644 --- a/app/views/admin/schedule_items/_form.html.erb +++ b/app/views/admin/schedule_items/_form.html.erb @@ -82,6 +82,38 @@ <%= f.label :is_public, "Public (appears on /schedule for all attendees)" %>
        +
        > + + Embassy-only settings + · expand when Kind = Embassy + + +
        +

        + These fields only take effect for items where Kind = Embassy. + Rendered as raw inputs for the mockup — a backend pass adds + capacity and embassy_mode columns to + schedule_items. +

        + +
        + + +
        + +
        + + +
        +
        +
        +
        <%= f.submit class: "btn btn-navy" %> <%= link_to "Cancel", admin_schedule_items_path, class: "btn btn-muted" %> diff --git a/app/views/embassy_applications/_appointment_card.html.erb b/app/views/embassy_applications/_appointment_card.html.erb new file mode 100644 index 0000000..e821ea0 --- /dev/null +++ b/app/views/embassy_applications/_appointment_card.html.erb @@ -0,0 +1,24 @@ +<%# locals: (serial:, schedule_item:, mode: "new_passport", state: "default") -%> +
        +
        <%= serial %>
        + +

        Ruby Embassy Appointment

        + +

        + <%= ScheduleItem::DAY_META.dig(schedule_item.day, :label) %> + · + <%= schedule_item.time_label %> +

        + +

        + <%= schedule_item.location.presence || "Embassy Office · Main Hall" %> +

        + +
        +
        Type
        +
        <%= FakeEmbassy.mode_label(mode) %>
        + +
        Attendee
        +
        <%= current_user.full_name %>
        +
        +
        diff --git a/app/views/embassy_applications/_expired.html.erb b/app/views/embassy_applications/_expired.html.erb new file mode 100644 index 0000000..9c8cf8f --- /dev/null +++ b/app/views/embassy_applications/_expired.html.erb @@ -0,0 +1,17 @@ +<%# locals: (plan_item:, schedule_item:) -%> +
        +
        SESSION EXPIRED
        + +

        Your application session has ended.

        + +

        + Your <%= ScheduleItem::DAY_META.dig(schedule_item.day, :label) %> + appointment at <%= schedule_item.time_label %> remains confirmed. + You may begin a fresh application at any time. +

        + + <%= link_to "Start a New Application", + new_embassy_application_path(plan_item_id: plan_item.id), + class: "btn btn-red", + data: { turbo_frame: "_top" } %> +
        diff --git a/app/views/embassy_applications/_pdf.html.erb b/app/views/embassy_applications/_pdf.html.erb new file mode 100644 index 0000000..7ed535d --- /dev/null +++ b/app/views/embassy_applications/_pdf.html.erb @@ -0,0 +1,92 @@ +<%# locals: (serial:, schedule_item:, sections:, applicant_name: nil, submitted_at: nil) -%> +
        +
        +
        +
        + Form RUBY-1.0-MATZ
        + Rev. 04/2026
        + Case No. 4-2-0-9-GEM +
        + + +
        + +

        RUBY EMBASSY

        +

        Application for Issuance of New Ruby Passport

        + +
        +
        Appointment
        +
        <%= ScheduleItem::DAY_META.dig(schedule_item.day, :label) %> · <%= schedule_item.time_label %>
        +
        Applicant
        +
        <%= applicant_name || current_user.full_name %>
        +
        Submitted
        +
        <%= submitted_at || Time.current.strftime("%Y-%m-%d %H:%M") %>
        +
        +
        + + <% sections.each do |section| %> +
        +

        + Section <%= section[:number].to_s.rjust(2, "0") %>. <%= section[:title] %> +

        + <% section[:questions].each do |q| %> +
        + <%= q[:id] %>. +
        +

        <%= q[:label] %>

        +
        + <% + ans = FakeEmbassy.filled_answer(q[:id]) + %> + <% if q[:type] == :checkbox %> + <%= ans ? "☒ I affirm the above declaration." : "☐ (not checked)" %> + <% elsif q[:type] == :long %> + <%= ans %> + <% else %> + <%= ans %> + <% end %> +
        +
        +
        + <% end %> +
        + <% end %> + +
        +
        +
        + X   <%= FakeEmbassy.filled_answer("3e") %> +
        +
        + <%= Date.current.strftime("%Y-%m-%d") %> +
        +
        +
        + Signature of Applicant + Date +
        +
        + +
        + <%= serial %> + Page 1 of 2 · Bring this form to your appointment +
        +
        diff --git a/app/views/embassy_applications/_question.html.erb b/app/views/embassy_applications/_question.html.erb new file mode 100644 index 0000000..821af82 --- /dev/null +++ b/app/views/embassy_applications/_question.html.erb @@ -0,0 +1,54 @@ +<%# locals: question: %> +
        + + + <% if question[:help].present? %> +

        <%= question[:help] %>

        + <% end %> + + <% case question[:type] %> + <% when :short %> + + + <% when :long %> + + + <% when :select %> + + + <% when :checkbox %> + + + <% when :date %> + + <% end %> +
        diff --git a/app/views/embassy_applications/_section.html.erb b/app/views/embassy_applications/_section.html.erb new file mode 100644 index 0000000..bb86799 --- /dev/null +++ b/app/views/embassy_applications/_section.html.erb @@ -0,0 +1,20 @@ +<%# locals: (section:) -%> +<%# + Intentionally agnostic to section[:scope] — user-facing section rendering + must not reveal random-pool status. Admin surfaces (question bank, + applications detail view) are where scope is surfaced. +-%> +
        +
        + Section <%= section[:number].to_s.rjust(2, "0") %> +

        <%= section[:title] %>

        +
        + +

        <%= section[:instructions] %>

        + +
        + <% section[:questions].each do |q| %> + <%= render "question", question: q %> + <% end %> +
        +
        diff --git a/app/views/embassy_applications/new.html.erb b/app/views/embassy_applications/new.html.erb new file mode 100644 index 0000000..ea5dd9b --- /dev/null +++ b/app/views/embassy_applications/new.html.erb @@ -0,0 +1,51 @@ +<% content_for :title, "New Passport Application — Ruby Embassy" %> + +
        +
        +
        Form RUBY-1.0-MATZ · Rev. 04/2026 · Case No. 4-2-0-9-GEM
        + +

        Ruby Embassy

        +

        Application for Issuance of New Ruby Passport

        + +
        + + Session expires in approximately <%= @minutes_left %> min +
        + +

        + Print clearly. Do not write in the margins. All fields marked with an + asterisk (*) are required for adjudication. The Embassy reserves the + right to reject applications deemed insufficiently Ruby. +

        +
        + + <%= form_with url: embassy_applications_path, + method: :post, + local: true, + class: "application-form" do |f| %> + <%= hidden_field_tag :plan_item_id, @plan_item&.id %> + + <% @sections.each do |section| %> + <%= render "section", section: section %> + <% end %> + +
        +
        + <%= @serial %> +
        + +
        + <%= f.submit "Submit Application", + class: "btn btn-red btn-lg", + data: { turbo_confirm: "Submit application? You cannot edit after submission." } %> + <%= link_to "Save & Return Later".html_safe, plan_path, + class: "btn btn-muted" %> +
        + + +
        + <% end %> +
        diff --git a/app/views/embassy_applications/show.html.erb b/app/views/embassy_applications/show.html.erb new file mode 100644 index 0000000..95dd1cd --- /dev/null +++ b/app/views/embassy_applications/show.html.erb @@ -0,0 +1,75 @@ +<% content_for :title, "Application Submitted — Ruby Embassy" %> + +
        +
        +

        Form RUBY-1.0-MATZ · Rev. 04/2026

        +

        Application Submitted

        +

        Your appointment is confirmed. Please print and bring the application below.

        +
        + +
        + <%= render "appointment_card", + serial: @serial, + schedule_item: @schedule_item, + mode: "new_passport", + state: "submitted" %> + +
        +

        YOUR SERIAL NUMBER

        +

        <%= @serial %>

        +

        + Keep this on your printed application. It is how the Attaché locates + your record when you arrive. +

        +
        + +
        + <%= link_to embassy_application_path(@serial, format: :pdf), + class: "btn btn-red btn-lg", + onclick: "event.preventDefault(); window.print(); return false;" do %> + Download & Print Application (PDF) + <% end %> +

        + 2 pages · black & white · standard letter size +

        +
        + +
        +

        Preview

        +

        This is what will print. Review before closing this page.

        +
        + <%= render "pdf", + serial: @serial, + schedule_item: @schedule_item, + sections: @sections %> +
        +
        + +
        +

        Embassy Hours & Etiquette

        +
        +
        +

        Hours of operation

        +
          + <% FakeEmbassy.embassy_hours.each do |h| %> +
        • <%= h %>
        • + <% end %> +
        +
        +
        +

        Please note

        +
          + <% FakeEmbassy.embassy_etiquette.each do |note| %> +
        • <%= note %>
        • + <% end %> +
        +
        +
        +
        + +
        + <%= link_to "← View My Plan", plan_path, class: "btn btn-navy" %> + <%= link_to "Back to Schedule", schedule_path, class: "btn btn-muted" %> +
        +
        +
        diff --git a/app/views/embassy_bookings/_confirm.html.erb b/app/views/embassy_bookings/_confirm.html.erb new file mode 100644 index 0000000..5b0e98f --- /dev/null +++ b/app/views/embassy_bookings/_confirm.html.erb @@ -0,0 +1,50 @@ +
        +

        Confirm Appointment

        + +

        + You are booking a + <%= FakeEmbassy.mode_label(mode) %> + appointment at the Ruby Embassy on + <%= ScheduleItem::DAY_META.dig(@schedule_item.day, :label) %> + at <%= @schedule_item.time_label %>. +

        + + <% if mode == "new_passport" %> +
        +

        + Upon confirmation you will be directed to the New Passport Application. + Your session will remain active for 60 minutes; + please complete and submit the form within that window. +

        +

        + Your appointment slot remains confirmed regardless of submission. + If your session lapses, you may begin a fresh application at any time. +

        +
        + <% else %> +
        +

        + Bring your existing Ruby Embassy Passport to the Embassy Office at the + time above. No application is required for stamping appointments. +

        +
        + <% end %> + + <%= button_to "Confirm Appointment", + embassy_bookings_path, + method: :post, + params: { schedule_item_id: @schedule_item.id, mode: mode }, + class: "btn btn-red", + data: { turbo_frame: "_top" } %> + + <% if @block_mode == "both" %> + <%= link_to "← Change purpose", + new_embassy_booking_path(schedule_item_id: @schedule_item.id), + class: "btn btn-muted", + data: { turbo_frame: "booking_body" } %> + <% end %> + + <%= link_to "Cancel", schedule_path, + class: "btn btn-muted", + data: { turbo_frame: "_top" } %> +
        diff --git a/app/views/embassy_bookings/_mode_picker.html.erb b/app/views/embassy_bookings/_mode_picker.html.erb new file mode 100644 index 0000000..ffa36af --- /dev/null +++ b/app/views/embassy_bookings/_mode_picker.html.erb @@ -0,0 +1,34 @@ +
        +

        State Your Purpose of Visit

        +

        + Please indicate the purpose of your appointment so the Attaché can prepare + accordingly. Appointments erroneously booked under the wrong category + may be denied at the gate. +

        + +
        + <%= link_to new_embassy_booking_path(schedule_item_id: @schedule_item.id, mode: "new_passport"), + class: "embassy-mode-card", + data: { turbo_frame: "booking_body" } do %> +

        New Passport

        +

        + I am a first-time visitor requesting issuance of a new Ruby Embassy + Passport. I understand this requires completing the official + application form prior to arrival. +

        + Select → + <% end %> + + <%= link_to new_embassy_booking_path(schedule_item_id: @schedule_item.id, mode: "stamping"), + class: "embassy-mode-card", + data: { turbo_frame: "booking_body" } do %> +

        Stamping

        +

        + I already hold a Ruby Embassy Passport in good standing and request + an additional commemorative stamp. I will bring the original to + the appointment. +

        + Select → + <% end %> +
        +
        diff --git a/app/views/embassy_bookings/create.html.erb b/app/views/embassy_bookings/create.html.erb new file mode 100644 index 0000000..5935079 --- /dev/null +++ b/app/views/embassy_bookings/create.html.erb @@ -0,0 +1,51 @@ +<% content_for :title, "Stamping Appointment Confirmed — Ruby Embassy" %> + +
        +
        +

        Form RUBY-1.0-MATZ · Rev. 04/2026

        +

        Ruby Embassy

        +

        Stamping Appointment · Confirmed

        +
        + +
        +
        STAMPED
        + +

        Your appointment is confirmed.

        + +

        + Bring your existing Ruby Passport to the Embassy Office at +

        + +

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

        + +

        + <%= @schedule_item.location.presence || "Embassy Office · Main Hall" %> +

        + +
        +
        Confirmation number
        +
        <%= FakeEmbassy.serial_for(@plan_item.id) %>
        + +
        Attendee
        +
        <%= current_user.full_name %>
        +
        + +
        +

        Please note

        +
          + <% FakeEmbassy.embassy_etiquette.first(3).each do |note| %> +
        • <%= note %>
        • + <% end %> +
        +
        + +
        + <%= link_to "View My Plan", plan_path, class: "btn btn-navy" %> + <%= link_to "Back to Schedule", schedule_path, class: "btn btn-muted" %> +
        +
        +
        diff --git a/app/views/embassy_bookings/new.html.erb b/app/views/embassy_bookings/new.html.erb new file mode 100644 index 0000000..d85bc02 --- /dev/null +++ b/app/views/embassy_bookings/new.html.erb @@ -0,0 +1,39 @@ +<% content_for :title, "Booking Confirmation — Ruby Embassy" %> + +
        +
        +

        Form RUBY-1.0-MATZ · Rev. 04/2026

        +

        Ruby Embassy

        +

        Application for Appointment · Booking Confirmation

        +
        + +
        +

        Block Details

        +
        +
        Date & Time
        +
        <%= ScheduleItem::DAY_META.dig(@schedule_item.day, :label) %> · <%= @schedule_item.time_label %>
        + +
        Location
        +
        <%= @schedule_item.location.presence || "Embassy Office · Main Hall" %>
        + +
        Block Type
        +
        + <%= FakeEmbassy.mode_label(@block_mode) %> + <% if @block_mode == "both" %> + (choose below) + <% end %> +
        + +
        Seats remaining
        +
        <%= FakeEmbassy.seats_remaining(@schedule_item.id) %> of <%= @capacity %>
        +
        +
        + + <%= turbo_frame_tag :booking_body do %> + <% if @chosen_mode.nil? && @block_mode == "both" %> + <%= render "mode_picker" %> + <% else %> + <%= render "confirm", mode: @chosen_mode %> + <% end %> + <% end %> +
        diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index 47d6a1d..71d121b 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb @@ -12,6 +12,9 @@ + + + <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> <%= javascript_importmap_tags %> @@ -21,6 +24,7 @@ <%= link_to "Ruby Embassy Admin", admin_root_path, class: "font-semibold" %> <%= link_to "Users", admin_users_path %> <%= link_to "Schedule Items", admin_schedule_items_path %> + <%= link_to "Embassy", admin_embassy_questions_path %> <%= link_to "Jobs", "/admin/jobs" %> <%= link_to "My dashboard", dashboard_path %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 5c37de4..77c76f6 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -17,6 +17,10 @@ <%# Typekit: colfax-web font %> + <%# Google Fonts: Playfair Display — used for Embassy section headers %> + + + <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> <%= javascript_importmap_tags %> diff --git a/app/views/plan/_plan_item.html.erb b/app/views/plan/_plan_item.html.erb index 1989e44..290045a 100644 --- a/app/views/plan/_plan_item.html.erb +++ b/app/views/plan/_plan_item.html.erb @@ -5,44 +5,108 @@ %> <%= turbo_frame_tag dom_id(plan_item) do %> -
        -
        <%= item.time_label %>
        - -
        - <% if item.talk? && item.host.present? %> -

        <%= item.host %>

        -

        “<%= item.title %>”

        - <% else %> -

        <%= item.title %>

        - <% if item.host.present? %> -

        <%= item.host %>

        + <% if item.embassy? %> + <% + state = FakeEmbassy.appointment_state_for(plan_item.id) + mode = state == "stamping" ? "stamping" : "new_passport" + serial = FakeEmbassy.serial_for(plan_item.id) + minutes_left = FakeEmbassy.reservation_minutes_left(plan_item.id) + %> +
        +
        + <%= render "embassy_applications/appointment_card", + serial: serial, + schedule_item: item, + mode: mode, + state: state %> + +
        + <% case state %> + <% when "stamping" %> +

        + Stamping appointment. + Bring your existing Ruby Passport to the Embassy at the time above. +

        + + <% when "pending" %> +

        + Application in progress · Session expires in + <%= minutes_left %> min +

        + <%= link_to "Complete Application", + new_embassy_application_path(plan_item_id: plan_item.id), + class: "btn btn-red", + data: { turbo_frame: "_top" } %> + + <% when "submitted" %> +

        + Application submitted · Serial <%= serial %> +

        + <%= link_to "Download PDF", + embassy_application_path(serial), + class: "btn btn-navy", + data: { turbo_frame: "_top" } %> + + <% when "expired" %> +

        + Session expired · Your appointment is still confirmed. +

        + <%= link_to "Start a New Application", + new_embassy_application_path(plan_item_id: plan_item.id), + class: "btn btn-red", + data: { turbo_frame: "_top" } %> + <% end %> +
        +
        + + <%= button_to "×", + plan_item_path(plan_item), + method: :delete, + class: "plan-item__remove", + aria: { label: "Cancel appointment" }, + data: { turbo_confirm: "Cancel your Embassy appointment?" } %> +
        + + <% else %> +
        +
        <%= item.time_label %>
        + +
        + <% if item.talk? && item.host.present? %> +

        <%= item.host %>

        +

        “<%= item.title %>”

        + <% else %> +

        <%= item.title %>

        + <% if item.host.present? %> +

        <%= item.host %>

        + <% end %> <% end %> - <% end %> - <% if item.location.present? %> -

        <%= item.location %>

        - <% end %> + <% if item.location.present? %> +

        <%= item.location %>

        + <% end %> + + <% if item.description.present? %> +

        <%= simple_format(item.description, {}, wrapper_tag: "span") %>

        + <% end %> - <% if item.description.present? %> -

        <%= simple_format(item.description, {}, wrapper_tag: "span") %>

        - <% end %> +
        + <%= item.kind.humanize %> + <% if custom %> + Custom + <% end %> +
        -
        - <%= item.kind.humanize %> - <% if custom %> - Custom + <% if plan_item.notes.present? %> +

        <%= plan_item.notes %>

        <% 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" } %> -
        + <%= button_to "×", + plan_item_path(plan_item), + method: :delete, + class: "plan-item__remove", + aria: { label: "Remove from plan" } %> +
        + <% end %> <% end %> diff --git a/app/views/schedule/_session_item.html.erb b/app/views/schedule/_session_item.html.erb index e41c268..f98a031 100644 --- a/app/views/schedule/_session_item.html.erb +++ b/app/views/schedule/_session_item.html.erb @@ -8,15 +8,28 @@ flexible_classes = item.flexible? ? "schedule-item--flexible" : "" existing_plan = current_user.plan_items.find_by(schedule_item: item) if planned + embassy_mode = FakeEmbassy.mode_for(item.id) if item.embassy? button_label = if planned - item.talk? ? "✓ Added" : "✓ RSVP'd" + if item.talk? + "✓ Added" + elsif item.embassy? + "✓ Booked" + else + "✓ RSVP'd" + end else - item.talk? ? "+ Add to plan" : "+ RSVP" + if item.talk? + "+ Add to plan" + elsif item.embassy? + "Schedule Appointment" + else + "+ RSVP" + end end - rsvp_note = (!item.talk? && item.rsvp_count > 0) ? "#{item.rsvp_count} going" : nil + rsvp_note = (!item.talk? && !item.embassy? && item.rsvp_count > 0) ? "#{item.rsvp_count} going" : nil %> <%= turbo_frame_tag dom_id(item) do %> @@ -36,6 +49,10 @@ <% end %> <% end %> + <% if item.embassy? %> +

        <%= FakeEmbassy.mode_label(embassy_mode) %>

        + <% end %> + <% if item.location.present? %>

        <%= item.location %>

        <% end %> @@ -55,7 +72,28 @@
        - <% if planned && existing_plan %> + <% if item.embassy? %> + <% if planned && existing_plan %> + <%= link_to button_label, plan_path, + class: "add-btn add-btn--added", + data: { turbo_frame: "_top" }, + aria: { pressed: true } %> + <% elsif FakeEmbassy.full?(item.id) %> + + <% else %> + <%= link_to button_label, + new_embassy_booking_path(schedule_item_id: item.id), + class: "add-btn", + data: { turbo_frame: "_top" } %> + <% end %> +

        + <% if FakeEmbassy.full?(item.id) %> + <%= FakeEmbassy.capacity_for(item.id) %> of <%= FakeEmbassy.capacity_for(item.id) %> · full + <% else %> + <%= FakeEmbassy.seats_taken_for(item.id) %> of <%= FakeEmbassy.capacity_for(item.id) %> seats + <% end %> +

        + <% elsif planned && existing_plan %> <%= button_to button_label, plan_item_path(existing_plan), method: :delete, @@ -71,6 +109,7 @@ data: { turbo_frame: dom_id(item) }, aria: { pressed: false } %> <% end %> + <% if rsvp_note %>

        <%= rsvp_note %>

        <% end %> diff --git a/config/routes.rb b/config/routes.rb index 1f98b2d..d6d70ac 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -18,6 +18,9 @@ post :sync, on: :collection end resources :schedule_items + resources :embassy_questions + resources :embassy_applications, only: %i[index show] + resource :embassy_blank_pdfs, only: %i[new create] end # Background jobs dashboard (admin-only mount inside /admin/) @@ -31,4 +34,8 @@ resources :plan_items, only: %i[create update destroy] resources :schedule_items, only: %i[new create edit update] + + # Embassy booking + application — attendee-facing mockup + resources :embassy_bookings, only: %i[new create] + resources :embassy_applications, only: %i[new create show edit update] end diff --git a/test/controllers/admin/embassy_applications_controller_test.rb b/test/controllers/admin/embassy_applications_controller_test.rb new file mode 100644 index 0000000..ec27d4a --- /dev/null +++ b/test/controllers/admin/embassy_applications_controller_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class Admin::EmbassyApplicationsControllerTest < ActionDispatch::IntegrationTest + # test "the truth" do + # assert true + # end +end diff --git a/test/controllers/admin/embassy_blank_pdfs_controller_test.rb b/test/controllers/admin/embassy_blank_pdfs_controller_test.rb new file mode 100644 index 0000000..4a81267 --- /dev/null +++ b/test/controllers/admin/embassy_blank_pdfs_controller_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class Admin::EmbassyBlankPdfsControllerTest < ActionDispatch::IntegrationTest + # test "the truth" do + # assert true + # end +end diff --git a/test/controllers/admin/embassy_questions_controller_test.rb b/test/controllers/admin/embassy_questions_controller_test.rb new file mode 100644 index 0000000..fe2973a --- /dev/null +++ b/test/controllers/admin/embassy_questions_controller_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class Admin::EmbassyQuestionsControllerTest < ActionDispatch::IntegrationTest + # test "the truth" do + # assert true + # end +end diff --git a/test/controllers/embassy_applications_controller_test.rb b/test/controllers/embassy_applications_controller_test.rb new file mode 100644 index 0000000..3bcb217 --- /dev/null +++ b/test/controllers/embassy_applications_controller_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class EmbassyApplicationsControllerTest < ActionDispatch::IntegrationTest + # test "the truth" do + # assert true + # end +end diff --git a/test/controllers/embassy_bookings_controller_test.rb b/test/controllers/embassy_bookings_controller_test.rb new file mode 100644 index 0000000..12cba98 --- /dev/null +++ b/test/controllers/embassy_bookings_controller_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class EmbassyBookingsControllerTest < ActionDispatch::IntegrationTest + # test "the truth" do + # assert true + # end +end From 512d5d6ccd4494158bf85012ee973c60b0f64268 Mon Sep 17 00:00:00 2001 From: Katya Sarmiento <5871075+Kitkatnik@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:56:36 -0400 Subject: [PATCH 26/29] Polish Embassy copy to official-government voice --- app/assets/stylesheets/application.css | 169 +++++++- .../admin/embassy_blank_pdfs_controller.rb | 3 +- .../admin/embassy_questions_controller.rb | 4 +- app/services/fake_embassy.rb | 382 ++++++++++------- .../admin/embassy_applications/show.html.erb | 10 +- .../admin/embassy_blank_pdfs/new.html.erb | 84 +--- .../admin/embassy_questions/index.html.erb | 127 +++++- .../_appointment_card.html.erb | 4 +- .../embassy_applications/_expired.html.erb | 9 +- app/views/embassy_applications/_pdf.html.erb | 121 +++++- .../embassy_applications/_question.html.erb | 15 +- app/views/embassy_applications/new.html.erb | 13 +- app/views/embassy_applications/show.html.erb | 21 +- app/views/embassy_bookings/_confirm.html.erb | 19 +- .../embassy_bookings/_mode_picker.html.erb | 9 +- app/views/embassy_bookings/create.html.erb | 4 +- app/views/embassy_bookings/new.html.erb | 8 +- app/views/plan/_plan_item.html.erb | 6 +- db/seeds/embassy_questions.rb | 392 ++++++++++++++++++ 19 files changed, 1092 insertions(+), 308 deletions(-) create mode 100644 db/seeds/embassy_questions.rb diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 62837c6..9f215fa 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -1965,6 +1965,32 @@ hr { border: 1px solid #f6d97a; } +.badge-scope--notary { + background: #F3E8FF; + color: #7E22CE; + border: 1px solid #e3ccff; +} + +.embassy-bank-category { + font-family: "colfax-web", "Inter", system-ui, sans-serif; + font-size: 0.875rem; + font-weight: 600; + color: #525C66; + margin: 1rem 0 0.375rem; + padding: 0.25rem 0; + display: flex; + align-items: baseline; + gap: 0.5rem; + border-bottom: 1px dotted #d9dee1; +} + +.embassy-bank-category__count { + font-size: 0.75rem; + color: #7a8189; + font-weight: 400; + font-style: italic; +} + /* Admin question bank badges (extras for new types) -------------------- */ .badge-short { background: #EEF1F4; color: #525C66; } @@ -2239,33 +2265,166 @@ hr { font-weight: 700; } +/* Checkbox group (multi-select on form) ------------------------------- */ + +.embassy-question__group { + display: grid; + grid-template-columns: 1fr; + gap: 0.375rem; + padding: 0.5rem 0.75rem; + background: #f9fafb; + border: 1px solid #d1d5db; + border-radius: 0.25rem; +} + +@media (min-width: 560px) { + .embassy-question__group { grid-template-columns: 1fr 1fr; } +} + +.embassy-question__group-option { + display: flex; + align-items: flex-start; + gap: 0.5rem; + font-size: 0.875rem; + color: #404040; + cursor: pointer; +} + +.embassy-question__group-option input { margin-top: 0.25rem; flex-shrink: 0; } + +/* PDF-rendered checkbox group ----------------------------------------- */ + +.pdf-q__checklist { + list-style: none; + padding: 0; + margin: 0; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); + gap: 0.125rem 1rem; +} + +.pdf-q__checklist li { + font-size: 0.8125rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + color: #000000; + line-height: 1.5; +} + +/* Notary page (PDF addendum) ------------------------------------------ */ + +.pdf-page--notary { padding-top: 2rem; } + +.pdf-notary__preamble { + font-size: 0.875rem; + color: #000000; + line-height: 1.5; + margin-bottom: 1.25rem; + font-style: italic; +} + +.pdf-notary__assignment { + margin: 1rem 0 1.5rem; + padding: 1rem 1.25rem; + border: 2px solid #000000; + background: #ffffff; +} + +.pdf-notary__preface { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.15em; + color: #000000; + margin: 0 0 0.375rem; +} + +.pdf-notary__description { + font-family: "Playfair Display", Georgia, serif; + font-size: 1.375rem; + font-weight: 700; + color: #000000; + margin: 0; + letter-spacing: 0.01em; +} + +.pdf-notary__followups { margin-bottom: 1.75rem; } + +.pdf-q--notary .pdf-q__answer--blank { + min-height: 1.75rem; + border-bottom: 1px solid #000000; +} + +.pdf-notary__certification { + margin-top: 1.5rem; + padding-top: 0.75rem; + border-top: 2px double #000000; +} + +.pdf-notary__field { + display: grid; + grid-template-columns: 8rem 1fr; + align-items: end; + gap: 0.75rem; + margin-bottom: 0.875rem; +} + +.pdf-notary__field-label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: #000000; +} + +.pdf-notary__field-line { + border-bottom: 1px solid #000000; + min-height: 1.5rem; +} + /* Print media: strip nav/chrome so Print Preview shows only the PDF ---- */ @media print { body { background: #ffffff !important; } + + /* Hide ALL page chrome. The ceremony page, admin detail page, and + blank-pdf preview all wrap the PDF inside .application-canvas--pdf; + @media print keeps only that and hides everything else. */ .admin-nav, nav, header, footer, + .embassy-doc__header, + .embassy-ceremony > .embassy-appointment-card, .embassy-ceremony__callout, .embassy-ceremony__download, .embassy-ceremony__etiquette, .embassy-ceremony__back, + .embassy-ceremony__preview > h2, + .embassy-ceremony__preview > p, + .flex.items-center.justify-between, /* admin page h1 + actions row */ .btn, .alert { display: none !important; } + .application-canvas, .application-canvas--pdf { border: none !important; box-shadow: none !important; max-width: 100% !important; padding: 0 !important; + margin: 0 !important; } - .pdf-page { - page-break-after: always; - padding: 1rem 1.25rem; - min-height: auto; + + .embassy-ceremony__preview { + background: none !important; + border: none !important; + padding: 0 !important; + margin: 0 !important; } - .embassy-ceremony__preview { background: none; border: none; padding: 0; } .embassy-ceremony__preview .application-canvas { padding: 0 !important; box-shadow: none !important; border: none !important; } + + .pdf-page { + page-break-after: always; + padding: 1rem 1.25rem; + min-height: auto; + } + .pdf-page--notary { page-break-before: always; } } diff --git a/app/controllers/admin/embassy_blank_pdfs_controller.rb b/app/controllers/admin/embassy_blank_pdfs_controller.rb index 67e8020..58fe02b 100644 --- a/app/controllers/admin/embassy_blank_pdfs_controller.rb +++ b/app/controllers/admin/embassy_blank_pdfs_controller.rb @@ -1,8 +1,9 @@ class Admin::EmbassyBlankPdfsController < AdminController def new - @default_count = 12 + @default_count = 12 @preview_sections = FakeEmbassy.sample_questions @preview_serial = "RE-0427-A" + @preview_notary = EmbassyQuestionsSeed::NOTARY_POOL.sample(random: Random.new(42)) end def create diff --git a/app/controllers/admin/embassy_questions_controller.rb b/app/controllers/admin/embassy_questions_controller.rb index 8946414..4eb4e3b 100644 --- a/app/controllers/admin/embassy_questions_controller.rb +++ b/app/controllers/admin/embassy_questions_controller.rb @@ -1,7 +1,7 @@ class Admin::EmbassyQuestionsController < AdminController def index - @questions = FakeEmbassy.question_bank - @sections = FakeEmbassy.sample_questions.map { |s| [ s[:number], s[:title] ] } + @questions = FakeEmbassy.question_bank + @notary_pool = FakeEmbassy.notary_pool end def new diff --git a/app/services/fake_embassy.rb b/app/services/fake_embassy.rb index d49ff52..4a02d50 100644 --- a/app/services/fake_embassy.rb +++ b/app/services/fake_embassy.rb @@ -1,3 +1,5 @@ +require Rails.root.join("db/seeds/embassy_questions").to_s + module FakeEmbassy module_function @@ -50,120 +52,171 @@ def reservation_minutes_left(plan_item_id) [ 47, 32, 18, 3 ][plan_item_id.to_i % 4] end - # === Section 1 · Declaration of Ruby-ness (COMMON) ======================== - # Identity, background, and the signature relationship question. Every - # applicant answers these. The Matz question lives here deliberately so - # every Ruby Embassy passport is stamped with a personal answer. - SECTION_1_QUESTIONS = [ - { id: "1a", label: "Given Name (as it appears on your Tito badge)", - type: :short, required: true, placeholder: "e.g., Katya" }, - { id: "1b", label: "GitHub handle", - type: :short, required: false, help: "Your @handle on GitHub." }, - { id: "1c", label: "Preferred pronouns", - type: :short, required: false }, - { id: "1d", label: "First Ruby release you remember using", - type: :select, required: true, - options: [ "1.8.x", "1.9.x", "2.0–2.6", "2.7–3.0", "3.1+", "I refuse to answer" ] }, - { id: "1e", label: "Ruby release you most miss", - type: :short, required: false, placeholder: "e.g., 1.8.7, for sentimental reasons" }, - { id: "1f", label: "Describe, in one paragraph, your relationship to Yukihiro Matsumoto", - type: :long, required: true, help: "Literal or metaphorical answers both accepted." } - ].freeze - - # === Section 2 · Supplementary Declarations (RANDOM POOL, draws 4 of 8) === - # Internally a random pool that draws a different subset per application. - # Users must not know this: the section title, instructions, and rendering - # should all look like any other section of a standard embassy form. - # Admins manage the pool via the Question Bank. - SECTION_2_POOL = [ - { id: "2a", label: "How many keyboards do you currently own?", - type: :select, required: false, - options: [ "0", "1", "2–3", "4–6", "More than I am willing to admit" ] }, - { id: "2b", label: "Preferred test runner", - type: :select, required: false, - options: [ "RSpec", "Minitest", "Both, depending on mood", "Neither, I test in production" ] }, - { id: "2c", label: "Most embarrassing production incident (brief)", - type: :long, required: false, help: "Names will be redacted. Scars will not." }, - { id: "2d", label: "Worst Rails upgrade story, in three sentences", - type: :long, required: false }, - { id: "2e", label: "If Ruby were a beverage, what beverage would it be?", - type: :short, required: false, placeholder: "e.g., a warm amaro, a slow espresso" }, - { id: "2f", label: "Have you ever written method_missing? If so, how did it feel?", - type: :long, required: false, - help: "Responses involving \"transcendent\" or \"regret\" will be evaluated equally." }, - { id: "2g", label: "Name one gem that does not spark joy", - type: :short, required: false }, - { id: "2h", label: "Pair programming: describe your emotional relationship to the practice", - type: :long, required: false } - ].freeze - - # Deterministic draw for the stable mockup preview. - SECTION_2_DRAWN_IDS = %w[2a 2b 2e 2f].freeze - SECTION_2_DRAWS = 4 - - # === Section 3 · Affidavit of Attendance (COMMON) ========================= - SECTION_3_QUESTIONS = [ - { id: "3a", label: "I affirm I will respect the Embassy's Ruby-only policy for the duration of my visit", - type: :checkbox, required: true }, - { id: "3b", label: "Date of arrival in Asheville", - type: :date, required: true }, - { id: "3c", label: "Are you currently in possession of unreleased gems of unknown provenance?", - type: :select, required: true, - options: [ "No", "Yes", "I plead the fifth" ] }, - { id: "3d", label: "Signature (type your full legal name)", - type: :short, required: true, - help: "This carries the same legal weight as a stamped declaration, which is to say: very little." } - ].freeze - - def sample_questions + # Deterministic draw shown on the default (Katya's) mockup preview. + DEFAULT_DRAWN_POOL_IDS = %w[3a01 3b05 3c04 3e03 3h03].freeze + DEFAULT_NOTARY_ID = "N01".freeze + + def sample_questions(drawn_ids: DEFAULT_DRAWN_POOL_IDS) [ - { number: 1, - title: "Declaration of Ruby-ness", - instructions: "Print clearly. This section establishes your eligibility under Embassy Ordinance §1.9.", + { + number: 1, title: "Declaration of Ruby-ness", scope: "common", - questions: SECTION_1_QUESTIONS }, - - # NOTE: title + instructions here are deliberately neutral — users - # must not know this section is randomized. Admin surfaces (question - # bank, applications detail) expose the scope/draws metadata. - { number: 2, - title: "Supplementary Declarations", - instructions: "Please respond to the following to the best of your ability. Answers are retained for statistical and adjudication purposes.", + instructions: "Print clearly. This section establishes the Applicant's eligibility for Passport issuance pursuant to Embassy Ordinance §1.9.", + questions: EmbassyQuestionsSeed::BASIC_INFO + }, + { + number: 2, title: "Statement of Intent & Character", + scope: "common", + instructions: "The Applicant is required to disclose, in their own words, certain particulars of their programming disposition. Literal or metaphorical responses both accepted. Do not leave blank.", + questions: EmbassyQuestionsSeed::PERSONAL_STATEMENT + }, + { + # NOTE: title + instructions here are deliberately neutral — users + # must not know this section is randomized. Admin surfaces expose + # the scope/draws metadata. + number: 3, title: "Supplementary Declarations", scope: "random_pool", - draws: SECTION_2_DRAWS, - pool_size: SECTION_2_POOL.length, - questions: SECTION_2_POOL.select { |q| SECTION_2_DRAWN_IDS.include?(q[:id]) } }, - - { number: 3, - title: "Affidavit of Attendance", - instructions: "Falsified answers may result in revocation of Ruby Embassy privileges for up to three business gems.", + instructions: "The following declarations are required under Embassy Ordinance §3.14. Answers are filed in perpetuity and may be referenced at any future Embassy proceeding.", + draws: EmbassyQuestionsSeed::RANDOM_POOL_DRAWS, + pool_size: EmbassyQuestionsSeed::RANDOM_POOL.length, + questions: EmbassyQuestionsSeed::RANDOM_POOL.select { |q| drawn_ids.include?(q[:id]) } + }, + { + number: 4, title: "Attestation of Community Standing", scope: "common", - questions: SECTION_3_QUESTIONS } + instructions: "Each of the following is deemed material to the Embassy's assessment of community fitness under Ordinance §4.1. The Applicant is asked to affirm or decline without reservation.", + questions: EmbassyQuestionsSeed::COMMUNITY_ALIGNMENT + }, + { + number: 5, title: "Affidavit of Attendance", + scope: "common", + instructions: "Falsified statements may result in revocation of Ruby Embassy privileges for up to three (3) business gems. The Applicant signs below under penalty of Rubocop.", + questions: EmbassyQuestionsSeed::DECLARATION + } ] end + # Returns sections customized for a specific submitted application so + # the admin sees the exact draw that attendee received. + def sections_for(application) + drawn = application[:drawn_ids] || DEFAULT_DRAWN_POOL_IDS + sample_questions(drawn_ids: drawn) + end + + # Notary drawn for a given application (or the default for the preview). + def notary_for(application = nil) + target_id = (application && application[:notary_id]) || DEFAULT_NOTARY_ID + EmbassyQuestionsSeed::NOTARY_POOL.find { |n| n[:id] == target_id } || + EmbassyQuestionsSeed::NOTARY_POOL.first + end + FILLED_ANSWERS = { - # Section 1 — common + # Section 1 · Basic Info ---------------------------------------------- "1a" => "Katya", "1b" => "@kitkatnik", "1c" => "she/her", - "1d" => "2.7–3.0", - "1e" => "1.8.7 — the heart wants what it wants", - "1f" => "We've never spoken, but I feel his presence every time I write a block. Once, while writing a method_missing, I felt we achieved mutual understanding.", - # Section 2 — random pool (values for all pool members; only drawn ones render) - "2a" => "2–3", - "2b" => "Minitest", - "2c" => "Rolled a migration, rolled the wrong direction, rolled my chair into a wall.", - "2d" => "We upgraded 4.2 to 5.0. We upgraded 5.0 to 5.1. We stopped upgrading.", - "2e" => "A warm amaro, drunk slowly on a porch.", - "2f" => "Yes. Transcendent, then regretful, then transcendent again.", - "2g" => "The one that shall not be named.", - "2h" => "Pair programming is the one time my keyboard is not mine.", - # Section 3 — common - "3a" => true, - "3b" => "2026-04-29", - "3c" => "I plead the fifth", - "3d" => "Katya Sarmiento" + "1d" => "9 years of Ruby, 14 years of existential dread", + "1e" => "$0. Audit me, I dare you.", + "1f" => "I use Ruby", + "1g" => "Go", + "1h" => [ "Networking", "Vibes", "Free coffee" ], + "1i" => [ "Powerful", "Confused", "Like quitting forever" ], + "1j" => [ "The Debugger", "The \"it works don't touch it\"" ], + + # Section 2 · Personal Statement -------------------------------------- + "2a" => "Programming and I have been together long enough that we finish each other's error messages.", + "2b" => "2.7–3.0", + "2c" => "1.8.7 — the heart wants what it wants", + "2d" => "We've never spoken, but I feel his presence every time I write a block. Once, while writing a method_missing, I felt we achieved mutual understanding.", + + # Section 3 · Supplementary Declarations (answers for every pool member, + # so any drawn subset renders correctly on any submitted application view) + "3a01" => "I once spent six hours debugging a production issue caused by a stray space in a CSV.", + "3a02" => "A migration that silently dropped 12% of user records. It 'passed review.'", + "3a03" => "The time I moved a file up two directories and 40 tests suddenly passed.", + "3a04" => "A method_missing handler that recursed through ActiveRecord looking for 'something that resembles a category.'", + "3a05" => "Regex. The answer is always regex.", + "3a06" => "I forgot to save the file for two hours.", + "3a07" => "Someone put a string comparison inside a callback that runs on every page load.", + + "3b01" => "If your test suite takes more than 90 seconds, it is a todo list, not a test suite.", + "3b02" => "I never run `bin/rails console --sandbox`. I live dangerously.", + "3b03" => "TDD. There, I said it.", + "3b04" => "Monorepos. They're fine. Stop fighting about this.", + "3b05" => "Misunderstood genius, but only for the first two years.", + "3b06" => "Situationship. It keeps calling me at 2am with production issues.", + "3b07" => "CoffeeScript. It is a haunted language and we all know it.", + + "3c01" => "It would tolerate me, the way a cat tolerates a roommate who pays the rent.", + "3c02" => "Rookie of the year, 2013. Did not win a match but showed up to all of them.", + "3c03" => "Passive-aggressive. Writes emails that are technically polite.", + "3c04" => "`tap`. Sneaky and elegant.", + "3c05" => "My past self would be horrified. We would not be speaking by the end of it.", + "3c06" => "Because the incident report writes itself.", + "3c07" => "I would simply start using Sinatra apps and eating dinner outside.", + + "3d01" => "A GeoCities page that would not load a rotating star GIF. I swore revenge.", + "3d02" => "The brief moment after a bug is fixed and before the next one appears.", + "3d03" => "A three-day Heisenbug that turned out to be a renamed column.", + "3d04" => "Knitting. It teaches you to unpick mistakes without crying.", + "3d05" => "Dark mode. Ambient music. A windowless room. No humans within 100 feet.", + "3d06" => "Walk. Shower. Complain to the duck on my desk. Return.", + + "3e01" => "Cottagecore with a vengeance.", + "3e02" => "Somewhere between 'I got this' and 'what is a kernel'.", + "3e03" => "A Heisenbug. You think you've seen me, then doubt it.", + "3e04" => "The distracted-boyfriend meme, but it's me looking at a new framework.", + "3e05" => "1 of 10. The rest are Stack Overflow tabs.", + "3e06" => "Three. Always three. I never learn.", + + "3f01" => "A community cookbook where every recipe comes with the grandmother's handwriting.", + "3f02" => "A habit tracker. I was not sufficiently in the habit of maintaining it.", + "3f03" => "A CLI that tells you whether your commit message is too funny for the branch.", + "3f04" => "A Mastodon bot that posts fake Ruby Weekly editions.", + "3f05" => "An entire second career in basket weaving.", + + "3g01" => "Yes. The project was 37% done. I said 'this weekend.'", + "3g02" => "10pm. Docker. Three caffeinated beverages. Sincere apology to my keyboard.", + "3g03" => "Fixed a race condition. Broke every feature flag.", + "3g04" => "I have nodded gravely through entire standups.", + "3g05" => "`legacy/billing_tax_legacy.rb`. Last touched 2014. Still running.", + "3g06" => "A cron job that periodically restarts a daemon. Nobody has ever fixed it.", + + "3h01" => "To remember why I liked Ruby in the first place.", + "3h02" => "To meet one person whose blog I've been reading since 2017.", + "3h03" => "Stand near the coffee. Wear sunglasses until 9am. Assume every hallway is a shortcut.", + "3h04" => "Four.", + "3h05" => "Avoid responsibilities, but enthusiastically.", + + "3i01" => "Pray. Monitor. Consider turning off the internet.", + "3i02" => "Check env vars. Check secrets. Blame the cache. Find the typo.", + "3i03" => "Find the oldest test file. Read it aloud. Decide if I can live here.", + "3i04" => "'Works on my machine' is the first half of a really useful sentence.", + "3i05" => "Open the editor. Stare. Close the editor. Rest.", + + "3j01" => "4–6.", + "3j02" => "Minitest.", + "3j03" => "Rolled a migration, rolled the wrong direction, rolled my chair into a wall.", + "3j04" => "We upgraded 4.2 to 5.0. We upgraded 5.0 to 5.1. We stopped upgrading.", + "3j05" => "A warm amaro, drunk slowly on a porch.", + "3j06" => "Yes. Transcendent, then regretful, then transcendent again.", + "3j07" => "The one that shall not be named.", + "3j08" => "Pair programming is the one time my keyboard is not mine.", + + # Section 4 · Community Alignment ------------------------------------- + "4a" => [ + "I believe debugging builds character", + "I have questioned my life choices while coding", + "I have said \"this should work\" (it did not)", + "I have fixed something and broken something else", + "I have Googled the same error more than once" + ], + + # Section 5 · Declaration --------------------------------------------- + "5a" => true, + "5b" => "2026-04-29", + "5c" => "I plead the fifth", + "5d" => "Katya Sarmiento" }.freeze def filled_answer(question_id) @@ -172,96 +225,137 @@ def filled_answer(question_id) def submitted_applications [ - { serial: "RE-0427-A", attendee_name: "Katya Sarmiento", attendee_email: "software@adhdcoder.com", + { serial: "RE-0427-A", attendee_name: "Katya Sarmiento", attendee_email: "software@adhdcoder.com", slot: "Sat May 2 · 2:00 PM", submitted_at: "Apr 30 · 3:14 PM", - drawn_ids: %w[2a 2b 2e 2f] }, - { serial: "RE-0427-B", attendee_name: "John Athayde", attendee_email: "john@example.com", + drawn_ids: %w[3a01 3b05 3c04 3e03 3h03], notary_id: "N01" }, + { serial: "RE-0427-B", attendee_name: "John Athayde", attendee_email: "john@example.com", slot: "Sat May 2 · 2:00 PM", submitted_at: "Apr 30 · 4:02 PM", - drawn_ids: %w[2c 2d 2f 2h] }, - { serial: "RE-0427-C", attendee_name: "Aaron Patterson", attendee_email: "aaron@example.com", + drawn_ids: %w[3a04 3b02 3c06 3d01 3g02], notary_id: "N05" }, + { serial: "RE-0427-C", attendee_name: "Aaron Patterson", attendee_email: "aaron@example.com", slot: "Sat May 2 · 2:15 PM", submitted_at: "May 1 · 9:11 AM", - drawn_ids: %w[2a 2d 2e 2g] }, - { serial: "RE-0427-D", attendee_name: "Sandi Metz", attendee_email: "sandi@example.com", + drawn_ids: %w[3a02 3b07 3c07 3d03 3h01], notary_id: "N13" }, + { serial: "RE-0427-D", attendee_name: "Sandi Metz", attendee_email: "sandi@example.com", slot: "Sat May 2 · 2:15 PM", submitted_at: "May 1 · 10:48 AM", - drawn_ids: %w[2b 2c 2f 2h] }, - { serial: "RE-0427-E", attendee_name: "Avdi Grimm", attendee_email: "avdi@example.com", + drawn_ids: %w[3a05 3b06 3c01 3f01 3g04], notary_id: "N02" }, + { serial: "RE-0427-E", attendee_name: "Avdi Grimm", attendee_email: "avdi@example.com", slot: "Sat May 2 · 2:30 PM", submitted_at: "May 1 · 11:02 AM", - drawn_ids: %w[2a 2c 2d 2g] }, - { serial: "RE-0427-F", attendee_name: "Mislav Marohnić", attendee_email: "mislav@example.com", + drawn_ids: %w[3a07 3b03 3c05 3d06 3i02], notary_id: "N09" }, + { serial: "RE-0427-F", attendee_name: "Mislav Marohnić", attendee_email: "mislav@example.com", slot: "Sat May 2 · 2:30 PM", submitted_at: "May 1 · 12:19 PM", - drawn_ids: %w[2a 2e 2f 2h] }, + drawn_ids: %w[3a03 3b04 3c03 3e01 3i05], notary_id: "N15" }, { serial: "RE-0427-G", attendee_name: "Penelope Phippen", attendee_email: "penelope@example.com", slot: "Sat May 2 · 2:45 PM", submitted_at: "May 1 · 1:45 PM", - drawn_ids: %w[2b 2c 2d 2g] } + drawn_ids: %w[3a06 3b01 3c02 3f03 3h02], notary_id: "N08" } ] end def find_submitted_application(serial) submitted_applications.find { |a| a[:serial] == serial } || { serial: serial, attendee_name: "Unknown", attendee_email: "—", - slot: "—", submitted_at: "—", drawn_ids: SECTION_2_DRAWN_IDS.dup } + slot: "—", submitted_at: "—", + drawn_ids: DEFAULT_DRAWN_POOL_IDS.dup, notary_id: DEFAULT_NOTARY_ID } end - # Sections + their questions rendered for a SPECIFIC submitted application. - # Admin detail view uses this to show the exact random draw that attendee - # received, instead of the default mockup draw. - def sections_for(application) - drawn = application[:drawn_ids] || SECTION_2_DRAWN_IDS - sample_questions.map do |section| - next section unless section[:scope] == "random_pool" - section.merge(questions: SECTION_2_POOL.select { |q| drawn.include?(q[:id]) }) - end - end - - # Flat bank for the admin Question Bank index. Includes ALL random-pool - # members (not just drawn) so admins can manage the full set. + # === Admin: flat question bank for the Question Bank index ============== def question_bank bank = [] - SECTION_1_QUESTIONS.each do |q| + + EmbassyQuestionsSeed::BASIC_INFO.each do |q| bank << q.merge( section: 1, section_title: "Declaration of Ruby-ness", section_scope: "common", status: "active", usage_count: deterministic_usage(q[:id]) ) end - SECTION_2_POOL.each do |q| + + EmbassyQuestionsSeed::PERSONAL_STATEMENT.each do |q| bank << q.merge( - section: 2, section_title: "Supplementary Declarations", - section_scope: "random_pool", status: "active", + section: 2, section_title: "Statement of Intent & Character", + section_scope: "common", status: "active", usage_count: deterministic_usage(q[:id]) ) end - SECTION_3_QUESTIONS.each do |q| + + EmbassyQuestionsSeed::RANDOM_POOL_CATEGORIES.each do |key, category| + category[:questions].each do |q| + bank << q.merge( + section: 3, section_title: "Supplementary Declarations", + section_scope: "random_pool", + category: key, category_title: category[:title], + status: "active", + usage_count: deterministic_usage(q[:id]) + ) + end + end + + EmbassyQuestionsSeed::COMMUNITY_ALIGNMENT.each do |q| + bank << q.merge( + section: 4, section_title: "Attestation of Community Standing", + section_scope: "common", status: "active", + usage_count: deterministic_usage(q[:id]) + ) + end + + EmbassyQuestionsSeed::DECLARATION.each do |q| bank << q.merge( - section: 3, section_title: "Affidavit of Attendance", + section: 5, section_title: "Affidavit of Attendance", section_scope: "common", status: "active", usage_count: deterministic_usage(q[:id]) ) end + bank << { id: "X1", section: 0, section_title: "Archived", section_scope: "common", label: "Preferred blend of Ruby Roast coffee (retired question)", type: :select, required: false, usage_count: 12, status: "archived" } + bank end - # Metadata per section — used on admin surfaces to expose the scope. + # Notary pool — shown in its own admin block below the question table. + def notary_pool + EmbassyQuestionsSeed::NOTARY_POOL.map do |n| + n.merge( + followup_count: n[:followups].length, + usage_count: deterministic_usage(n[:id]), + status: "active" + ) + end + end + + # Per-section metadata for admin question-bank index headers. SECTION_META = { - 1 => { title: "Declaration of Ruby-ness", scope: "common", - hint: "Every applicant fills these out." }, - 2 => { title: "Supplementary Declarations", scope: "random_pool", - hint: "Internally a random pool — draws 4 of 8 per application. Users see only a numbered section.", - draws: 4 }, - 3 => { title: "Affidavit of Attendance", scope: "common", - hint: "Every applicant fills these out." } + 1 => { title: "Declaration of Ruby-ness", scope: "common", + hint: "Identity, proficiency, declared disposition. Every applicant fills these out." }, + 2 => { title: "Statement of Intent & Character", scope: "common", + hint: "Free-form personal statement. Every applicant fills these out." }, + 3 => { title: "Supplementary Declarations", scope: "random_pool", + hint: "Internally a random pool — draws %{draws} of %{total} per application. Users see only a numbered section.", + draws: EmbassyQuestionsSeed::RANDOM_POOL_DRAWS }, + 4 => { title: "Attestation of Community Standing", scope: "common", + hint: "Single checkbox group. Every applicant affirms the applicable statements." }, + 5 => { title: "Affidavit of Attendance", scope: "common", + hint: "Signature, arrival date, final attestations. Every applicant fills these out." } }.freeze def section_meta SECTION_META end + def random_pool_total + EmbassyQuestionsSeed::RANDOM_POOL.length + end + + def random_pool_draws + EmbassyQuestionsSeed::RANDOM_POOL_DRAWS + end + + def random_pool_categories + EmbassyQuestionsSeed::RANDOM_POOL_CATEGORIES + end + def find_question(question_id) question_bank.find { |q| q[:id] == question_id } || question_bank.first end diff --git a/app/views/admin/embassy_applications/show.html.erb b/app/views/admin/embassy_applications/show.html.erb index 14914b8..e94b22e 100644 --- a/app/views/admin/embassy_applications/show.html.erb +++ b/app/views/admin/embassy_applications/show.html.erb @@ -20,12 +20,15 @@
        <% drawn_ids = @application[:drawn_ids] %> +<% notary = FakeEmbassy.notary_for(@application) %> <% if drawn_ids %>
        - Random pool draw for this application: + Random pool draw: <%= drawn_ids.join(", ") %> - (from the <%= FakeEmbassy::SECTION_2_POOL.length %>-question Section 2 pool). - The rendered application below shows exactly what this attendee saw. + (from the <%= FakeEmbassy.random_pool_total %>-question Section 3 pool). +
        + Notary assignment: + <%= notary[:id] %> · “Someone who <%= notary[:description].downcase.sub(/^./, &:downcase) %>”
        <% end %> @@ -34,6 +37,7 @@ serial: @application[:serial], schedule_item: ScheduleItem.embassy.first || ScheduleItem.first, sections: FakeEmbassy.sections_for(@application), + notary: FakeEmbassy.notary_for(@application), applicant_name: @application[:attendee_name], submitted_at: @application[:submitted_at] %>
    diff --git a/app/views/admin/embassy_blank_pdfs/new.html.erb b/app/views/admin/embassy_blank_pdfs/new.html.erb index bc4578c..b204fd0 100644 --- a/app/views/admin/embassy_blank_pdfs/new.html.erb +++ b/app/views/admin/embassy_blank_pdfs/new.html.erb @@ -3,9 +3,10 @@

    Generate Blank Applications

    Print blank, randomized applications for on-site walk-ins. Each generated form - contains a unique, randomly-drawn set of questions from the active bank. - Forms are serialized sequentially (RE-0427-A, RE-0427-B, …) so paper can be - reconciled with Embassy records later. + contains a unique, randomly-drawn set of questions from the active bank + and a unique notary assignment. Forms are serialized + sequentially (RE-0427-A, RE-0427-B, …) so paper can be reconciled with + Embassy records later.

    @@ -26,74 +27,17 @@

    Preview · one blank form

    - A randomly-generated blank with serial <%= @preview_serial %>. - The real batch will contain a unique set of questions per form. + A randomly-generated blank with serial <%= @preview_serial %> + and notary assignment <%= @preview_notary[:id] %> + (“<%= @preview_notary[:description] %>”). + The real batch will randomize both per form.

    -
    -
    -
    -
    - Form RUBY-1.0-MATZ
    - Rev. 04/2026
    - BLANK · Walk-in Copy -
    - -
    -

    RUBY EMBASSY

    -

    Application for Issuance of New Ruby Passport

    -
    - - <% @preview_sections.each do |section| %> -
    -

    - Section <%= section[:number].to_s.rjust(2, "0") %>. <%= section[:title] %> -

    - <% section[:questions].each do |q| %> -
    - <%= q[:id] %>. -
    -

    <%= q[:label] %>

    -
    -
    -
    - <% end %> -
    - <% end %> - -
    -
    -
    -
    -
    -
    - Signature of Applicant - Date -
    -
    - -
    - <%= @preview_serial %> - Page 1 of 2 · Bring this form to the Embassy -
    -
    + <%= render "embassy_applications/pdf", + serial: @preview_serial, + schedule_item: ScheduleItem.embassy.first || ScheduleItem.first, + sections: @preview_sections, + notary: @preview_notary, + blank: true %>
    diff --git a/app/views/admin/embassy_questions/index.html.erb b/app/views/admin/embassy_questions/index.html.erb index 18104c8..2b4970f 100644 --- a/app/views/admin/embassy_questions/index.html.erb +++ b/app/views/admin/embassy_questions/index.html.erb @@ -4,9 +4,9 @@

    Question Bank

    - <%= @questions.count { |q| q[:status] == "active" } %> active · - <%= @questions.count { |q| q[:status] == "archived" } %> archived · - Random-pool sections draw a unique subset per application. + <%= @questions.count { |q| q[:status] == "active" } %> active questions · + <%= FakeEmbassy.random_pool_total %> in the random pool (draws <%= FakeEmbassy.random_pool_draws %> per application) · + <%= @notary_pool.length %> notary descriptions

    <%= link_to "+ Add Question", new_admin_embassy_question_path, class: "btn btn-red" %> @@ -22,12 +22,14 @@

    Section <%= number.to_s.rjust(2, "0") %>. <%= meta[:title] %>

    -

    <%= meta[:hint] %>

    +

    + <%= meta[:hint] % { draws: FakeEmbassy.random_pool_draws, total: rows.size } %> +

    <% if meta[:scope] == "random_pool" %> - Random pool · draws <%= meta[:draws] %> of <%= rows.size %> + Random pool · draws <%= FakeEmbassy.random_pool_draws %> of <%= rows.size %> <% else %> @@ -37,26 +39,105 @@
    - - - - - - - - - - - - - <% rows.each do |q| %> - <%= render "question_row", question: q %> - <% end %> - -
    IDQuestionTypeRequiredUsage
    + <% if meta[:scope] == "random_pool" %> + <%# Section 3 is nested by category for easier admin management %> + <% FakeEmbassy.random_pool_categories.each do |cat_key, cat| %> + <% cat_rows = rows.select { |q| q[:category] == cat_key } %> + <% next if cat_rows.empty? %> + +

    + <%= cat[:title] %> + <%= cat_rows.size %> questions +

    + + + + + + + + + + + + <% cat_rows.each do |q| %> + <%= render "question_row", question: q %> + <% end %> + +
    IDQuestionTypeUsage
    + <% end %> + <% else %> + + + + + + + + + + + + + <% rows.each do |q| %> + <%= render "question_row", question: q %> + <% end %> + +
    IDQuestionTypeRequiredUsage
    + <% end %> <% end %> +<%# ========== Notary Pool — PDF addendum ========== %> +
    +
    +
    +

    Notary Pool · PDF addendum

    +

    + The ice-breaker. One is drawn per printed application. Users locate someone + matching the description at the Embassy and have them sign. Notary + descriptions do not appear on-screen — they print only. +

    +
    +
    + + Notary pool · <%= @notary_pool.length %> descriptions · draws 1 per form + +
    +
    + + + + + + + + + + + + + <% @notary_pool.each do |n| %> + + + + + + + + <% end %> + +
    IDDescription (“Someone who…”)Follow-upsUsage
    <%= n[:id] %><%= n[:description] %> + <%= n[:followup_count] %> + q<%= n[:followup_count] == 1 ? "" : "s" %> + + <%= n[:usage_count] %> + uses + + edit coming +
    +
    + <% archived = @questions.select { |q| q[:status] == "archived" } %> <% if archived.any? %>
    @@ -64,7 +145,7 @@

    Archived

    - Excluded from randomization. Useful for records, retired seasonal questions, + Excluded from randomization. Useful for retired seasonal questions or ones pulled for adjudication reasons.

    diff --git a/app/views/embassy_applications/_appointment_card.html.erb b/app/views/embassy_applications/_appointment_card.html.erb index e821ea0..4cb8c15 100644 --- a/app/views/embassy_applications/_appointment_card.html.erb +++ b/app/views/embassy_applications/_appointment_card.html.erb @@ -15,10 +15,10 @@

    -
    Type
    +
    Classification
    <%= FakeEmbassy.mode_label(mode) %>
    -
    Attendee
    +
    Applicant
    <%= current_user.full_name %>
    diff --git a/app/views/embassy_applications/_expired.html.erb b/app/views/embassy_applications/_expired.html.erb index 9c8cf8f..5d2d693 100644 --- a/app/views/embassy_applications/_expired.html.erb +++ b/app/views/embassy_applications/_expired.html.erb @@ -2,12 +2,13 @@
    SESSION EXPIRED
    -

    Your application session has ended.

    +

    The Applicant's session has lapsed.

    - Your <%= ScheduleItem::DAY_META.dig(schedule_item.day, :label) %> - appointment at <%= schedule_item.time_label %> remains confirmed. - You may begin a fresh application at any time. + The <%= ScheduleItem::DAY_META.dig(schedule_item.day, :label) %> + appointment at <%= schedule_item.time_label %> remains confirmed in + Embassy records. The Applicant may commence a fresh application at any + time; a new form shall be issued.

    <%= link_to "Start a New Application", diff --git a/app/views/embassy_applications/_pdf.html.erb b/app/views/embassy_applications/_pdf.html.erb index 7ed535d..fe1b7fc 100644 --- a/app/views/embassy_applications/_pdf.html.erb +++ b/app/views/embassy_applications/_pdf.html.erb @@ -1,4 +1,4 @@ -<%# locals: (serial:, schedule_item:, sections:, applicant_name: nil, submitted_at: nil) -%> +<%# locals: (serial:, schedule_item:, sections:, notary: nil, applicant_name: nil, submitted_at: nil, blank: false) -%>
    @@ -32,14 +32,22 @@

    RUBY EMBASSY

    Application for Issuance of New Ruby Passport

    -
    -
    Appointment
    -
    <%= ScheduleItem::DAY_META.dig(schedule_item.day, :label) %> · <%= schedule_item.time_label %>
    -
    Applicant
    -
    <%= applicant_name || current_user.full_name %>
    -
    Submitted
    -
    <%= submitted_at || Time.current.strftime("%Y-%m-%d %H:%M") %>
    -
    + <% if blank %> +
    +
    Appointment
     
    +
    Applicant
     
    +
    Submitted
     
    +
    + <% else %> +
    +
    Appointment
    +
    <%= ScheduleItem::DAY_META.dig(schedule_item.day, :label) %> · <%= schedule_item.time_label %>
    +
    Applicant
    +
    <%= applicant_name || current_user.full_name %>
    +
    Submitted
    +
    <%= submitted_at || Time.current.strftime("%Y-%m-%d %H:%M") %>
    +
    + <% end %>
    <% sections.each do |section| %> @@ -53,12 +61,18 @@

    <%= q[:label] %>

    - <% - ans = FakeEmbassy.filled_answer(q[:id]) - %> - <% if q[:type] == :checkbox %> - <%= ans ? "☒ I affirm the above declaration." : "☐ (not checked)" %> - <% elsif q[:type] == :long %> + <% ans = blank ? nil : FakeEmbassy.filled_answer(q[:id]) %> + <% case q[:type] %> + <% when :checkbox %> + <%= ans ? "☒ I affirm" : "☐" %> + <% when :checkbox_group %> +
      + <% q[:options].each do |opt| %> + <% checked = ans.is_a?(Array) && ans.include?(opt) %> +
    • <%= checked ? "☒" : "☐" %> <%= opt %>
    • + <% end %> +
    + <% when :long %> <%= ans %> <% else %> <%= ans %> @@ -73,10 +87,18 @@
    - X   <%= FakeEmbassy.filled_answer("3e") %> + <% if blank %> +   + <% else %> + X   <%= FakeEmbassy.filled_answer("5d") %> + <% end %>
    - <%= Date.current.strftime("%Y-%m-%d") %> + <% if blank %> +   + <% else %> + <%= Date.current.strftime("%Y-%m-%d") %> + <% end %>
    @@ -90,3 +112,68 @@ Page 1 of 2 · Bring this form to your appointment
    + +<%# ==================== Notary Addendum ==================== %> +<%# A second printed page. The notary description is randomized per +<%# application — this is the ice-breaker mechanic. Users MUST NOT see +<%# this on screen before printing. It's rendered inside the PDF canvas +<%# which @media print exposes while hiding everything else. %> +<% if notary %> +
    +
    +

    NOTARY CERTIFICATION

    +

    Required for Validation at the Embassy Gate · Ordinance §4.2(b)

    +
    + +
    +

    + Pursuant to Embassy Ordinance §4.2(b), every application for issuance + of a new Ruby Embassy Passport must be certified by a qualified + individual physically present within the conference grounds. The + Applicant's assigned notary description has been determined at the + time of form issuance and is recorded below. Identification and + procurement of the notary is the sole responsibility of the Applicant. +

    +
    + +
    +

    Someone who…

    +

    <%= notary[:description] %>

    +
    + +
    +

    Supplementary Particulars

    + <% notary[:followups].each_with_index do |f, i| %> +
    + N<%= (i + 1).to_s.rjust(2, "0") %>. +
    +

    <%= f %>

    +
    +
    +
    + <% end %> +
    + +
    +

    Certified By

    + +
    + Name + +
    +
    + Signature + +
    +
    + Official Title + +
    +
    + +
    + <%= serial %> · <%= notary[:id] %> + Page 2 of 2 · Bring this form to the Embassy +
    +
    +<% end %> diff --git a/app/views/embassy_applications/_question.html.erb b/app/views/embassy_applications/_question.html.erb index 821af82..13782b6 100644 --- a/app/views/embassy_applications/_question.html.erb +++ b/app/views/embassy_applications/_question.html.erb @@ -1,4 +1,4 @@ -<%# locals: question: %> +<%# locals: (question:) -%>
    + <% when :checkbox_group %> +
    + <% question[:options].each_with_index do |opt, i| %> + + <% end %> +
    + <% when :date %>

    - Print clearly. Do not write in the margins. All fields marked with an - asterisk (*) are required for adjudication. The Embassy reserves the - right to reject applications deemed insufficiently Ruby. + Print clearly. Do not write in the margins. Fields marked with an + asterisk (*) are required for adjudication under Embassy Ordinance §1.1. + The Embassy reserves the right, in its sole discretion, to reject + applications deemed insufficiently Ruby.

    @@ -43,8 +44,10 @@
    <% end %> diff --git a/app/views/embassy_applications/show.html.erb b/app/views/embassy_applications/show.html.erb index 95dd1cd..a27eb9a 100644 --- a/app/views/embassy_applications/show.html.erb +++ b/app/views/embassy_applications/show.html.erb @@ -3,8 +3,8 @@

    Form RUBY-1.0-MATZ · Rev. 04/2026

    -

    Application Submitted

    -

    Your appointment is confirmed. Please print and bring the application below.

    +

    Application Received

    +

    The Applicant's appointment is confirmed. Please print and bring the attached application to the Embassy.

    @@ -15,11 +15,11 @@ state: "submitted" %>
    -

    YOUR SERIAL NUMBER

    +

    OFFICIAL SERIAL

    <%= @serial %>

    - Keep this on your printed application. It is how the Attaché locates - your record when you arrive. + Retain this serial on the printed application. The Embassy Attaché + shall reference it to locate the Applicant's record upon arrival.

    @@ -30,23 +30,24 @@ Download & Print Application (PDF) <% end %>

    - 2 pages · black & white · standard letter size + 2 pages · black & white · standard letter size · includes Notary Certification

    Preview

    -

    This is what will print. Review before closing this page.

    +

    The document below is what will print. Review before closing this page.

    <%= render "pdf", serial: @serial, schedule_item: @schedule_item, - sections: @sections %> + sections: @sections, + notary: FakeEmbassy.notary_for %>
    -

    Embassy Hours & Etiquette

    +

    Embassy Hours & Protocol

    Hours of operation

    @@ -57,7 +58,7 @@
    -

    Please note

    +

    Standing protocol

      <% FakeEmbassy.embassy_etiquette.each do |note| %>
    • <%= note %>
    • diff --git a/app/views/embassy_bookings/_confirm.html.erb b/app/views/embassy_bookings/_confirm.html.erb index 5b0e98f..433605c 100644 --- a/app/views/embassy_bookings/_confirm.html.erb +++ b/app/views/embassy_bookings/_confirm.html.erb @@ -2,7 +2,7 @@

      Confirm Appointment

      - You are booking a + The Applicant hereby elects to book a <%= FakeEmbassy.mode_label(mode) %> appointment at the Ruby Embassy on <%= ScheduleItem::DAY_META.dig(@schedule_item.day, :label) %> @@ -12,20 +12,23 @@ <% if mode == "new_passport" %>

      - Upon confirmation you will be directed to the New Passport Application. - Your session will remain active for 60 minutes; - please complete and submit the form within that window. + Upon confirmation the Applicant will be directed to the + New Passport Application. Pursuant to Embassy + Ordinance §2.3, the session shall remain active for sixty (60) + minutes; submission must be completed within that window.

      - Your appointment slot remains confirmed regardless of submission. - If your session lapses, you may begin a fresh application at any time. + The appointment slot remains confirmed irrespective of submission. + If the session lapses, the Applicant may begin a fresh application + at any time.

      <% else %>

      - Bring your existing Ruby Embassy Passport to the Embassy Office at the - time above. No application is required for stamping appointments. + The Applicant shall present their existing Ruby Embassy Passport at + the Embassy Office at the time above. No application is required + for stamping appointments.

      <% end %> diff --git a/app/views/embassy_bookings/_mode_picker.html.erb b/app/views/embassy_bookings/_mode_picker.html.erb index ffa36af..6258a77 100644 --- a/app/views/embassy_bookings/_mode_picker.html.erb +++ b/app/views/embassy_bookings/_mode_picker.html.erb @@ -1,9 +1,10 @@
      -

      State Your Purpose of Visit

      +

      Declare Purpose of Visit

      - Please indicate the purpose of your appointment so the Attaché can prepare - accordingly. Appointments erroneously booked under the wrong category - may be denied at the gate. + The Applicant is required to state the purpose of their appointment so + the Embassy Attaché may prepare accordingly. Appointments erroneously + elected under the wrong category may be denied at the gate without + recourse.

      diff --git a/app/views/embassy_bookings/create.html.erb b/app/views/embassy_bookings/create.html.erb index 5935079..a7bb8c0 100644 --- a/app/views/embassy_bookings/create.html.erb +++ b/app/views/embassy_bookings/create.html.erb @@ -10,10 +10,10 @@
      STAMPED
      -

      Your appointment is confirmed.

      +

      The Applicant's appointment is confirmed.

      - Bring your existing Ruby Passport to the Embassy Office at + Present existing Ruby Embassy Passport at the Embassy Office at

      diff --git a/app/views/embassy_bookings/new.html.erb b/app/views/embassy_bookings/new.html.erb index d85bc02..0f6b615 100644 --- a/app/views/embassy_bookings/new.html.erb +++ b/app/views/embassy_bookings/new.html.erb @@ -8,7 +8,7 @@

      -

      Block Details

      +

      Particulars of the Block

      Date & Time
      <%= ScheduleItem::DAY_META.dig(@schedule_item.day, :label) %> · <%= @schedule_item.time_label %>
      @@ -16,15 +16,15 @@
      Location
      <%= @schedule_item.location.presence || "Embassy Office · Main Hall" %>
      -
      Block Type
      +
      Block classification
      <%= FakeEmbassy.mode_label(@block_mode) %> <% if @block_mode == "both" %> - (choose below) + (Applicant to elect below) <% end %>
      -
      Seats remaining
      +
      Seats available
      <%= FakeEmbassy.seats_remaining(@schedule_item.id) %> of <%= @capacity %>
      diff --git a/app/views/plan/_plan_item.html.erb b/app/views/plan/_plan_item.html.erb index 290045a..afc47d8 100644 --- a/app/views/plan/_plan_item.html.erb +++ b/app/views/plan/_plan_item.html.erb @@ -25,7 +25,7 @@ <% when "stamping" %>

      Stamping appointment. - Bring your existing Ruby Passport to the Embassy at the time above. + Present existing Ruby Embassy Passport at the Embassy Office at the time above.

      <% when "pending" %> @@ -40,7 +40,7 @@ <% when "submitted" %>

      - Application submitted · Serial <%= serial %> + Application filed · Serial <%= serial %>

      <%= link_to "Download PDF", embassy_application_path(serial), @@ -49,7 +49,7 @@ <% when "expired" %>

      - Session expired · Your appointment is still confirmed. + Session lapsed · Appointment remains confirmed.

      <%= link_to "Start a New Application", new_embassy_application_path(plan_item_id: plan_item.id), diff --git a/db/seeds/embassy_questions.rb b/db/seeds/embassy_questions.rb new file mode 100644 index 0000000..b6c3b77 --- /dev/null +++ b/db/seeds/embassy_questions.rb @@ -0,0 +1,392 @@ +# Ruby Embassy — Application Question Seed Data +# +# Single source of truth for the Embassy application form. Consumed by: +# - app/services/fake_embassy.rb (mockup rendering) +# - db/seeds.rb (eventually, once the Question model exists) +# +# Question IDs are stable, human-readable, and printed on every form — +# the Attaché references them at the Embassy ("see box 1h"). Do not +# renumber casually. +# +# Section scope: +# - common — every applicant answers these +# - random_pool — admin-managed pool; a subset is drawn per application. +# Users must NEVER see scope metadata. + +module EmbassyQuestionsSeed + # === Section 1 · Declaration of Ruby-ness (COMMON) ======================= + BASIC_INFO = [ + { id: "1a", label: "Given Name (as it appears on your Tito badge)", + type: :short, required: true, + placeholder: "e.g., Katya" }, + + { id: "1b", label: "GitHub handle", + type: :short, required: false, + help: "The Applicant's @handle on GitHub, where applicable." }, + + { id: "1c", label: "Preferred pronouns", + type: :short, required: false }, + + { id: "1d", label: "Age, in Coding Years", + type: :short, required: false, + placeholder: "e.g., 9 years of Ruby, 14 years of existential dread", + help: "For adjudication purposes only. The Embassy does not acknowledge biological years." }, + + { id: "1e", label: "Today's Lottery Winnings", + type: :short, required: false, + placeholder: "$0 is an acceptable declaration. So is $47.12 in Coinstar.", + help: "For audit purposes. Amounts in excess of $10M must be disclosed to the Embassy Attaché. Receipts may be requested." }, + + { id: "1f", label: "Declared Ruby Proficiency", + type: :select, required: true, + options: [ + "Just installed it", + "I use Ruby", + "I fight Ruby", + "I am Ruby" + ] }, + + { id: "1g", label: "Primary language, other than Ruby", + type: :short, required: false, + placeholder: "e.g., Go, Elixir, Fortran, Emotional Support" }, + + { id: "1h", label: "Stated purpose of visit (select all that apply)", + type: :checkbox_group, required: false, + options: [ "Learning", "Networking", "Vibes", "Free coffee" ] }, + + { id: "1i", label: "Most recent sentiment experienced while programming (select all that apply)", + type: :checkbox_group, required: false, + options: [ "Powerful", "Confused", "Betrayed", "Like a genius", "Like quitting forever" ] }, + + { id: "1j", label: "Declared developer persona (select all that apply)", + type: :checkbox_group, required: false, + options: [ + "The Debugger", + "The Ship-It Gremlin", + "The Perfectionist", + "The \"it works, don't touch it\"", + "The Vibe Coder" + ] } + ].freeze + + # === Section 2 · Statement of Intent & Character (COMMON) ================ + PERSONAL_STATEMENT = [ + { id: "2a", label: "Describe the Applicant's current relationship with programming, in one sentence", + type: :long, required: true }, + + { id: "2b", label: "First Ruby release the Applicant remembers using", + type: :select, required: false, + options: [ "1.8.x", "1.9.x", "2.0–2.6", "2.7–3.0", "3.1+", "I refuse to answer" ] }, + + { id: "2c", label: "Ruby release for which the Applicant harbors sentimental attachment", + type: :short, required: false, + placeholder: "e.g., 1.8.7, for reasons the Embassy need not know." }, + + { id: "2d", label: "Describe, in one paragraph, the Applicant's relationship to Yukihiro Matsumoto", + type: :long, required: true, + help: "Literal or metaphorical responses both accepted." } + ].freeze + + # === Section 3 · Supplementary Declarations (RANDOM POOL) ================ + # Organized into categories for the admin question bank. Draws happen + # across the flattened pool (RANDOM_POOL). Users see only a numbered + # section with a handful of questions; they must not infer randomization. + RANDOM_POOL_CATEGORIES = { + programming_reality: { + title: "Programming Reality", + questions: [ + { id: "3a01", type: :long, + label: "What's a bug that made you question your entire existence?" }, + { id: "3a02", type: :long, + label: "What's something you confidently pushed that absolutely should not have been pushed?" }, + { id: "3a03", type: :long, + label: "What's your \"this worked and I don't know why\" moment?" }, + { id: "3a04", type: :long, + label: "What's the most cursed piece of code you've ever written?" }, + { id: "3a05", type: :long, + label: "What's a problem you solved in the worst possible way?" }, + { id: "3a06", type: :long, + label: "What's something simple that took you far too long to figure out?" }, + { id: "3a07", type: :long, + label: "What's your most recent \"I hate this\" moment while coding?" } + ] + }, + hot_takes: { + title: "Hot Takes & Chaos", + questions: [ + { id: "3b01", type: :long, + label: "State your most controversial programming opinion, for the record." }, + { id: "3b02", type: :long, + label: "Which \"best practice\" do you quietly ignore?" }, + { id: "3b03", type: :long, + label: "What is something universally beloved that the Applicant considers overrated?" }, + { id: "3b04", type: :long, + label: "What is something universally reviled that the Applicant secretly enjoys?" }, + { id: "3b05", type: :long, + label: "Rails: misunderstood genius, or toxic relationship? Defend your position." }, + { id: "3b06", type: :long, + label: "JavaScript: enemy, ally, or situationship?" }, + { id: "3b07", type: :long, + label: "If the Applicant were permitted to delete one programming language forever, which and why?" } + ] + }, + hypotheticals: { + title: "Hypotheticals", + questions: [ + { id: "3c01", type: :long, + label: "If the Applicant's codebase became sentient, would it like them?" }, + { id: "3c02", type: :short, + label: "If debugging were a sanctioned sport, the Applicant's ranking would be:" }, + { id: "3c03", type: :long, + label: "If the Applicant's most recent bug had a personality, describe it." }, + { id: "3c04", type: :short, + label: "The Applicant may use only one Ruby method forever. Which is it?" }, + { id: "3c05", type: :long, + label: "The Applicant's code is being reviewed by their past self. Describe the proceeding." }, + { id: "3c06", type: :long, + label: "The Applicant deploys on Friday. Honestly: why?" }, + { id: "3c07", type: :long, + label: "The Applicant wakes up and Rails is gone. Describe the Applicant's next move." } + ] + }, + personal: { + title: "Personal (but still chaotic)", + questions: [ + { id: "3d01", type: :long, + label: "What first drove the Applicant to begin coding?" }, + { id: "3d02", type: :long, + label: "What keeps the Applicant coding even when it's painful?" }, + { id: "3d03", type: :long, + label: "Describe an \"I should quit\" moment the Applicant survived without quitting." }, + { id: "3d04", type: :long, + label: "Name a non-technical influence on the Applicant's coding habits." }, + { id: "3d05", type: :long, + label: "Describe the Applicant's ideal coding environment (be specific)." }, + { id: "3d06", type: :long, + label: "When stuck, the Applicant does what?" } + ] + }, + vibe_meme: { + title: "Vibe / Meme Energy", + questions: [ + { id: "3e01", type: :short, + label: "Describe the Applicant's coding style using only vibes." }, + { id: "3e02", type: :short, + label: "State the Applicant's current developer mood." }, + { id: "3e03", type: :short, + label: "What kind of bug is the Applicant, as a person?" }, + { id: "3e04", type: :short, + label: "If the Applicant's workflow were a meme, what would it be?" }, + { id: "3e05", type: :short, + label: "State the Applicant's \"10 tabs open, hoping one helps\" ratio." }, + { id: "3e06", type: :short, + label: "How many times does the Applicant Google the same error before accepting defeat?" } + ] + }, + side_projects: { + title: "Side Projects & Dreams", + questions: [ + { id: "3f01", type: :long, + label: "Describe the Applicant's dream project, in the absence of time and money constraints." }, + { id: "3f02", type: :short, + label: "Name something the Applicant started but never finished." }, + { id: "3f03", type: :short, + label: "Name something the Applicant wishes to build but has not begun." }, + { id: "3f04", type: :short, + label: "Name the most \"vibe-coded\" artifact in the Applicant's portfolio." }, + { id: "3f05", type: :long, + label: "What would the Applicant build if no one could judge them for it?" } + ] + }, + unhinged: { + title: "Slightly Unhinged", + questions: [ + { id: "3g01", type: :long, + label: "Has the Applicant ever declared a project \"almost done\" when it was not? Elaborate." }, + { id: "3g02", type: :long, + label: "Describe the Applicant's most dramatic debugging session." }, + { id: "3g03", type: :long, + label: "Name a fix the Applicant shipped that immediately broke something else." }, + { id: "3g04", type: :long, + label: "Has the Applicant ever nodded through a concept they did not understand? Describe the occasion." }, + { id: "3g05", type: :short, + label: "Name the Applicant's \"I'm not touching that\" file or module." }, + { id: "3g06", type: :long, + label: "Describe the most chaotic workaround the Applicant has ever shipped to production." } + ] + }, + conference: { + title: "Conference Particulars", + questions: [ + { id: "3h01", type: :long, + label: "Why is the Applicant really here?" }, + { id: "3h02", type: :long, + label: "What is the Applicant honestly hoping to extract from this conference?" }, + { id: "3h03", type: :long, + label: "State the Applicant's strategy for surviving today's social interactions." }, + { id: "3h04", type: :short, + label: "How many conversations before the Applicant requires recharging?" }, + { id: "3h05", type: :short, + label: "Is the Applicant here to learn, to network, or to avoid responsibilities?" } + ] + }, + scenarios: { + title: "Scenarios", + questions: [ + { id: "3i01", type: :long, + label: "The Applicant deploys on Friday. Describe what follows." }, + { id: "3i02", type: :long, + label: "The Applicant's code works locally but fails in production. Describe next steps." }, + { id: "3i03", type: :long, + label: "The Applicant inherits a legacy Rails application. Describe the first action taken." }, + { id: "3i04", type: :long, + label: "A teammate declares \"it works on my machine.\" State the Applicant's response." }, + { id: "3i05", type: :long, + label: "The Applicant experiences a complete lack of motivation to code. Describe the remedy." } + ] + }, + ruby_flavor: { + title: "Ruby Flavor", + questions: [ + { id: "3j01", type: :select, + label: "Declared number of keyboards currently owned by the Applicant", + options: [ "0", "1", "2–3", "4–6", "More than the Applicant wishes to state" ] }, + { id: "3j02", type: :select, + label: "Preferred test runner", + options: [ "RSpec", "Minitest", "Both, as mood dictates", "Neither; the Applicant tests in production" ] }, + { id: "3j03", type: :long, + label: "Describe the Applicant's most embarrassing production incident (brief)", + help: "Names will be redacted. Scars will not." }, + { id: "3j04", type: :long, + label: "State the Applicant's worst Rails upgrade narrative, in no more than three sentences." }, + { id: "3j05", type: :short, + label: "If Ruby were a beverage, it would be:" }, + { id: "3j06", type: :long, + label: "Has the Applicant written method_missing? If so, describe the sensation.", + help: "Responses involving \"transcendent\" or \"regret\" will be evaluated equally." }, + { id: "3j07", type: :short, + label: "Name one gem that does not spark joy" }, + { id: "3j08", type: :long, + label: "Describe the Applicant's emotional relationship to the practice of pair programming." } + ] + } + }.freeze + + # Flat view of the pool — used for drawing + the admin question bank. + RANDOM_POOL = RANDOM_POOL_CATEGORIES.flat_map { |_, cat| cat[:questions] }.freeze + + # How many pool questions each application draws. + RANDOM_POOL_DRAWS = 5 + + # === Section 4 · Attestation of Community Standing (COMMON) ============== + COMMUNITY_ALIGNMENT = [ + { id: "4a", + label: "The Applicant hereby affirms or declines each of the following. Check each that applies.", + type: :checkbox_group, + required: false, + options: [ + "I believe debugging builds character", + "I have questioned my life choices while coding", + "I have said \"this should work\" (it did not)", + "I have copied code and hoped for the best", + "I have fixed something and broken something else", + "I have Googled the same error more than once", + "I have considered quitting (temporarily)" + ] } + ].freeze + + # === Section 5 · Affidavit of Attendance (COMMON) ======================== + DECLARATION = [ + { id: "5a", + label: "I hereby declare that the information provided is true to the best of my knowledge and current debugging ability.", + type: :checkbox, required: true }, + + { id: "5b", label: "Date of arrival in Asheville", + type: :date, required: true }, + + { id: "5c", label: "Does the Applicant currently possess unreleased gems of unknown provenance?", + type: :select, required: true, + options: [ "No", "Yes", "I plead the fifth" ] }, + + { id: "5d", label: "Signature of Applicant (type full legal name)", + type: :short, required: true, + help: "Electronic signature. Carries the same legal weight as a stamped declaration — which is to say, very little." } + ].freeze + + # === Notary Requirement (PDF-only addendum) ============================== + # The ice-breaker. One is drawn per application and appears on the + # PRINTED form only — users must physically locate someone matching + # the description at the Embassy and have them sign. Admins manage + # the pool via the question bank. + NOTARY_POOL = [ + { id: "N01", description: "Uses a different language than Ruby", + followups: [ "What does the notary like about it?" ] }, + + { id: "N02", description: "Has attended three (3) or more Ruby conferences", + followups: [ + "Which is the notary's favorite?", + "State the notary's rubyevents.com URL.", + "Are you and the notary best friends on rubyevents.com yet?" + ] }, + + { id: "N03", description: "Harbors documented resentment toward Ruby on Rails", + followups: [ "State the notary's grievance." ] }, + + { id: "N04", description: "Has deployed to production on today's date", + followups: [ "Describe what the notary shipped." ] }, + + { id: "N05", description: "Actively uses Hotwire", + followups: [ + "State what the notary loves about Hotwire.", + "State what the notary loathes about Hotwire." + ] }, + + { id: "N06", description: "Prefers tabs over spaces", + followups: [ "State the notary's justification." ] }, + + { id: "N07", description: "Has formally rage-quit JavaScript at least once", + followups: [ "Describe what broke them." ] }, + + { id: "N08", description: "Has consumed food on camera during a Zoom standup", + followups: [ "Describe the item consumed." ] }, + + { id: "N09", description: "Holds the opinion that Rails is dying", + followups: [ "Summarize the notary's reasoning." ] }, + + { id: "N10", description: "Believes JavaScript is, in fact, fine", + followups: [ "Assess the notary's well-being." ] }, + + { id: "N11", description: "Prefers monoliths to microservices", + followups: [ + "State the notary's justification.", + "Assess the notary's well-being." + ] }, + + { id: "N12", description: "Is new to the Ruby programming language", + followups: [ "State what the notary is currently learning." ] }, + + { id: "N13", description: "Maintains an interesting side project", + followups: [ "Record the notary's pitch." ] }, + + { id: "N14", description: "Does not code, and is present strictly for vibes", + followups: [ "State the vibes the notary is seeking." ] }, + + { id: "N15", description: "Has published a fully vibe-coded application to the public", + followups: [ + "Define \"vibe coding\" in the notary's own words.", + "Name the application.", + "State the URL.", + "Is the application actually good? (Applicant's opinion.)", + "Is the application actually good? (Notary's opinion.)" + ] }, + + { id: "N16", description: "Can name a Ruby method known to no one else in attendance", + followups: [ "Transcribe the method and its behavior here." ] }, + + { id: "N17", description: "Possesses the funniest documented bug story", + followups: [ "Record the account in full." ] }, + + { id: "N18", description: "Sustained the worst production incident in recent memory", + followups: [ "Record the account in full." ] } + ].freeze +end From 98e866f397a6e901fead84ae83ba0d5e6e6df73b Mon Sep 17 00:00:00 2001 From: Katya Sarmiento <5871075+Kitkatnik@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:18:56 -0400 Subject: [PATCH 27/29] Drop Lottery Winnings question; remove handwriting-era copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section 1 is now 1a–1i (renumbered after removing 1e lottery winnings), so IDs stay sequential. FILLED_ANSWERS keys shifted to match. Also dropped "Print clearly" / "Do not write in the margins" from the online form — those are handwriting instructions, and applicants type into the browser. Section 1 and form-header copy reworded to stay bureaucratic without the paper-era phrasing. --- app/services/fake_embassy.rb | 15 +++++++-------- app/views/embassy_applications/new.html.erb | 7 +++---- db/seeds/embassy_questions.rb | 15 +++++---------- 3 files changed, 15 insertions(+), 22 deletions(-) diff --git a/app/services/fake_embassy.rb b/app/services/fake_embassy.rb index 4a02d50..fd82615 100644 --- a/app/services/fake_embassy.rb +++ b/app/services/fake_embassy.rb @@ -61,7 +61,7 @@ def sample_questions(drawn_ids: DEFAULT_DRAWN_POOL_IDS) { number: 1, title: "Declaration of Ruby-ness", scope: "common", - instructions: "Print clearly. This section establishes the Applicant's eligibility for Passport issuance pursuant to Embassy Ordinance §1.9.", + instructions: "This section establishes the Applicant's eligibility for Passport issuance pursuant to Embassy Ordinance §1.9. Complete all required fields.", questions: EmbassyQuestionsSeed::BASIC_INFO }, { @@ -111,17 +111,16 @@ def notary_for(application = nil) end FILLED_ANSWERS = { - # Section 1 · Basic Info ---------------------------------------------- + # Section 1 · Declaration of Ruby-ness -------------------------------- "1a" => "Katya", "1b" => "@kitkatnik", "1c" => "she/her", "1d" => "9 years of Ruby, 14 years of existential dread", - "1e" => "$0. Audit me, I dare you.", - "1f" => "I use Ruby", - "1g" => "Go", - "1h" => [ "Networking", "Vibes", "Free coffee" ], - "1i" => [ "Powerful", "Confused", "Like quitting forever" ], - "1j" => [ "The Debugger", "The \"it works don't touch it\"" ], + "1e" => "I use Ruby", + "1f" => "Go", + "1g" => [ "Networking", "Vibes", "Free coffee" ], + "1h" => [ "Powerful", "Confused", "Like quitting forever" ], + "1i" => [ "The Debugger", "The \"it works, don't touch it\"" ], # Section 2 · Personal Statement -------------------------------------- "2a" => "Programming and I have been together long enough that we finish each other's error messages.", diff --git a/app/views/embassy_applications/new.html.erb b/app/views/embassy_applications/new.html.erb index 9724b6e..9033856 100644 --- a/app/views/embassy_applications/new.html.erb +++ b/app/views/embassy_applications/new.html.erb @@ -13,10 +13,9 @@

      - Print clearly. Do not write in the margins. Fields marked with an - asterisk (*) are required for adjudication under Embassy Ordinance §1.1. - The Embassy reserves the right, in its sole discretion, to reject - applications deemed insufficiently Ruby. + Fields marked with an asterisk (*) are required for adjudication under + Embassy Ordinance §1.1. The Embassy reserves the right, in its sole + discretion, to reject applications deemed insufficiently Ruby.

      diff --git a/db/seeds/embassy_questions.rb b/db/seeds/embassy_questions.rb index b6c3b77..dd9556a 100644 --- a/db/seeds/embassy_questions.rb +++ b/db/seeds/embassy_questions.rb @@ -32,12 +32,7 @@ module EmbassyQuestionsSeed placeholder: "e.g., 9 years of Ruby, 14 years of existential dread", help: "For adjudication purposes only. The Embassy does not acknowledge biological years." }, - { id: "1e", label: "Today's Lottery Winnings", - type: :short, required: false, - placeholder: "$0 is an acceptable declaration. So is $47.12 in Coinstar.", - help: "For audit purposes. Amounts in excess of $10M must be disclosed to the Embassy Attaché. Receipts may be requested." }, - - { id: "1f", label: "Declared Ruby Proficiency", + { id: "1e", label: "Declared Ruby Proficiency", type: :select, required: true, options: [ "Just installed it", @@ -46,19 +41,19 @@ module EmbassyQuestionsSeed "I am Ruby" ] }, - { id: "1g", label: "Primary language, other than Ruby", + { id: "1f", label: "Primary language, other than Ruby", type: :short, required: false, placeholder: "e.g., Go, Elixir, Fortran, Emotional Support" }, - { id: "1h", label: "Stated purpose of visit (select all that apply)", + { id: "1g", label: "Stated purpose of visit (select all that apply)", type: :checkbox_group, required: false, options: [ "Learning", "Networking", "Vibes", "Free coffee" ] }, - { id: "1i", label: "Most recent sentiment experienced while programming (select all that apply)", + { id: "1h", label: "Most recent sentiment experienced while programming (select all that apply)", type: :checkbox_group, required: false, options: [ "Powerful", "Confused", "Betrayed", "Like a genius", "Like quitting forever" ] }, - { id: "1j", label: "Declared developer persona (select all that apply)", + { id: "1i", label: "Declared developer persona (select all that apply)", type: :checkbox_group, required: false, options: [ "The Debugger", From 14810d9a0af69f8d6cd04665cf8f52e6ef07f561 Mon Sep 17 00:00:00 2001 From: Katya Sarmiento <5871075+Kitkatnik@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:34:17 -0400 Subject: [PATCH 28/29] Embassy plan card: let the top stripe reach edge-to-edge The outer .plan-item--embassy wrapper had a 1px grey border on all four sides, so the inner card's 4px double-line stripe was inset by 1px at the top-left and top-right corners (visible as a thin grey line above the stripe). Removed the outer top border; the inner stripe now owns that edge, clipped to the card's rounded corners by the existing overflow:hidden. --- app/assets/stylesheets/application.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 28d5f7b..83fc552 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -1867,6 +1867,11 @@ hr { margin-bottom: 0.875rem; background: #ffffff; border: 1px solid #d9dee1; + /* Top border is owned by the inner .embassy-appointment-card so its + double-line stripe reaches edge-to-edge instead of being inset by + the outer hairline. overflow:hidden still clips the stripe to the + card's rounded corners. */ + border-top: 0; border-radius: 0.375rem; overflow: hidden; transition: box-shadow 0.15s ease; From 302a002ce5db35156dd571c6e516a0384612ebd9 Mon Sep 17 00:00:00 2001 From: Katya Sarmiento <5871075+Kitkatnik@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:37:31 -0400 Subject: [PATCH 29/29] Embassy polish: full-width plan-card stripe + side-by-side confirm buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan card: move the 4px double-line stripe from the inner .embassy-appointment-card up to the outer .plan-item--embassy wrapper so it spans the full card width including the × column. State modifiers (--submitted / --stamping / --pending / --expired) now set border-top-color + border-top-style directly on the outer element. Booking confirm: wrap the Confirm / (Change purpose) / Cancel controls in an .embassy-confirm__actions flex container. button_to renders a
      that's otherwise block-level and stacked the three controls vertically; flex + gap lines them up horizontally. --- app/assets/stylesheets/application.css | 41 +++++++++++++------- app/views/embassy_bookings/_confirm.html.erb | 32 ++++++++------- 2 files changed, 44 insertions(+), 29 deletions(-) diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 83fc552..d2e3abd 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -1325,6 +1325,22 @@ hr { margin: 0; } +/* Booking confirm actions --------------------------------------------- */ + +/* button_to wraps its button in a , which is block-level by default. + Forcing the wrapper to display: flex puts the Confirm button, optional + "Change purpose" link, and Cancel link side-by-side with consistent + spacing. */ +.embassy-confirm__actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + margin-top: 0.5rem; +} + +.embassy-confirm__actions form { margin: 0; } + /* Booking mode picker -------------------------------------------------- */ .embassy-mode-grid { @@ -1867,11 +1883,10 @@ hr { margin-bottom: 0.875rem; background: #ffffff; border: 1px solid #d9dee1; - /* Top border is owned by the inner .embassy-appointment-card so its - double-line stripe reaches edge-to-edge instead of being inset by - the outer hairline. overflow:hidden still clips the stripe to the - card's rounded corners. */ - border-top: 0; + /* The 4px double-line stripe lives on the outer wrapper so it spans + the full card width — including the × column. State variants below + override border-top-color / style. */ + border-top: 4px double #C41C1C; border-radius: 0.375rem; overflow: hidden; transition: box-shadow 0.15s ease; @@ -1889,8 +1904,10 @@ hr { } .plan-item__embassy-body .embassy-appointment-card { + /* Inner card loses all decoration — the outer .plan-item--embassy + owns the stripe + borders. This keeps the stripe full-width across + both the embassy-body and the × column. */ border: none; - border-top: 4px double #C41C1C; border-radius: 0; box-shadow: none; padding: 1.25rem 1.25rem 1rem; @@ -1915,18 +1932,14 @@ hr { line-height: 1.4; } -.plan-item--embassy-expired .plan-item__embassy-body .embassy-appointment-card { +.plan-item--embassy-expired { border-top-style: dashed; border-top-color: #7a8189; } -.plan-item--embassy-submitted .plan-item__embassy-body .embassy-appointment-card { - border-top-color: #0C2866; -} - -.plan-item--embassy-stamping .plan-item__embassy-body .embassy-appointment-card { - border-top-color: #2672B5; -} +.plan-item--embassy-submitted { border-top-color: #0C2866; } +.plan-item--embassy-stamping { border-top-color: #2672B5; } +.plan-item--embassy-pending { border-top-style: dashed; } /* Admin question bank sections ---------------------------------------- */ diff --git a/app/views/embassy_bookings/_confirm.html.erb b/app/views/embassy_bookings/_confirm.html.erb index 433605c..2237baa 100644 --- a/app/views/embassy_bookings/_confirm.html.erb +++ b/app/views/embassy_bookings/_confirm.html.erb @@ -33,21 +33,23 @@
    <% end %> - <%= button_to "Confirm Appointment", - embassy_bookings_path, - method: :post, - params: { schedule_item_id: @schedule_item.id, mode: mode }, - class: "btn btn-red", - data: { turbo_frame: "_top" } %> +
    + <%= button_to "Confirm Appointment", + embassy_bookings_path, + method: :post, + params: { schedule_item_id: @schedule_item.id, mode: mode }, + class: "btn btn-red", + data: { turbo_frame: "_top" } %> - <% if @block_mode == "both" %> - <%= link_to "← Change purpose", - new_embassy_booking_path(schedule_item_id: @schedule_item.id), - class: "btn btn-muted", - data: { turbo_frame: "booking_body" } %> - <% end %> + <% if @block_mode == "both" %> + <%= link_to "← Change purpose", + new_embassy_booking_path(schedule_item_id: @schedule_item.id), + class: "btn btn-muted", + data: { turbo_frame: "booking_body" } %> + <% end %> - <%= link_to "Cancel", schedule_path, - class: "btn btn-muted", - data: { turbo_frame: "_top" } %> + <%= link_to "Cancel", schedule_path, + class: "btn btn-muted", + data: { turbo_frame: "_top" } %> +