When the allocation spans over multiple schedules, also show this in the warning

This commit is contained in:
Klaus Zanders
2026-06-10 12:44:09 +02:00
parent c2b3ec89cc
commit caefdd0ab0
6 changed files with 93 additions and 33 deletions
@@ -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)
@@ -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)
@@ -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<UserWorkingHours>]
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
@@ -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
@@ -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),
@@ -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