diff --git a/README.md b/README.md index d9c83fa..9bc1ebe 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,10 @@ The system is inspired by real-world healthcare scheduling challenges in 24/7 de - Interactive tables - Shift explanation viewer - Report export to `.txt` +- Editable calendar (dates × day/evening/night) with staffing status per shift +- Manual add, remove and replace of employees, checked by the scheduler's own rules +- Undo of manual changes (button or Ctrl+Z) with a change history +- PDF export of the current, possibly edited, schedule ### Assignment Transparency @@ -57,9 +61,23 @@ The system explains WHY employees were assigned: # Screenshots -## Main GUI +## Editable calendar -_Add screenshot here later_ +A change that breaks a scheduling rule is refused with every reason (here Jonas is at capacity and would get too little rest after an evening shift): + +![Refused change in the calendar](docs/images/calendar-rule-refused.png) + +After replacing Jonas with Peter on Sunday evening, Jonas can take Monday's open UL shift. The history lists both changes: + +![Edited calendar with change history](docs/images/calendar-edited.png) + +Undo (button or Ctrl+Z) reverts the latest change exactly: + +![Calendar after undo](docs/images/calendar-undo.png) + +## PDF export + +![First page of the exported PDF](docs/images/pdf-export.png) --- @@ -67,6 +85,7 @@ _Add screenshot here later_ - Python 3.12 - Tkinter +- ReportLab (PDF export) - Pytest - CSV-based data import @@ -79,6 +98,9 @@ python-shift-planner-desktop/ │ ├── app/ │ ├── gui.py +│ ├── calendar_view.py +│ ├── schedule_editor.py +│ ├── pdf_export.py │ ├── main.py │ ├── scheduler.py │ ├── models.py @@ -138,6 +160,20 @@ Run tests: pytest ``` +## Editing the schedule + +1. Click **Kør vagtplan** to generate the schedule from the CSV files. +2. Open the **Kalender** tab and click a shift. Green shifts are fully staffed, red shifts miss people, blue is selected. +3. Choose an employee and click **Tilføj**, select someone on the shift and click **Fjern valgte**, or select both and click **Erstat valgte**. The list shows every employee; those who cannot take the shift show why. +4. Every change is checked with the same rules as the scheduler: required skill, maximum number of shifts, one shift per day, and rest time (no shift the day after a night, no day shift after an evening). A refused change shows all reasons and changes nothing. +5. **Fortryd (Ctrl+Z)** reverts the latest change. The other tabs (assigned, unassigned, risk, explanations) update after every change. + +Changes live in memory only: **the CSV files are never written**. Clicking **Kør vagtplan** again rebuilds the plan from the CSV files and asks first if there are manual changes. + +## PDF export + +**Eksportér PDF** saves the current schedule: period, print time, number of manual changes, every shift with its staff (shifts missing people are highlighted) and each employee's shifts and workload. The save dialog asks before replacing an existing file. The PDF is written to a temporary file first and then moved into place, so a failed export never damages an existing file. + --- # CSV Data Format @@ -177,6 +213,9 @@ employee_id,name,night_tolerance,weekend_tolerance,late_tolerance,max_weekly_loa - GUI reporting - Report export - Interactive explanations +- Editable calendar with rule-checked manual changes +- Undo of manual changes +- PDF export --- @@ -184,11 +223,10 @@ employee_id,name,night_tolerance,weekend_tolerance,late_tolerance,max_weekly_loa - SQLite database - Drag-and-drop scheduling -- Calendar view +- Saving an edited schedule (today edits are kept in memory and exported as PDF) - Fairness optimization - AI-assisted scheduling - What-if simulations -- PDF export - Employee preference management - Real-world agreement rules @@ -205,6 +243,11 @@ Current coverage includes: - CSV loading - Personalized workload scoring - Scheduler behavior +- Schedule editing: add, remove and replace through the scheduler's rules, refused changes leaving the schedule untouched, and undo restoring the exact earlier state (including order) +- Calendar grouping by date and shift type +- PDF export: content, no overwrite without permission, an existing file kept when an export fails, and input CSV files unchanged after editing and exporting + +The GUI itself (`calendar_view.py`, `gui.py`) is not covered by automated tests; its rules and state live in `schedule_editor.py`, which is. See [docs/PROJECT_WALKTHROUGH.md](docs/PROJECT_WALKTHROUGH.md) for the desktop demonstration. --- diff --git a/app/calendar_view.py b/app/calendar_view.py new file mode 100644 index 0000000..3b3aab0 --- /dev/null +++ b/app/calendar_view.py @@ -0,0 +1,280 @@ +"""The editable calendar tab: a date × shift type grid and a panel for changing one shift.""" + +import tkinter as tk +from collections.abc import Callable +from datetime import date +from tkinter import ttk + +from app.models import Shift +from app.schedule_editor import ScheduleEditError, ScheduleEditor, build_calendar, describe_blocker, describe_shift + +WEEKDAYS = ["Man", "Tir", "Ons", "Tor", "Fre", "Lør", "Søn"] + +STAFFED_COLOUR = "#d1fae5" +MISSING_COLOUR = "#fee2e2" +SELECTED_COLOUR = "#bfdbfe" +OK_TEXT_COLOUR = "#065f46" +ERROR_TEXT_COLOUR = "#991b1b" + + +class CalendarView(ttk.Frame): + def __init__(self, parent, on_change: Callable[[], None]): + super().__init__(parent) + self.on_change = on_change + self.editor: ScheduleEditor | None = None + self.selected_shift: Shift | None = None + self.shift_buttons: dict[int, tk.Button] = {} + self.candidate_employees = [] + + # The panel is packed first so it keeps its width; the calendar gets the rest and scrolls. + self._create_edit_panel() + self._create_scrollable_grid_area() + tk.Label(self.grid_area, text="Kør vagtplanen for at se kalenderen.").pack(pady=20) + + def _create_scrollable_grid_area(self) -> None: + container = tk.Frame(self) + container.pack(side=tk.LEFT, fill="both", expand=True, padx=(10, 4), pady=6) + + history = tk.LabelFrame(container, text="Ændringer (nyeste først) – fortryd med Ctrl+Z", padx=6, pady=4) + history.pack(side=tk.BOTTOM, fill="x", pady=(6, 0)) + self.history_list = tk.Listbox(history, height=3) + self.history_list.pack(fill="x") + + canvas = tk.Canvas(container, highlightthickness=0) + horizontal = ttk.Scrollbar(container, orient="horizontal", command=canvas.xview) + vertical = ttk.Scrollbar(container, orient="vertical", command=canvas.yview) + canvas.configure(xscrollcommand=horizontal.set, yscrollcommand=vertical.set) + + horizontal.pack(side=tk.BOTTOM, fill="x") + vertical.pack(side=tk.RIGHT, fill="y") + canvas.pack(side=tk.LEFT, fill="both", expand=True) + + self.grid_area = tk.Frame(canvas) + canvas.create_window((0, 0), window=self.grid_area, anchor="nw") + self.grid_area.bind( + "", + lambda event: canvas.configure(scrollregion=canvas.bbox("all")), + ) + + def _create_edit_panel(self) -> None: + panel = tk.LabelFrame(self, text="Redigér vagt", padx=8, pady=6) + panel.pack(side=tk.RIGHT, fill="y", padx=(4, 10), pady=6) + + self.shift_label = tk.Label(panel, text="Vælg en vagt i kalenderen.", font=("Arial", 10, "bold"), + wraplength=250, justify="left") + self.shift_label.pack(anchor="w") + + # Directly under the heading so the result of a change is always visible. + self.message = tk.Label(panel, text="", wraplength=250, justify="left") + self.message.pack(anchor="w", pady=(6, 0)) + + tk.Label(panel, text="På vagten:").pack(anchor="w", pady=(8, 0)) + self.assigned_list = tk.Listbox(panel, height=2, exportselection=False) + self.assigned_list.pack(fill="x") + + self.remove_button = tk.Button(panel, text="Fjern valgte", command=self.remove_selected, state=tk.DISABLED) + self.remove_button.pack(anchor="w", pady=4) + + tk.Label(panel, text="Medarbejder:").pack(anchor="w", pady=(10, 0)) + self.candidate_box = ttk.Combobox(panel, state="readonly", width=34) + self.candidate_box.pack(fill="x") + + buttons = tk.Frame(panel) + buttons.pack(anchor="w", pady=4) + self.add_button = tk.Button(buttons, text="Tilføj", command=self.add_selected, state=tk.DISABLED) + self.add_button.pack(side=tk.LEFT) + self.replace_button = tk.Button(buttons, text="Erstat valgte", command=self.replace_selected, + state=tk.DISABLED) + self.replace_button.pack(side=tk.LEFT, padx=6) + + + def load(self, editor: ScheduleEditor) -> None: + self.editor = editor + self.selected_shift = None + self.show_message("") + self.refresh() + + def clear(self) -> None: + self.editor = None + self.selected_shift = None + self.show_message("") + self.refresh() + + def refresh(self) -> None: + self._draw_grid() + self._update_panel() + + def _draw_grid(self) -> None: + for child in self.grid_area.winfo_children(): + child.destroy() + + self.shift_buttons = {} + + if self.editor is None or not self.editor.shifts: + tk.Label(self.grid_area, text="Kør vagtplanen for at se kalenderen.").pack(pady=20) + return + + dates, shift_types, cells = build_calendar(self.editor.shifts) + table = tk.Frame(self.grid_area) + table.pack(anchor="nw") + + for column, day in enumerate(dates, start=1): + weekday = WEEKDAYS[date.fromisoformat(day).weekday()] + tk.Label(table, text=f"{weekday}\n{day}", font=("Arial", 9, "bold")).grid(row=0, column=column, padx=2) + + for row, shift_type in enumerate(shift_types, start=1): + tk.Label(table, text=shift_type.display_name, font=("Arial", 9, "bold")).grid(row=row, column=0, sticky="e", + padx=4) + + for column, day in enumerate(dates, start=1): + cell_frame = tk.Frame(table, bd=1, relief="solid", width=130, height=70) + cell_frame.grid(row=row, column=column, padx=2, pady=2, sticky="nsew") + cell = cells.get((day, shift_type)) + + if cell is None: + tk.Label(cell_frame, text="–", fg="#9ca3af", width=12).pack(pady=8) + continue + + for shift in cell.shifts: + self._add_shift_button(cell_frame, shift) + + def _add_shift_button(self, parent: tk.Frame, shift: Shift) -> None: + names = ", ".join(employee.name for employee in shift.assigned_employees) or "Ingen" + staffing = f"{len(shift.assigned_employees)}/{shift.required_staff}" + + button = tk.Button( + parent, + text=f"{shift.required_skill} {staffing}\n{names}", + width=12, + wraplength=100, + justify="center", + relief="flat", + bg=self._colour(shift), + command=lambda: self.select_shift(shift), + ) + button.pack(fill="x", padx=2, pady=2) + self.shift_buttons[id(shift)] = button + + def _colour(self, shift: Shift) -> str: + if shift is self.selected_shift: + return SELECTED_COLOUR + + return STAFFED_COLOUR if shift.is_fully_staffed else MISSING_COLOUR + + def select_shift(self, shift: Shift) -> None: + self.selected_shift = shift + self.show_message("") + + for button_shift in self.editor.shifts: + button = self.shift_buttons.get(id(button_shift)) + if button is not None: + button.configure(bg=self._colour(button_shift)) + + self._update_panel() + + def _update_panel(self) -> None: + self.assigned_list.delete(0, tk.END) + self.history_list.delete(0, tk.END) + self.candidate_box.set("") + + if self.editor is not None: + for description in self.editor.history: + self.history_list.insert(tk.END, description) + + shift = self.selected_shift + + if self.editor is None or shift is None: + self.shift_label.configure(text="Vælg en vagt i kalenderen.") + self.candidate_box.configure(values=[]) + for button in (self.remove_button, self.add_button, self.replace_button): + button.configure(state=tk.DISABLED) + return + + self.shift_label.configure( + text=f"{describe_shift(shift)}\nBemanding {len(shift.assigned_employees)}/{shift.required_staff}, " + f"prioritet {shift.priority}" + ) + + for employee in shift.assigned_employees: + self.assigned_list.insert(tk.END, employee.name) + + candidates = self.editor.candidates(shift) + self.candidate_employees = [employee for employee, _ in candidates] + self.candidate_box.configure( + values=[ + employee.name if not blockers + else f"{employee.name} – {', '.join(describe_blocker(blocker) for blocker in blockers)}" + for employee, blockers in candidates + ] + ) + + has_people = bool(shift.assigned_employees) + self.remove_button.configure(state=tk.NORMAL if has_people else tk.DISABLED) + self.replace_button.configure(state=tk.NORMAL if has_people and candidates else tk.DISABLED) + self.add_button.configure(state=tk.NORMAL if candidates else tk.DISABLED) + + def _chosen_candidate(self): + index = self.candidate_box.current() + + if index < 0: + self.show_message("Vælg en medarbejder først.", error=True) + return None + + return self.candidate_employees[index] + + def _chosen_assigned(self): + selection = self.assigned_list.curselection() + + if not selection: + self.show_message("Vælg en medarbejder på vagten først.", error=True) + return None + + return self.selected_shift.assigned_employees[selection[0]] + + def add_selected(self) -> None: + employee = self._chosen_candidate() + + if employee is not None: + self._apply(lambda: self.editor.assign(self.selected_shift, employee), employee.name) + + def remove_selected(self) -> None: + employee = self._chosen_assigned() + + if employee is not None: + self._apply(lambda: self.editor.unassign(self.selected_shift, employee), employee.name) + + def replace_selected(self) -> None: + current = self._chosen_assigned() + replacement = self._chosen_candidate() if current is not None else None + + if current is not None and replacement is not None: + self._apply(lambda: self.editor.replace(self.selected_shift, current, replacement), replacement.name) + + def undo(self) -> None: + if self.editor is None: + return + + edit = self.editor.undo() + + if edit is None: + self.show_message("Der er intet at fortryde.", error=True) + return + + self.refresh() + self.show_message(f"Fortrudt: {edit.description}") + self.on_change() + + def _apply(self, change: Callable, employee_name: str) -> None: + try: + edit = change() + except ScheduleEditError as error: + reasons = ", ".join(describe_blocker(reason) for reason in error.reasons) + self.show_message(f"Ikke tilladt for {employee_name}: {reasons}.", error=True) + return + + self.refresh() + self.show_message(edit.description) + self.on_change() + + def show_message(self, text: str, error: bool = False) -> None: + self.message.configure(text=text, fg=ERROR_TEXT_COLOUR if error else OK_TEXT_COLOUR) diff --git a/app/gui.py b/app/gui.py index 8c830a6..de5d609 100644 --- a/app/gui.py +++ b/app/gui.py @@ -1,6 +1,9 @@ import tkinter as tk +from datetime import datetime from tkinter import filedialog, messagebox, scrolledtext, ttk +from app.calendar_view import CalendarView + from app.config import ( WORKLOAD_RATIO_LIMIT, AVERAGE_SCORE_LIMIT, @@ -14,6 +17,8 @@ load_shifts_from_csv, load_employee_profiles_from_csv, ) +from app.pdf_export import export_schedule_pdf +from app.schedule_editor import ScheduleEditor from app.scheduler import assign_shifts from app.personalized_workload import calculate_personalized_workload_score @@ -148,7 +153,7 @@ class ShiftPlannerApp: def __init__(self, root: tk.Tk): self.root = root self.root.title("Vagtplanlægning") - self.root.geometry("1150x800") + self.root.geometry("1250x820") self.employees_path = tk.StringVar(value=EMPLOYEES_CSV_PATH) self.shifts_path = tk.StringVar(value=SHIFTS_CSV_PATH) @@ -156,6 +161,8 @@ def __init__(self, root: tk.Tk): self.current_shifts = [] self.current_profiles = [] + self.current_employees = [] + self.editor: ScheduleEditor | None = None title = tk.Label( root, @@ -200,9 +207,30 @@ def __init__(self, root: tk.Tk): ) export_button.pack(side=tk.LEFT, padx=5) + pdf_button = tk.Button( + button_frame, + text="Eksportér PDF", + command=self.export_pdf, + width=20, + ) + pdf_button.pack(side=tk.LEFT, padx=5) + + self.undo_button = tk.Button( + button_frame, + text="Fortryd (Ctrl+Z)", + command=self.undo_last_edit, + width=20, + state=tk.DISABLED, + ) + self.undo_button.pack(side=tk.LEFT, padx=5) + root.bind_all("", lambda event: self.undo_last_edit()) + self.tabs = ttk.Notebook(root) self.tabs.pack(expand=True, fill="both", padx=10, pady=10) + self.calendar = CalendarView(self.tabs, on_change=self.refresh_after_edit) + self.tabs.add(self.calendar, text="Kalender") + self.assigned_table = self.create_table_tab( "Tildelte vagter", ["Dato", "Vagt", "Kompetence", "Tildelt", "Prioritet", "Score"], @@ -306,6 +334,16 @@ def create_tab(self, title: str): return output def run_scheduler(self) -> None: + if self.editor is not None and self.editor.edit_count > 0: + confirmed = messagebox.askyesno( + "Kør vagtplan igen?", + f"Vagtplanen bygges forfra fra CSV-filerne, og dine {self.editor.edit_count} " + "manuelle ændringer går tabt. Fortsæt?", + ) + + if not confirmed: + return + try: employees, shifts, employee_profiles = load_schedule_data( employees_path=self.employees_path.get(), @@ -315,18 +353,32 @@ def run_scheduler(self) -> None: self.current_shifts = shifts self.current_profiles = employee_profiles + self.current_employees = employees - self.populate_assigned_table(shifts) - self.populate_unassigned_table(shifts) - self.populate_risk_table(employees, employee_profiles) - self.set_output( - self.explanation_output, - build_explanation_output(shifts, employee_profiles), - ) + # Edits only change the schedule in memory; the CSV files are never written. + self.editor = ScheduleEditor(employees, shifts) + self.calendar.load(self.editor) + self.refresh_after_edit() except Exception as error: messagebox.showerror("Error", str(error)) + def refresh_after_edit(self) -> None: + """Shows the current, possibly edited, schedule in every tab.""" + self.populate_assigned_table(self.current_shifts) + self.populate_unassigned_table(self.current_shifts) + self.populate_risk_table(self.current_employees, self.current_profiles) + self.set_output( + self.explanation_output, + build_explanation_output(self.current_shifts, self.current_profiles), + ) + self.undo_button.configure( + state=tk.NORMAL if self.editor is not None and self.editor.can_undo else tk.DISABLED + ) + + def undo_last_edit(self) -> None: + self.calendar.undo() + def set_output(self, output, text: str) -> None: output.delete("1.0", tk.END) output.insert(tk.END, text) @@ -334,6 +386,10 @@ def set_output(self, output, text: str) -> None: def clear_output(self) -> None: self.current_shifts = [] self.current_profiles = [] + self.current_employees = [] + self.editor = None + self.calendar.clear() + self.undo_button.configure(state=tk.DISABLED) self.assigned_table.delete(*self.assigned_table.get_children()) self.unassigned_table.delete(*self.unassigned_table.get_children()) @@ -533,6 +589,39 @@ def export_report(self) -> None: messagebox.showinfo("Export complete", f"Report saved to:\n{selected_file}") + def export_pdf(self) -> None: + if not self.current_shifts: + messagebox.showinfo("Ingen data", "Kør vagtplanen før du eksporterer en PDF.") + return + + dates = sorted(shift.date for shift in self.current_shifts) + selected_file = filedialog.asksaveasfilename( + title="Gem vagtplan som PDF", + defaultextension=".pdf", + initialfile=f"vagtplan-{dates[0]}-{dates[-1]}.pdf", + filetypes=[("PDF-filer", "*.pdf")], + # The dialog asks before an existing file is replaced. + confirmoverwrite=True, + ) + + if not selected_file: + return + + try: + path = export_schedule_pdf( + selected_file, + self.current_shifts, + self.current_employees, + generated_at=datetime.now(), + manual_edit_count=self.editor.edit_count if self.editor else 0, + overwrite=True, + ) + except (OSError, ValueError) as error: + messagebox.showerror("PDF kunne ikke gemmes", str(error)) + return + + messagebox.showinfo("PDF gemt", f"Vagtplanen er gemt som:\n{path}") + def build_current_risk_report_text(self) -> str: lines = ["PERSONALIZED RISK REPORT", "=" * 50] diff --git a/app/pdf_export.py b/app/pdf_export.py new file mode 100644 index 0000000..9008d2e --- /dev/null +++ b/app/pdf_export.py @@ -0,0 +1,164 @@ +"""PDF export of the current schedule. + +The file is written to a temporary file next to the target and then moved into place, so a failed +export never leaves a half-written PDF or damages an existing file. An existing file is only +replaced when the caller says so (the GUI asks first through the save dialog). +""" + +import os +import tempfile +from datetime import date, datetime +from pathlib import Path +from xml.sax.saxutils import escape + +from reportlab.lib import colors +from reportlab.lib.pagesizes import A4, landscape +from reportlab.lib.styles import getSampleStyleSheet +from reportlab.lib.units import mm +from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle + +from app.models import Employee, Shift +from app.schedule_editor import SHIFT_TYPE_ORDER + +_MISSING_COLOUR = colors.HexColor("#fde2e2") +_HEADER_COLOUR = colors.HexColor("#1f3a5f") + + +def export_schedule_pdf( + target: str | Path, + shifts: list[Shift], + employees: list[Employee], + generated_at: datetime, + manual_edit_count: int = 0, + overwrite: bool = False, + compress: bool = True, +) -> Path: + target = Path(target) + + if target.suffix.lower() != ".pdf": + raise ValueError("The file name must end with .pdf.") + + if target.exists() and not overwrite: + raise FileExistsError(f"{target} already exists.") + + file_descriptor, temporary_name = tempfile.mkstemp(suffix=".pdf.tmp", dir=target.parent) + os.close(file_descriptor) + + try: + _build(Path(temporary_name), shifts, employees, generated_at, manual_edit_count, compress) + os.replace(temporary_name, target) + except BaseException: + Path(temporary_name).unlink(missing_ok=True) + raise + + return target + + +def _sort_key(shift: Shift): + return (date.fromisoformat(shift.date), SHIFT_TYPE_ORDER.index(shift.shift_type), shift.required_skill) + + +def _build( + path: Path, + shifts: list[Shift], + employees: list[Employee], + generated_at: datetime, + manual_edit_count: int, + compress: bool, +) -> None: + styles = getSampleStyleSheet() + ordered = sorted(shifts, key=_sort_key) + missing = [shift for shift in ordered if not shift.is_fully_staffed] + + first_day = ordered[0].date if ordered else "-" + last_day = ordered[-1].date if ordered else "-" + + story = [ + Paragraph("Vagtplan", styles["Title"]), + Paragraph(f"Periode: {first_day} til {last_day}", styles["Normal"]), + Paragraph(f"Udskrevet: {generated_at:%Y-%m-%d %H:%M}", styles["Normal"]), + Paragraph( + f"{len(ordered)} vagter, {len(ordered) - len(missing)} fuldt bemandede, " + f"{len(missing)} mangler personale. Manuelle ændringer: {manual_edit_count}.", + styles["Normal"], + ), + Spacer(1, 6 * mm), + Paragraph("Vagter", styles["Heading2"]), + _schedule_table(ordered, styles["BodyText"]), + Spacer(1, 6 * mm), + Paragraph("Medarbejdere", styles["Heading2"]), + _employee_table(employees), + ] + + document = SimpleDocTemplate( + str(path), + pagesize=landscape(A4), + leftMargin=15 * mm, + rightMargin=15 * mm, + topMargin=15 * mm, + bottomMargin=15 * mm, + title="Vagtplan", + author="Python Shift Planner Desktop", + pageCompression=1 if compress else 0, + ) + document.build(story) + + +def _schedule_table(shifts: list[Shift], cell_style) -> Table: + rows = [["Dato", "Vagt", "Kompetence", "Tildelt", "Bemanding", "Prioritet"]] + missing_rows = [] + + for index, shift in enumerate(shifts, start=1): + names = ", ".join(employee.name for employee in shift.assigned_employees) or "Ingen" + staffing = f"{len(shift.assigned_employees)}/{shift.required_staff}" + + if not shift.is_fully_staffed: + staffing += f" (mangler {shift.missing_staff_count})" + missing_rows.append(index) + + # A paragraph wraps long name lists inside the column. + rows.append( + [shift.date, shift.shift_type.display_name, shift.required_skill, Paragraph(escape(names), cell_style), staffing, str(shift.priority)] + ) + + table = Table(rows, repeatRows=1, colWidths=[28 * mm, 22 * mm, 28 * mm, 110 * mm, 40 * mm, 20 * mm]) + style = [ + ("BACKGROUND", (0, 0), (-1, 0), _HEADER_COLOUR), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ("GRID", (0, 0), (-1, -1), 0.4, colors.grey), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ] + style.extend(("BACKGROUND", (0, row), (-1, row), _MISSING_COLOUR) for row in missing_rows) + table.setStyle(TableStyle(style)) + + return table + + +def _employee_table(employees: list[Employee]) -> Table: + rows = [["Medarbejder", "Vagter", "Maks.", "Belastningsscore", "Kompetencer"]] + + for employee in sorted(employees, key=lambda item: item.name.casefold()): + rows.append( + [ + employee.name, + str(employee.assigned_shift_count), + str(employee.max_shifts), + str(employee.total_workload_score), + ", ".join(employee.skills), + ] + ) + + table = Table(rows, repeatRows=1, colWidths=[50 * mm, 22 * mm, 22 * mm, 38 * mm, 80 * mm]) + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), _HEADER_COLOUR), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ("GRID", (0, 0), (-1, -1), 0.4, colors.grey), + ] + ) + ) + + return table diff --git a/app/schedule_editor.py b/app/schedule_editor.py new file mode 100644 index 0000000..e76c0e0 --- /dev/null +++ b/app/schedule_editor.py @@ -0,0 +1,226 @@ +"""Manual changes to a generated schedule, validated by the same rules as the scheduler, with undo. + +The editor changes the in-memory Shift and Employee objects only. It never writes to the CSV +files the schedule was loaded from. +""" + +from dataclasses import dataclass, field +from datetime import date + +from app.constants import ShiftType +from app.models import Employee, Shift + +SHIFT_TYPE_ORDER = [ShiftType.DAY, ShiftType.EVENING, ShiftType.NIGHT] + + +class ScheduleEditError(ValueError): + """Raised when a change would break a scheduling rule. `reasons` lists every broken rule.""" + + def __init__(self, reasons: list[str]): + super().__init__("; ".join(reasons)) + self.reasons = reasons + + +@dataclass +class _Placement: + """Where an assignment sits in both lists, so undo can put it back in the same position.""" + + shift: Shift + employee: Employee + shift_index: int + employee_index: int + + +@dataclass +class ScheduleEdit: + description: str + removed: list[_Placement] = field(default_factory=list) + added: list[_Placement] = field(default_factory=list) + + +_BLOCKER_TEXTS = { + "at capacity": "har nået sit maksimale antal vagter", + "already has shift same day": "har allerede en vagt samme dag", + "rest time violation": "får for kort hviletid (nat eller aften før en vagt)", + "shift is fully staffed": "vagten er fuldt bemandet", + "already assigned to this shift": "er allerede på vagten", + "not assigned to this shift": "er ikke på vagten", +} + + +def describe_blocker(blocker: str) -> str: + """Danish text for a rule the change would break, as shown in the GUI.""" + if blocker.startswith("missing skill "): + return f"mangler kompetencen {blocker.removeprefix('missing skill ')}" + + return _BLOCKER_TEXTS.get(blocker, blocker) + + +def describe_shift(shift: Shift) -> str: + return f"{shift.date} {shift.shift_type.display_name} ({shift.required_skill})" + + +class ScheduleEditor: + def __init__(self, employees: list[Employee], shifts: list[Shift]): + self.employees = employees + self.shifts = shifts + self._undo_stack: list[ScheduleEdit] = [] + + @property + def can_undo(self) -> bool: + return bool(self._undo_stack) + + @property + def history(self) -> list[str]: + """Descriptions of the edits that can be undone, newest first.""" + return [edit.description for edit in reversed(self._undo_stack)] + + @property + def edit_count(self) -> int: + return len(self._undo_stack) + + def assignment_blockers(self, shift: Shift, employee: Employee) -> list[str]: + """Every reason the employee cannot be added to the shift; empty when the change is allowed.""" + if employee in shift.assigned_employees: + return ["already assigned to this shift"] + + blockers = [] + + if shift.is_fully_staffed: + blockers.append("shift is fully staffed") + + # The scheduler's own checks: capacity, skill, same day and rest time. + blockers.extend(employee.get_shift_blockers(shift)) + + return blockers + + def candidates(self, shift: Shift) -> list[tuple[Employee, list[str]]]: + """All employees not on the shift, each with the reasons they cannot take it (if any).""" + return [ + (employee, self.assignment_blockers(shift, employee)) + for employee in self.employees + if employee not in shift.assigned_employees + ] + + def assign(self, shift: Shift, employee: Employee) -> ScheduleEdit: + blockers = self.assignment_blockers(shift, employee) + + if blockers: + raise ScheduleEditError(blockers) + + edit = ScheduleEdit(description=f"Tilføjede {employee.name} til {describe_shift(shift)}") + edit.added.append(self._add(shift, employee)) + self._undo_stack.append(edit) + + return edit + + def unassign(self, shift: Shift, employee: Employee) -> ScheduleEdit: + if employee not in shift.assigned_employees: + raise ScheduleEditError(["not assigned to this shift"]) + + edit = ScheduleEdit(description=f"Fjernede {employee.name} fra {describe_shift(shift)}") + edit.removed.append(self._remove(shift, employee)) + self._undo_stack.append(edit) + + return edit + + def replace(self, shift: Shift, current: Employee, replacement: Employee) -> ScheduleEdit: + """Swaps one employee on a shift for another; the replacement is checked as if the current one had left.""" + if current not in shift.assigned_employees: + raise ScheduleEditError(["not assigned to this shift"]) + + removed = self._remove(shift, current) + blockers = self.assignment_blockers(shift, replacement) + + if blockers: + self._restore(removed) + raise ScheduleEditError(blockers) + + edit = ScheduleEdit( + description=f"Erstattede {current.name} med {replacement.name} på {describe_shift(shift)}", + removed=[removed], + added=[self._add(shift, replacement)], + ) + self._undo_stack.append(edit) + + return edit + + def undo(self) -> ScheduleEdit | None: + """Reverts the latest edit exactly, including the order of the assignment lists.""" + if not self._undo_stack: + return None + + edit = self._undo_stack.pop() + + for placement in reversed(edit.added): + placement.shift.assigned_employees.remove(placement.employee) + placement.employee.assigned_shifts.remove(placement.shift) + + for placement in reversed(edit.removed): + self._restore(placement) + + return edit + + def clear_history(self) -> None: + """Called when a new schedule is generated: earlier edits belong to the old schedule.""" + self._undo_stack.clear() + + @staticmethod + def _add(shift: Shift, employee: Employee) -> _Placement: + placement = _Placement( + shift=shift, + employee=employee, + shift_index=len(shift.assigned_employees), + employee_index=len(employee.assigned_shifts), + ) + shift.assigned_employees.append(employee) + employee.assigned_shifts.append(shift) + + return placement + + @staticmethod + def _remove(shift: Shift, employee: Employee) -> _Placement: + placement = _Placement( + shift=shift, + employee=employee, + shift_index=shift.assigned_employees.index(employee), + employee_index=employee.assigned_shifts.index(shift), + ) + shift.assigned_employees.pop(placement.shift_index) + employee.assigned_shifts.pop(placement.employee_index) + + return placement + + @staticmethod + def _restore(placement: _Placement) -> None: + placement.shift.assigned_employees.insert(placement.shift_index, placement.employee) + placement.employee.assigned_shifts.insert(placement.employee_index, placement.shift) + + +@dataclass +class CalendarCell: + date: str + shift_type: ShiftType + shifts: list[Shift] + + @property + def missing_staff(self) -> int: + return sum(max(shift.missing_staff_count, 0) for shift in self.shifts) + + +def build_calendar(shifts: list[Shift]) -> tuple[list[str], list[ShiftType], dict[tuple[str, ShiftType], CalendarCell]]: + """Groups shifts into a date × shift type grid for the calendar view. + + Returns the dates in order, the shift types in day order, and the cells that have shifts. + """ + dates = sorted({shift.date for shift in shifts}, key=date.fromisoformat) + cells: dict[tuple[str, ShiftType], CalendarCell] = {} + + for shift in shifts: + key = (shift.date, shift.shift_type) + cells.setdefault(key, CalendarCell(shift.date, shift.shift_type, [])).shifts.append(shift) + + for cell in cells.values(): + cell.shifts.sort(key=lambda shift: (shift.priority, shift.required_skill)) + + return dates, SHIFT_TYPE_ORDER, cells diff --git a/docs/PROJECT_WALKTHROUGH.md b/docs/PROJECT_WALKTHROUGH.md new file mode 100644 index 0000000..3e82211 --- /dev/null +++ b/docs/PROJECT_WALKTHROUGH.md @@ -0,0 +1,107 @@ +# Python Shift Planner Desktop — project walkthrough + +A developer-level guide to the system and, in depth, to the editable calendar, undo and PDF export. It describes only what exists in the repository. + +## 1. System overview + +```text +CSV files (data/) ──► csv_loader ──► Employee / Shift / EmployeeProfile (models.py) + │ + ▼ + scheduler.assign_shifts rule-based assignment + │ + ┌───────────────────┼───────────────────────┐ + ▼ ▼ ▼ + gui.ShiftPlannerApp schedule_editor pdf_export + (Tkinter tabs) (manual changes, undo) (ReportLab) + │ + └─ calendar_view.CalendarView (Kalender tab) +``` + +The scheduler's rules live on `Employee`: `can_take_shift` and `get_shift_blockers` check capacity, required skill, one shift per day, and rest time (no shift the day after a night; no day shift the morning after an evening). + +## 2. Editable calendar, undo and PDF export + +### Problem + +A generated schedule is a starting point. Planners need to correct it by hand without breaking the rules the scheduler respects, take back a mistake, and hand the result out as a document. + +### `app/schedule_editor.py` (no Tk, fully tested) + +- `ScheduleEditor(employees, shifts)` changes the same objects the GUI displays. +- `assignment_blockers(shift, employee)` = "already assigned", "shift is fully staffed", plus `employee.get_shift_blockers(shift)`. **The scheduler's rules are reused, not copied**, so a rule change applies to both automatic and manual planning. +- `assign`, `unassign`, `replace` raise `ScheduleEditError(reasons)` and change nothing when a rule would break. `replace` removes the current employee first, checks the replacement against that state, and restores the original if the check fails. +- **Undo (command pattern):** each edit records `_Placement`s: the shift, the employee and their positions in both lists. `undo()` removes what was added and re-inserts what was removed at the original positions, so the state, including list order, is exactly as before. A test compares full snapshots of both sides of every assignment. +- `build_calendar(shifts)` groups shifts into a date × shift type grid. +- `describe_blocker` turns rule codes into Danish text for the GUI. + +### `app/calendar_view.py` (Tk) + +- The edit panel is packed before the grid, so it keeps its width; the grid sits in a scrollable canvas. +- Cells show skill, staffing (`1/2`) and names; green = fully staffed, red = missing staff, blue = selected. +- The employee list shows every candidate, with the reasons for those who cannot take the shift, so the planner sees *why* before trying. +- After each change the view calls back into `ShiftPlannerApp.refresh_after_edit`, which refreshes the other tabs and the undo button. + +### `app/pdf_export.py` + +- ReportLab (BSD licence) builds an A4 landscape document: period, print time, number of manual changes, all shifts (rows missing staff highlighted) and an employee table. +- **File safety:** the name must end in `.pdf`; an existing file is refused unless `overwrite=True`; the PDF is written to a temporary file in the same folder and moved into place with `os.replace`, so a failure leaves any existing file untouched and no temporary file behind. The GUI passes `overwrite=True` only after the native save dialog (`confirmoverwrite=True`) has asked the user. +- Names are escaped before going into ReportLab paragraphs, which interpret markup. + +### Input files are never written + +Edits change objects in memory only. "Kør vagtplan" rebuilds from the CSV files and asks for confirmation when there are manual changes, so work is not lost silently. A test hashes the three CSV files before and after editing and exporting. + +## 3. Testing + +| File | What it proves | +| --- | --- | +| `tests/test_schedule_editor.py` (19) | assignments go through the scheduler's rules (skill, capacity, same day, rest time), full shifts, duplicates, candidates with reasons, replace semantics, refused changes leave the state unchanged, undo order and completeness, calendar grouping, Danish rule text | +| `tests/test_pdf_export.py` (5) | PDF content (period, change count, names), no overwrite without permission, existing file kept and no temp file left when the build fails, `.pdf` required, CSV inputs unchanged | +| existing tests (48) | scheduler, models, CSV loading, personalised workload | + +Total: **72 passed** locally (Python 3.12). CI runs Python 3.12 and 3.13 on Ubuntu. The tests do not import Tkinter, so they need no display. + +Not automated: the Tk widgets. They contain no rules; they call the tested editor. + +## 4. Desktop demonstration (1 November 2026, Windows 11, Python 3.12, Tk 8.6) + +The real application window was opened and driven by a script (`invoke()` on the real buttons, which runs the same code as a click). Only the modal save dialog and message boxes were replaced. Screenshots are of the actual window: + +1. "Kør vagtplan": 12 shifts; Monday 2026-05-11 Day UL is unstaffed (1 row in "Ubesatte vagter"). +2. Adding Jonas to it was refused: "Ikke tilladt for Jonas: har nået sit maksimale antal vagter, får for kort hviletid (nat eller aften før en vagt)." +3. Replacing Jonas with Peter on Sunday evening succeeded; then Jonas could take Monday's shift. "Ubesatte vagter" became empty, and the history showed both changes. +4. "Eksportér PDF" (dialog options included `confirmoverwrite: True`) wrote a 2-page PDF: "12 vagter, 12 fuldt bemandede … Manuelle ændringer: 2." +5. "Fortryd" reverted the last change; Monday was unstaffed again. +6. The SHA-256 of all three CSV files was unchanged. + +The demo also found two layout problems in the first version, both fixed: the edit panel could be pushed out of the window at the default width, and the change history was cut off. + +## 5. Limitations + +- An edited schedule cannot be saved for later (only exported as PDF). +- Undo has no redo. +- The calendar is a grid, not drag-and-drop. +- The GUI is not DPI-aware on Windows; at high display scaling it is scaled by Windows. + +## 6. Ownership and AI assistance + +Implemented with AI assistance (Claude Code). The owner defined the requirements (editable calendar, undo, PDF export, reuse of validation, no overwriting of user files) and reviews and merges. + +## 7. Interview preparation + +**Non-technical:** After the plan is generated you can click any shift, swap or add people, and the program stops changes that break the rest and skill rules, telling you why. You can undo, and print the plan as a PDF. + +**Developer:** `ScheduleEditor` reuses `Employee.get_shift_blockers`, records each change as a command with list positions, and undo replays it backwards. The Tk view only calls the editor and redraws. + +**Senior:** Rules have a single source of truth shared by automatic and manual planning. State changes are reversible and atomic (a refused replace restores itself). File output is atomic and never overwrites without consent. The GUI is a thin layer, so the logic is tested without a display. + +**Questions and answers** + +1. *Why the command pattern instead of saving a copy of the whole schedule for undo?* Each edit touches one or two assignments; recording them is small and exact, and it keeps object identity (the GUI holds references to the same objects). +2. *Why check the replacement after removing the current employee?* Otherwise a full shift or the current person's own shift would block every replacement. +3. *How do you avoid overwriting a user's file?* The function refuses existing files unless told otherwise; the GUI only says so after the OS dialog asked; the write goes through a temporary file and an atomic rename. +4. *Why ReportLab?* Mature, BSD-licensed, pure Python with wheels, and good table layout. It is the only new runtime dependency. +5. *What is not tested and why is that acceptable?* The Tk widgets. They hold no rules; the editor they call is tested, and the window was demonstrated on Windows. + +**Hands-on exercise:** Add redo: keep a second stack, push to it in `undo()`, clear it on every new edit, add `redo()` with tests, and bind Ctrl+Y in the GUI. diff --git a/docs/images/calendar-edited.png b/docs/images/calendar-edited.png new file mode 100644 index 0000000..ad65da2 Binary files /dev/null and b/docs/images/calendar-edited.png differ diff --git a/docs/images/calendar-rule-refused.png b/docs/images/calendar-rule-refused.png new file mode 100644 index 0000000..51a8821 Binary files /dev/null and b/docs/images/calendar-rule-refused.png differ diff --git a/docs/images/calendar-undo.png b/docs/images/calendar-undo.png new file mode 100644 index 0000000..556d6ce Binary files /dev/null and b/docs/images/calendar-undo.png differ diff --git a/docs/images/pdf-export.png b/docs/images/pdf-export.png new file mode 100644 index 0000000..8af3067 Binary files /dev/null and b/docs/images/pdf-export.png differ diff --git a/requirements.txt b/requirements.txt index 92438f9..559121b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ -pytest>=9.0.0 \ No newline at end of file +pytest>=9.0.0 +reportlab>=5.1,<6 diff --git a/tests/test_pdf_export.py b/tests/test_pdf_export.py new file mode 100644 index 0000000..a64a4aa --- /dev/null +++ b/tests/test_pdf_export.py @@ -0,0 +1,93 @@ +import hashlib +from datetime import datetime +from pathlib import Path + +import pytest + +import app.pdf_export as pdf_export +from app.config import EMPLOYEE_PROFILES_CSV_PATH, EMPLOYEES_CSV_PATH, SHIFTS_CSV_PATH +from app.csv_loader import load_employee_profiles_from_csv, load_employees_from_csv, load_shifts_from_csv +from app.pdf_export import export_schedule_pdf +from app.schedule_editor import ScheduleEditor +from app.scheduler import assign_shifts + +GENERATED_AT = datetime(2026, 11, 1, 10, 30) + + +def load_sample_schedule(): + employees = load_employees_from_csv(EMPLOYEES_CSV_PATH) + shifts = assign_shifts( + employees, + load_shifts_from_csv(SHIFTS_CSV_PATH), + employee_profiles=load_employee_profiles_from_csv(EMPLOYEE_PROFILES_CSV_PATH), + ) + return employees, shifts + + +def test_export_writes_a_pdf_with_the_schedule(tmp_path): + employees, shifts = load_sample_schedule() + target = tmp_path / "vagtplan.pdf" + + result = export_schedule_pdf(target, shifts, employees, GENERATED_AT, manual_edit_count=2, compress=False) + + content = target.read_bytes() + assert result == target + assert content.startswith(b"%PDF-") + assert b"Vagtplan" in content + assert b"Periode: 2026-05-05 til 2026-05-11" in content + assert b"Manuelle \\346ndringer: 2." in content # "æ" in the PDF's WinAnsi text encoding + for employee in employees: + assert employee.name.encode() in content + + +def test_export_refuses_to_overwrite_unless_asked(tmp_path): + employees, shifts = load_sample_schedule() + target = tmp_path / "vagtplan.pdf" + target.write_bytes(b"existing report") + + with pytest.raises(FileExistsError): + export_schedule_pdf(target, shifts, employees, GENERATED_AT) + + assert target.read_bytes() == b"existing report" + + export_schedule_pdf(target, shifts, employees, GENERATED_AT, overwrite=True) + + assert target.read_bytes().startswith(b"%PDF-") + + +def test_a_failed_export_keeps_the_existing_file_and_leaves_no_temporary_file(tmp_path, monkeypatch): + employees, shifts = load_sample_schedule() + target = tmp_path / "vagtplan.pdf" + target.write_bytes(b"existing report") + + def failing_build(path, *args): + Path(path).write_bytes(b"half written") + raise RuntimeError("disk full") + + monkeypatch.setattr(pdf_export, "_build", failing_build) + + with pytest.raises(RuntimeError): + export_schedule_pdf(target, shifts, employees, GENERATED_AT, overwrite=True) + + assert target.read_bytes() == b"existing report" + assert [path.name for path in tmp_path.iterdir()] == ["vagtplan.pdf"] + + +def test_export_requires_a_pdf_file_name(tmp_path): + employees, shifts = load_sample_schedule() + + with pytest.raises(ValueError): + export_schedule_pdf(tmp_path / "data" / "shifts.csv", shifts, employees, GENERATED_AT) + + +def test_editing_and_exporting_never_change_the_input_files(tmp_path): + inputs = [Path(EMPLOYEES_CSV_PATH), Path(SHIFTS_CSV_PATH), Path(EMPLOYEE_PROFILES_CSV_PATH)] + before = {path: hashlib.sha256(path.read_bytes()).hexdigest() for path in inputs} + + employees, shifts = load_sample_schedule() + editor = ScheduleEditor(employees, shifts) + shift = next(shift for shift in shifts if shift.assigned_employees) + editor.unassign(shift, shift.assigned_employees[0]) + export_schedule_pdf(tmp_path / "vagtplan.pdf", shifts, employees, GENERATED_AT, editor.edit_count) + + assert {path: hashlib.sha256(path.read_bytes()).hexdigest() for path in inputs} == before diff --git a/tests/test_schedule_editor.py b/tests/test_schedule_editor.py new file mode 100644 index 0000000..d8ea8c4 --- /dev/null +++ b/tests/test_schedule_editor.py @@ -0,0 +1,215 @@ +import pytest + +from app.constants import ShiftType +from app.models import Employee, Shift +from app.schedule_editor import ScheduleEditError, ScheduleEditor, build_calendar, describe_blocker + + +def make_schedule(): + anna = Employee(name="Anna", max_shifts=2, skills=["CT"]) + kris = Employee(name="Kris", max_shifts=3, skills=["CT", "MR"]) + peter = Employee(name="Peter", max_shifts=3, skills=["MR"]) + + monday_day = Shift(date="2026-05-11", shift_type=ShiftType.DAY, required_skill="CT", required_staff=2) + monday_night = Shift(date="2026-05-11", shift_type=ShiftType.NIGHT, required_skill="MR") + tuesday_day = Shift(date="2026-05-12", shift_type=ShiftType.DAY, required_skill="CT") + + editor = ScheduleEditor([anna, kris, peter], [monday_day, monday_night, tuesday_day]) + + return editor, anna, kris, peter, monday_day, monday_night, tuesday_day + + +def snapshot(editor: ScheduleEditor): + """The full assignment state, including list order, for exact comparisons.""" + return ( + [[employee.name for employee in shift.assigned_employees] for shift in editor.shifts], + [[id(shift) for shift in employee.assigned_shifts] for employee in editor.employees], + ) + + +def test_assign_adds_the_employee_on_both_sides(): + editor, anna, _, _, monday_day, _, _ = make_schedule() + + edit = editor.assign(monday_day, anna) + + assert monday_day.assigned_employees == [anna] + assert anna.assigned_shifts == [monday_day] + assert edit.description == "Tilføjede Anna til 2026-05-11 Dag (CT)" + assert editor.can_undo + + +@pytest.mark.parametrize( + ("setup", "employee_name", "shift_index", "expected"), + [ + ("none", "Peter", 0, "missing skill CT"), + ("kris_monday_day", "Kris", 1, "already has shift same day"), + ("kris_monday_night", "Kris", 2, "rest time violation"), + ("anna_two_shifts", "Anna", 0, "at capacity"), + ], +) +def test_assign_reuses_the_scheduler_rules(setup, employee_name, shift_index, expected): + editor, anna, kris, peter, monday_day, monday_night, tuesday_day = make_schedule() + extra_day = Shift(date="2026-05-13", shift_type=ShiftType.DAY, required_skill="CT") + extra_day_2 = Shift(date="2026-05-14", shift_type=ShiftType.DAY, required_skill="CT") + + if setup == "kris_monday_day": + editor.assign(monday_day, kris) + elif setup == "kris_monday_night": + editor.assign(monday_night, kris) + elif setup == "anna_two_shifts": + editor.assign(extra_day, anna) + editor.assign(extra_day_2, anna) + + employee = {"Anna": anna, "Kris": kris, "Peter": peter}[employee_name] + shift = [monday_day, monday_night, tuesday_day][shift_index] + before = snapshot(editor) + + with pytest.raises(ScheduleEditError) as error: + editor.assign(shift, employee) + + assert expected in error.value.reasons + assert snapshot(editor) == before + + +def test_a_fully_staffed_shift_cannot_take_more_people(): + editor, anna, kris, peter, monday_day, monday_night, _ = make_schedule() + editor.assign(monday_night, peter) + + with pytest.raises(ScheduleEditError) as error: + editor.assign(monday_night, kris) + + assert error.value.reasons == ["shift is fully staffed"] + + +def test_assigning_the_same_employee_twice_is_refused(): + editor, anna, _, _, monday_day, _, _ = make_schedule() + editor.assign(monday_day, anna) + + with pytest.raises(ScheduleEditError) as error: + editor.assign(monday_day, anna) + + assert error.value.reasons == ["already assigned to this shift"] + + +def test_candidates_list_every_employee_not_on_the_shift_with_their_blockers(): + editor, anna, kris, peter, monday_day, _, _ = make_schedule() + editor.assign(monday_day, anna) + + candidates = {employee.name: blockers for employee, blockers in editor.candidates(monday_day)} + + assert candidates == {"Kris": [], "Peter": ["missing skill CT"]} + + +def test_replace_checks_the_replacement_as_if_the_current_employee_had_left(): + editor, _, kris, peter, _, monday_night, _ = make_schedule() + editor.assign(monday_night, peter) + + # The shift is full, but Kris may take Peter's place. + edit = editor.replace(monday_night, peter, kris) + + assert monday_night.assigned_employees == [kris] + assert peter.assigned_shifts == [] + assert edit.description == "Erstattede Peter med Kris på 2026-05-11 Nat (MR)" + + +def test_a_refused_replacement_leaves_the_schedule_unchanged(): + editor, anna, _, peter, _, monday_night, _ = make_schedule() + editor.assign(monday_night, peter) + before = snapshot(editor) + + with pytest.raises(ScheduleEditError) as error: + editor.replace(monday_night, peter, anna) + + assert error.value.reasons == ["missing skill MR"] + assert snapshot(editor) == before + assert editor.edit_count == 1 + + +def test_undo_reverts_edits_one_at_a_time_and_restores_list_order(): + editor, anna, kris, peter, monday_day, monday_night, tuesday_day = make_schedule() + editor.assign(monday_day, anna) + editor.assign(monday_day, kris) + editor.assign(monday_night, peter) + after_assignments = snapshot(editor) + + editor.unassign(monday_day, anna) + editor.assign(tuesday_day, anna) + + assert editor.history == [ + "Tilføjede Anna til 2026-05-12 Dag (CT)", + "Fjernede Anna fra 2026-05-11 Dag (CT)", + "Tilføjede Peter til 2026-05-11 Nat (MR)", + "Tilføjede Kris til 2026-05-11 Dag (CT)", + "Tilføjede Anna til 2026-05-11 Dag (CT)", + ] + + editor.undo() + editor.undo() + + # Anna is back in front of Kris, exactly as before the removal. + assert snapshot(editor) == after_assignments + assert monday_day.assigned_employees == [anna, kris] + + while editor.can_undo: + editor.undo() + + assert all(not shift.assigned_employees for shift in editor.shifts) + assert all(not employee.assigned_shifts for employee in editor.employees) + assert editor.undo() is None + + +def test_undoing_a_replacement_brings_back_the_original_employee(): + editor, _, kris, peter, _, monday_night, _ = make_schedule() + editor.assign(monday_night, peter) + before = snapshot(editor) + + editor.replace(monday_night, peter, kris) + undone = editor.undo() + + assert undone.description.startswith("Erstattede Peter med Kris") + assert snapshot(editor) == before + + +def test_unassigning_someone_not_on_the_shift_is_refused(): + editor, anna, _, _, monday_day, _, _ = make_schedule() + + with pytest.raises(ScheduleEditError): + editor.unassign(monday_day, anna) + + assert not editor.can_undo + + +def test_clear_history_forgets_edits_without_changing_the_schedule(): + editor, anna, _, _, monday_day, _, _ = make_schedule() + editor.assign(monday_day, anna) + + editor.clear_history() + + assert not editor.can_undo + assert monday_day.assigned_employees == [anna] + + +def test_build_calendar_groups_shifts_by_date_and_type(): + _, anna, _, _, monday_day, monday_night, tuesday_day = make_schedule() + second_monday_day = Shift(date="2026-05-11", shift_type="Day", required_skill="MR", priority=1) + + dates, shift_types, cells = build_calendar([tuesday_day, monday_night, monday_day, second_monday_day]) + + assert dates == ["2026-05-11", "2026-05-12"] + assert shift_types == [ShiftType.DAY, ShiftType.EVENING, ShiftType.NIGHT] + assert cells[("2026-05-11", ShiftType.DAY)].shifts == [second_monday_day, monday_day] + assert cells[("2026-05-11", ShiftType.DAY)].missing_staff == 3 + assert ("2026-05-12", ShiftType.EVENING) not in cells + + +@pytest.mark.parametrize( + ("blocker", "text"), + [ + ("missing skill CT", "mangler kompetencen CT"), + ("rest time violation", "får for kort hviletid (nat eller aften før en vagt)"), + ("shift is fully staffed", "vagten er fuldt bemandet"), + ("something new", "something new"), + ], +) +def test_blockers_are_described_in_danish(blocker, text): + assert describe_blocker(blocker) == text