recurrence_ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +208 -0
- data/Rakefile +8 -0
- data/app/assets/config/recurrence_ui_manifest.js +1 -0
- data/app/assets/stylesheets/recurrence_ui/application.css +15 -0
- data/app/assets/stylesheets/recurrence_ui/components.css +295 -0
- data/app/assets/stylesheets/recurrence_ui/tokens.css +102 -0
- data/app/components/recurrence_ui/recurrence_picker_component.html.erb +234 -0
- data/app/components/recurrence_ui/recurrence_picker_component.rb +204 -0
- data/app/controllers/recurrence_ui/application_controller.rb +32 -0
- data/app/helpers/recurrence_ui/application_helper.rb +23 -0
- data/app/javascript/recurrence_ui/index.js +22 -0
- data/app/javascript/recurrence_ui/recurrence_ui_controller.js +954 -0
- data/app/jobs/recurrence_ui/application_job.rb +4 -0
- data/app/mailers/recurrence_ui/application_mailer.rb +6 -0
- data/app/models/concerns/recurrence_ui/has_recurrence.rb +351 -0
- data/app/models/recurrence_ui/application_record.rb +5 -0
- data/app/services/recurrence_ui/recurrence_formatter_service.rb +536 -0
- data/app/views/layouts/recurrence_ui/application.html.erb +15 -0
- data/app/views/recurrence_ui/_daily.html.erb +22 -0
- data/app/views/recurrence_ui/_exceptions.html.erb +50 -0
- data/app/views/recurrence_ui/_footer_actions.html.erb +17 -0
- data/app/views/recurrence_ui/_monthly_by_day_of_month.html.erb +47 -0
- data/app/views/recurrence_ui/_monthly_by_weekday.html.erb +55 -0
- data/app/views/recurrence_ui/_rdates.html.erb +50 -0
- data/app/views/recurrence_ui/_rule_card.html.erb +61 -0
- data/app/views/recurrence_ui/_summary_link.html.erb +25 -0
- data/app/views/recurrence_ui/_termination.html.erb +44 -0
- data/app/views/recurrence_ui/_weekly.html.erb +44 -0
- data/app/views/recurrence_ui/_yearly_by_day_of_month.html.erb +63 -0
- data/app/views/recurrence_ui/_yearly_by_weekday.html.erb +72 -0
- data/config/i18n-tasks.yml +212 -0
- data/config/importmap.rb +3 -0
- data/config/locales/recurrence_ui.be.yml +245 -0
- data/config/locales/recurrence_ui.en.yml +210 -0
- data/config/locales/recurrence_ui.ja.yml +201 -0
- data/config/locales/recurrence_ui.ru.yml +245 -0
- data/config/locales/recurrence_ui.uk.yml +245 -0
- data/config/routes.rb +5 -0
- data/lib/generators/recurrence_ui/views_generator.rb +26 -0
- data/lib/recurrence_ui/configuration.rb +116 -0
- data/lib/recurrence_ui/engine.rb +57 -0
- data/lib/recurrence_ui/version.rb +3 -0
- data/lib/recurrence_ui.rb +25 -0
- data/lib/tasks/recurrence_ui_tasks.rake +4 -0
- metadata +195 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
# app/services/recurrence_formatter_service.rb
|
|
2
|
+
module RecurrenceUi
|
|
3
|
+
class RecurrenceFormatterService
|
|
4
|
+
include ActionView::Helpers::TagHelper
|
|
5
|
+
include ActionView::Helpers::TextHelper
|
|
6
|
+
|
|
7
|
+
DAY_SYMBOL_TO_INDEX = {
|
|
8
|
+
sunday: 0, monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6,
|
|
9
|
+
sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :event, :schedule, :locale, :time_zone, :week_start
|
|
13
|
+
|
|
14
|
+
# Initializes the formatter service with either an event object or a schedule hash.
|
|
15
|
+
#
|
|
16
|
+
# @param target_or_hash [Object, Hash] Event record object (responding to #recurrence_raw_rules)
|
|
17
|
+
# or a schedule raw hash.
|
|
18
|
+
# @param options [Hash] Configuration options.
|
|
19
|
+
# @option options [Symbol, String] :locale Custom locale for formatting (e.g., :en, :ru).
|
|
20
|
+
# @option options [String, ActiveSupport::TimeZone] :time_zone Time zone override.
|
|
21
|
+
# @option options [Integer, Symbol] :week_start Day index to start the week on.
|
|
22
|
+
# @return [RecurrenceFormatterService]
|
|
23
|
+
def initialize(target_or_hash, options = {})
|
|
24
|
+
if target_or_hash.respond_to?(:recurrence_raw_rules) || target_or_hash.is_a?(ActiveRecord::Base)
|
|
25
|
+
@event = target_or_hash
|
|
26
|
+
@schedule = (@event.recurrence_raw_rules || {}).with_indifferent_access.except(:form_duration_mode, 'form_duration_mode')
|
|
27
|
+
else
|
|
28
|
+
@event = nil
|
|
29
|
+
@schedule = (target_or_hash || {}).with_indifferent_access.except(:form_duration_mode, 'form_duration_mode')
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# 1. Configuration resolution
|
|
33
|
+
@locale = resolve_locale(options[:locale])
|
|
34
|
+
# Приоритет таймзоны: из опций -> из модели @event -> из конфига
|
|
35
|
+
#@time_zone = resolve_time_zone(options[:time_zone] || (@event.respond_to?(:event_time_zone) ? @event.event_time_zone : nil))
|
|
36
|
+
#@week_start = resolve_week_start(options[:week_start])
|
|
37
|
+
|
|
38
|
+
raw_rrules = @schedule[:rrules]
|
|
39
|
+
@rrules = if raw_rrules.is_a?(Array)
|
|
40
|
+
raw_rrules
|
|
41
|
+
elsif @schedule[:rule_type].present?
|
|
42
|
+
[@schedule]
|
|
43
|
+
else
|
|
44
|
+
[]
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Generates a human-readable summary of the recurrence rules, exceptions, and additional dates.
|
|
49
|
+
#
|
|
50
|
+
# @return [ActiveSupport::SafeBuffer] HTML-safe string containing structured summary spans.
|
|
51
|
+
def to_s
|
|
52
|
+
main_text = if @rrules.present?
|
|
53
|
+
@rrules.map { |rule_hash| SingleRuleSummary.new(rule_hash, @locale).to_s }.join("; ")
|
|
54
|
+
else
|
|
55
|
+
I18n.t("recurrence_ui.one_time", locale: @locale, default: "Once")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
exceptions_text = format_date_list(:exdates, "recurrence_ui.labels.exceptions", "Except", "rui-meta-info--exceptions")
|
|
59
|
+
rdates_text = format_date_list(:rdates, "recurrence_ui.labels.rdates", "Also on", "rui-meta-info--rdates")
|
|
60
|
+
|
|
61
|
+
main_html = tag.span(main_text, class: "rui-meta-info rui-meta-info--main")
|
|
62
|
+
"#{main_html}#{exceptions_text}#{rdates_text}".strip.html_safe
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Formats the event's start time according to the given timezone and format.
|
|
66
|
+
#
|
|
67
|
+
# @param zone_type [Symbol, String] Target time zone scope (:application, :event, or custom zone string/object).
|
|
68
|
+
# @param format [Symbol] Localization format key for I18n.l (e.g., :default, :short).
|
|
69
|
+
# @param show_zone [Boolean] Whether to append the time zone abbreviation to the formatted output.
|
|
70
|
+
# @return [String] Formatted start time string or "—" if event/time is absent.
|
|
71
|
+
def start_time(zone_type = :application, format = :default, show_zone: false)
|
|
72
|
+
format_event_time(:start_time_in_zone, zone_type, format, show_zone)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Formats the event's end time according to the given timezone and format.
|
|
76
|
+
#
|
|
77
|
+
# @param zone_type [Symbol, String] Target time zone scope (:application, :event, or custom zone string/object).
|
|
78
|
+
# @param format [Symbol] Localization format key for I18n.l (e.g., :default, :short).
|
|
79
|
+
# @param show_zone [Boolean] Whether to append the time zone abbreviation to the formatted output.
|
|
80
|
+
# @return [String] Formatted end time string or "—" if event/time is absent.
|
|
81
|
+
def end_time(zone_type = :application, format = :default, show_zone: false)
|
|
82
|
+
format_event_time(:end_time_in_zone, zone_type, format, show_zone)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Форматирование времени начала (всегда выводит дату + время)
|
|
86
|
+
def formatted_start_time(zone_type = :application, show_zone: false)
|
|
87
|
+
return "—" unless @event
|
|
88
|
+
|
|
89
|
+
target_zone = resolve_zone_type(zone_type)
|
|
90
|
+
time = @event.start_time_in_zone(target_zone)
|
|
91
|
+
return "—" unless time
|
|
92
|
+
|
|
93
|
+
format_full_datetime(time, show_zone: show_zone)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Форматирование времени окончания (с умной проверкой дат)
|
|
97
|
+
def formatted_end_time(zone_type = :application, show_zone: false)
|
|
98
|
+
return "—" unless @event
|
|
99
|
+
|
|
100
|
+
target_zone = resolve_zone_type(zone_type)
|
|
101
|
+
start_time = @event.start_time_in_zone(target_zone)
|
|
102
|
+
end_time = @event.end_time_in_zone(target_zone)
|
|
103
|
+
return "—" unless end_time
|
|
104
|
+
|
|
105
|
+
# Если дата начала совпадает с датой окончания — выводим ТОЛЬКО время
|
|
106
|
+
if start_time && start_time.to_date == end_time.to_date
|
|
107
|
+
format_time_only(end_time, show_zone: show_zone)
|
|
108
|
+
else
|
|
109
|
+
# Если даты разные — выводим полную дату и время окончания
|
|
110
|
+
format_full_datetime(end_time, show_zone: show_zone)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# Formats the event's duration according to the given format.
|
|
117
|
+
#
|
|
118
|
+
# @param format [Symbol] Localization format key for I18n.l (e.g., :default, :short).
|
|
119
|
+
# @return [String] Formatted duration string or "—" if event/time is absent.
|
|
120
|
+
|
|
121
|
+
def formatted_duration
|
|
122
|
+
dur = @event.current_duration_seconds
|
|
123
|
+
return "—" if dur <= 0
|
|
124
|
+
|
|
125
|
+
total_minutes = (dur / 60).to_i
|
|
126
|
+
return "—" if total_minutes <= 0
|
|
127
|
+
|
|
128
|
+
days = total_minutes / 1440
|
|
129
|
+
hours = (total_minutes % 1440) / 60
|
|
130
|
+
minutes = total_minutes % 60
|
|
131
|
+
|
|
132
|
+
parts = []
|
|
133
|
+
parts << I18n.t('recurrence_ui.duration.days', count: days) if days > 0
|
|
134
|
+
parts << I18n.t('recurrence_ui.duration.hours', count: hours) if hours > 0
|
|
135
|
+
parts << I18n.t('recurrence_ui.duration.minutes', count: minutes) if minutes > 0
|
|
136
|
+
|
|
137
|
+
parts.any? ? parts.join(' ') : I18n.t('recurrence_ui.duration.minutes', count: 0)
|
|
138
|
+
end
|
|
139
|
+
# Форматированные ближайшие повторения с умной проверкой окончания
|
|
140
|
+
def formatted_upcoming_occurrences(limit = 5, zone_type = :event, show_end_time: false, show_zone: false)
|
|
141
|
+
return [] unless @event
|
|
142
|
+
|
|
143
|
+
target_zone = resolve_zone_type(zone_type)
|
|
144
|
+
occurrences = @event.upcoming_occurrences_in_zone(limit, Time.current, zone_type)
|
|
145
|
+
|
|
146
|
+
occurrences.map do |occ|
|
|
147
|
+
start_str = format_full_datetime(occ.start_time, show_zone: show_zone)
|
|
148
|
+
|
|
149
|
+
if show_end_time && occ.end_time.present?
|
|
150
|
+
end_str = if occ.start_time.to_date == occ.end_time.to_date
|
|
151
|
+
format_time_only(occ.end_time, show_zone: show_zone)
|
|
152
|
+
else
|
|
153
|
+
format_full_datetime(occ.end_time, show_zone: show_zone)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Разделитель между началом и окончанием (можно настроить в YAML или оставить дефис)
|
|
157
|
+
separator = " – "
|
|
158
|
+
"#{start_str}#{separator}#{end_str}"
|
|
159
|
+
else
|
|
160
|
+
start_str
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Calculates and formats upcoming occurrences of the event.
|
|
166
|
+
#
|
|
167
|
+
# @param limit [Integer] Maximum number of occurrences to retrieve.
|
|
168
|
+
# @param zone_type [Symbol, String] Target time zone scope (:event, :application, or custom zone).
|
|
169
|
+
# @param format [Symbol] Localization format key for I18n.l.
|
|
170
|
+
# @param show_zone [Boolean] Whether to append the time zone abbreviation to each formatted time.
|
|
171
|
+
# @return [Array<Hash{Symbol => String, nil}>] Array of hashes containing formatted :start_time and :end_time strings.
|
|
172
|
+
def upcoming_occurrences(limit = 5, zone_type = :event, format = :default, show_zone: false)
|
|
173
|
+
return [] unless @event
|
|
174
|
+
|
|
175
|
+
occurrences = @event.upcoming_occurrences_in_zone(limit, Time.current, zone_type)
|
|
176
|
+
|
|
177
|
+
occurrences.map do |occ|
|
|
178
|
+
start_str = I18n.l(occ.start_time, format: format, locale: @locale)
|
|
179
|
+
end_str = occ.end_time ? I18n.l(occ.end_time, format: format, locale: @locale) : nil
|
|
180
|
+
|
|
181
|
+
if show_zone
|
|
182
|
+
start_str += " (#{occ.start_time.strftime('%Z')})"
|
|
183
|
+
end_str += " (#{occ.end_time.strftime('%Z')})" if end_str
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
{ start_time: start_str, end_time: end_str }
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Formats a date or date-string into a localized string with optional time zone offset.
|
|
191
|
+
#
|
|
192
|
+
# @param date_or_string [Date, Time, String] Date representation to be formatted.
|
|
193
|
+
# @param locale [Symbol, String] Target locale for translation.
|
|
194
|
+
# @param show_time_zone [Boolean] Whether to append time zone offset information.
|
|
195
|
+
# @param time_zone [ActiveSupport::TimeZone, String, nil] Time zone object or string name.
|
|
196
|
+
# @return [String] Formatted date string, or an empty string if input is blank.
|
|
197
|
+
def self.format_date(date_or_string, locale, show_time_zone: false, time_zone: nil)
|
|
198
|
+
return '' if date_or_string.blank?
|
|
199
|
+
|
|
200
|
+
date = date_or_string.is_a?(String) ? Date.parse(date_or_string) : date_or_string
|
|
201
|
+
|
|
202
|
+
format_pattern = I18n.t("recurrence_ui.date.formats.recurrence_date", locale: locale, default: "%-d %B %Y")
|
|
203
|
+
month_names = I18n.t("recurrence_ui.date.month_names", locale: locale, default: Date::MONTHNAMES)
|
|
204
|
+
month_name = month_names[date.month] || ""
|
|
205
|
+
|
|
206
|
+
formatted = format_pattern
|
|
207
|
+
.gsub("%Y", date.year.to_s)
|
|
208
|
+
.gsub("%-m", date.month.to_s)
|
|
209
|
+
.gsub("%m", date.month.to_s.rjust(2, '0'))
|
|
210
|
+
.gsub("%-d", date.day.to_s)
|
|
211
|
+
.gsub("%d", date.day.to_s.rjust(2, '0'))
|
|
212
|
+
.gsub("%B", month_name)
|
|
213
|
+
|
|
214
|
+
if show_time_zone && time_zone.present?
|
|
215
|
+
tz = time_zone.is_a?(ActiveSupport::TimeZone) ? time_zone : Time.find_zone(time_zone)
|
|
216
|
+
formatted += " (#{tz&.formatted_offset || tz.to_s})"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
formatted
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
private
|
|
223
|
+
|
|
224
|
+
# Вспомогательный метод для вывода полной даты + времени
|
|
225
|
+
def format_full_datetime(time, show_zone: false)
|
|
226
|
+
# 1. Берем дату через ваш кастомный метод (с правильными падежами месяцев)
|
|
227
|
+
date_str = self.class.format_date(time, @locale)
|
|
228
|
+
|
|
229
|
+
# 2. Берем единый формат времени из локали
|
|
230
|
+
time_format = I18n.t("recurrence_ui.time.formats.time_only", locale: @locale, default: "%H:%M")
|
|
231
|
+
time_str = time.strftime(time_format)
|
|
232
|
+
|
|
233
|
+
# 3. Соединяем через разделитель из локали
|
|
234
|
+
separator = I18n.t("recurrence_ui.time.formats.separator", locale: @locale, default: ", ")
|
|
235
|
+
|
|
236
|
+
result = "#{date_str}#{separator}#{time_str}"
|
|
237
|
+
show_zone ? "#{result} (#{time.strftime('%Z')})" : result
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Вспомогательный метод для вывода только времени (если дата та же)
|
|
241
|
+
def format_time_only(time, show_zone: false)
|
|
242
|
+
time_format = I18n.t("recurrence_ui.time.formats.time_only", locale: @locale, default: "%H:%M")
|
|
243
|
+
result = time.strftime(time_format)
|
|
244
|
+
|
|
245
|
+
show_zone ? "#{result} (#{time.strftime('%Z')})" : result
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# Formats a specific event time (start or end) based on the provided method name, time zone, and format.
|
|
250
|
+
#
|
|
251
|
+
# @param method_name [Symbol] Method to call on the event object (:start_time_in_zone or :end_time_in_zone).
|
|
252
|
+
# @param zone_type [Symbol, String] Time zone scope (:event, :application, or custom zone).
|
|
253
|
+
# @param format [Symbol] Localization format key for I18n.l.
|
|
254
|
+
# @param show_zone [Boolean] Whether to append the time zone abbreviation to the formatted output.
|
|
255
|
+
# @return [String] Formatted time string or "—" if event/time is absent.
|
|
256
|
+
def format_event_time(method_name, zone_type, format, show_zone)
|
|
257
|
+
return "—" unless @event
|
|
258
|
+
|
|
259
|
+
target_zone = resolve_zone_type(zone_type)
|
|
260
|
+
time = @event.public_send(method_name, target_zone)
|
|
261
|
+
return "—" unless time
|
|
262
|
+
|
|
263
|
+
formatted = I18n.l(time, format: format, locale: @locale)
|
|
264
|
+
show_zone ? "#{formatted} (#{time.strftime('%Z')})" : formatted
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Formats a list of dates from the schedule for a given key, returning an HTML span with the label and formatted dates.
|
|
268
|
+
#
|
|
269
|
+
# @param schedule_key [Symbol] Key in the schedule hash (e.g., :exdates, :rdates).
|
|
270
|
+
# @param i18n_key [String] I18n translation key for the label.
|
|
271
|
+
# @param default_label [String] Default label to use if translation is missing.
|
|
272
|
+
# @param css_class [String] Additional CSS class to apply to the span.
|
|
273
|
+
# @return [ActiveSupport::SafeBuffer, String] HTML span tag with formatted dates, or an empty string if no dates are present.
|
|
274
|
+
def format_date_list(schedule_key, i18n_key, default_label, css_class)
|
|
275
|
+
dates = @schedule[schedule_key]
|
|
276
|
+
return "" if dates.blank?
|
|
277
|
+
|
|
278
|
+
label = I18n.t(i18n_key, locale: @locale, default: default_label)
|
|
279
|
+
formatted_dates = dates.map { |d| self.class.format_date(d, @locale) }
|
|
280
|
+
|
|
281
|
+
tag.span("#{label}: #{formatted_dates.join(', ')}", class: "rui-meta-info #{css_class}")
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Resolves and normalizes the target time zone based on the provided scope or type.
|
|
285
|
+
#
|
|
286
|
+
# @param zone_type [Symbol, String, nil] Time zone selector (:event, :application, :app, or a specific zone name/object).
|
|
287
|
+
# @return [ActiveSupport::TimeZone] Resolved ActiveSupport time zone object.
|
|
288
|
+
def resolve_zone_type(zone_type)
|
|
289
|
+
case zone_type
|
|
290
|
+
when :event
|
|
291
|
+
@event.event_time_zone
|
|
292
|
+
when :application, :app
|
|
293
|
+
Time.zone
|
|
294
|
+
else
|
|
295
|
+
Time.find_zone(zone_type) || Time.zone
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Resolves the locale to be used, falling back to the gem configuration default.
|
|
300
|
+
#
|
|
301
|
+
# @param passed_locale [Symbol, String, nil] Explicitly requested locale.
|
|
302
|
+
# @return [Symbol, String] Resolved locale code.
|
|
303
|
+
def resolve_locale(passed_locale)
|
|
304
|
+
passed_locale || RecurrenceUi.config.resolved_locale
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Resolves the time zone to be used, falling back to the gem configuration default.
|
|
308
|
+
#
|
|
309
|
+
# @param passed_tz [String, ActiveSupport::TimeZone, nil] Explicitly requested time zone.
|
|
310
|
+
# @return [ActiveSupport::TimeZone, String] Resolved time zone.
|
|
311
|
+
def resolve_time_zone(passed_tz)
|
|
312
|
+
passed_tz || RecurrenceUi.config.resolved_time_zone
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Resolves the starting day of the week, falling back to the gem configuration default.
|
|
316
|
+
#
|
|
317
|
+
# @param passed_ws [Integer, String, nil] Explicitly requested week start index (0-6).
|
|
318
|
+
# @return [Integer] Resolved week start day index.
|
|
319
|
+
def resolve_week_start(passed_ws)
|
|
320
|
+
return passed_ws.to_i if passed_ws.present?
|
|
321
|
+
RecurrenceUi.config.resolved_week_start
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Inner class for formatting an individual rule (equivalent to _buildRuleSummaryText in JS)
|
|
325
|
+
class SingleRuleSummary
|
|
326
|
+
include ActionView::Helpers::TextHelper
|
|
327
|
+
|
|
328
|
+
# Initializes the single rule summary formatter with a rule hash and locale.
|
|
329
|
+
#
|
|
330
|
+
# @param rule_hash [Hash] Raw hash containing rule definitions and validations.
|
|
331
|
+
# @param locale [Symbol, String] Target locale for translation.
|
|
332
|
+
# @return [SingleRuleSummary]
|
|
333
|
+
def initialize(rule_hash, locale)
|
|
334
|
+
@hash = rule_hash.with_indifferent_access
|
|
335
|
+
@locale = locale
|
|
336
|
+
@validations = @hash[:validations] || {}
|
|
337
|
+
@type = determine_rule_type
|
|
338
|
+
@interval = (@hash[:interval] || 1).to_i
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Generates a localized human-readable summary string for the individual recurrence rule.
|
|
342
|
+
#
|
|
343
|
+
# @return [String] Formatted and squished rule summary string.
|
|
344
|
+
def to_s
|
|
345
|
+
return I18n.t("recurrence_ui.one_time", locale: @locale) if @type == :never
|
|
346
|
+
|
|
347
|
+
# 1. Interval number is formatted purely via I18n (no hardcoded languages in code!)
|
|
348
|
+
count_f = format_interval_number(@interval, interval_gender)
|
|
349
|
+
|
|
350
|
+
# 2. Termination (until date or count)
|
|
351
|
+
# Use format_date for :until
|
|
352
|
+
termination = if @hash[:until]
|
|
353
|
+
date_str = RecurrenceFormatterService.format_date(@hash[:until], @locale)
|
|
354
|
+
I18n.t("recurrence_ui.until_label", date: date_str, locale: @locale)
|
|
355
|
+
elsif @hash[:count]
|
|
356
|
+
I18n.t("recurrence_ui.count_label", count: @hash[:count], locale: @locale)
|
|
357
|
+
else
|
|
358
|
+
""
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# 3. Days of week / Days of month
|
|
362
|
+
weekdays_str = format_weekdays
|
|
363
|
+
month_str = format_month
|
|
364
|
+
days_of_month_str = format_days_of_month
|
|
365
|
+
|
|
366
|
+
# 4. Main template assembly (pass count: @interval for pluralization)
|
|
367
|
+
# Determine key suffix (.one or .other)
|
|
368
|
+
plural_suffix = @interval == 1 ? "one" : "other"
|
|
369
|
+
base_key = "recurrence_ui.summary.#{@type}"
|
|
370
|
+
|
|
371
|
+
I18n.t(
|
|
372
|
+
"#{base_key}.#{plural_suffix}",
|
|
373
|
+
count: @interval,
|
|
374
|
+
count_f: count_f,
|
|
375
|
+
until: termination,
|
|
376
|
+
weekdays: weekdays_str,
|
|
377
|
+
month: month_str,
|
|
378
|
+
days_of_month: days_of_month_str,
|
|
379
|
+
locale: @locale,
|
|
380
|
+
default: [base_key.to_sym] # Fallback to base key if .one/.other keys do not exist
|
|
381
|
+
).to_s.squish
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
private
|
|
385
|
+
# Determines the specific type of rule based on rule configurations and validations.
|
|
386
|
+
#
|
|
387
|
+
# @return [Symbol] Rule type identifier (e.g., :never, :daily, :monthly_by_weekday, etc.).
|
|
388
|
+
def determine_rule_type
|
|
389
|
+
return :never if @hash[:rule_type].blank?
|
|
390
|
+
base = @hash[:rule_type].gsub(/IceCube::|Rule/, "").downcase
|
|
391
|
+
if base == "monthly"
|
|
392
|
+
@validations[:day_of_week] ? :monthly_by_weekday : :monthly_by_day_of_month
|
|
393
|
+
elsif base == "yearly"
|
|
394
|
+
@validations[:day_of_week] ? :yearly_by_weekday : :yearly_by_day_of_month
|
|
395
|
+
else
|
|
396
|
+
base.to_sym
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Formats the interval number using localized ordinal templates.
|
|
401
|
+
#
|
|
402
|
+
# @param number [Integer] The interval step value.
|
|
403
|
+
# @param gender [Symbol] Gender/context classification key for localization.
|
|
404
|
+
# @return [String] Formatted interval number string.
|
|
405
|
+
def format_interval_number(number, gender)
|
|
406
|
+
I18n.t("recurrence_ui.ordinals.#{gender}.other", count: number, locale: @locale, default: number.to_s)
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# Formats an ordinal number (e.g., 1st, 2nd) considering language rules and target contexts.
|
|
410
|
+
#
|
|
411
|
+
# @param number [Integer] The day or position number.
|
|
412
|
+
# @param gender [Symbol] Context or gender key for translation lookups.
|
|
413
|
+
# @return [String] Formatted ordinal string.
|
|
414
|
+
def format_ordinal(number, gender = :male)
|
|
415
|
+
key = number.to_s
|
|
416
|
+
|
|
417
|
+
# 1. Search for exact match considering context/gender (e.g., :day_of_month, :female, etc.)
|
|
418
|
+
if gender.present?
|
|
419
|
+
specific_gender = I18n.t("recurrence_ui.nth_names.#{gender}.#{key}", locale: @locale, default: nil)
|
|
420
|
+
return specific_gender if specific_gender.present?
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# 2. Search in general nth_names ONLY for weekdays (when gender is NOT :day_of_month)
|
|
424
|
+
if gender != :day_of_month
|
|
425
|
+
specific_general = I18n.t("recurrence_ui.nth_names.#{key}", locale: @locale, default: nil)
|
|
426
|
+
return specific_general if specific_general.present?
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# 3. Format via ordinals (for days of month and regular intervals)
|
|
430
|
+
# Get the pluralization rule for the number (for English 2 -> :two, 3 -> :few, 21 -> :one etc.)
|
|
431
|
+
rule = ordinal_rule_for(number)
|
|
432
|
+
|
|
433
|
+
ordinal = if gender.present?
|
|
434
|
+
I18n.t("recurrence_ui.ordinals.#{gender}.#{rule}", locale: @locale, default: nil) ||
|
|
435
|
+
I18n.t("recurrence_ui.ordinals.#{gender}.other", locale: @locale, default: nil)
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
ordinal ||= I18n.t("recurrence_ui.ordinals.#{rule}", locale: @locale, default: nil) ||
|
|
439
|
+
I18n.t("recurrence_ui.ordinals.other", locale: @locale, default: nil)
|
|
440
|
+
|
|
441
|
+
if ordinal.is_a?(String) && ordinal.include?("%{count}")
|
|
442
|
+
return ordinal.gsub("%{count}", number.to_s)
|
|
443
|
+
elsif ordinal.is_a?(String)
|
|
444
|
+
return ordinal
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
number.to_s
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# Provides localized connector options for sentence assembly (joins arrays of strings).
|
|
451
|
+
#
|
|
452
|
+
# @return [Hash{Symbol => String}] Options hash containing connectors (`words_connector`, etc.).
|
|
453
|
+
def sentence_options
|
|
454
|
+
{
|
|
455
|
+
words_connector: I18n.t("recurrence_ui.list.words_connector", locale: @locale, default: ", "),
|
|
456
|
+
two_words_connector: I18n.t("recurrence_ui.list.two_words_connector", locale: @locale, default: " and "),
|
|
457
|
+
last_word_connector: I18n.t("recurrence_ui.list.last_word_connector", locale: @locale, default: " and ")
|
|
458
|
+
}
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# Determines the grammatical gender/context key for interval pluralization rules.
|
|
462
|
+
#
|
|
463
|
+
# @return [Symbol] Gender key (`:weekly_interval` or `:male`).
|
|
464
|
+
def interval_gender
|
|
465
|
+
@type == :weekly ? :weekly_interval : :male
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# Determines the language-independent pluralization rule category for a given number.
|
|
469
|
+
#
|
|
470
|
+
# @param number [Integer] The numeric value to evaluate.
|
|
471
|
+
# @return [Symbol] The pluralization category rule (`:one`, `:two`, `:few`, or `:other`).
|
|
472
|
+
def ordinal_rule_for(number)
|
|
473
|
+
n = number.abs
|
|
474
|
+
|
|
475
|
+
# Handling exceptions for 11, 12, 13 (for EN and other languages)
|
|
476
|
+
return :other if (11..13).cover?(n % 100)
|
|
477
|
+
|
|
478
|
+
case n % 10
|
|
479
|
+
when 1 then :one
|
|
480
|
+
when 2 then :two
|
|
481
|
+
when 3 then :few
|
|
482
|
+
else :other
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# Formats days of the week or weekday validation rules into a human-readable sentence.
|
|
487
|
+
#
|
|
488
|
+
# @return [String, nil] Formatted weekdays description string.
|
|
489
|
+
def format_weekdays
|
|
490
|
+
if @type == :weekly && @validations[:day]
|
|
491
|
+
day_names = I18n.t("recurrence_ui.weekdays.day_names_plural", locale: @locale)
|
|
492
|
+
names = @validations[:day].map do |d|
|
|
493
|
+
idx = d.is_a?(Symbol) ? DAY_SYMBOL_TO_INDEX[d] : d.to_i
|
|
494
|
+
day_names[idx] if idx
|
|
495
|
+
end.compact
|
|
496
|
+
names.to_sentence(sentence_options)
|
|
497
|
+
elsif @validations[:day_of_week]
|
|
498
|
+
parts = @validations[:day_of_week].map do |day_idx, nths|
|
|
499
|
+
day_idx = day_idx.is_a?(Symbol) ? DAY_SYMBOL_TO_INDEX[day_idx] : day_idx.to_i
|
|
500
|
+
gender = I18n.t("recurrence_ui.weekdays.weekday_genders", locale: @locale)[day_idx]
|
|
501
|
+
formatted_nths = nths.map { |n| format_ordinal(n, gender) }
|
|
502
|
+
day_name = I18n.t("recurrence_ui.weekdays.day_names", locale: @locale)[day_idx]
|
|
503
|
+
"#{formatted_nths.to_sentence(sentence_options)} #{day_name}"
|
|
504
|
+
end
|
|
505
|
+
parts.join("; ")
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# Formats the target month name for yearly rules.
|
|
510
|
+
#
|
|
511
|
+
# @return [String, nil] Localized month name.
|
|
512
|
+
def format_month
|
|
513
|
+
return unless @validations[:month_of_year]
|
|
514
|
+
idx = @validations[:month_of_year].first
|
|
515
|
+
return unless idx
|
|
516
|
+
|
|
517
|
+
I18n.t("recurrence_ui.in_month_names_yearly", locale: @locale)[idx]
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# Formats days of the month validation rules.
|
|
521
|
+
#
|
|
522
|
+
# @return [String, nil] Formatted days of the month string.
|
|
523
|
+
def format_days_of_month
|
|
524
|
+
return nil unless @validations[:day_of_month]
|
|
525
|
+
|
|
526
|
+
formatted_dates = @validations[:day_of_month].map do |d|
|
|
527
|
+
format_ordinal(d, :day_of_month)
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
list_str = formatted_dates.to_sentence(sentence_options)
|
|
531
|
+
I18n.t("recurrence_ui.days_of_month_fmt", list: list_str, locale: @locale)
|
|
532
|
+
end
|
|
533
|
+
end
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<%# app/views/recurrence_ui/_daily.html.erb %>
|
|
2
|
+
<%# Daily Recurrence Rule Options %>
|
|
3
|
+
<div class="space-y-3" data-item="specificFields" data-rule="daily">
|
|
4
|
+
<div class="flex items-center space-x-2">
|
|
5
|
+
<span class="rui-inline-label">
|
|
6
|
+
<%= t("recurrence_ui.labels.every", default: "Every") %>
|
|
7
|
+
</span>
|
|
8
|
+
|
|
9
|
+
<%= number_field_tag nil, rec_ui.initial_interval,
|
|
10
|
+
value: 1,
|
|
11
|
+
min: 1,
|
|
12
|
+
class: "rui-input w-16 text-center",
|
|
13
|
+
data: {
|
|
14
|
+
item: "interval",
|
|
15
|
+
action: "input->#{identifier}#onCardInputChanged"
|
|
16
|
+
} %>
|
|
17
|
+
|
|
18
|
+
<span class="rui-inline-label">
|
|
19
|
+
<%= t("recurrence_ui.labels.day", default: "day(s)") %>
|
|
20
|
+
</span>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<%# app/views/recurrence_ui/_exceptions.html.erb %>
|
|
2
|
+
<%# Excluded Dates (EXDATES) Section %>
|
|
3
|
+
<div class="space-y-1.5">
|
|
4
|
+
<%# 1. Section Label %>
|
|
5
|
+
<label class="rui-label flex items-center gap-1">
|
|
6
|
+
<%= t("recurrence_ui.labels.exceptions", locale: locale, default: "Exceptions") %>
|
|
7
|
+
</label>
|
|
8
|
+
|
|
9
|
+
<%# 2. Container for EXDATE Chips %>
|
|
10
|
+
<div data-<%= identifier %>-target="exdatesContainer" class="flex flex-wrap gap-1">
|
|
11
|
+
<% exdates.each do |exdate| %>
|
|
12
|
+
<% date_str = exdate.is_a?(Date) || exdate.is_a?(Time) ? exdate.strftime("%Y-%m-%d") : exdate.to_s %>
|
|
13
|
+
<span class="rui-chip rui-chip--exdate" data-exdate-value="<%= date_str %>">
|
|
14
|
+
<button type="button"
|
|
15
|
+
data-action="click-><%= identifier %>#removeExdate"
|
|
16
|
+
class="rui-chip__remove-btn"
|
|
17
|
+
title="<%= t('recurrence_ui.actions.remove', default: 'Delete') %>">✕</button>
|
|
18
|
+
<span><%= rec_ui._formatDate(date_str) rescue date_str %></span>
|
|
19
|
+
</span>
|
|
20
|
+
<% end %>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<%# 3. Date Input and Add Button %>
|
|
24
|
+
<div class="flex items-center gap-1.5 pt-1">
|
|
25
|
+
<input type="date"
|
|
26
|
+
data-<%= identifier %>-target="exdateInput"
|
|
27
|
+
data-action="input-><%= identifier %>#onDateInputChange"
|
|
28
|
+
class="rui-input w-full text-xs py-1 px-2">
|
|
29
|
+
|
|
30
|
+
<button type="button"
|
|
31
|
+
data-<%= identifier %>-target="addExdateBtn"
|
|
32
|
+
data-action="click-><%= identifier %>#addExdate"
|
|
33
|
+
disabled
|
|
34
|
+
title="<%= t('recurrence_ui.actions.add_exdate', default: 'Add exception') %>"
|
|
35
|
+
class="rui-btn rui-btn-primary px-2 py-1 text-xs font-bold">
|
|
36
|
+
+
|
|
37
|
+
</button>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<%# 4. Template for Dynamically Generated EXDATE Chips via JS %>
|
|
42
|
+
<template data-<%= identifier %>-target="exdateTemplate">
|
|
43
|
+
<span class="rui-chip rui-chip--exdate" data-exdate-value="VALUE">
|
|
44
|
+
<button type="button"
|
|
45
|
+
data-action="click-><%= identifier %>#removeExdate"
|
|
46
|
+
class="rui-chip__remove-btn"
|
|
47
|
+
title="<%= t('recurrence_ui.actions.remove', default: 'Delete') %>">✕</button>
|
|
48
|
+
<span>LABEL</span>
|
|
49
|
+
</span>
|
|
50
|
+
</template>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<%# app/views/recurrence_ui/_footer_actions.html.erb %>
|
|
2
|
+
<%# Footer Action Buttons (Cancel / Save) %>
|
|
3
|
+
<div class="rui-divider flex justify-end gap-2 pt-3 mt-3">
|
|
4
|
+
<%# Cancel Editing Button %>
|
|
5
|
+
<button type="button"
|
|
6
|
+
data-action="click-><%= identifier %>#cancelEdit"
|
|
7
|
+
class="rui-btn rui-btn-secondary">
|
|
8
|
+
<%= t("recurrence_ui.actions.cancel", default: "Cancel") %>
|
|
9
|
+
</button>
|
|
10
|
+
|
|
11
|
+
<%# Save/Apply Changes Button %>
|
|
12
|
+
<button type="button"
|
|
13
|
+
data-action="click-><%= identifier %>#saveRule"
|
|
14
|
+
class="rui-btn rui-btn-primary">
|
|
15
|
+
<%= t("recurrence_ui.actions.save", default: "OK") %>
|
|
16
|
+
</button>
|
|
17
|
+
</div>
|