@@ -95,36 +95,174 @@ def self.no_class_days_between(start_date, end_date)
9595 where ( category : %w[ holiday finals ] ) . in_date_range ( start_date , end_date ) . order ( :start_time )
9696 end
9797
98- # Detect term dates from university calendar events
99- # Looks for "Classes Begin" and "Classes End"/"Final Exam" patterns
98+ # Detect term dates for a term.
99+ #
100+ # Date rules:
101+ # - start_date: The day LeopardWeb reports registration open for the term.
102+ # Once captured, keep the stored past start_date stable across
103+ # later syncs.
104+ # - end_date: Last day of finals period for the term.
105+ # Falls back to latest imported course end_date for the term
106+ # when finals period events are unavailable.
107+ #
100108 # @param year [Integer] The academic year
101109 # @param season [Symbol] The season (:fall, :spring, :summer)
102110 # @return [Hash] Hash with :start_date and :end_date keys
103111 def self . detect_term_dates ( year , season )
104- term_name = "#{ season . to_s . capitalize } #{ year } "
105- term_name_alt = season . to_s . capitalize . to_s
106-
107- # Find "Classes Begin" events (now in term_dates category)
108- classes_begin = term_dates . where ( "summary ILIKE ?" , "%classes begin%" )
109- . where ( "academic_term ILIKE ? OR summary ILIKE ?" , "%#{ term_name_alt } %" , "%#{ year } %" )
110- . where ( start_time : Date . new ( year - 1 , 7 , 1 ) ..Date . new ( year + 1 , 2 , 1 ) )
111- . order ( :start_time )
112- . first
113-
114- # Find term end indicators - check both term_dates and finals categories
115- term_end = where ( category : %w[ term_dates finals ] )
116- . where ( "summary ILIKE ? OR summary ILIKE ?" , "%final exam%" , "%classes end%" )
117- . where ( "academic_term ILIKE ? OR summary ILIKE ?" , "%#{ term_name_alt } %" , "%#{ year } %" )
118- . where ( start_time : Date . new ( year - 1 , 7 , 1 ) ..Date . new ( year + 1 , 7 , 1 ) )
119- . order ( start_time : :desc )
120- . first
112+ term = Term . find_by ( year : year , season : season )
113+
114+ candidate_events = where ( start_time : Date . new ( year - 1 , 1 , 1 ) . beginning_of_day ..Date . new ( year + 1 , 12 , 31 ) . end_of_day )
115+ matching_events = candidate_events . select { |event | event_matches_term_for_date_detection? ( event , year , season , term ) }
116+
117+ leopard_web_start_date = leopard_web_registration_open_date ( year , season , term )
118+
119+ # Prefer explicit schedule-available events when LeopardWeb does not expose
120+ # the term as currently open for registration.
121+ schedule_available_event = matching_events
122+ . select { |event | schedule_available_summary? ( event . summary ) }
123+ . min_by ( &:start_time )
124+
125+
126+ estimated_schedule_available_date = registration_event &.start_time &.to_date &.-( 14 )
127+
128+ finals_period_event = matching_events
129+ . select { |event | event . category == "finals" && finals_period_summary? ( event . summary ) }
130+ . max_by { |event | event . end_time || event . start_time }
131+
132+ finals_end_date = finals_period_event &.end_time &.to_date || finals_period_event &.start_time &.to_date
133+ course_end_fallback = fallback_end_date_from_courses ( term )
121134
122135 {
123- start_date : classes_begin &.start_time &.to_date ,
124- end_date : term_end &. end_time &. to_date || term_end &. start_time &. to_date
136+ start_date : leopard_web_start_date || schedule_available_event &.start_time &.to_date || estimated_schedule_available_date ,
137+ end_date : finals_end_date || course_end_fallback
125138 }
126139 end
127140
141+ def self . leopard_web_registration_open_date ( year , season , term )
142+ result = LeopardWebService . get_active_terms
143+ return nil unless result [ :success ]
144+
145+ target_uid = term &.uid || generated_term_uid ( year , season )
146+ return nil unless target_uid
147+
148+ active_term_found = Array ( result [ :terms ] ) . any? do |active_term |
149+ ( active_term [ :code ] || active_term [ "code" ] ) . to_i == target_uid
150+ end
151+ return nil unless active_term_found
152+
153+ existing_start_date = term &.start_date
154+ return existing_start_date if existing_start_date . present? && existing_start_date <= Time . zone . today
155+
156+ Time . zone . today
157+ rescue => e
158+ Rails . logger . warn ( "Failed to determine LeopardWeb registration-open date for #{ season } #{ year } : #{ e . message } " )
159+ nil
160+ end
161+
162+ def self . generated_term_uid ( year , season )
163+ case season . to_sym
164+ when :fall
165+ ( ( year + 1 ) * 100 ) + 10
166+ when :spring
167+ ( year * 100 ) + 20
168+ when :summer
169+ ( year * 100 ) + 30
170+ end
171+ end
172+
173+ # Returns true when an event can be considered part of the target term for
174+ # date detection purposes.
175+ def self . event_matches_term_for_date_detection? ( event , year , season , term )
176+ season_name = season . to_s . capitalize
177+ summary = event . summary . to_s
178+ academic_term = event . academic_term . to_s
179+
180+ # If summary explicitly names a term, trust that first.
181+ explicit_summary_term = extract_explicit_term_from_summary ( summary )
182+ if explicit_summary_term
183+ return explicit_summary_term [ :season ] == season . to_sym && explicit_summary_term [ :year ] == year
184+ end
185+
186+ # Strongest signal: explicit DB term linkage from sync processing.
187+ return true if term && event . term_id == term . id
188+
189+ # Next strongest signal: summary explicitly mentions season + year.
190+ return true if summary . match? ( /\b #{ Regexp . escape ( season_name ) } \b /i ) && summary . match? ( /\b #{ year } \b / )
191+
192+ # Weak signal: academic term only. Restrict with season-specific date windows
193+ # to avoid cross-year contamination.
194+ return false unless academic_term . match? ( /\b #{ Regexp . escape ( season_name ) } \b /i )
195+
196+ event_date = event . start_time &.to_date
197+ return false unless event_date
198+
199+ event_date . in? ( term_detection_date_window ( year , season ) )
200+ end
201+
202+ # Extract explicit term mention like "Fall 2026" from summary text.
203+ # Returns nil when no explicit term/year is present.
204+ def self . extract_explicit_term_from_summary ( summary )
205+ match = summary . to_s . match ( /\b (Fall|Spring|Summer)\s +(\d {4})\b /i )
206+ return nil unless match
207+
208+ season = case match [ 1 ] . downcase
209+ when "fall" then :fall
210+ when "spring" then :spring
211+ when "summer" then :summer
212+ end
213+
214+ return nil unless season
215+
216+ { season : season , year : match [ 2 ] . to_i }
217+ end
218+
219+ # Date windows tuned for academic term event timing (including preregistration).
220+ def self . term_detection_date_window ( year , season )
221+ case season . to_sym
222+ when :spring
223+ Date . new ( year - 1 , 8 , 1 ) ..Date . new ( year , 6 , 30 )
224+ when :summer
225+ Date . new ( year , 1 , 1 ) ..Date . new ( year , 8 , 31 )
226+ when :fall
227+ Date . new ( year , 1 , 1 ) ..Date . new ( year , 12 , 31 )
228+ else
229+ Date . new ( year - 1 , 1 , 1 ) ..Date . new ( year + 1 , 12 , 31 )
230+ end
231+ end
232+
233+ def self . schedule_available_summary? ( summary )
234+ normalized = summary . to_s
235+ return false if registration_summary? ( normalized )
236+
237+ normalized . match? ( /course\s +schedule/i ) ||
238+ normalized . match? ( /schedule\s +(available|release|released|posted|opens|open|begins|begin)/i )
239+ end
240+
241+ def self . registration_summary? ( summary )
242+ summary . to_s . match? ( /registration\s +(opens|open|begins|begin)|registration/i )
243+ end
244+
245+ # Finals period events should represent actual exam days, not announcement-only
246+ # "schedule available" events.
247+ def self . finals_period_summary? ( summary )
248+ normalized = summary . to_s
249+ return false if normalized . match? ( /schedule\s +(available|online|release|released|posted)/i )
250+
251+ normalized . match? ( /final\s +exam\s +period|final\s +exams?|finals\s +week|examination\s +period|study\s +day/i )
252+ end
253+
254+ # Fallback end date from imported course data for the same term.
255+ # This is used only when finals period events are not available yet.
256+ def self . fallback_end_date_from_courses ( term )
257+ return nil unless term
258+
259+ term . courses . where . not ( start_date : nil )
260+ . where . not ( end_date : nil )
261+ . where ( "EXTRACT(YEAR FROM start_date) >= ? AND EXTRACT(YEAR FROM start_date) <= ?" , term . year - 1 , term . year )
262+ . where ( "EXTRACT(YEAR FROM end_date) >= ? AND EXTRACT(YEAR FROM end_date) <= ?" , term . year , term . year + 1 )
263+ . maximum ( :end_date )
264+ end
265+
128266 # Infer category from event summary and raw event type
129267 # @param summary [String] The event summary/title
130268 # @param event_type_raw [String] The raw event type from ICS
0 commit comments