From 35dc05de97677d57123617a7c4e78aad1611b28d Mon Sep 17 00:00:00 2001 From: erdgeist Date: Thu, 16 Jul 2026 16:43:11 +0200 Subject: 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. --- app/models/concerns/rrule_humanizer.rb | 34 +++++++++++++++++--- app/views/events/edit.html.erb | 8 ++++- public/javascripts/admin_interface.js | 46 ++++++++++++++++++++++++++-- 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 8231de88..ca296488 100644 --- a/app/models/concerns/rrule_humanizer.rb +++ b/app/models/concerns/rrule_humanizer.rb @@ -15,8 +15,8 @@ module RruleHumanizer }.freeze ORDINAL_NAMES = { - de: { 1=>"ersten", 2=>"zweiten", 3=>"dritten", 4=>"vierten", -1=>"letzten", -2=>"vorletzten" }, - en: { 1=>"first", 2=>"second", 3=>"third", 4=>"fourth", -1=>"last", -2=>"second-to-last" } + de: { 1=>"ersten", 2=>"zweiten", 3=>"dritten", 4=>"vierten", 5=>"fünften", -1=>"letzten", -2=>"vorletzten" }, + en: { 1=>"first", 2=>"second", 3=>"third", 4=>"fourth", 5=>"fifth", -1=>"last", -2=>"second-to-last" } }.freeze MONTH_NAMES = { @@ -35,7 +35,8 @@ module RruleHumanizer ordinals = ORDINAL_NAMES[loc] || ORDINAL_NAMES[:en] months = MONTH_NAMES[loc] || MONTH_NAMES[:en] - days = byday&.split(",")&.map do |d| + byday_values = byday&.split(",") + days = byday_values&.map do |d| if d =~ /^(-?\d+)([A-Z]{2})$/ "#{ordinals[$1.to_i]} #{weekdays[$2]}" else @@ -43,6 +44,21 @@ module RruleHumanizer end end + excluded_monthly_ordinal = nil + excluded_monthly_weekday = nil + if freq == "MONTHLY" && byday_values.present? + ordinal_days = byday_values.map { |d| d.match(/^([1-5])([A-Z]{2})$/) } + if ordinal_days.all? + positions = ordinal_days.map { |match| match[1].to_i }.uniq.sort + weekdays_in_rule = ordinal_days.map { |match| match[2] }.uniq + missing_positions = (1..5).to_a - positions + if positions.size == 4 && weekdays_in_rule.size == 1 && missing_positions.size == 1 + excluded_monthly_ordinal = missing_positions.first + excluded_monthly_weekday = weekdays_in_rule.first + end + end + end + base = case loc when :de @@ -59,14 +75,22 @@ module RruleHumanizer interval == 2 ? "Alle zwei Wochen" : "Wöchentlich" end when "MONTHLY" - days ? "Jeden #{days.join(' und ')} im Monat" : "Monatlich" + if excluded_monthly_ordinal + "Jeden #{weekdays[excluded_monthly_weekday]} im Monat, außer dem #{ordinals[excluded_monthly_ordinal]} #{weekdays[excluded_monthly_weekday]}" + else + days ? "Jeden #{days.join(' und ')} im Monat" : "Monatlich" + end end else case freq when "WEEKLY" days ? "#{interval == 2 ? 'Every other' : 'Every'} #{days.join(' and ')}" : (interval == 2 ? "Every other week" : "Weekly") when "MONTHLY" - days ? "Every #{days.join(' and ')} of the month" : "Monthly" + if excluded_monthly_ordinal + "Every #{weekdays[excluded_monthly_weekday]} of the month, except the #{ordinals[excluded_monthly_ordinal]} #{weekdays[excluded_monthly_weekday]}" + else + days ? "Every #{days.join(' and ')} of the month" : "Monthly" + end end end return nil unless base diff --git a/app/views/events/edit.html.erb b/app/views/events/edit.html.erb index b6564a4e..bf8cb359 100644 --- a/app/views/events/edit.html.erb +++ b/app/views/events/edit.html.erb @@ -56,9 +56,15 @@

diff --git a/public/javascripts/admin_interface.js b/public/javascripts/admin_interface.js index 9ce0b9e7..39880168 100644 --- a/public/javascripts/admin_interface.js +++ b/public/javascripts/admin_interface.js @@ -415,8 +415,10 @@ rrule_builder = { }); $("#rrule_monthly_ordinal").bind("change", function() { $("#rrule_ordinal_fields").toggle($(this).is(":checked")); + rrule_builder.toggle_custom_ordinal_fields(); rrule_builder.sync(); }); + $("#rrule_ordinal").bind("change", rrule_builder.toggle_custom_ordinal_fields); $("#rrule_exclude_month").bind("change", function() { $("#rrule_excluded_month").toggle($(this).is(":checked")); rrule_builder.sync(); @@ -433,6 +435,12 @@ rrule_builder = { $("#rrule_monthly_options").show(); }, + toggle_custom_ordinal_fields : function() { + $("#rrule_custom_ordinal_fields").toggle( + $("#rrule_monthly_ordinal").is(":checked") && $("#rrule_ordinal").val() === "custom" + ); + }, + sync : function() { var freq = $("input[name='rrule_freq']:checked").val(); var parts = []; @@ -448,7 +456,16 @@ rrule_builder = { } else { parts.push("FREQ=MONTHLY"); if ($("#rrule_monthly_ordinal").is(":checked")) { - parts.push("BYDAY=" + $("#rrule_ordinal").val() + $("#rrule_ordinal_day").val()); + var ordinal = $("#rrule_ordinal").val(); + if (ordinal === "custom") { + var customDays = []; + $("input[id^='rrule_custom_ordinal_']:checked").each(function() { + customDays.push($(this).attr("id").replace("rrule_custom_ordinal_", "") + $("#rrule_ordinal_day").val()); + }); + if (customDays.length > 0) parts.push("BYDAY=" + customDays.join(",")); + } else { + parts.push("BYDAY=" + ordinal + $("#rrule_ordinal_day").val()); + } } } @@ -483,8 +500,33 @@ rrule_builder = { } else if (parts.FREQ === "MONTHLY") { $("input[name='rrule_freq'][value='monthly']").prop("checked", true); rrule_builder.show_monthly_options(); + var ordinalDays = parts.BYDAY && parts.BYDAY.split(","); + var customOrdinalDay = null; + var customOrdinals = []; + var customOrdinalsValid = ordinalDays && ordinalDays.length > 0; + if (customOrdinalsValid) { + ordinalDays.forEach(function(value) { + var customMatch = value.match(/^([1-5])([A-Z]{2})$/); + if (!customMatch || (customOrdinalDay && customOrdinalDay !== customMatch[2])) { + customOrdinalsValid = false; + return; + } + customOrdinalDay = customMatch[2]; + customOrdinals.push(customMatch[1]); + }); + } + var match = parts.BYDAY && parts.BYDAY.match(/^(-?\d+)([A-Z]{2})$/); - if (match) { + if (customOrdinalsValid && (customOrdinals.length > 1 || customOrdinals[0] === "5")) { + $("#rrule_monthly_ordinal").prop("checked", true); + $("#rrule_ordinal_fields").show(); + $("#rrule_ordinal").val("custom"); + $("#rrule_ordinal_day").val(customOrdinalDay); + customOrdinals.forEach(function(ordinal) { + $("#rrule_custom_ordinal_" + ordinal).prop("checked", true); + }); + rrule_builder.toggle_custom_ordinal_fields(); + } else if (match) { $("#rrule_monthly_ordinal").prop("checked", true); $("#rrule_ordinal_fields").show(); $("#rrule_ordinal").val(match[1]); diff --git a/test/models/concerns/rrule_humanizer_test.rb b/test/models/concerns/rrule_humanizer_test.rb index 279ff73f..c4e78d48 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 assert_equal "Every second-to-last Thursday of the month", humanize("FREQ=MONTHLY;BYDAY=-2TH", :en) end + test "monthly selected weeks" do + assert_equal "Jeden ersten Dienstag und dritten Dienstag im Monat", + humanize("FREQ=MONTHLY;BYDAY=1TU,3TU") + assert_equal "Every first Tuesday and third Tuesday of the month", + humanize("FREQ=MONTHLY;BYDAY=1TU,3TU", :en) + end + + test "monthly fifth weekday" do + assert_equal "Jeden fünften Dienstag im Monat", humanize("FREQ=MONTHLY;BYDAY=5TU") + assert_equal "Every fifth Tuesday of the month", humanize("FREQ=MONTHLY;BYDAY=5TU", :en) + end + + test "monthly weekday excluding one ordinal" do + rrule = "FREQ=MONTHLY;BYDAY=1TU,3TU,4TU,5TU" + assert_equal "Jeden Dienstag im Monat, außer dem zweiten Dienstag", humanize(rrule) + assert_equal "Every Tuesday of the month, except the second Tuesday", humanize(rrule, :en) + end + test "monthly no byday" do assert_equal "Monatlich", humanize("FREQ=MONTHLY") assert_equal "Monthly", humanize("FREQ=MONTHLY", :en) -- cgit v1.3