diff --git a/output/timetable.js b/output/timetable.js index 791f546..8cf4859 100644 --- a/output/timetable.js +++ b/output/timetable.js @@ -422,12 +422,11 @@ function renderWorkerList() { const displayName = (w.display_name || '') + ''; const site = (w.site || '') + ''; const searchName = (displayName || name).toLowerCase(); - if ( - filter - && searchName.indexOf(filter) === -1 - && name.toLowerCase().indexOf(filter) === -1 - && site.toLowerCase().indexOf(filter) === -1 - ) return; + if (filter) { + const matchesName = searchName.indexOf(filter) !== -1; + const matchesSite = site.toLowerCase().indexOf(filter) !== -1; + if (!matchesName && !matchesSite) return; + } // if shiftFilter, ensure worker has target or assigned for that shift // attempt to read per-worker targets and counts from DOM if not present in JSON let targets = w.shift_target_number || {}; @@ -451,21 +450,112 @@ function renderWorkerList() { } const fte = w.fte || ''; const labelName = displayName || name; - const card = $(`
${labelName} ${site}
FTE: ${fte}
`); + const grade = w.grade || ''; + const id = w.id ? String(w.id).substring(0, 8) : ''; + const card = $(`
${labelName} ${id}
${site}Grade: ${grade}FTE: ${fte}%
`); // show shift targets summary - if (w.shift_target_number) { - const tbl = $("
"); + if (w.shift_target_number && Object.keys(w.shift_target_number).length > 0) { + const tbl = $("
"); + const header = $("ShiftAssignedTarget"); + tbl.append(header); + const tbody = $(""); Object.keys(w.shift_target_number).forEach(s => { const t = w.shift_target_number[s]; const c = (w.shift_counts && w.shift_counts[s]) ? w.shift_counts[s] : 0; - tbl.append(`${s}${c} assignedtarget ${parseFloat(t).toFixed(2)}`); + tbody.append(`${s}${c}${parseFloat(t).toFixed(2)}`); }); + tbl.append(tbody); card.append(tbl); } $list.append(card); }); } +// Add CSS for worker details styling and dark mode +if ($('#worker-details-style').length === 0) { + $('head').append(``); +} + // initialize panels renderShiftSettings(); renderWorkerFilterOptions(); diff --git a/rota_generator/shifts.py b/rota_generator/shifts.py index 55af49a..4653e3d 100644 --- a/rota_generator/shifts.py +++ b/rota_generator/shifts.py @@ -2405,33 +2405,59 @@ class RotaBuilder(object): for constraint in worker.max_days_per_week_block: for week_blocks in self.get_week_block_iterator(constraint.week_block): - self.model.constraints.add( - sum( - self.model.works_day[worker.id, week, day] - for week, day in self.get_week_day_combinations() - if week in week_blocks + # Determine which (week, day) combinations to ignore + ignored_week_days = set() + for ignore_date in (constraint.ignore_dates or []): + try: + w, d = self.date_week_day_map.get(ignore_date, (None, None)) + if w is not None and d is not None: + ignored_week_days.add((w, d)) + except Exception: + pass + + included_days = [ + (week, day) + for week, day in self.get_week_day_combinations() + if week in week_blocks and (week, day) not in ignored_week_days + ] + if included_days: + self.model.constraints.add( + sum( + self.model.works_day[worker.id, week, day] + for week, day in included_days + ) + <= constraint.max_days ) - <= constraint.max_days - ) for constraint in worker.max_unique_shifts_per_week_block: for week_blocks in self.get_week_block_iterator(constraint.week_block): if not week_blocks: continue + # Determine which (week, day) combinations to ignore + ignored_week_days = set() + for ignore_date in (constraint.ignore_dates or []): + try: + w, d = self.date_week_day_map.get(ignore_date, (None, None)) + if w is not None and d is not None: + ignored_week_days.add((w, d)) + except Exception: + pass + block_start_week = week_blocks[0] used_shift_vars = [] for shift_name in self.get_shift_names(): - shift_assignments = [ - self.model.works[worker.id, week, day, shift_name] - for week, day in self.get_week_day_combinations() - if week in week_blocks - and shift_name in self.get_shift_names_by_week_day( - week, - day, - return_empty_if_week_day_not_found=True, - ) - ] + shift_assignments = [] + for week, day in self.get_week_day_combinations(): + if week in week_blocks and (week, day) not in ignored_week_days: + if shift_name in self.get_shift_names_by_week_day( + week, + day, + return_empty_if_week_day_not_found=True, + ): + shift_assignments.append( + self.model.works[worker.id, week, day, shift_name] + ) if not shift_assignments: continue diff --git a/rota_generator/workers.py b/rota_generator/workers.py index 92559a8..8186772 100644 --- a/rota_generator/workers.py +++ b/rota_generator/workers.py @@ -146,6 +146,10 @@ class MaxDaysPerWeekBlockConstraint(BaseModel): ge=1, description="Length of the sliding week block (for example, 2 means every consecutive 2-week window).", ) + ignore_dates: list[datetime.date] = Field( + default_factory=list, + description="List of specific dates to exclude from this constraint.", + ) class MaxUniqueShiftsPerWeekBlockConstraint(BaseModel): @@ -159,6 +163,10 @@ class MaxUniqueShiftsPerWeekBlockConstraint(BaseModel): ge=1, description="Length of the sliding week block (for example, 2 means every consecutive 2-week window).", ) + ignore_dates: list[datetime.date] = Field( + default_factory=list, + description="List of specific dates to exclude from this constraint.", + ) class Worker(BaseModel): diff --git a/test/test_shifts.py b/test/test_shifts.py index e3ec13b..84e364e 100644 --- a/test/test_shifts.py +++ b/test/test_shifts.py @@ -161,6 +161,101 @@ def test_worker_block_constraint_validation(): with pytest.raises(ValidationError): MaxUniqueShiftsPerWeekBlockConstraint(max_unique_shifts=0, week_block=1) + +def test_worker_max_days_per_week_block_pass(): + """Positive test: constraint allowing reasonable days.""" + Rota = generate_basic_rota() + # Add constraint allowing up to 4 days per week + Rota.workers[0].max_days_per_week_block = [ + MaxDaysPerWeekBlockConstraint(max_days=4, week_block=1) + ] + # Add shifts to the rota + Rota.add_shifts( + SingleShift( + sites=("group1", "group2"), + name="a", + length=12.5, + days=days, + ) + ) + Rota.build_and_solve(options={"ratio": 0.000}) + assert Rota.results.solver.status == "ok" + assert Rota.results.solver.termination_condition == "optimal" + + +def test_worker_max_unique_shifts_per_week_block_pass(): + """Positive test: allowing multiple shifts within week block.""" + Rota = generate_basic_rota() + # Add constraint allowing up to 2 unique shifts per week + Rota.workers[0].max_unique_shifts_per_week_block = [ + MaxUniqueShiftsPerWeekBlockConstraint(max_unique_shifts=2, week_block=1) + ] + # Add shifts to the rota + Rota.add_shifts( + SingleShift( + sites=("group1", "group2"), + name="a", + length=12.5, + days=days, + ) + ) + Rota.build_and_solve(options={"ratio": 0.000}) + assert Rota.results.solver.status == "ok" + assert Rota.results.solver.termination_condition == "optimal" + + +def test_worker_max_days_per_week_block_with_ignore_dates(): + """Test max_days_per_week_block with ignored dates works without error.""" + Rota = generate_basic_rota() + rota_start = Rota.start_date + ignored_date = rota_start # Ignore first Monday + # Add constraint with ignore_dates + Rota.workers[0].max_days_per_week_block = [ + MaxDaysPerWeekBlockConstraint( + max_days=3, + week_block=1, + ignore_dates=[ignored_date], + ) + ] + # Add shifts to the rota + Rota.add_shifts( + SingleShift( + sites=("group1", "group2"), + name="a", + length=12.5, + days=days, + ) + ) + Rota.build_and_solve(options={"ratio": 0.000}) + assert Rota.results.solver.status == "ok" + + +def test_worker_max_unique_shifts_per_week_block_with_ignore_dates(): + """Test max_unique_shifts_per_week_block with ignored dates works without error.""" + Rota = generate_basic_rota() + rota_start = Rota.start_date + ignored_date = rota_start + datetime.timedelta(days=1) # Ignore Tuesday + # Add constraint with ignore_dates + Rota.workers[0].max_unique_shifts_per_week_block = [ + MaxUniqueShiftsPerWeekBlockConstraint( + max_unique_shifts=1, + week_block=1, + ignore_dates=[ignored_date], + ) + ] + # Add shifts to the rota + Rota.add_shifts( + SingleShift( + sites=("group1", "group2"), + name="a", + length=12.5, + days=days, + ) + ) + Rota.build_and_solve(options={"ratio": 0.000}) + # This should still be feasible with only one shift type in main rota + assert Rota.results.solver.status == "ok" + def test_pre_shift_constraint(): Rota = generate_basic_rota() for i, d in enumerate(days[:6]):