diff options
| author | erdgeist <erdgeist@erdgeist.org> | 2026-07-16 16:43:11 +0200 |
|---|---|---|
| committer | erdgeist <erdgeist@erdgeist.org> | 2026-07-16 16:43:11 +0200 |
| commit | 35dc05de97677d57123617a7c4e78aad1611b28d (patch) | |
| tree | 2960f3a8d363a12edd497fc96a022a9da77929fd | |
| parent | b701669d0d9a5d7be01f99b8e725c05c6c5d52ce (diff) | |
managed RRULE constructs now include week selection
Improve the humanizer to also understand weekly patters that
an editor can manually select, on top of the single week
rules.
Extend template and javascript controller to reflect these
changes.
| -rw-r--r-- | app/models/concerns/rrule_humanizer.rb | 34 | ||||
| -rw-r--r-- | app/views/events/edit.html.erb | 8 | ||||
| -rw-r--r-- | public/javascripts/admin_interface.js | 46 | ||||
| -rw-r--r-- | test/models/concerns/rrule_humanizer_test.rb | 18 |
4 files changed, 98 insertions, 8 deletions
diff --git a/app/models/concerns/rrule_humanizer.rb b/app/models/concerns/rrule_humanizer.rb index 8231de8..ca29648 100644 --- a/app/models/concerns/rrule_humanizer.rb +++ b/app/models/concerns/rrule_humanizer.rb | |||
| @@ -15,8 +15,8 @@ module RruleHumanizer | |||
| 15 | }.freeze | 15 | }.freeze |
| 16 | 16 | ||
| 17 | ORDINAL_NAMES = { | 17 | ORDINAL_NAMES = { |
| 18 | de: { 1=>"ersten", 2=>"zweiten", 3=>"dritten", 4=>"vierten", -1=>"letzten", -2=>"vorletzten" }, | 18 | de: { 1=>"ersten", 2=>"zweiten", 3=>"dritten", 4=>"vierten", 5=>"fünften", -1=>"letzten", -2=>"vorletzten" }, |
| 19 | en: { 1=>"first", 2=>"second", 3=>"third", 4=>"fourth", -1=>"last", -2=>"second-to-last" } | 19 | en: { 1=>"first", 2=>"second", 3=>"third", 4=>"fourth", 5=>"fifth", -1=>"last", -2=>"second-to-last" } |
| 20 | }.freeze | 20 | }.freeze |
| 21 | 21 | ||
| 22 | MONTH_NAMES = { | 22 | MONTH_NAMES = { |
| @@ -35,7 +35,8 @@ module RruleHumanizer | |||
| 35 | ordinals = ORDINAL_NAMES[loc] || ORDINAL_NAMES[:en] | 35 | ordinals = ORDINAL_NAMES[loc] || ORDINAL_NAMES[:en] |
| 36 | months = MONTH_NAMES[loc] || MONTH_NAMES[:en] | 36 | months = MONTH_NAMES[loc] || MONTH_NAMES[:en] |
| 37 | 37 | ||
| 38 | days = byday&.split(",")&.map do |d| | 38 | byday_values = byday&.split(",") |
| 39 | days = byday_values&.map do |d| | ||
| 39 | if d =~ /^(-?\d+)([A-Z]{2})$/ | 40 | if d =~ /^(-?\d+)([A-Z]{2})$/ |
| 40 | "#{ordinals[$1.to_i]} #{weekdays[$2]}" | 41 | "#{ordinals[$1.to_i]} #{weekdays[$2]}" |
| 41 | else | 42 | else |
| @@ -43,6 +44,21 @@ module RruleHumanizer | |||
| 43 | end | 44 | end |
| 44 | end | 45 | end |
| 45 | 46 | ||
| 47 | excluded_monthly_ordinal = nil | ||
| 48 | excluded_monthly_weekday = nil | ||
| 49 | if freq == "MONTHLY" && byday_values.present? | ||
| 50 | ordinal_days = byday_values.map { |d| d.match(/^([1-5])([A-Z]{2})$/) } | ||
| 51 | if ordinal_days.all? | ||
| 52 | positions = ordinal_days.map { |match| match[1].to_i }.uniq.sort | ||
| 53 | weekdays_in_rule = ordinal_days.map { |match| match[2] }.uniq | ||
| 54 | missing_positions = (1..5).to_a - positions | ||
| 55 | if positions.size == 4 && weekdays_in_rule.size == 1 && missing_positions.size == 1 | ||
| 56 | excluded_monthly_ordinal = missing_positions.first | ||
| 57 | excluded_monthly_weekday = weekdays_in_rule.first | ||
| 58 | end | ||
| 59 | end | ||
| 60 | end | ||
| 61 | |||
| 46 | base = | 62 | base = |
| 47 | case loc | 63 | case loc |
| 48 | when :de | 64 | when :de |
| @@ -59,14 +75,22 @@ module RruleHumanizer | |||
| 59 | interval == 2 ? "Alle zwei Wochen" : "Wöchentlich" | 75 | interval == 2 ? "Alle zwei Wochen" : "Wöchentlich" |
| 60 | end | 76 | end |
| 61 | when "MONTHLY" | 77 | when "MONTHLY" |
| 62 | days ? "Jeden #{days.join(' und ')} im Monat" : "Monatlich" | 78 | if excluded_monthly_ordinal |
| 79 | "Jeden #{weekdays[excluded_monthly_weekday]} im Monat, außer dem #{ordinals[excluded_monthly_ordinal]} #{weekdays[excluded_monthly_weekday]}" | ||
| 80 | else | ||
| 81 | days ? "Jeden #{days.join(' und ')} im Monat" : "Monatlich" | ||
| 82 | end | ||
| 63 | end | 83 | end |
| 64 | else | 84 | else |
| 65 | case freq | 85 | case freq |
| 66 | when "WEEKLY" | 86 | when "WEEKLY" |
| 67 | days ? "#{interval == 2 ? 'Every other' : 'Every'} #{days.join(' and ')}" : (interval == 2 ? "Every other week" : "Weekly") | 87 | days ? "#{interval == 2 ? 'Every other' : 'Every'} #{days.join(' and ')}" : (interval == 2 ? "Every other week" : "Weekly") |
| 68 | when "MONTHLY" | 88 | when "MONTHLY" |
| 69 | days ? "Every #{days.join(' and ')} of the month" : "Monthly" | 89 | if excluded_monthly_ordinal |
| 90 | "Every #{weekdays[excluded_monthly_weekday]} of the month, except the #{ordinals[excluded_monthly_ordinal]} #{weekdays[excluded_monthly_weekday]}" | ||
| 91 | else | ||
| 92 | days ? "Every #{days.join(' and ')} of the month" : "Monthly" | ||
| 93 | end | ||
| 70 | end | 94 | end |
| 71 | end | 95 | end |
| 72 | return nil unless base | 96 | return nil unless base |
diff --git a/app/views/events/edit.html.erb b/app/views/events/edit.html.erb index b6564a4..bf8cb35 100644 --- a/app/views/events/edit.html.erb +++ b/app/views/events/edit.html.erb | |||
| @@ -56,9 +56,15 @@ | |||
| 56 | <div id="rrule_monthly_options" style="display: none;"> | 56 | <div id="rrule_monthly_options" style="display: none;"> |
| 57 | <p><label><%= check_box_tag "rrule_monthly_ordinal" %> On a specific weekday each month</label></p> | 57 | <p><label><%= check_box_tag "rrule_monthly_ordinal" %> On a specific weekday each month</label></p> |
| 58 | <p id="rrule_ordinal_fields" style="display: none;"> | 58 | <p id="rrule_ordinal_fields" style="display: none;"> |
| 59 | <%= select_tag "rrule_ordinal", options_for_select([["1.", 1], ["2.", 2], ["3.", 3], ["4.", 4], ["letzter", -1], ["vorletzter", -2]]) %> | 59 | <%= select_tag "rrule_ordinal", options_for_select([["1.", 1], ["2.", 2], ["3.", 3], ["4.", 4], ["letzter", -1], ["vorletzter", -2], ["Selected weeks", "custom"]]) %> |
| 60 | <%= select_tag "rrule_ordinal_day", options_for_select(%w[MO TU WE TH FR SA SU].map { |c| [RruleHumanizer::WEEKDAY_NAMES[:de][c], c] }) %> | 60 | <%= select_tag "rrule_ordinal_day", options_for_select(%w[MO TU WE TH FR SA SU].map { |c| [RruleHumanizer::WEEKDAY_NAMES[:de][c], c] }) %> |
| 61 | </p> | 61 | </p> |
| 62 | <p id="rrule_custom_ordinal_fields" style="display: none;"> | ||
| 63 | Weeks of the month: | ||
| 64 | <% (1..5).each do |ordinal| %> | ||
| 65 | <label><%= check_box_tag "rrule_custom_ordinal_#{ordinal}" %> <%= ordinal %>.</label> | ||
| 66 | <% end %> | ||
| 67 | </p> | ||
| 62 | </div> | 68 | </div> |
| 63 | <p> | 69 | <p> |
| 64 | <label><%= check_box_tag "rrule_exclude_month" %> Except in one month</label> | 70 | <label><%= check_box_tag "rrule_exclude_month" %> Except in one month</label> |
diff --git a/public/javascripts/admin_interface.js b/public/javascripts/admin_interface.js index 9ce0b9e..3988016 100644 --- a/public/javascripts/admin_interface.js +++ b/public/javascripts/admin_interface.js | |||
| @@ -415,8 +415,10 @@ rrule_builder = { | |||
| 415 | }); | 415 | }); |
| 416 | $("#rrule_monthly_ordinal").bind("change", function() { | 416 | $("#rrule_monthly_ordinal").bind("change", function() { |
| 417 | $("#rrule_ordinal_fields").toggle($(this).is(":checked")); | 417 | $("#rrule_ordinal_fields").toggle($(this).is(":checked")); |
| 418 | rrule_builder.toggle_custom_ordinal_fields(); | ||
| 418 | rrule_builder.sync(); | 419 | rrule_builder.sync(); |
| 419 | }); | 420 | }); |
| 421 | $("#rrule_ordinal").bind("change", rrule_builder.toggle_custom_ordinal_fields); | ||
| 420 | $("#rrule_exclude_month").bind("change", function() { | 422 | $("#rrule_exclude_month").bind("change", function() { |
| 421 | $("#rrule_excluded_month").toggle($(this).is(":checked")); | 423 | $("#rrule_excluded_month").toggle($(this).is(":checked")); |
| 422 | rrule_builder.sync(); | 424 | rrule_builder.sync(); |
| @@ -433,6 +435,12 @@ rrule_builder = { | |||
| 433 | $("#rrule_monthly_options").show(); | 435 | $("#rrule_monthly_options").show(); |
| 434 | }, | 436 | }, |
| 435 | 437 | ||
| 438 | toggle_custom_ordinal_fields : function() { | ||
| 439 | $("#rrule_custom_ordinal_fields").toggle( | ||
| 440 | $("#rrule_monthly_ordinal").is(":checked") && $("#rrule_ordinal").val() === "custom" | ||
| 441 | ); | ||
| 442 | }, | ||
| 443 | |||
| 436 | sync : function() { | 444 | sync : function() { |
| 437 | var freq = $("input[name='rrule_freq']:checked").val(); | 445 | var freq = $("input[name='rrule_freq']:checked").val(); |
| 438 | var parts = []; | 446 | var parts = []; |
| @@ -448,7 +456,16 @@ rrule_builder = { | |||
| 448 | } else { | 456 | } else { |
| 449 | parts.push("FREQ=MONTHLY"); | 457 | parts.push("FREQ=MONTHLY"); |
| 450 | if ($("#rrule_monthly_ordinal").is(":checked")) { | 458 | if ($("#rrule_monthly_ordinal").is(":checked")) { |
| 451 | parts.push("BYDAY=" + $("#rrule_ordinal").val() + $("#rrule_ordinal_day").val()); | 459 | var ordinal = $("#rrule_ordinal").val(); |
| 460 | if (ordinal === "custom") { | ||
| 461 | var customDays = []; | ||
| 462 | $("input[id^='rrule_custom_ordinal_']:checked").each(function() { | ||
| 463 | customDays.push($(this).attr("id").replace("rrule_custom_ordinal_", "") + $("#rrule_ordinal_day").val()); | ||
| 464 | }); | ||
| 465 | if (customDays.length > 0) parts.push("BYDAY=" + customDays.join(",")); | ||
| 466 | } else { | ||
| 467 | parts.push("BYDAY=" + ordinal + $("#rrule_ordinal_day").val()); | ||
| 468 | } | ||
| 452 | } | 469 | } |
| 453 | } | 470 | } |
| 454 | 471 | ||
| @@ -483,8 +500,33 @@ rrule_builder = { | |||
| 483 | } else if (parts.FREQ === "MONTHLY") { | 500 | } else if (parts.FREQ === "MONTHLY") { |
| 484 | $("input[name='rrule_freq'][value='monthly']").prop("checked", true); | 501 | $("input[name='rrule_freq'][value='monthly']").prop("checked", true); |
| 485 | rrule_builder.show_monthly_options(); | 502 | rrule_builder.show_monthly_options(); |
| 503 | var ordinalDays = parts.BYDAY && parts.BYDAY.split(","); | ||
| 504 | var customOrdinalDay = null; | ||
| 505 | var customOrdinals = []; | ||
| 506 | var customOrdinalsValid = ordinalDays && ordinalDays.length > 0; | ||
| 507 | if (customOrdinalsValid) { | ||
| 508 | ordinalDays.forEach(function(value) { | ||
| 509 | var customMatch = value.match(/^([1-5])([A-Z]{2})$/); | ||
| 510 | if (!customMatch || (customOrdinalDay && customOrdinalDay !== customMatch[2])) { | ||
| 511 | customOrdinalsValid = false; | ||
| 512 | return; | ||
| 513 | } | ||
| 514 | customOrdinalDay = customMatch[2]; | ||
| 515 | customOrdinals.push(customMatch[1]); | ||
| 516 | }); | ||
| 517 | } | ||
| 518 | |||
| 486 | var match = parts.BYDAY && parts.BYDAY.match(/^(-?\d+)([A-Z]{2})$/); | 519 | var match = parts.BYDAY && parts.BYDAY.match(/^(-?\d+)([A-Z]{2})$/); |
| 487 | if (match) { | 520 | if (customOrdinalsValid && (customOrdinals.length > 1 || customOrdinals[0] === "5")) { |
| 521 | $("#rrule_monthly_ordinal").prop("checked", true); | ||
| 522 | $("#rrule_ordinal_fields").show(); | ||
| 523 | $("#rrule_ordinal").val("custom"); | ||
| 524 | $("#rrule_ordinal_day").val(customOrdinalDay); | ||
| 525 | customOrdinals.forEach(function(ordinal) { | ||
| 526 | $("#rrule_custom_ordinal_" + ordinal).prop("checked", true); | ||
| 527 | }); | ||
| 528 | rrule_builder.toggle_custom_ordinal_fields(); | ||
| 529 | } else if (match) { | ||
| 488 | $("#rrule_monthly_ordinal").prop("checked", true); | 530 | $("#rrule_monthly_ordinal").prop("checked", true); |
| 489 | $("#rrule_ordinal_fields").show(); | 531 | $("#rrule_ordinal_fields").show(); |
| 490 | $("#rrule_ordinal").val(match[1]); | 532 | $("#rrule_ordinal").val(match[1]); |
diff --git a/test/models/concerns/rrule_humanizer_test.rb b/test/models/concerns/rrule_humanizer_test.rb index 279ff73..c4e78d4 100644 --- a/test/models/concerns/rrule_humanizer_test.rb +++ b/test/models/concerns/rrule_humanizer_test.rb | |||
| @@ -47,6 +47,24 @@ class RruleHumanizerTest < ActiveSupport::TestCase | |||
| 47 | assert_equal "Every second-to-last Thursday of the month", humanize("FREQ=MONTHLY;BYDAY=-2TH", :en) | 47 | assert_equal "Every second-to-last Thursday of the month", humanize("FREQ=MONTHLY;BYDAY=-2TH", :en) |
| 48 | end | 48 | end |
| 49 | 49 | ||
| 50 | test "monthly selected weeks" do | ||
| 51 | assert_equal "Jeden ersten Dienstag und dritten Dienstag im Monat", | ||
| 52 | humanize("FREQ=MONTHLY;BYDAY=1TU,3TU") | ||
| 53 | assert_equal "Every first Tuesday and third Tuesday of the month", | ||
| 54 | humanize("FREQ=MONTHLY;BYDAY=1TU,3TU", :en) | ||
| 55 | end | ||
| 56 | |||
| 57 | test "monthly fifth weekday" do | ||
| 58 | assert_equal "Jeden fünften Dienstag im Monat", humanize("FREQ=MONTHLY;BYDAY=5TU") | ||
| 59 | assert_equal "Every fifth Tuesday of the month", humanize("FREQ=MONTHLY;BYDAY=5TU", :en) | ||
| 60 | end | ||
| 61 | |||
| 62 | test "monthly weekday excluding one ordinal" do | ||
| 63 | rrule = "FREQ=MONTHLY;BYDAY=1TU,3TU,4TU,5TU" | ||
| 64 | assert_equal "Jeden Dienstag im Monat, außer dem zweiten Dienstag", humanize(rrule) | ||
| 65 | assert_equal "Every Tuesday of the month, except the second Tuesday", humanize(rrule, :en) | ||
| 66 | end | ||
| 67 | |||
| 50 | test "monthly no byday" do | 68 | test "monthly no byday" do |
| 51 | assert_equal "Monatlich", humanize("FREQ=MONTHLY") | 69 | assert_equal "Monatlich", humanize("FREQ=MONTHLY") |
| 52 | assert_equal "Monthly", humanize("FREQ=MONTHLY", :en) | 70 | assert_equal "Monthly", humanize("FREQ=MONTHLY", :en) |
