From 4eee7409ade19da2345941c72dbb1b9f9e688778 Mon Sep 17 00:00:00 2001 From: moveson Date: Thu, 20 Aug 2026 10:37:42 -0600 Subject: [PATCH 1/2] Gate the projection engine on use_for_projections Replaces the concealment predicate in Projection.sql's event selection with the new events.use_for_projections flag, decoupling projection and planning data inclusion from public concealment. A concealed seed event can now power planning and race-day predictions on a new course, and a visible event with unreliable times can be kept out of the stream. PlanDisplay now anchors on the same projectable events the engine draws from, so a seed-only course can plan; the event form exposes the flag with help text. Resolves #2229 Co-Authored-By: Claude Fable 5 --- app/models/course.rb | 5 +++++ app/models/event.rb | 1 + app/models/projection.rb | 3 +-- app/parameters/event_parameters.rb | 1 + app/view_models/plan_display.rb | 12 ++++++++-- app/views/events/_form.html.erb | 11 +++++++++ spec/models/projection_spec.rb | 16 ++++++++++++++ spec/system/plan_effort_flow_spec.rb | 4 ++-- spec/view_models/plan_display_spec.rb | 32 +++++++++++++++++++++++++++ 9 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 spec/view_models/plan_display_spec.rb diff --git a/app/models/course.rb b/app/models/course.rb index 2e55a93c9..db856ad61 100644 --- a/app/models/course.rb +++ b/app/models/course.rb @@ -68,6 +68,11 @@ def visible_events events.visible end + # @return [Event::ActiveRecord_AssociationRelation] + def projectable_events + events.used_for_projections + end + # @return [String, nil] def home_time_zone events.latest&.home_time_zone diff --git a/app/models/event.rb b/app/models/event.rb index 2d227a563..94d15af81 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -43,6 +43,7 @@ class Event < ApplicationRecord after_touch :notify_event_update, if: :topic_resource_key? scope :name_search, ->(search_param) { where("events.name ILIKE ?", "%#{search_param}%") } + scope :used_for_projections, -> { where(use_for_projections: true) } scope :select_with_params, lambda { |search_param| search(search_param) .left_joins(:efforts).left_joins(:event_group) diff --git a/app/models/projection.rb b/app/models/projection.rb index 892661a80..e71c4ecf8 100644 --- a/app/models/projection.rb +++ b/app/models/projection.rb @@ -39,11 +39,10 @@ def self.sql(split_time:, starting_time_point:, subject_time_points:, ignore_tim relevant_event_ids as ( select events.id as event_id from events - join event_groups on event_groups.id = events.event_group_id join courses on courses.id = events.course_id join splits on splits.course_id = courses.id where splits.id = #{starting_split_id} - and (event_groups.concealed is false or event_groups.concealed is null) + and events.use_for_projections is true and (#{ignore_timestamp} is null or events.scheduled_start_time <= #{ignore_timestamp}) order by scheduled_start_time desc limit #{EVENT_LOOKBACK_COUNT} diff --git a/app/parameters/event_parameters.rb b/app/parameters/event_parameters.rb index 4420dea5e..c6d1bb088 100644 --- a/app/parameters/event_parameters.rb +++ b/app/parameters/event_parameters.rb @@ -14,6 +14,7 @@ def self.permitted :results_template_id, :notice_text, :lottery_id, + :use_for_projections, ] end diff --git a/app/view_models/plan_display.rb b/app/view_models/plan_display.rb index 16819fa8b..3c959c41d 100644 --- a/app/view_models/plan_display.rb +++ b/app/view_models/plan_display.rb @@ -3,18 +3,26 @@ class PlanDisplay < EffortWithLapSplitRows include CourseAnalysisMethods include TimeFormats + attr_reader :course, :error_messages delegate :name, :organization, :simple?, to: :course delegate :multiple_laps?, to: :event, allow_nil: true - def initialize(args) + def initialize(args) # rubocop:disable Lint/MissingSuper @course = args[:course] @params = args[:params] @error_messages = [] validate_setup end + # Overrides CourseAnalysisMethods#event: planning anchors on the same + # events the projection engine draws data from, so a course whose only + # events are concealed seed events can still plan + def event + @event ||= course.projectable_events.latest + end + def effort @effort ||= event.efforts.new end @@ -103,7 +111,7 @@ def lap_splits end def validate_setup - error_messages << "No events have been held on this course." if course.visible_events.empty? + error_messages << "No events on this course are available for planning." if course.projectable_events.empty? AssignSegmentTimes.perform(ordered_split_times) if error_messages.empty? rescue ArgumentError => e error_messages << e.message diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb index 8dd328faa..b984f7a7d 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -197,6 +197,17 @@ <%= form.text_field :beacon_url, class: "form-control", placeholder: "example.com/tracking/my-event" %> + +
+
+ <%= form.label :use_for_projections, "Use this event's times for projections", class: "me-2" %> + + <%= form.check_box :use_for_projections %> +
+

