Skip to content

Commit 6870e75

Browse files
committed
fix(schedule): normalize frontend status aliases before validation
1 parent 96d9ba5 commit 6870e75

1 file changed

Lines changed: 182 additions & 0 deletions

File tree

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
# frozen_string_literal: true
2+
3+
# Represents a scheduled event or match in the organization's calendar
4+
class Schedule < ApplicationRecord
5+
# Concerns
6+
include Constants
7+
include OrganizationScoped
8+
9+
# Associations
10+
belongs_to :organization
11+
belongs_to :match, optional: true
12+
belongs_to :created_by, class_name: 'User', optional: true
13+
14+
# Validations
15+
validates :title, presence: true, length: { maximum: 255 }
16+
validates :event_type, presence: true, inclusion: { in: Constants::Schedule::EVENT_TYPES }
17+
validates :start_time, :end_time, presence: true
18+
validates :status, inclusion: { in: Constants::Schedule::STATUSES }
19+
validate :end_time_after_start_time
20+
21+
# Callbacks
22+
before_validation :normalize_status
23+
before_save :set_timezone_if_blank
24+
after_update :log_audit_trail, if: :saved_changes?
25+
26+
# Scopes
27+
scope :by_type, ->(type) { where(event_type: type) }
28+
scope :by_status, ->(status) { where(status: status) }
29+
scope :upcoming, -> { where('start_time > ?', Time.current) }
30+
scope :today, -> { where(start_time: Date.current.beginning_of_day..Date.current.end_of_day) }
31+
scope :this_week, -> { where(start_time: Date.current.beginning_of_week..Date.current.end_of_week) }
32+
scope :in_date_range, ->(start_date, end_date) { where(start_time: start_date..end_date) }
33+
scope :for_player, lambda { |player_id|
34+
where('? = ANY(required_players) OR ? = ANY(optional_players)', player_id, player_id)
35+
}
36+
37+
# Instance methods
38+
def duration_minutes
39+
return 0 unless start_time && end_time
40+
41+
((end_time - start_time) / 1.minute).round
42+
end
43+
44+
def duration_formatted
45+
minutes = duration_minutes
46+
hours = minutes / 60
47+
mins = minutes % 60
48+
49+
if hours.positive?
50+
"#{hours}h #{mins}m"
51+
else
52+
"#{mins}m"
53+
end
54+
end
55+
56+
def status_color
57+
case status
58+
when 'scheduled' then 'blue'
59+
when 'ongoing' then 'green'
60+
when 'cancelled' then 'red'
61+
else 'gray' # covers 'completed' and any other values
62+
end
63+
end
64+
65+
def is_today?
66+
start_time.to_date == Date.current
67+
end
68+
69+
def is_upcoming?
70+
start_time > Time.current
71+
end
72+
73+
def is_past?
74+
end_time < Time.current
75+
end
76+
77+
def is_ongoing?
78+
Time.current.between?(start_time, end_time)
79+
end
80+
81+
def can_be_cancelled?
82+
%w[scheduled].include?(status) && is_upcoming?
83+
end
84+
85+
def can_be_completed?
86+
%w[scheduled ongoing].include?(status)
87+
end
88+
89+
def required_player_names
90+
return [] if required_players.blank?
91+
92+
Player.where(id: required_players).pluck(:summoner_name)
93+
end
94+
95+
def optional_player_names
96+
return [] if optional_players.blank?
97+
98+
Player.where(id: optional_players).pluck(:summoner_name)
99+
end
100+
101+
def all_participants
102+
required_player_names + optional_player_names
103+
end
104+
105+
def reminder_times
106+
return [] if reminder_minutes.blank?
107+
108+
reminder_minutes.map do |minutes|
109+
start_time - minutes.minutes
110+
end
111+
end
112+
113+
def next_reminder
114+
now = Time.current
115+
reminder_times.select { |time| time > now }.min
116+
end
117+
118+
def conflict_with?(other_schedule)
119+
return false if other_schedule == self
120+
121+
time_overlap?(other_schedule) && participant_overlap?(other_schedule)
122+
end
123+
124+
def mark_as_completed!
125+
update!(status: 'completed')
126+
end
127+
128+
def mark_as_cancelled!
129+
update!(status: 'cancelled')
130+
end
131+
132+
def mark_as_ongoing!
133+
update!(status: 'ongoing')
134+
end
135+
136+
private
137+
138+
def end_time_after_start_time
139+
return unless start_time && end_time
140+
141+
errors.add(:end_time, 'must be after start time') if end_time <= start_time
142+
end
143+
144+
# Map frontend aliases to canonical backend values before validation
145+
STATUS_ALIASES = {
146+
'in_progress' => 'ongoing',
147+
'active' => 'ongoing',
148+
'done' => 'completed',
149+
'finished' => 'completed'
150+
}.freeze
151+
152+
def normalize_status
153+
self.status = STATUS_ALIASES.fetch(status, status) if status.present?
154+
self.status ||= 'scheduled'
155+
end
156+
157+
def set_timezone_if_blank
158+
self.timezone = 'UTC' if timezone.blank?
159+
end
160+
161+
def time_overlap?(other)
162+
start_time < other.end_time && end_time > other.start_time
163+
end
164+
165+
def participant_overlap?(other)
166+
our_participants = required_players + optional_players
167+
other_participants = other.required_players + other.optional_players
168+
169+
our_participants.intersect?(other_participants)
170+
end
171+
172+
def log_audit_trail
173+
AuditLog.create!(
174+
organization: organization,
175+
action: 'update',
176+
entity_type: 'Schedule',
177+
entity_id: id,
178+
old_values: saved_changes.transform_values(&:first),
179+
new_values: saved_changes.transform_values(&:last)
180+
)
181+
end
182+
end

0 commit comments

Comments
 (0)