summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-07-16 16:43:11 +0200
committererdgeist <erdgeist@erdgeist.org>2026-07-16 16:43:11 +0200
commit35dc05de97677d57123617a7c4e78aad1611b28d (patch)
tree2960f3a8d363a12edd497fc96a022a9da77929fd
parentb701669d0d9a5d7be01f99b8e725c05c6c5d52ce (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.rb34
-rw-r--r--app/views/events/edit.html.erb8
-rw-r--r--public/javascripts/admin_interface.js46
-rw-r--r--test/models/concerns/rrule_humanizer_test.rb18
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)