diff --git a/spec/models/projection_spec.rb b/spec/models/projection_spec.rb index cf9bf4986..de1f00b88 100644 --- a/spec/models/projection_spec.rb +++ b/spec/models/projection_spec.rb @@ -85,6 +85,22 @@ end end + context "when an event on the course is flagged not to be used for projections" do + before { events(:hardrock_2014).update_column(:use_for_projections, false) } + + it "does not draw from that event's efforts" do + expect(subject.first.effort_years).to eq([2016]) + end + end + + context "when an event in a concealed event group is flagged for projections" do + before { event_groups(:hardrock_2014).update_column(:concealed, true) } + + it "draws from that event's efforts" do + expect(subject.first.effort_years).to eq([2014, 2016]) + end + end + context "when given a starting split time" do let(:split_time) { effort.ordered_split_times.first } it "returns an empty array" do diff --git a/spec/system/plan_effort_flow_spec.rb b/spec/system/plan_effort_flow_spec.rb index 4b404848b..dc4ce4fe1 100644 --- a/spec/system/plan_effort_flow_spec.rb +++ b/spec/system/plan_effort_flow_spec.rb @@ -50,11 +50,11 @@ visit_page verify_content_present(course) - expect(page).to have_content("No events have been held on this course.") + expect(page).to have_content("No events on this course are available for planning.") end end - scenario "Inputmask works", js: true do + scenario "Inputmask works", :js do visit_page expected_values = { diff --git a/spec/view_models/plan_display_spec.rb b/spec/view_models/plan_display_spec.rb new file mode 100644 index 000000000..6b9cd2da3 --- /dev/null +++ b/spec/view_models/plan_display_spec.rb @@ -0,0 +1,32 @@ +require "rails_helper" + +RSpec.describe PlanDisplay do + subject { described_class.new(course: course, params: ActionController::Parameters.new(params)) } + + let(:course) { courses(:hardrock_ccw) } + let(:params) { { expected_time: "38:00" } } + + context "when the course has visible events with relevant data" do + it "returns no errors and builds projected split times" do + expect(subject.error_messages).to be_empty + expect(subject.ordered_split_times).to be_present + end + end + + context "when the course's only event is in a concealed event group but flagged for projections" do + before { event_groups(:hardrock_2015).update_column(:concealed, true) } + + it "returns no errors and builds projected split times" do + expect(subject.error_messages).to be_empty + expect(subject.ordered_split_times).to be_present + end + end + + context "when no events on the course are flagged for projections" do + before { course.events.each { |event| event.update_column(:use_for_projections, false) } } + + it "returns an error message" do + expect(subject.error_messages).to include("No events on this course are available for planning.") + end + end +end From 4399b4dc1600b801af53e4b3b98ce217e69fc195 Mon Sep 17 00:00:00 2001 From: moveson Date: Thu, 20 Aug 2026 11:08:58 -0600 Subject: [PATCH 2/2] Reword use_for_projections tooltip examples Co-Authored-By: Claude Fable 5 --- app/views/events/_form.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb index b984f7a7d..1aacc8fac 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -204,7 +204,7 @@ + data-bs-original-title="When checked, this event's split times help power pacing plans and live projections for events on this course, even if this event is not public. Uncheck if this event's times are unreliable (for example, unrealistic test data or serious timing problems) and should not influence predictions."> <%= form.check_box :use_for_projections %>