Skip to content

Commit 6ec979f

Browse files
committed
Add event duration handling and display logic; enhance localization support
1 parent a4966af commit 6ec979f

File tree

12 files changed

+527
-58
lines changed

12 files changed

+527
-58
lines changed

.rubocop.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ plugins:
1313
- rubocop-rspec_rails
1414
- rubocop-capybara
1515
- rubocop-factory_bot
16+
RSpec/MultipleExpectations:
17+
Enabled: false
1618
Style/StringLiterals:
1719
Exclude:
1820
- 'db/migrate/*'
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Event DateTime Controller
2+
// Handles dynamic synchronization between start time, end time, and duration fields
3+
//
4+
// Behavior:
5+
// - When start time changes: Updates end time based on current duration
6+
// - When end time changes: Updates duration based on start/end time difference
7+
// - When duration changes: Updates end time based on start time + duration
8+
// - Validates minimum duration (5 minutes)
9+
// - Defaults duration to 30 minutes when not set
10+
11+
import { Controller } from "@hotwired/stimulus"
12+
13+
export default class extends Controller {
14+
static targets = ["startTime", "endTime", "duration"]
15+
16+
connect() {
17+
// Set default duration if not already set
18+
if (!this.durationTarget.value || this.durationTarget.value === "0") {
19+
this.durationTarget.value = "30"
20+
}
21+
22+
// Initialize end time if start time is set but end time is not
23+
if (this.startTimeTarget.value && !this.endTimeTarget.value) {
24+
this.updateEndTimeFromDuration()
25+
}
26+
}
27+
28+
// Called when start time changes
29+
updateEndTime() {
30+
if (!this.startTimeTarget.value) {
31+
this.endTimeTarget.value = ""
32+
return
33+
}
34+
35+
// Use current duration or default to 30 minutes
36+
const duration = this.getDurationInMinutes()
37+
this.calculateEndTime(duration)
38+
}
39+
40+
// Called when end time changes
41+
updateDuration() {
42+
if (!this.startTimeTarget.value || !this.endTimeTarget.value) {
43+
return
44+
}
45+
46+
const startTime = new Date(this.startTimeTarget.value)
47+
const endTime = new Date(this.endTimeTarget.value)
48+
49+
// Validate end time is after start time
50+
if (endTime <= startTime) {
51+
this.showTemporaryError("End time must be after start time")
52+
return
53+
}
54+
55+
// Calculate duration in minutes
56+
const diffInMs = endTime.getTime() - startTime.getTime()
57+
const diffInMinutes = Math.round(diffInMs / (1000 * 60))
58+
59+
// Enforce minimum duration
60+
if (diffInMinutes < 5) {
61+
this.durationTarget.value = "5"
62+
this.calculateEndTime(5)
63+
} else {
64+
this.durationTarget.value = diffInMinutes.toString()
65+
}
66+
}
67+
68+
// Called when duration changes
69+
updateEndTimeFromDuration() {
70+
if (!this.startTimeTarget.value) {
71+
return
72+
}
73+
74+
const duration = this.getDurationInMinutes()
75+
76+
// Enforce minimum duration
77+
if (duration < 5) {
78+
this.durationTarget.value = "5"
79+
this.calculateEndTime(5)
80+
} else {
81+
this.calculateEndTime(duration)
82+
}
83+
}
84+
85+
// Helper methods
86+
getDurationInMinutes() {
87+
const duration = parseInt(this.durationTarget.value) || 30
88+
return Math.max(duration, 5) // Minimum 5 minutes
89+
}
90+
91+
calculateEndTime(durationMinutes) {
92+
if (!this.startTimeTarget.value) return
93+
94+
const startTime = new Date(this.startTimeTarget.value)
95+
const endTime = new Date(startTime.getTime() + (durationMinutes * 60 * 1000))
96+
97+
// Format for datetime-local input (YYYY-MM-DDTHH:MM)
98+
const year = endTime.getFullYear()
99+
const month = String(endTime.getMonth() + 1).padStart(2, '0')
100+
const day = String(endTime.getDate()).padStart(2, '0')
101+
const hours = String(endTime.getHours()).padStart(2, '0')
102+
const minutes = String(endTime.getMinutes()).padStart(2, '0')
103+
104+
this.endTimeTarget.value = `${year}-${month}-${day}T${hours}:${minutes}`
105+
}
106+
107+
showTemporaryError(message) {
108+
// Create or update error message
109+
let errorElement = this.element.querySelector('.datetime-sync-error')
110+
111+
if (!errorElement) {
112+
errorElement = document.createElement('div')
113+
errorElement.className = 'alert alert-warning datetime-sync-error mt-2'
114+
errorElement.setAttribute('role', 'alert')
115+
this.element.appendChild(errorElement)
116+
}
117+
118+
errorElement.textContent = message
119+
120+
// Remove error after 3 seconds
121+
setTimeout(() => {
122+
if (errorElement && errorElement.parentNode) {
123+
errorElement.parentNode.removeChild(errorElement)
124+
}
125+
}, 3000)
126+
}
127+
}

