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,4 @@
1
+ module RecurrenceUi
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,6 @@
1
+ module RecurrenceUi
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,351 @@
1
+ module RecurrenceUi
2
+ module HasRecurrence
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ # Class attributes for storing model field name configuration
7
+ class_attribute :recurrence_rules_field, default: RecurrenceUi.config.default_recurring_rules_field
8
+ class_attribute :recurrence_start_time_field, default: RecurrenceUi.config.default_start_time_field
9
+ class_attribute :recurrence_end_time_field, default: RecurrenceUi.config.default_end_time_field
10
+ class_attribute :recurrence_time_zone_field, default: RecurrenceUi.config.default_time_zone_field
11
+ class_attribute :recurrence_duration_field, default: RecurrenceUi.config.default_duration_field
12
+ attr_accessor :form_start_date, :form_start_time,
13
+ :form_duration_mode, :form_duration_days, :form_duration_hours, :form_duration_minutes,
14
+ :form_end_date, :form_end_time
15
+
16
+ before_save :process_recurrence_time_attributes
17
+ end
18
+
19
+ class_methods do
20
+ # DSL method to configure model recurrence and time fields.
21
+ #
22
+ # @param rules [Symbol, String, nil] Field name for storing rules (JSON/Hash).
23
+ # @param start_time [Symbol, String, nil] Field name for start date/time.
24
+ # @param end_time [Symbol, String, nil] Field name for end date/time.
25
+ # @param time_zone [Symbol, String, nil] Field name for time zone.
26
+ # @param duration [Symbol, String, nil] Field name for duration in seconds.
27
+ # @return [void]
28
+ def has_recurrence(rules: nil, start_time: nil, end_time: nil, time_zone: nil, duration: nil)
29
+ self.recurrence_rules_field = rules if rules.present?
30
+ self.recurrence_start_time_field = start_time if start_time.present?
31
+ self.recurrence_end_time_field = end_time if end_time.present?
32
+ self.recurrence_time_zone_field = time_zone if time_zone.present?
33
+ self.recurrence_duration_field = duration if duration.present?
34
+
35
+ field_name = recurrence_rules_field.to_s
36
+
37
+ # READ: Returns a Hash containing rules and UI metadata
38
+ define_method(field_name) do
39
+ raw_val = read_attribute(field_name)
40
+
41
+ # What radio button mode was selected in the form? 'duration' or 'end_at'
42
+ return { 'form_duration_mode' => 'duration', 'rrules' => [] }.with_indifferent_access if raw_val.blank?
43
+
44
+ parsed = if raw_val.is_a?(Hash)
45
+ raw_val.with_indifferent_access
46
+ elsif raw_val.is_a?(String)
47
+ JSON.parse(raw_val).with_indifferent_access rescue {}
48
+ else
49
+ {}
50
+ end
51
+
52
+ # Ensure that the form_duration_mode key is always present, defaulting to 'duration' if missing
53
+ parsed[:form_duration_mode] ||= 'duration'
54
+ parsed
55
+ end
56
+
57
+ # WRITE: Accepts a Hash or JSON string and stores it in the database (it could contain both rules and UI metadata)
58
+ define_method("#{field_name}=") do |value|
59
+ parsed_value = case value
60
+ when String
61
+ JSON.parse(value) rescue {}
62
+ when Hash, ActionController::Parameters
63
+ value.to_h
64
+ else
65
+ {}
66
+ end
67
+
68
+ write_attribute(field_name, parsed_value.deep_stringify_keys)
69
+ end
70
+ end
71
+
72
+ # Method to return the list of permitted parameters for recurrence fields, useful for strong parameters in controllers.
73
+ def recurrence_permitted_params
74
+ [
75
+ recurrence_rules_field.to_sym,
76
+ recurrence_start_time_field.to_sym,
77
+ recurrence_end_time_field.to_sym,
78
+ recurrence_time_zone_field.to_sym,
79
+ recurrence_duration_field.to_sym,
80
+ :form_start_date,
81
+ :form_start_time,
82
+ :form_duration_mode,
83
+ :form_duration_days,
84
+ :form_duration_hours,
85
+ :form_duration_minutes,
86
+ :form_end_date,
87
+ :form_end_time
88
+ ]
89
+ end
90
+
91
+ end
92
+ # Returns a RecurrenceFormatterService instance for formatting recurrence data.
93
+ # @param options [Hash] Options to customize formatting (e.g., :zone_type, :show_zone)
94
+ # @return [RecurrenceFormatterService]
95
+ def summary(options = {})
96
+ RecurrenceFormatterService.new(self, options)
97
+ end
98
+
99
+
100
+
101
+ # --- Instance Methods ---
102
+ # Returns the raw recurrence rules stored in the model, which may include both recurrence rules and UI metadata.
103
+ # @return [Hash] The raw recurrence rules hash.
104
+ def recurrence_raw_rules
105
+ public_send(self.class.recurrence_rules_field)
106
+ end
107
+
108
+ # Returns the event's time zone, either from the model's time zone field or falling back to the application's default time zone.
109
+ # @return [ActiveSupport::TimeZone] The time zone for the event.
110
+ def event_time_zone
111
+ tz_name = if self.class.recurrence_time_zone_field &&
112
+ respond_to?(self.class.recurrence_time_zone_field)
113
+ public_send(self.class.recurrence_time_zone_field)
114
+ end
115
+ # If the time zone name is not present (nil or empty string), safely return the application's Time.zone
116
+ return Time.zone unless tz_name.present?
117
+
118
+ ActiveSupport::TimeZone[tz_name] || Time.zone || ActiveSupport::TimeZone[Rails.application.config.time_zone]
119
+ end
120
+
121
+ # Returns the recurrence start time in the event's time zone, defaulting to Time.current if not set.
122
+ # @return [ActiveSupport::TimeWithZone] The start time of the recurrence.
123
+ def recurrence_start_time
124
+ raw_start = nil
125
+ if self.class.recurrence_start_time_field && respond_to?(self.class.recurrence_start_time_field)
126
+ raw_start = public_send(self.class.recurrence_start_time_field)
127
+ end
128
+ (raw_start || Time.current).in_time_zone(event_time_zone)
129
+ end
130
+
131
+ # Returns the recurrence end time in the event's time zone, or nil if not set.
132
+ # @return [ActiveSupport::TimeWithZone, nil] The end time of the recurrence, or nil if not set.
133
+ def recurrence_end_time
134
+ end_field = self.class.recurrence_end_time_field
135
+ return nil unless end_field && respond_to?(end_field)
136
+
137
+ raw_end = public_send(end_field)
138
+ return nil unless raw_end.present?
139
+
140
+ raw_end.in_time_zone(event_time_zone)
141
+ end
142
+
143
+ # Returns the recurrence start time in the specified time zone type (:event, :application, or a specific zone name).
144
+ # @param zone_type [Symbol, String] The type of time zone to convert to (:event, :application, or a specific zone name).
145
+ # @return [ActiveSupport::TimeWithZone] The start time of the recurrence in the specified time zone.
146
+ def start_time_in_zone(zone_type = :event)
147
+ time_in_zone(recurrence_start_time, zone_type)
148
+ end
149
+
150
+ # Returns the recurrence end time in the specified time zone type (:event, :application, or a specific zone name).
151
+ # @param zone_type [Symbol, String] The type of time zone to convert to (:event, :application, or a specific zone name).
152
+ # @return [ActiveSupport::TimeWithZone, nil] The end time of the recurrence in the specified time zone, or nil
153
+ def end_time_in_zone(zone_type = :event)
154
+ time_in_zone(recurrence_end_time, zone_type)
155
+ end
156
+
157
+ # Returns the current duration of the recurrence in seconds, based on the model's duration field.
158
+ # @return [Integer] The duration in seconds, or 0 if not set or invalid.
159
+ def current_duration_seconds
160
+ dur_field = self.class.recurrence_duration_field
161
+ if dur_field && respond_to?(dur_field)
162
+ val = public_send(dur_field)
163
+ return val.to_i if val.present? && val.to_i > 0
164
+ end
165
+ 0
166
+ end
167
+
168
+
169
+ # --- Building IceCube::Schedule ---
170
+ # Returns an IceCube::Schedule object based on the stored recurrence rules and start time.
171
+ # @param start_time_override [Time, nil] Optional override for the start time of the schedule.
172
+ # @return [IceCube::Schedule] The IceCube schedule object representing the recurrence rules.
173
+ def schedule(start_time_override = nil)
174
+ base_time = start_time_override || recurrence_start_time
175
+ s = IceCube::Schedule.new(base_time)
176
+
177
+ dur = current_duration_seconds
178
+ s.duration = dur if dur > 0
179
+
180
+ raw_data = recurrence_raw_rules
181
+ return s if raw_data.blank?
182
+
183
+ # Превращаем в хэш с строковыми ключами на всякий случай
184
+ raw_data = raw_data.deep_stringify_keys if raw_data.is_a?(Hash)
185
+
186
+ # Достаем массив правил безопасно, игнорируя UI-ключи типа form_duration_mode
187
+ rules_array = raw_data['rrules'] || (raw_data['rule_type'] ? [raw_data] : [])
188
+
189
+ rules_array.each do |rule_hash|
190
+ next if rule_hash.blank? || !rule_hash.is_a?(Hash)
191
+
192
+ if rule_hash['rule_type'] == 'IceCube::MonthlyRule' &&
193
+ rule_hash.dig('validations', 'day_of_week').is_a?(Hash)
194
+
195
+ rule_hash['validations']['day_of_week'].transform_values! do |days|
196
+ Array(days).map { |day| day.to_i == 0 ? 7 : day.to_i }
197
+ end
198
+ end
199
+
200
+ begin
201
+ r = IceCube::Rule.from_hash(rule_hash)
202
+ if respond_to?(:repeat_until) && repeat_until.present? && !rule_hash['until']
203
+ r = r.until(repeat_until.in_time_zone(event_time_zone).end_of_day)
204
+ end
205
+ s.add_recurrence_rule(r)
206
+ rescue StandardError => e
207
+ Rails.logger.error "[RecurrenceUi] Error building IceCube::Rule: #{e.message}"
208
+ end
209
+ end
210
+
211
+ Array(raw_data['exdates']).each do |exdate|
212
+ next if exdate.blank?
213
+ parsed_date = Date.parse(exdate.to_s) rescue nil
214
+ next unless parsed_date
215
+
216
+ exact_datetime = base_time.change(
217
+ year: parsed_date.year,
218
+ month: parsed_date.month,
219
+ day: parsed_date.day
220
+ )
221
+ s.add_exception_time(exact_datetime)
222
+ end
223
+
224
+ Array(raw_data['rdates']).each do |rdate|
225
+ next if rdate.blank?
226
+ parsed_date = Date.parse(rdate.to_s) rescue nil
227
+ next unless parsed_date
228
+
229
+ exact_datetime = base_time.change(
230
+ year: parsed_date.year,
231
+ month: parsed_date.month,
232
+ day: parsed_date.day
233
+ )
234
+ s.add_recurrence_time(exact_datetime)
235
+ end
236
+
237
+ s
238
+ end
239
+
240
+ # Returns the next occurrences of the event based on the schedule, limited to a specified number and starting from a given time.
241
+ # @param limit [Integer] The maximum number of occurrences to return.
242
+ # @param from_time [Time] The time from which to start looking for occurrences.
243
+ # @return [Array<Time>] An array of the next occurrence times in the event's time zone.
244
+ def upcoming_occurrences(limit = 5, from_time = Time.current)
245
+ schedule.next_occurrences(limit, from_time.in_time_zone(event_time_zone))
246
+ end
247
+
248
+ # Returns the next occurrences of the event in a specified time zone, limited to a specified number and starting from a given time.
249
+ # @param limit [Integer] The maximum number of occurrences to return.
250
+ # @param from_time [Time] The time from which to start looking for occurrences.
251
+ # @param zone_type [Symbol, String] The type of time zone to convert to (:event, :application, or a specific zone name).
252
+ # @return [Array<Time>] An array of the next occurrence times in the specified time zone.
253
+ def upcoming_occurrences_in_zone(limit = 5, from_time = Time.current, zone_type = :event)
254
+ target_zone = resolve_zone_type(zone_type)
255
+ schedule.next_occurrences(limit, from_time.in_time_zone(target_zone))
256
+ end
257
+
258
+ # Returns the first occurrence of the event based on the schedule.
259
+ # @return [Time, nil] The time of the first occurrence in the event's time zone, or nil if there are no occurrences.
260
+ def first_occurrence
261
+ schedule.first
262
+ end
263
+
264
+ # Returns the rule object for the first recurrence rule in the schedule, if any.
265
+ # @return [IceCube::Rule, nil] The first recurrence rule, or nil if there are no rules.
266
+ def rule
267
+ schedule.recurrence_rules.first
268
+ end
269
+
270
+ # Delegate formatting methods to the summary service
271
+ delegate :formatted_start_time, :formatted_end_time, :formatted_duration, :formatted_upcoming_occurrences, :to_s, to: :summary
272
+
273
+ private
274
+
275
+ # Returns the given time in the specified time zone type (:event, :application, or a specific zone name).
276
+ # @param time [Time, nil] The time to convert.
277
+ # @param zone_type [Symbol, String] The type of time zone to convert to (:event, :application, or a specific zone name).
278
+ # @return [ActiveSupport::TimeWithZone, nil] The time in the specified time zone, or nil if the input time is nil.
279
+ def time_in_zone(time, zone_type = :event)
280
+ return nil unless time
281
+
282
+ target_zone = resolve_zone_type(zone_type)
283
+ time.in_time_zone(target_zone)
284
+ end
285
+
286
+ # Processes the form attributes related to recurrence time and updates the model's fields accordingly.
287
+ # This method is called before saving the record to ensure that the recurrence start time, end time, and duration are correctly calculated and stored based on the form inputs.
288
+ def process_recurrence_time_attributes
289
+ tz = event_time_zone
290
+
291
+ if respond_to?(:form_start_date=) && form_start_date.present?
292
+ time_str = form_start_time.presence || '09:00'
293
+ parsed_start = tz.parse("#{form_start_date} #{time_str}")
294
+ write_attribute(self.class.recurrence_start_time_field, parsed_start) if parsed_start
295
+ end
296
+
297
+ start_val = public_send(self.class.recurrence_start_time_field)
298
+ return unless start_val.present?
299
+
300
+ dur_field = self.class.recurrence_duration_field
301
+ end_field = self.class.recurrence_end_time_field
302
+
303
+ # Получаем текущий хэш (он уже содержит и правила, и метаданные)
304
+ current_rules = recurrence_raw_rules.deep_dup
305
+
306
+ # Сохраняем выбранный в форме режим в общие метаданные правил
307
+ if form_duration_mode.present?
308
+ current_rules[:form_duration_mode] = form_duration_mode
309
+ end
310
+
311
+ if form_duration_mode == 'end_at' && form_end_date.present?
312
+ end_time_str = form_end_time.presence || '09:00'
313
+ parsed_end = tz.parse("#{form_end_date} #{end_time_str}")
314
+
315
+ if parsed_end.present?
316
+ write_attribute(end_field, parsed_end) if read_attribute(end_field) != parsed_end
317
+ calculated_dur = (parsed_end - start_val).to_i
318
+ write_attribute(dur_field, calculated_dur) if dur_field && respond_to?("#{dur_field}=") && read_attribute(dur_field) != calculated_dur
319
+ end
320
+ else
321
+ days = form_duration_days.to_i
322
+ hours = form_duration_hours.to_i
323
+ minutes = form_duration_minutes.to_i
324
+ total_seconds = (days * 1.day + hours * 1.hour + minutes * 1.minute).to_i
325
+ total_seconds = 1.hour if total_seconds <= 0
326
+
327
+ write_attribute(dur_field, total_seconds) if dur_field && respond_to?("#{dur_field}=") && read_attribute(dur_field) != total_seconds
328
+
329
+ calculated_end = start_val + total_seconds.seconds
330
+ write_attribute(end_field, calculated_end) if end_field && respond_to?("#{end_field}=") && read_attribute(end_field) != calculated_end
331
+ end
332
+
333
+ # Записываем обновленный хэш (вместе с form_duration_mode) обратно через наш сеттер
334
+ public_send("#{self.class.recurrence_rules_field}=", current_rules)
335
+ end
336
+
337
+ # Resolves the appropriate time zone based on the provided zone type.
338
+ # @param zone_type [Symbol, String] The type of time zone to resolve (:event, :application, or a specific zone name).
339
+ # @return [ActiveSupport::TimeZone] The resolved time zone object.
340
+ def resolve_zone_type(zone_type)
341
+ case zone_type
342
+ when :event
343
+ event_time_zone
344
+ when :application, :app
345
+ Time.zone
346
+ else
347
+ Time.find_zone(zone_type) || Time.zone
348
+ end
349
+ end
350
+ end
351
+ end
@@ -0,0 +1,5 @@
1
+ module RecurrenceUi
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end