diff --git a/gen_proc.py b/gen_proc.py index 6dd9937..2a82003 100644 --- a/gen_proc.py +++ b/gen_proc.py @@ -49,7 +49,7 @@ def main( time_to_run: int = 60 * 60 * 4, ratio: float = 0.001, start_date: datetime.datetime = "2026-03-02", - weeks: int = 12, + weeks: int = 26, bom: int = 2, ): rota_start_date = start_date.date() @@ -276,7 +276,7 @@ def main( ) # Prevent shifts for ST2s for 2 weeks - Rota.add_grade_constraint_by_week([2], [1, 2], [shift.name for shift in Rota.shifts]) + #Rota.add_grade_constraint_by_week([2], [1, 2], [shift.name for shift in Rota.shifts]) Rota.add_min_summed_grade_by_shifts_per_day_constraint(["weekend_plymouth1", "weekend_plymouth2"], 6) Rota.add_min_summed_grade_by_shifts_per_day_constraint(["plymouth_twilight", "plymouth_bank_holidays"], 6) @@ -463,10 +463,12 @@ def main( print(w) Rota.add_worker(w) + leave.load_academy(Rota) # Rota.build_workers() # Rota.build_model() + solver_options = {"ratio": ratio, "seconds": time_to_run, "threads": 10} # solver_options = {"seconds": time_to_run, "threads": 10} diff --git a/leave.py b/leave.py index 107e33a..72ab3fd 100644 --- a/leave.py +++ b/leave.py @@ -1,5 +1,5 @@ import csv -from datetime import datetime +from datetime import datetime, timedelta import re import sys from requests import Session @@ -264,4 +264,92 @@ def load_academy(Rota): """ Some trainees spend month blocks in the academy. This function loads that information from a google sheet that published it as a csv file. """ - url = "https://docs.google.com/spreadsheets/d/e/2PACX-1vQtNrp9F1fjtAh8vFX-W_R3RlD9H-2P_vCLMoHrVKR0e24yabbsdrD65VjwxoHlV1Qnatmw1NeghQHG/pub?gid=1113837782&single=true&output=csv" \ No newline at end of file + url = "https://docs.google.com/spreadsheets/d/e/2PACX-1vQtNrp9F1fjtAh8vFX-W_R3RlD9H-2P_vCLMoHrVKR0e24yabbsdrD65VjwxoHlV1Qnatmw1NeghQHG/pub?gid=1113837782&single=true&output=csv" + + with Session() as s: + headers = {"Cache-Control": "no-cache", "Pragma": "no-cache"} + download = s.get(url, headers=headers) + decoded = download.content.decode("utf-8") + + reader = csv.reader(decoded.splitlines(), delimiter=',') + + n = 0 + names = [] + # collect date rows (these are the month start boundaries) and their cells + date_rows: list[datetime.date] = [] + date_row_cells: list[list[str]] = [] + + for row in reader: + n += 1 + if n == 1: + # header row: first cell is column title, remaining are worker names + names = [c.strip() for c in row[1:]] + continue + + if not row: + continue + + row_title = row[0].strip() + cells = row[1:] + + # If this row is a date row like '02/03/2026' then treat it as a month-start boundary + if re.match(date_re, row_title): + try: + try: + d = datetime.strptime(row_title, "%d/%m/%Y").date() + except ValueError: + d = datetime.strptime(row_title, "%d/%m/%y").date() + except Exception: + continue + + date_rows.append(d) + # normalize cells length to match names + row_cells = [ (cells[i] if i < len(cells) else "").strip() for i in range(len(names)) ] + date_row_cells.append(row_cells) + + # If no date rows found, nothing to do + if not date_rows: + return + + # Identify twilight shift names available in the rota + twilight_shift_names = [s.name for s in Rota.get_shifts() if "twilight" in s.name] + + # For each date boundary, compute its end (next boundary - 1 day) and register avoids where cell=='academy' + for idx, start_date in enumerate(date_rows): + if idx + 1 < len(date_rows): + end_date = date_rows[idx + 1] - timedelta(days=1) + else: + end_date = Rota.rota_end_date + + cells = date_row_cells[idx] + for i, cell in enumerate(cells): + if i >= len(names): + break + v = (cell or "").lower() + if "academy" in v: + worker_name = names[i] + matching_workers = [w for w in Rota.workers if w.name.strip() == worker_name.strip()] + if not matching_workers: + logger.warning(f"Academy data: worker '{worker_name}' not found in rota, skipping") + continue + worker = matching_workers[0] + + # choose twilight shifts relevant to the worker's site + candidate_shifts = [] + for sh_name in twilight_shift_names: + try: + sh = Rota.get_shift_by_name(sh_name) + except Exception: + continue + if hasattr(worker, "site") and worker.site in sh.sites: + candidate_shifts.append(sh_name) + elif sh_name.startswith(f"{worker.site}_"): + candidate_shifts.append(sh_name) + + if not candidate_shifts: + candidate_shifts = twilight_shift_names.copy() + + # register as a date range with a reason 'academy' + Rota.add_avoid_shifts_for_workers_on_dates( + names=[worker.name], shifts=candidate_shifts, start_date=start_date, end_date=end_date, reason="academy" + ) \ No newline at end of file diff --git a/output/timetable.js b/output/timetable.js index 89547a7..9231436 100644 --- a/output/timetable.js +++ b/output/timetable.js @@ -259,6 +259,52 @@ function generateExtra() { $(table).before(summary_button); + // Add per-table avoid-toggle button (near the summary button) + avoid_button = $("").click((evt) => { + // ensure style exists once + if ($('#avoid-shift-style').length === 0) { + $('head').append(` + + `); + } + + let $btn = $(evt.target); + let enabled = $btn.data('enabled') || false; + enabled = !enabled; + $btn.data('enabled', enabled); + + let $table = $(table); + $table.find("td.rota-day.avoid-shift").each((i, el) => { + let $el = $(el); + if (enabled) { + $el.addClass('avoid-highlight'); + // preserve original title + if (!$el.data('orig-title')) $el.data('orig-title', $el.attr('title') || ''); + let avoid_shifts = $el.attr('data-avoid-shifts') || ''; + let avoid_reason = $el.attr('data-avoid-reason') || ''; + let suffix = []; + // make the constraint type explicit in the tooltip + suffix.push(`constraint: avoid_shifts_by_worker_dates`); + if (avoid_shifts) suffix.push(`shifts: ${avoid_shifts}`); + if (avoid_reason) suffix.push(`reason: ${avoid_reason}`); + if (suffix.length) { + let newTitle = $el.data('orig-title') + ' [' + suffix.join('; ') + ']'; + $el.attr('title', newTitle); + } + } else { + $el.removeClass('avoid-highlight'); + // restore original title + if ($el.data('orig-title') !== undefined) $el.attr('title', $el.data('orig-title')); + } + }); + + $btn.text(enabled ? 'Hide Avoids' : 'Toggle Avoids'); + }); + + $(table).before(avoid_button); + }); @@ -281,6 +327,8 @@ $("#toggle-prior-adjust").on("click", (evt) => { $("table.tsummary").find(`td.prior-adjust, th.prior-adjust-col`).toggle(); }); +// Note: per-table avoid-toggle buttons are added next to each table's summary button inside generateExtra(). + $("table.tsummary").append("