app/helpers/better_together/events_helper.rb

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,72 @@ def visible_event_hosts(event)
1010

1111
event.event_hosts.map { |eh| eh.host if policy(eh.host).show? }.compact
1212
end
13+
14+
# Intelligently displays event time based on duration and date span
15+
#
16+
# Rules:
17+
# - Under 1 hour: "Sept 4, 2:00 PM (30 minutes)" or "Sept 4, 2025 2:00 PM (30 minutes)" if not current year
18+
# - 1-5 hours (same day): "Sept 4, 2:00 PM (3 hours)" or "Sept 4, 2025 2:00 PM (3 hours)" if not current year
19+
# - Same day, over 5 hours: "Sept 4, 2:00 PM - 8:00 PM" or "Sept 4, 2025 2:00 PM - 8:00 PM" if not current year
20+
# rubocop:todo Layout/LineLength
21+
# - Different days: "Sept 4, 2:00 PM - Sept 5, 10:00 AM" or "Sept 4, 2025 2:00 PM - Sept 5, 2025 10:00 AM" if not current year
22+
# rubocop:enable Layout/LineLength
23+
#
24+
# @param event [Event] The event object with starts_at and ends_at
25+
# @return [String] Formatted time display
26+
# rubocop:todo Metrics/PerceivedComplexity
27+
# rubocop:todo Metrics/MethodLength
28+
# rubocop:todo Metrics/AbcSize
29+
def display_event_time(event) # rubocop:todo Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity
30+
return '' unless event&.starts_at
31+
32+
start_time = event.starts_at
33+
end_time = event.ends_at
34+
current_year = Time.current.year
35+
36+
# Determine format based on whether year differs from current
37+
start_format = start_time.year == current_year ? :event_date_time : :event_date_time_with_year
38+
time_only_format = :time_only
39+
time_only_with_year_format = :time_only_with_year
40+
41+
# No end time
42+
return l(start_time, format: start_format) unless end_time
43+
44+
duration_minutes = ((end_time - start_time) / 60).round
45+
duration_hours = duration_minutes / 60.0
46+
same_day = start_time.to_date == end_time.to_date
47+
48+
if duration_minutes < 60
49+
# Under 1 hour: show minutes
50+
"#{l(start_time, format: start_format)} (#{duration_minutes} #{t('better_together.events.time.minutes')})"
51+
elsif duration_hours <= 5 && same_day
52+
# 1-5 hours, same day: show hours
53+
# rubocop:todo Layout/LineLength
54+
hours_text = duration_hours == 1 ? t('better_together.events.time.hour') : t('better_together.events.time.hours')
55+
# rubocop:enable Layout/LineLength
56+
"#{l(start_time, format: start_format)} (#{duration_hours.to_i} #{hours_text})"
57+
elsif same_day
58+
# Same day, over 5 hours: show start and end times
59+
# If start and end are different years, show year for both
60+
end_time_format = if start_time.year == end_time.year
61+
current_year == end_time.year ? time_only_format : time_only_with_year_format
62+
else
63+
time_only_with_year_format
64+
end
65+
"#{l(start_time, format: start_format)} - #{l(end_time, format: end_time_format)}"
66+
else
67+
# Different days: show full dates for both
68+
# If start and end are different years, show year for both
69+
end_format = if start_time.year == end_time.year
70+
current_year == end_time.year ? :event_date_time : :event_date_time_with_year
71+
else
72+
:event_date_time_with_year
73+
end
74+
"#{l(start_time, format: start_format)} - #{l(end_time, format: end_format)}"
75+
end
76+
end
77+
# rubocop:enable Metrics/AbcSize
78+
# rubocop:enable Metrics/MethodLength
79+
# rubocop:enable Metrics/PerceivedComplexity
1380
end
1481
end
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Event DateTime Controller
2+
// Handles dynamic synchronization between start time, end time, and duration fields
3+
//
4+
// Behavior:
5+
// - When start time changes: Updates end time based on current duration
6+
// - When end time changes: Updates duration based on start/end time difference
7+
// - When duration changes: Updates end time based on start time + duration
8+
// - Validates minimum duration (5 minutes)
9+
// - Defaults duration to 30 minutes when not set
10+
11+
import { Controller } from "@hotwired/stimulus"
12+
13+
export default class extends Controller {
14+
static targets = ["startTime", "endTime", "duration"]
15+
16+
connect() {
17+
// Set default duration if not already set
18+
if (!this.durationTarget.value || this.durationTarget.value === "0") {
19+
this.durationTarget.value = "30"
20+
}
21+
22+
// Initialize end time if start time is set but end time is not
23+
if (this.startTimeTarget.value && !this.endTimeTarget.value) {
24+
this.updateEndTimeFromDuration()
25+
}
26+
}
27+
28+
// Called when start time changes
29+
updateEndTime() {
30+
if (!this.startTimeTarget.value) {
31+
this.endTimeTarget.value = ""
32+
return
33+
}
34+
35+
// Use current duration or default to 30 minutes
36+
const duration = this.getDurationInMinutes()
37+
this.calculateEndTime(duration)
38+
}
39+
40+
// Called when end time changes
41+
updateDuration() {
42+
if (!this.startTimeTarget.value || !this.endTimeTarget.value) {
43+
return
44+
}
45+
46+
const startTime = new Date(this.startTimeTarget.value)
47+
const endTime = new Date(this.endTimeTarget.value)
48+
49+
// Validate end time is after start time
50+
if (endTime <= startTime) {
51+
this.showTemporaryError("End time must be after start time")
52+
return
53+
}
54+
55+
// Calculate duration in minutes
56+
const diffInMs = endTime.getTime() - startTime.getTime()
57+
const diffInMinutes = Math.round(diffInMs / (1000 * 60))
58+
59+
// Enforce minimum duration
60+
if (diffInMinutes < 5) {
61+
this.durationTarget.value = "5"
62+
this.calculateEndTime(5)
63+
} else {
64+
this.durationTarget.value = diffInMinutes.toString()
65+
}
66+
}
67+
68+
// Called when duration changes
69+
updateEndTimeFromDuration() {
70+
if (!this.startTimeTarget.value) {
71+
return
72+
}
73+
74+
const duration = this.getDurationInMinutes()
75+
76+
// Enforce minimum duration
77+
if (duration < 5) {
78+
this.durationTarget.value = "5"
79+
this.calculateEndTime(5)
80+
} else {
81+
this.calculateEndTime(duration)
82+
}
83+
}
84+
85+
// Helper methods
86+
getDurationInMinutes() {
87+
const duration = parseInt(this.durationTarget.value) || 30
88+
return Math.max(duration, 5) // Minimum 5 minutes
89+
}
90+
91+
calculateEndTime(durationMinutes) {
92+
if (!this.startTimeTarget.value) return
93+
94+
const startTime = new Date(this.startTimeTarget.value)
95+
const endTime = new Date(startTime.getTime() + (durationMinutes * 60 * 1000))
96+
97+
// Format for datetime-local input (YYYY-MM-DDTHH:MM)
98+
const year = endTime.getFullYear()
99+
const month = String(endTime.getMonth() + 1).padStart(2, '0')
100+
const day = String(endTime.getDate()).padStart(2, '0')
101+
const hours = String(endTime.getHours()).padStart(2, '0')
102+
const minutes = String(endTime.getMinutes()).padStart(2, '0')
103+
104+
this.endTimeTarget.value = `${year}-${month}-${day}T${hours}:${minutes}`
105+
}
106+
107+
showTemporaryError(message) {
108+
// Create or update error message
109+
let errorElement = this.element.querySelector('.datetime-sync-error')
110+
111+
if (!errorElement) {
112+
errorElement = document.createElement('div')
113+
errorElement.className = 'alert alert-warning datetime-sync-error mt-2'
114+
errorElement.setAttribute('role', 'alert')
115+
this.element.appendChild(errorElement)
116+
}
117+
118+
errorElement.textContent = message
119+
120+
// Remove error after 3 seconds
121+
setTimeout(() => {
122+
if (errorElement && errorElement.parentNode) {
123+
errorElement.parentNode.removeChild(errorElement)
124+
}
125+
}, 3000)
126+
}
127+
}

0 commit comments

Comments
 (0)