From caefdd0ab09e5f3bfe91b7e88f8fa044ea99bfeb Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 10 Jun 2026 12:44:09 +0200 Subject: [PATCH] When the allocation spans over multiple schedules, also show this in the warning --- .../warning_step/form_component.rb | 31 ++++++++---- .../resource_allocations_controller.rb | 11 +++-- .../resource_allocations/availability.rb | 20 +++++--- .../resource_management/config/locales/en.yml | 3 +- .../requests/resource_allocations_spec.rb | 14 ++++++ .../resource_allocations/availability_spec.rb | 47 ++++++++++++++----- 6 files changed, 93 insertions(+), 33 deletions(-) diff --git a/modules/resource_management/app/components/resource_allocations/warning_step/form_component.rb b/modules/resource_management/app/components/resource_allocations/warning_step/form_component.rb index 03431bfd184..c5761e47cf3 100644 --- a/modules/resource_management/app/components/resource_allocations/warning_step/form_component.rb +++ b/modules/resource_management/app/components/resource_allocations/warning_step/form_component.rb @@ -38,14 +38,14 @@ module ResourceAllocations include OpPrimer::ComponentHelpers def initialize(allocation:, project:, allocation_kind:, form_values:, overbooked_ranges: [], - working_schedule: nil, filters: nil) + working_schedules: [], filters: nil) super @allocation = allocation @project = project @allocation_kind = allocation_kind @form_values = form_values @overbooked_ranges = overbooked_ranges - @working_schedule = working_schedule + @working_schedules = working_schedules @filters = filters end @@ -68,18 +68,31 @@ module ResourceAllocations end def working_schedule? - @working_schedule.present? + @working_schedules.any? end + # One sentence chaining each schedule effective during the overbooked + # span, e.g. "This user works Mon-Fri 8h until 03/31/2026, then Mon-Thu + # 6h (80% available for project work)." def schedule_note - schedule = @working_schedule.working_days_summary + first, *rest = @working_schedules - if @working_schedule.availability_factor < 100 - t("resource_management.allocate_resource_dialog.overbooking.schedule_note_with_availability", - schedule:, factor: @working_schedule.availability_factor) - else - t("resource_management.allocate_resource_dialog.overbooking.schedule_note", schedule:) + segments = [schedule_summary(first)] + rest.each do |schedule| + segments << t("resource_management.allocate_resource_dialog.overbooking.schedule_change", + date: helpers.format_date(schedule.valid_from - 1), + schedule: schedule_summary(schedule)) end + + t("resource_management.allocate_resource_dialog.overbooking.schedule_note", schedule: segments.join(" ")) + end + + def schedule_summary(schedule) + summary = schedule.working_days_summary + return summary if schedule.availability_factor >= 100 + + t("resource_management.allocate_resource_dialog.overbooking.schedule_availability", + schedule: summary, factor: schedule.availability_factor) end def capacity_summary(range) diff --git a/modules/resource_management/app/controllers/resource_management/resource_allocations_controller.rb b/modules/resource_management/app/controllers/resource_management/resource_allocations_controller.rb index 7eb2ba8999e..5356b69c2cd 100644 --- a/modules/resource_management/app/controllers/resource_management/resource_allocations_controller.rb +++ b/modules/resource_management/app/controllers/resource_management/resource_allocations_controller.rb @@ -107,7 +107,7 @@ module ::ResourceManagement form_values: submitted_allocation_params, filters: params[:filters], overbooked_ranges: ranges, - working_schedule: working_schedule(allocation, ranges) + working_schedules: working_schedules(allocation, ranges) ) ) replace_via_turbo_stream( @@ -166,10 +166,13 @@ module ::ResourceManagement UserWorkingHours.for_user(allocation.principal).exists? end - def working_schedule(allocation, ranges) - return if ranges.empty? + # The schedule note covers the span of the displayed overbooked ranges so + # it explains the capacity figures the user actually sees. + def working_schedules(allocation, ranges) + return [] if ranges.empty? - availability(allocation).working_schedule(date: allocation.start_date) + span = ranges.map(&:start_date).min..ranges.map(&:end_date).max + availability(allocation).working_schedules(span) end def availability(allocation) diff --git a/modules/resource_management/app/services/resource_allocations/availability.rb b/modules/resource_management/app/services/resource_allocations/availability.rb index 5a9f42072e6..908e77ddd09 100644 --- a/modules/resource_management/app/services/resource_allocations/availability.rb +++ b/modules/resource_management/app/services/resource_allocations/availability.rb @@ -86,13 +86,21 @@ module ResourceAllocations OverbookingAnalysis.new(calendar: calendar_for(work_items), items: work_items).call end - # The user's working schedule active on the given date, carrying the - # per-day hours and the availability factor. Nil when no schedule is in - # effect on that date. + # The user's working schedules effective within the given date range, in + # chronological order: the schedule active at the range start (if any) + # followed by any schedules taking effect within the range. Empty when no + # schedule is in effect at any point of the range. # - # @return [UserWorkingHours, nil] - def working_schedule(date:) - UserWorkingHours.for_user(@user).valid_for_date(date) + # @return [Array] + def working_schedules(range) + records = UserWorkingHours + .for_user(@user) + .where(valid_from: ..range.end) + .order(:valid_from) + .to_a + active_index = records.rindex { |record| record.valid_from <= range.begin } + + active_index.nil? ? records : records[active_index..] end private diff --git a/modules/resource_management/config/locales/en.yml b/modules/resource_management/config/locales/en.yml index 6557e33e6b7..57d68a0434f 100644 --- a/modules/resource_management/config/locales/en.yml +++ b/modules/resource_management/config/locales/en.yml @@ -80,8 +80,9 @@ en: description: >- The selected user, %{user}, would be allocated beyond their working hours during this period. hidden_work: Other allocations + schedule_availability: "%{schedule} (%{factor}% available for project work)" + schedule_change: until %{date}, then %{schedule} schedule_note: This user works %{schedule}. - schedule_note_with_availability: This user works %{schedule} (%{factor}% available for project work). submit: Allocate overtime title: Do you want to allocate overtime to the user? submit: Allocate diff --git a/modules/resource_management/spec/requests/resource_allocations_spec.rb b/modules/resource_management/spec/requests/resource_allocations_spec.rb index e5d57e60b10..271b557b653 100644 --- a/modules/resource_management/spec/requests/resource_allocations_spec.rb +++ b/modules/resource_management/spec/requests/resource_allocations_spec.rb @@ -377,6 +377,20 @@ RSpec.describe "ResourceAllocations requests", expect(response.body).to include("Mon-Fri 8h (80% available for project work)") end + it "lists each schedule with its effective dates when the schedule changes during the period" do + # Switches to Mon-Fri 6h at 80% on the second day of the allocation. + create(:user_working_hours, user: working_assignee, valid_from: Date.new(2026, 3, 3), + monday: 360, tuesday: 360, wednesday: 360, thursday: 360, friday: 360, + availability_factor: 80) + + post project_resource_allocations_path(project), params: base_params, as: :turbo_stream + + expect(response.body).to include( + "Mon-Fri 8h until #{I18n.l(Date.new(2026, 3, 2))}, " \ + "then Mon-Fri 6h (80% available for project work)" + ) + end + it "creates the allocation once confirmed" do expect do post project_resource_allocations_path(project), diff --git a/modules/resource_management/spec/services/resource_allocations/availability_spec.rb b/modules/resource_management/spec/services/resource_allocations/availability_spec.rb index 5dba5d53692..8bc4927a2f1 100644 --- a/modules/resource_management/spec/services/resource_allocations/availability_spec.rb +++ b/modules/resource_management/spec/services/resource_allocations/availability_spec.rb @@ -160,23 +160,44 @@ RSpec.describe ResourceAllocations::Availability do end end - describe "#working_schedule" do - it "returns the working hours effective on the given date" do - expect(availability.working_schedule(date: monday)) - .to have_attributes(working_days_summary: "Mon-Fri 8h", availability_factor: 100) + describe "#working_schedules" do + it "returns the single schedule covering the whole range" do + expect(availability.working_schedules(monday..friday)) + .to contain_exactly(have_attributes(working_days_summary: "Mon-Fri 8h")) end - it "is nil when the user has no working time configured" do + it "includes schedules taking effect within the range, in order" do + switched = create(:user_working_hours, user:, valid_from: tuesday) + + expect(availability.working_schedules(monday..friday).last).to eq(switched) + expect(availability.working_schedules(monday..friday).size).to eq(2) + end + + it "drops schedules superseded before the range starts" do + create(:user_working_hours, user:, valid_from: Date.new(2024, 1, 1)) + + expect(availability.working_schedules(monday..friday)) + .to contain_exactly(have_attributes(valid_from: Date.new(2025, 1, 1))) + end + + it "excludes schedules taking effect after the range ends" do + create(:user_working_hours, user:, valid_from: Date.new(2027, 1, 1)) + + expect(availability.working_schedules(monday..friday)) + .to contain_exactly(have_attributes(valid_from: Date.new(2025, 1, 1))) + end + + it "starts with a mid-range schedule when none is in effect at the range start" do + newcomer = create(:user) + starting = create(:user_working_hours, user: newcomer, valid_from: tuesday) + + expect(described_class.new(user: newcomer).working_schedules(monday..friday)).to eq([starting]) + end + + it "is empty when the user has no working time configured" do other = described_class.new(user: create(:user)) - expect(other.working_schedule(date: monday)).to be_nil - end - - it "is nil when no schedule is in effect yet on that date" do - future_user = create(:user) - create(:user_working_hours, user: future_user, valid_from: Date.new(2027, 1, 1)) - - expect(described_class.new(user: future_user).working_schedule(date: monday)).to be_nil + expect(other.working_schedules(monday..friday)).to be_empty end end end