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 = $(`${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 = $("| Shift | Assigned | Target |
");
+ 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} assigned | target ${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]):