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,954 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static classes = [ "selected" ]
5
+ static values = {
6
+ locale: String,
7
+ timeZone: String,
8
+ weekStart: Number,
9
+ translations: Object
10
+ }
11
+
12
+ static targets = [
13
+ "hiddenField", "displayMode", "editMode", "summaryText",
14
+ "rulesContainer", "ruleCardTemplate", "ruleCard",
15
+ "exceptionsBlock", "exdatesContainer", "exdateInput", "addExdateBtn", "exdateTemplate",
16
+ "rdatesBlock", "rdatesContainer", "rdateInput", "addRdateBtn", "rdateTemplate",
17
+
18
+ "iceCubeField",
19
+ "visualEditor", // Блок с карточками и кнопками (Level 2)
20
+ "codeEditor", // Блок с textarea для JSON (Level 2 alt)
21
+ "cardHeader", // Текстовая заголовок-шапка карточки (для аккордеона Level 3)
22
+ "cardBody", // Раскрывающийся блок с полями карточки (Level 3)
23
+ "startDate",
24
+ "startTime",
25
+ "endDateField",
26
+ "endTimeField",
27
+ "modeDuration",
28
+ "modeEnd",
29
+ "daysField",
30
+ "hoursField",
31
+ "minutesField"
32
+ ]
33
+
34
+ ruleSnapshot = null
35
+
36
+ connect() {
37
+ this.isInitializing = true
38
+ this.initializeState()
39
+ this.updateDebugField()
40
+ this.initializeDurationMode()
41
+ this.isInitializing = false
42
+ }
43
+
44
+ // ==========================================
45
+ // INITIALIZATION & STATE MANAGEMENT
46
+ // ==========================================
47
+
48
+ initializeState() {
49
+ const jsonString = this.hasHiddenFieldTarget ? this.hiddenFieldTarget.value : null
50
+
51
+ // Clear rules container if target exists
52
+ if (this.hasRulesContainerTarget) {
53
+ this.rulesContainerTarget.innerHTML = ""
54
+ }
55
+
56
+ // Process rules, exdates, and rdates if JSON string is present
57
+ if (jsonString && jsonString.length > 2) {
58
+ try {
59
+ const schedule = JSON.parse(jsonString)
60
+ const rules = schedule.rrules || (schedule.rule_type ? [schedule] : [])
61
+
62
+ if (rules.length > 0) {
63
+ rules.forEach(ruleHash => this._createRuleCardFromHash(ruleHash))
64
+ } else if (this.hasRuleCardTemplateTarget) {
65
+ this.addRule()
66
+ }
67
+
68
+ if (this.hasExdatesContainerTarget && this.hasExdateTemplateTarget) {
69
+ this._fillDatesSection(this.exdatesContainerTarget, this.exdateTemplateTarget, schedule.exdates)
70
+ }
71
+
72
+ if (this.hasRdatesContainerTarget && this.hasRdateTemplateTarget) {
73
+ this._fillDatesSection(this.rdatesContainerTarget, this.rdateTemplateTarget, schedule.rdates)
74
+ }
75
+ } catch (e) {
76
+ console.error("[RecurrenceUi] JSON Parsing error:", e)
77
+ if (this.hasRuleCardTemplateTarget) this.addRule()
78
+ }
79
+ } else if (this.hasRuleCardTemplateTarget) {
80
+ this.addRule()
81
+ }
82
+
83
+ this.toggleExceptionsBlock()
84
+ this.toggleRdatesBlock()
85
+ this.syncButtonsUI()
86
+
87
+ // Update the summary text only if there is a target to display it in
88
+ if (this.hasSummaryTextTarget) {
89
+ this.updateSummary()
90
+ }
91
+ }
92
+ // Установка начального состояния полей на основе выбранной радиокнопки
93
+ initializeDurationMode() {
94
+ if (this.hasModeEndTarget) {
95
+ const isEndMode = this.modeEndTarget.checked
96
+
97
+ if (this.hasEndDateFieldTarget) this.endDateFieldTarget.disabled = !isEndMode
98
+ if (this.hasEndTimeFieldTarget) this.endTimeFieldTarget.disabled = !isEndMode
99
+ if (this.hasDaysFieldTarget) this.daysFieldTarget.disabled = isEndMode
100
+ if (this.hasHoursFieldTarget) this.hoursFieldTarget.disabled = isEndMode
101
+ if (this.hasMinutesFieldTarget) this.minutesFieldTarget.disabled = isEndMode
102
+ }
103
+ }
104
+ // =========================================
105
+ //Переключение длительности и окончания одного события
106
+ // =========================================
107
+
108
+ // Переключение между режимом "Длительность" и "Точное окончание"
109
+ toggleDurationEndMode(event) {
110
+ const isEndMode = this.hasModeEndTarget && this.modeEndTarget.checked
111
+
112
+ // 1. Блокируем/разблокируем поля длительности
113
+ if (this.hasDaysFieldTarget) this.daysFieldTarget.disabled = isEndMode
114
+ if (this.hasHoursFieldTarget) this.hoursFieldTarget.disabled = isEndMode
115
+ if (this.hasMinutesFieldTarget) this.minutesFieldTarget.disabled = isEndMode
116
+
117
+ // 2. Блокируем/разблокируем поля точного окончания (дата и время)
118
+ if (this.hasEndDateFieldTarget) this.endDateFieldTarget.disabled = !isEndMode
119
+ if (this.hasEndTimeFieldTarget) this.endTimeFieldTarget.disabled = !isEndMode
120
+
121
+ // 3. Если выбрали режим окончания — сразу пересчитываем дату/время окончания на базе Старта и Длительности
122
+ if (isEndMode) {
123
+ this.calculateEndDateFromDuration()
124
+ }
125
+ }
126
+
127
+ // Метод пересчета даты и времени окончания на основе Старта + Длительность
128
+ calculateEndDateFromDuration() {
129
+ if (!this.hasStartDateTarget || !this.hasStartTimeTarget) return
130
+ if (!this.hasEndDateFieldTarget || !this.hasEndTimeFieldTarget) return
131
+
132
+ const startDateVal = this.startDateTarget.value
133
+ const startTimeVal = this.startTimeTarget.value || "09:00"
134
+
135
+ if (!startDateVal) return
136
+
137
+ // Собираем объект Date из даты и времени начала
138
+ const startDateTime = new Date(`${startDateVal}T${startTimeVal}`)
139
+ if (isNaN(startDateTime.getTime())) return
140
+
141
+ // Получаем значения длительности с защитой от пустых полей
142
+ const days = parseInt(this.hasDaysFieldTarget ? this.daysFieldTarget.value : 0) || 0
143
+ const hours = parseInt(this.hasHoursFieldTarget ? this.hoursFieldTarget.value : 0) || 0
144
+ const minutes = parseInt(this.hasMinutesFieldTarget ? this.minutesFieldTarget.value : 0) || 0
145
+
146
+ // Считаем общую длительность в миллисекундах
147
+ const totalMs = (days * 24 * 60 * 60 + hours * 60 * 60 + minutes * 60) * 1000
148
+
149
+ // Прибавляем к старту
150
+ const endDateTime = new Date(startDateTime.getTime() + totalMs)
151
+
152
+ // Форматируем обратно в YYYY-MM-DD для date_field
153
+ const year = endDateTime.getFullYear()
154
+ const month = String(endDateTime.getMonth() + 1).padStart(2, '0')
155
+ const day = String(endDateTime.getDate()).padStart(2, '0')
156
+ this.endDateFieldTarget.value = `${year}-${month}-${day}`
157
+
158
+ // Форматируем в HH:MM для time_field
159
+ const endHours = String(endDateTime.getHours()).padStart(2, '0')
160
+ const endMinutes = String(endDateTime.getMinutes()).padStart(2, '0')
161
+ this.endTimeFieldTarget.value = `${endHours}:${endMinutes}`
162
+ }
163
+ // Переключение U1 -> U2 (Раскрытие общего модуля)
164
+ // Toggle Main Panel Accordion Mode
165
+ toggleMainAccordion() {
166
+ if (!this.hasEditModeTarget) return
167
+ const isHidden = this.editModeTarget.classList.contains('hidden')
168
+
169
+ if (isHidden) {
170
+ this.editModeTarget.classList.remove('hidden')
171
+ } else {
172
+ this.editModeTarget.classList.add('hidden')
173
+ this.updateSummary() // Recalculate summary text on close
174
+ }
175
+ }
176
+
177
+ // Toggle Rule Item Accordion Mode
178
+ toggleCardAccordion(event) {
179
+ if (event) event.preventDefault()
180
+
181
+ // Ignore accordion toggle if delete button was clicked directly
182
+ if (event.target.closest('.rui-btn-remove-card')) return
183
+
184
+ const card = event.currentTarget.closest(`[data-${this.identifier}-target="ruleCard"]`)
185
+ if (!card) return
186
+
187
+ const body = card.querySelector(`[data-${this.identifier}-target="cardBody"]`)
188
+ if (body) {
189
+ const isHidden = body.classList.contains('hidden')
190
+
191
+ if (isHidden) {
192
+ body.classList.remove('hidden')
193
+ card.classList.add('is-open') // Add active state class
194
+ } else {
195
+ body.classList.add('hidden')
196
+ card.classList.remove('is-open') // Remove active state class
197
+ this.updateCardSummary(card) // Update rule text on collapse
198
+ }
199
+ }
200
+ }
201
+ // 1. Открытие/закрытие панели кода
202
+ toggleCodeEditor(event) {
203
+ if (event) event.preventDefault()
204
+
205
+ const isCodeHidden = this.codeEditorTarget.classList.contains('hidden')
206
+
207
+ if (isCodeHidden) {
208
+ // Считываем текущий JSON из скрытого поля формы
209
+ const currentJson = this.hiddenFieldTarget.value || "{}"
210
+
211
+ try {
212
+ // Форматируем JSON с отступами в 2 пробела для красивого отображения в textarea
213
+ const parsed = JSON.parse(currentJson)
214
+ this.iceCubeFieldTarget.value = JSON.stringify(parsed, null, 2)
215
+ } catch (e) {
216
+ // Если там был невалидный JSON, выводим как есть
217
+ this.iceCubeFieldTarget.value = currentJson
218
+ }
219
+
220
+ // Переключаем видимость блоков
221
+ this.visualEditorTarget.classList.add('hidden')
222
+ this.codeEditorTarget.classList.remove('hidden')
223
+ } else {
224
+ // Возврат назад без сохранения
225
+ this.codeEditorTarget.classList.add('hidden')
226
+ this.visualEditorTarget.classList.remove('hidden')
227
+ }
228
+ }
229
+
230
+ // 2. Применение кода по кнопке "ОК"
231
+ applyCodeChanges(event) {
232
+ if (event) event.preventDefault()
233
+
234
+ const rawJson = this.iceCubeFieldTarget.value.trim()
235
+
236
+ try {
237
+ // Валидируем JSON перед сохранением
238
+ if (rawJson.length > 0) {
239
+ JSON.parse(rawJson)
240
+ }
241
+
242
+ // Сохраняем в основное скрытое поле
243
+ this.hiddenFieldTarget.value = rawJson.length > 0 ? rawJson : "{}"
244
+
245
+ // Переинициализируем visual UI (карточки правил, теги дат) под новый JSON
246
+ this.initializeState()
247
+
248
+ // Переключаем обратно в визуальный режим
249
+ this.codeEditorTarget.classList.add('hidden')
250
+ this.visualEditorTarget.classList.remove('hidden')
251
+
252
+ } catch (e) {
253
+ alert(this.t('errors.invalid_json', { default: 'Ошибка в синтаксисе JSON' }))
254
+ }
255
+ }
256
+
257
+
258
+
259
+
260
+ // ==========================================
261
+ // CARD ACTIONS (ADD / REMOVE / TOGGLE)
262
+ // ==========================================
263
+
264
+ addRule(event) {
265
+ if (event) event.preventDefault()
266
+
267
+ const content = this.ruleCardTemplateTarget.innerHTML
268
+ this.rulesContainerTarget.insertAdjacentHTML('beforeend', content)
269
+
270
+ const newCard = this.rulesContainerTarget.lastElementChild
271
+ const body = newCard.querySelector(`[data-${this.identifier}-target="cardBody"]`)
272
+
273
+ // Раскрываем тело новой карточки
274
+ if (body) body.classList.remove('hidden')
275
+
276
+ // Запускаем первичную синхронизацию полей новой карточки
277
+ const select = newCard.querySelector('[data-item="ruleType"]')
278
+ if (select) {
279
+ this.toggleCardFields({ currentTarget: select })
280
+ }
281
+
282
+ this.toggleExceptionsBlock()
283
+ this.toggleRdatesBlock()
284
+
285
+ this.updateCardSummary(newCard)
286
+ this.updateHiddenHash()
287
+ }
288
+
289
+
290
+
291
+
292
+ removeRule(e) {
293
+ e.preventDefault()
294
+ const card = e.target.closest(`[data-${this.identifier}-target="ruleCard"]`)
295
+ if (card) {
296
+ card.remove()
297
+ this.toggleExceptionsBlock()
298
+ this.toggleRdatesBlock()
299
+ this.updateHiddenHash()
300
+ }
301
+ }
302
+
303
+ toggleCardFields(e) {
304
+ const select = e.currentTarget
305
+ const card = select.closest(`[data-${this.identifier}-target="ruleCard"]`) || select.closest('[data-item="ruleCard"]')
306
+ if (!card) return
307
+
308
+ const selectedValue = select.value
309
+ const specificFields = card.querySelectorAll('[data-item="specificFields"]')
310
+
311
+ specificFields.forEach(el => {
312
+ const isCurrent = el.dataset.rule === selectedValue
313
+ el.classList.toggle('hidden', !isCurrent)
314
+
315
+ // Если выбрали тип с полем (например, daily) и интервал не задан, выставляем 1 по умолчанию
316
+ if (isCurrent) {
317
+ const intervalInput = el.querySelector('[data-item="interval"]')
318
+ if (intervalInput && (!intervalInput.value || parseInt(intervalInput.value) < 1)) {
319
+ intervalInput.value = 1
320
+ }
321
+ }
322
+ })
323
+
324
+ this.updateHiddenHash()
325
+ this.updateCardSummary(card)
326
+ }
327
+
328
+ toggleDay(event) {
329
+ event.preventDefault()
330
+ const btn = event.currentTarget
331
+ btn.dataset.selected = (btn.dataset.selected !== 'true').toString()
332
+ this.syncButtonsUI()
333
+ this.updateHiddenHash()
334
+
335
+ const card = btn.closest(`[data-${this.identifier}-target="ruleCard"]`)
336
+ if (card) this.updateCardSummary(card)
337
+
338
+ }
339
+
340
+ toggleEndFields(event) {
341
+ const select = event.currentTarget
342
+ const card = select.closest(`[data-${this.identifier}-target="ruleCard"]`)
343
+ if (!card) return
344
+
345
+ const mode = select.value
346
+ const endDate = card.querySelector('[data-item="endDateInput"]')
347
+ const endCount = card.querySelector('[data-item="endCount"]')
348
+
349
+ if (endDate) endDate.classList.toggle('hidden', mode !== 'until')
350
+ if (endCount) {
351
+ endCount.classList.toggle('hidden', mode !== 'count')
352
+ if (mode === 'count') endCount.classList.add('flex')
353
+ }
354
+
355
+ this.updateHiddenHash()
356
+ this.updateCardSummary(card)
357
+ }
358
+ onCardInputChanged(event) {
359
+ const card = event.currentTarget.closest(`[data-${this.identifier}-target="ruleCard"]`)
360
+ this.updateHiddenHash()
361
+ if (card) this.updateCardSummary(card)
362
+ }
363
+ // ==========================================
364
+ // MODE SWITCHING & ACTIONS
365
+ // ==========================================
366
+
367
+ saveRule(event) {
368
+ if (event) event.preventDefault()
369
+ this.updateHiddenHash()
370
+ this.showDisplayMode()
371
+ }
372
+
373
+ editRule(event) {
374
+ if (event) event.preventDefault()
375
+ this.ruleSnapshot = this.hiddenFieldTarget.value
376
+ this.showEditMode()
377
+ }
378
+
379
+ cancelEdit(event) {
380
+ if (event) event.preventDefault()
381
+ this.hiddenFieldTarget.value = this.ruleSnapshot || ""
382
+ this.initializeState()
383
+ this.showDisplayMode()
384
+ }
385
+
386
+ showDisplayMode() {
387
+ if (this.hasDisplayModeTarget) this.displayModeTarget.classList.remove('hidden')
388
+ if (this.hasEditModeTarget) this.editModeTarget.classList.add('hidden')
389
+ this.updateSummary()
390
+ }
391
+
392
+ showEditMode() {
393
+ if (this.hasDisplayModeTarget) this.displayModeTarget.classList.add('hidden')
394
+ if (this.hasEditModeTarget) this.editModeTarget.classList.remove('hidden')
395
+ }
396
+
397
+ // ==========================================
398
+ // EXCEPTIONS (EXDATES) & ADDITIONS (RDATES)
399
+ // ==========================================
400
+
401
+
402
+ // 1. Активация/деактивация кнопки "+" при вводе даты
403
+ onDateInputChange(event) {
404
+ const input = event.target
405
+ const value = input.value.trim()
406
+
407
+ if (input === this.exdateInputTarget && this.hasAddExdateBtnTarget) {
408
+ this.addExdateBtnTarget.disabled = !value
409
+ } else if (input === this.rdateInputTarget && this.hasAddRdateBtnTarget) {
410
+ this.addRdateBtnTarget.disabled = !value
411
+ }
412
+ }
413
+
414
+ // 2. Универсальный метод вставки из <template>
415
+ _addDateFromTemplate(inputTarget, containerTarget, templateTarget) {
416
+ if (!inputTarget || !containerTarget || !templateTarget) return
417
+
418
+ const dateValue = inputTarget.value.trim()
419
+ if (!dateValue) return
420
+
421
+ // Проверка на дубликаты
422
+ const existingDates = Array.from(containerTarget.children).map(
423
+ el => el.dataset.exdateValue || el.dataset.rdateValue || el.dataset.date
424
+ )
425
+ if (existingDates.includes(dateValue)) return
426
+
427
+ // Подставляем данные в template
428
+ const html = templateTarget.innerHTML
429
+ .replace(/VALUE/g, dateValue)
430
+ .replace(/LABEL/g, this._formatDate(dateValue))
431
+
432
+ containerTarget.insertAdjacentHTML('beforeend', html)
433
+ inputTarget.value = ''
434
+ this.updateHiddenHash()
435
+ }
436
+
437
+ // 3. Добавление исключения (Exdate)
438
+ addExdate(event) {
439
+ if (event) event.preventDefault()
440
+ this._addDateFromTemplate(this.exdateInputTarget, this.exdatesContainerTarget, this.exdateTemplateTarget)
441
+ if (this.hasAddExdateBtnTarget) this.addExdateBtnTarget.disabled = true
442
+ }
443
+
444
+ // 4. Добавление дополнительной даты (Rdate)
445
+ addRdate(event) {
446
+ if (event) event.preventDefault()
447
+ this._addDateFromTemplate(this.rdateInputTarget, this.rdatesContainerTarget, this.rdateTemplateTarget)
448
+ if (this.hasAddRdateBtnTarget) this.addRdateBtnTarget.disabled = true
449
+ }
450
+
451
+ // 5. Заполнение секции при первичной загрузке из JSON
452
+ _fillDatesSection(container, template, datesArray) {
453
+ if (!container || !template || !datesArray) return
454
+ container.innerHTML = ""
455
+
456
+ datesArray.forEach(dateValue => {
457
+ const html = template.innerHTML
458
+ .replace(/VALUE/g, dateValue)
459
+ .replace(/LABEL/g, this._formatDate(dateValue))
460
+ container.insertAdjacentHTML('beforeend', html)
461
+ })
462
+ }
463
+
464
+
465
+ removeExdate(e) {
466
+ e.preventDefault()
467
+ const badge = e.currentTarget.closest('[data-exdate-value]')
468
+ console.log("Removing exdate badge:", badge)
469
+ if (badge) {
470
+ badge.remove()
471
+ this.updateHiddenHash()
472
+ }
473
+ }
474
+
475
+ removeRdate(e) {
476
+ e.preventDefault()
477
+ const badge = e.currentTarget.closest('[data-rdate-value]')
478
+ console.log("Removing rdate badge:", badge)
479
+ if (badge) {
480
+ badge.remove()
481
+ this.updateHiddenHash()
482
+ }
483
+ }
484
+
485
+ toggleExceptionsBlock() {
486
+ if (!this.hasExceptionsBlockTarget) return
487
+ // Всегда показываем блок исключений в режиме редактирования U2
488
+ this.exceptionsBlockTarget.classList.remove('hidden')
489
+ }
490
+
491
+ toggleRdatesBlock() {
492
+ if (!this.hasRdatesBlockTarget) return
493
+ // Всегда показываем блок доп. дат в режиме редактирования U2
494
+ this.rdatesBlockTarget.classList.remove('hidden')
495
+ }
496
+
497
+ // ==========================================
498
+ // HASH BUILDER & SUMMARY GENERATION
499
+ // ==========================================
500
+
501
+ updateHiddenHash() {
502
+ if (this.isInitializing) return
503
+
504
+ // 1. Фильтруем и собираем только валидные rrules (игнорируем тип 'never' или сбойные правила)
505
+ const rrules = this.ruleCardTargets
506
+ .map(card => this._buildSingleRuleHash(card))
507
+ .filter(Boolean)
508
+
509
+ // 2. Извлекаем списки дат
510
+ const exdates = this._getDatesArray(this.exdatesContainerTarget, 'exdateValue')
511
+ const rdates = this._getDatesArray(this.rdatesContainerTarget, 'rdateValue')
512
+
513
+ // 3. Формируем итоговый объект расписания
514
+ const schedule = {}
515
+
516
+ // Записываем rrules только если они есть
517
+ if (rrules.length > 0) {
518
+ schedule.rrules = rrules
519
+ }
520
+
521
+ // Записываем exdates только если они есть
522
+ if (exdates.length > 0) {
523
+ schedule.exdates = exdates
524
+ }
525
+
526
+ // Записываем rdates только если они есть
527
+ if (rdates.length > 0) {
528
+ schedule.rdates = rdates
529
+ }
530
+
531
+ // 4. Записываем в hidden_field чистый JSON (если всё пусто — сохранится "{}" вместо пустых структур)
532
+ this.hiddenFieldTarget.value = JSON.stringify(schedule)
533
+
534
+ // 5. Обновляем текстовую сводку и отладчик
535
+ this.updateSummary()
536
+ this.updateDebugField()
537
+ }
538
+
539
+
540
+ updateCardSummary(card) {
541
+ if (!card) return
542
+ const summaryEl = card.querySelector('[data-item="cardSummary"]')
543
+ if (!summaryEl) return
544
+ // Create a summary text for the card based on its current state
545
+ const ruleHash = this._buildSingleRuleHash(card)
546
+
547
+ if (!ruleHash) {
548
+ summaryEl.textContent = this.t('labels.new_rule', { default: 'Новое правило' })
549
+ return
550
+ }
551
+ // Generate a human-readable summary for the rule with _buildRuleSummaryText
552
+ const text = this._buildRuleSummaryText(ruleHash)
553
+ summaryEl.textContent = text || this.t('labels.new_rule', { default: 'Новое правило' })
554
+ }
555
+ updateSummary() {
556
+ if (!this.hasSummaryTextTarget || !this.hasHiddenFieldTarget) return
557
+
558
+ let schedule = {}
559
+ try {
560
+ schedule = JSON.parse(this.hiddenFieldTarget.value || "{}")
561
+ } catch (e) {
562
+ schedule = {}
563
+ }
564
+
565
+ const rrules = schedule.rrules || []
566
+ let mainText = this.t('one_time')
567
+
568
+ if (rrules.length > 0) {
569
+ // Build human-readable summary for each rule
570
+
571
+ const ruleTexts = rrules.map(ruleHash => this._buildRuleSummaryText(ruleHash))
572
+ mainText = ruleTexts.join('; ')
573
+ }
574
+
575
+
576
+ let exceptionsHtml = ""
577
+ if (schedule.exdates && schedule.exdates.length > 0) {
578
+ const exceptLabel = this.t('labels.exceptions')
579
+ const formattedExdates = schedule.exdates
580
+ .sort((a, b) => new Date(a) - new Date(b))
581
+ .map(d => this._formatDate(d))
582
+ .join(', ')
583
+
584
+ exceptionsHtml = `<span class="rui-meta-info rui-meta-info--exceptions">${exceptLabel}: ${formattedExdates}</span>`
585
+ }
586
+
587
+ let rdatesHtml = ""
588
+ if (schedule.rdates && schedule.rdates.length > 0) {
589
+ const rdateLabel = this.t('labels.rdates')
590
+ const formattedRdates = schedule.rdates
591
+ .sort((a, b) => new Date(a) - new Date(b))
592
+ .map(d => this._formatDate(d))
593
+ .join(', ')
594
+
595
+ rdatesHtml = `<span class="rui-meta-info rui-meta-info--rdates">${rdateLabel}: ${formattedRdates}</span>`
596
+ }
597
+ let mainHtml = `<span class="rui-meta-info rui-meta-info--main">${mainText}</span>`
598
+
599
+ this.summaryTextTarget.innerHTML = mainHtml + exceptionsHtml + rdatesHtml
600
+ }
601
+
602
+ // Updates the debug textarea with the current JSON from the hidden field
603
+ updateDebugField() {
604
+ if (this.hasIceCubeFieldTarget) {
605
+ this.iceCubeFieldTarget.value = this.hiddenFieldTarget.value
606
+ }
607
+ }
608
+
609
+ // Allows editing the JSON directly in the debug textarea
610
+ updateHiddenFromDebug() {
611
+ if (!this.hasIceCubeFieldTarget) return
612
+ try {
613
+ JSON.parse(this.iceCubeFieldTarget.value)
614
+ this.hiddenFieldTarget.value = this.iceCubeFieldTarget.value
615
+ this.initializeState()
616
+ } catch (e) {
617
+ // If the JSON is invalid, we can choose to ignore or show an error. For now, we'll just ignore it.
618
+ }
619
+ }
620
+
621
+ // ==========================================
622
+ // HELPERS & PRIVATE METHODS
623
+ // ==========================================
624
+
625
+ _buildSingleRuleHash(card) {
626
+ const typeSelect = card.querySelector('[data-item="ruleType"]')
627
+ if (!typeSelect) return null
628
+
629
+ const ruleType = typeSelect.value
630
+ // Если выбрано "Без повторения", это правило не идет в массив rrules
631
+ if (ruleType === 'never' || !ruleType) return null
632
+
633
+ const container = card.querySelector(`[data-item="specificFields"][data-rule="${ruleType}"]`)
634
+ if (!container) return null
635
+
636
+ const baseType = ruleType.split('_')[0]
637
+ const iceCubeType = `IceCube::${baseType.charAt(0).toUpperCase()}${baseType.slice(1)}Rule`
638
+
639
+ let ruleHash = {
640
+ rule_type: iceCubeType,
641
+ interval: parseInt(container.querySelector('[data-item="interval"]')?.value) || 1,
642
+ validations: {}
643
+ }
644
+
645
+ if (ruleType === 'weekly') {
646
+ const days = Array.from(container.querySelectorAll('[data-selected="true"]')).map(b => parseInt(b.dataset.value))
647
+ if (days.length > 0) ruleHash.validations.day = days
648
+ } else if (ruleType.endsWith('_day_of_month')) {
649
+ const days = Array.from(container.querySelectorAll('[data-selected="true"]')).map(b => parseInt(b.dataset.day))
650
+ if (days.length > 0) ruleHash.validations.day_of_month = days
651
+ } else if (ruleType.endsWith('_by_weekday')) {
652
+ const nthDays = {}
653
+ container.querySelectorAll('[data-selected="true"]').forEach(btn => {
654
+ const day = btn.dataset.day
655
+ const nth = parseInt(btn.dataset.nth)
656
+ if (!nthDays[day]) nthDays[day] = []
657
+ nthDays[day].push(nth)
658
+ })
659
+ if (Object.keys(nthDays).length > 0) ruleHash.validations.day_of_week = nthDays
660
+ }
661
+
662
+ if (ruleType.startsWith('yearly')) {
663
+ const month = parseInt(container.querySelector('[data-item="month"]')?.value)
664
+ if (month) ruleHash.validations.month_of_year = [month]
665
+ }
666
+
667
+ // Termination for THIS card
668
+ const endTypeSelect = card.querySelector('[data-item="endType"]')
669
+ if (endTypeSelect) {
670
+ const endType = endTypeSelect.value
671
+ if (endType === 'until') {
672
+ const endDateInput = card.querySelector('[data-item="endDateInput"]')
673
+ if (endDateInput && endDateInput.value) ruleHash.until = endDateInput.value + "T23:59:59Z"
674
+ } else if (endType === 'count') {
675
+ const endCountInput = card.querySelector('[data-item="endCountInput"]')
676
+ if (endCountInput && endCountInput.value) ruleHash.count = parseInt(endCountInput.value)
677
+ }
678
+ }
679
+
680
+ if (Object.keys(ruleHash.validations).length === 0) delete ruleHash.validations
681
+ return ruleHash
682
+ }
683
+
684
+ _createRuleCardFromHash(ruleHash) {
685
+
686
+ if (!this.hasRuleCardTemplateTarget) return
687
+ const content = this.ruleCardTemplateTarget.innerHTML
688
+ this.rulesContainerTarget.insertAdjacentHTML('beforeend', content)
689
+
690
+ const card = this.rulesContainerTarget.lastElementChild
691
+ const ruleType = this._determineRuleType(ruleHash)
692
+
693
+ const typeSelect = card.querySelector('[data-item="ruleType"]')
694
+ if (typeSelect) typeSelect.value = ruleType
695
+
696
+ this.toggleCardFields({ currentTarget: typeSelect })
697
+
698
+ const container = card.querySelector(`[data-item="specificFields"][data-rule="${ruleType}"]`)
699
+ if (container) {
700
+ const intervalInput = container.querySelector('[data-item="interval"]')
701
+ if (intervalInput && ruleHash.interval) intervalInput.value = ruleHash.interval
702
+
703
+ if (ruleType.startsWith('yearly')) {
704
+ const monthInput = container.querySelector('[data-item="month"]')
705
+ if (monthInput && ruleHash.validations?.month_of_year) {
706
+ monthInput.value = ruleHash.validations.month_of_year[0]
707
+ }
708
+ }
709
+
710
+ this._syncButtonsFromHash(container, ruleHash)
711
+ }
712
+
713
+ this._fillTerminationFieldsForCard(card, ruleHash)
714
+ // Card should be collapsed by default, so we don't show the body unless the user expands it
715
+ const body = card.querySelector(`[data-${this.identifier}-target="cardBody"]`)
716
+ if (body) {
717
+ body.classList.add('hidden')
718
+ }
719
+ // Update header summary text for the card based on JSON data
720
+ this.updateCardSummary(card)
721
+ }
722
+
723
+
724
+ _buildRuleSummaryText(hash) {
725
+ const type = this._determineRuleType(hash)
726
+ const interval = hash.interval || 1
727
+ const vals = hash.validations || {}
728
+
729
+ const intervalGender = (type === 'weekly') ? 'weekly_interval' : 'male'
730
+ const count_f = this.formatOrdinal(interval, intervalGender)
731
+
732
+ let termination = ""
733
+ if (hash.until) {
734
+ termination = this.t('until_label', { date: this._formatDate(hash.until.split('T')[0]) })
735
+ } else if (hash.count) {
736
+ termination = this.t('count_label', { count: hash.count })
737
+ }
738
+
739
+ let weekdays = ""
740
+ if (vals.day || vals.day_of_week) {
741
+ const listFormatter = new Intl.ListFormat(this.localeValue, { style: 'long', type: 'conjunction' })
742
+
743
+ if (type === 'weekly' && vals.day) {
744
+ const names = vals.day.map(i => this.translationsValue.weekdays?.day_names_plural?.[i])
745
+ weekdays = listFormatter.format(names.filter(Boolean))
746
+ } else if (vals.day_of_week) {
747
+ const parts = Object.keys(vals.day_of_week).map(dayKey => {
748
+ const dayIdx = parseInt(dayKey)
749
+ const gender = this.translationsValue.weekdays?.weekday_genders?.[dayIdx] || 'male'
750
+
751
+ const nths = vals.day_of_week[dayKey].map(n => {
752
+ const nStr = n.toString()
753
+ const genderSpecific = this.translationsValue.nth_names?.[gender]?.[nStr]
754
+ if (genderSpecific) return genderSpecific
755
+
756
+ const generalSpecific = this.translationsValue.nth_names?.[nStr]
757
+ if (generalSpecific) return generalSpecific
758
+
759
+ return this.formatOrdinal(n, gender)
760
+ })
761
+
762
+ return `${listFormatter.format(nths)} ${this.translationsValue.weekdays?.day_names?.[dayIdx]}`
763
+ })
764
+ weekdays = parts.join('; ')
765
+ }
766
+ }
767
+
768
+ const month = (vals.month_of_year) ?
769
+ this.translationsValue.in_month_names_yearly?.[vals.month_of_year[0]] : ""
770
+
771
+ let days_of_month = ""
772
+ if (vals.day_of_month) {
773
+ const formattedDates = vals.day_of_month.map(d => this.formatOrdinal(d, 'day_of_month'))
774
+ const listFormatter = new Intl.ListFormat(this.localeValue, { style: 'long', type: 'conjunction' })
775
+ days_of_month = this.t('days_of_month_fmt', { list: listFormatter.format(formattedDates) })
776
+ }
777
+
778
+ return this.t(`summary.${type}`, {
779
+ count: interval,
780
+ count_f: count_f,
781
+ until: termination,
782
+ weekdays: weekdays,
783
+ month: month,
784
+ days_of_month: days_of_month
785
+ }).replace(/\s+/g, ' ').trim()
786
+ }
787
+
788
+ _determineRuleType(hash) {
789
+ if (!hash || !hash.rule_type) return 'never'
790
+ const base = hash.rule_type.replace('IceCube::', '').replace('Rule', '').toLowerCase()
791
+
792
+ if (base === 'monthly') return hash.validations?.day_of_week ? 'monthly_by_weekday' : 'monthly_by_day_of_month'
793
+ if (base === 'yearly') return hash.validations?.day_of_week ? 'yearly_by_weekday' : 'yearly_by_day_of_month'
794
+ return base
795
+ }
796
+
797
+ _syncButtonsFromHash(container, hash) {
798
+ container.querySelectorAll('[data-selected]').forEach(btn => btn.dataset.selected = "false")
799
+ const vals = hash.validations || {}
800
+
801
+ if (vals.day) {
802
+ vals.day.forEach(d => {
803
+ const btn = container.querySelector(`[data-value="${d}"]`)
804
+ if (btn) btn.dataset.selected = "true"
805
+ })
806
+ }
807
+ if (vals.day_of_month) {
808
+ vals.day_of_month.forEach(d => {
809
+ const btn = container.querySelector(`[data-day="${d}"]`)
810
+ if (btn) btn.dataset.selected = "true"
811
+ })
812
+ }
813
+ if (vals.day_of_week) {
814
+ Object.keys(vals.day_of_week).forEach(dayNum => {
815
+ vals.day_of_week[dayNum].forEach(nth => {
816
+ const btn = container.querySelector(`[data-day="${dayNum}"][data-nth="${nth}"]`)
817
+ if (btn) btn.dataset.selected = "true"
818
+ })
819
+ })
820
+ }
821
+ this.syncButtonsUI()
822
+ }
823
+
824
+ _fillTerminationFieldsForCard(card, hash) {
825
+ const endTypeSelect = card.querySelector('[data-item="endType"]')
826
+ if (!endTypeSelect) return
827
+
828
+ if (hash.until) {
829
+ endTypeSelect.value = 'until'
830
+ const input = card.querySelector('[data-item="endDateInput"]')
831
+ if (input) input.value = hash.until.split('T')[0]
832
+ } else if (hash.count) {
833
+ endTypeSelect.value = 'count'
834
+ const input = card.querySelector('[data-item="endCountInput"]')
835
+ if (input) input.value = hash.count
836
+ } else {
837
+ endTypeSelect.value = 'never'
838
+ }
839
+
840
+ this.toggleEndFields({ currentTarget: endTypeSelect })
841
+ }
842
+
843
+ _addDateToList(inputTarget, containerTarget, templateTarget) {
844
+ const dateValue = inputTarget.value
845
+ if (!dateValue) return
846
+
847
+ const existingDates = Array.from(containerTarget.querySelectorAll('[data-date-value]')).map(el => el.dataset.dateValue)
848
+ if (existingDates.includes(dateValue)) return
849
+
850
+ const content = templateTarget.innerHTML
851
+ .replace(/VALUE/g, dateValue)
852
+ .replace(/LABEL/g, this._formatDate(dateValue))
853
+
854
+ containerTarget.insertAdjacentHTML('beforeend', content)
855
+ inputTarget.value = ""
856
+ this.updateHiddenHash()
857
+ }
858
+
859
+ _fillDatesSection(container, template, datesArray) {
860
+ if (!container || !template || !datesArray) return
861
+ container.innerHTML = ""
862
+ datesArray.forEach(dateValue => {
863
+ const content = template.innerHTML
864
+ .replace(/VALUE/g, dateValue)
865
+ .replace(/LABEL/g, this._formatDate(dateValue))
866
+ container.insertAdjacentHTML('beforeend', content)
867
+ })
868
+ }
869
+
870
+ _getDatesArray(container, dataKey) {
871
+ if (!container) return []
872
+ return Array.from(container.children)
873
+ .map(el => el.dataset[dataKey] || el.getAttribute(`data-${dataKey.replace(/([A-Z])/g, '-$1').toLowerCase()}`))
874
+ .filter(Boolean)
875
+ }
876
+
877
+ syncButtonsUI() {
878
+ if (!this.hasSelectedClasses) return
879
+ this.element.querySelectorAll('[data-selected]').forEach(btn => btn.classList.remove(...this.selectedClasses))
880
+ this.element.querySelectorAll('[data-selected="true"]').forEach(btn => btn.classList.add(...this.selectedClasses))
881
+ }
882
+
883
+ t(path, params = {}) {
884
+ let result = path.split('.').reduce((o, i) => o ? o[i] : null, this.translationsValue)
885
+ if (!result) return path
886
+
887
+ const count = params.count ?? params.interval
888
+
889
+ if (typeof count === 'number' && typeof result === 'object') {
890
+ if (count === 1 && result['one']) {
891
+ result = result['one']
892
+ } else {
893
+ const locale = this.localeValue || 'en'
894
+ const pr = new Intl.PluralRules(locale, { type: 'cardinal' })
895
+ const rule = pr.select(count)
896
+ result = result[rule] || result['other'] || result['one'] || path
897
+ }
898
+ }
899
+
900
+ if (typeof result !== 'string') return path
901
+
902
+ Object.keys(params).forEach(key => {
903
+ result = result.replace(`%{${key}}`, params[key])
904
+ })
905
+
906
+ return result
907
+ }
908
+
909
+ formatOrdinal(number, gender = 'male') {
910
+ const locale = this.localeValue || 'en'
911
+ const ruleType = locale.startsWith('en') ? 'ordinal' : 'cardinal'
912
+ const pr = new Intl.PluralRules(locale, { type: ruleType })
913
+ const rule = pr.select(number)
914
+
915
+ const ordinals = this.translationsValue.ordinals || {}
916
+ let template = null
917
+
918
+ if (ordinals[gender] && typeof ordinals[gender] === 'object') {
919
+ template = ordinals[gender][rule] || ordinals[gender]['other']
920
+ }
921
+
922
+ if (!template) {
923
+ template = ordinals[rule] || ordinals['other']
924
+ }
925
+
926
+ if (typeof template !== 'string') return number.toString()
927
+
928
+ return template.replace('%{count}', number)
929
+ }
930
+
931
+ _formatDate(dateString) {
932
+ if (!dateString) return ''
933
+
934
+ const [year, month, day] = dateString.split('T')[0].split('-').map(Number)
935
+ const date = new Date(Date.UTC(year, month - 1, day))
936
+
937
+ const y = date.getUTCFullYear()
938
+ const m = date.getUTCMonth() + 1
939
+ const d = date.getUTCDate()
940
+
941
+ const dateConfig = this.translationsValue.date || {}
942
+ const formatPattern = dateConfig.formats?.recurrence_date || "%Y-%m-%d"
943
+ const monthNames = dateConfig.month_names || []
944
+ const monthName = monthNames[m] || ""
945
+
946
+ return formatPattern
947
+ .replace(/%Y/g, y)
948
+ .replace(/%-m/g, m)
949
+ .replace(/%m/g, String(m).padStart(2, '0'))
950
+ .replace(/%-d/g, d)
951
+ .replace(/%d/g, String(d).padStart(2, '0'))
952
+ .replace(/%B/g, monthName)
953
+ }
954
+ }