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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +208 -0
  4. data/Rakefile +8 -0
  5. data/app/assets/config/recurrence_ui_manifest.js +1 -0
  6. data/app/assets/stylesheets/recurrence_ui/application.css +15 -0
  7. data/app/assets/stylesheets/recurrence_ui/components.css +295 -0
  8. data/app/assets/stylesheets/recurrence_ui/tokens.css +102 -0
  9. data/app/components/recurrence_ui/recurrence_picker_component.html.erb +234 -0
  10. data/app/components/recurrence_ui/recurrence_picker_component.rb +204 -0
  11. data/app/controllers/recurrence_ui/application_controller.rb +32 -0
  12. data/app/helpers/recurrence_ui/application_helper.rb +23 -0
  13. data/app/javascript/recurrence_ui/index.js +22 -0
  14. data/app/javascript/recurrence_ui/recurrence_ui_controller.js +954 -0
  15. data/app/jobs/recurrence_ui/application_job.rb +4 -0
  16. data/app/mailers/recurrence_ui/application_mailer.rb +6 -0
  17. data/app/models/concerns/recurrence_ui/has_recurrence.rb +351 -0
  18. data/app/models/recurrence_ui/application_record.rb +5 -0
  19. data/app/services/recurrence_ui/recurrence_formatter_service.rb +536 -0
  20. data/app/views/layouts/recurrence_ui/application.html.erb +15 -0
  21. data/app/views/recurrence_ui/_daily.html.erb +22 -0
  22. data/app/views/recurrence_ui/_exceptions.html.erb +50 -0
  23. data/app/views/recurrence_ui/_footer_actions.html.erb +17 -0
  24. data/app/views/recurrence_ui/_monthly_by_day_of_month.html.erb +47 -0
  25. data/app/views/recurrence_ui/_monthly_by_weekday.html.erb +55 -0
  26. data/app/views/recurrence_ui/_rdates.html.erb +50 -0
  27. data/app/views/recurrence_ui/_rule_card.html.erb +61 -0
  28. data/app/views/recurrence_ui/_summary_link.html.erb +25 -0
  29. data/app/views/recurrence_ui/_termination.html.erb +44 -0
  30. data/app/views/recurrence_ui/_weekly.html.erb +44 -0
  31. data/app/views/recurrence_ui/_yearly_by_day_of_month.html.erb +63 -0
  32. data/app/views/recurrence_ui/_yearly_by_weekday.html.erb +72 -0
  33. data/config/i18n-tasks.yml +212 -0
  34. data/config/importmap.rb +3 -0
  35. data/config/locales/recurrence_ui.be.yml +245 -0
  36. data/config/locales/recurrence_ui.en.yml +210 -0
  37. data/config/locales/recurrence_ui.ja.yml +201 -0
  38. data/config/locales/recurrence_ui.ru.yml +245 -0
  39. data/config/locales/recurrence_ui.uk.yml +245 -0
  40. data/config/routes.rb +5 -0
  41. data/lib/generators/recurrence_ui/views_generator.rb +26 -0
  42. data/lib/recurrence_ui/configuration.rb +116 -0
  43. data/lib/recurrence_ui/engine.rb +57 -0
  44. data/lib/recurrence_ui/version.rb +3 -0
  45. data/lib/recurrence_ui.rb +25 -0
  46. data/lib/tasks/recurrence_ui_tasks.rake +4 -0
  47. 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,15 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Recurrence ui</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+
8
+ <%= stylesheet_link_tag "recurrence_ui/application", media: "all" %>
9
+ </head>
10
+ <body>
11
+
12
+ <%= yield %>
13
+
14
+ </body>
15
+ </html>
@@ -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>