#!/usr/bin/env python3 from __future__ import annotations import json import sys from datetime import date, datetime, time, timedelta from pathlib import Path from openpyxl import Workbook from openpyxl.styles import Alignment, Border, Font, PatternFill, Side from openpyxl.utils import get_column_letter CONFIG_FILE = Path("config.json") def parse_date(value: str) -> date: return datetime.strptime(value, "%Y-%m-%d").date() def parse_time(value: str) -> time: return datetime.strptime(value, "%H:%M").time() def get_monday(any_day: date) -> date: return any_day - timedelta(days=any_day.weekday()) def load_config(path: Path) -> dict: if not path.exists(): raise FileNotFoundError(f"Missing config file: {path}") with path.open("r", encoding="utf-8") as handle: data = json.load(handle) required_keys = ["start_time", "end_time", "slot_minutes", "crews"] for key in required_keys: if key not in data: raise ValueError(f"config.json is missing '{key}'") crews = data["crews"] if not isinstance(crews, list) or not crews: raise ValueError("'crews' must be a non-empty array") clean_crews: list[str] = [] for crew in crews: if not isinstance(crew, str): raise ValueError("Each crew name must be a string") crew_name = crew.strip() if not crew_name: raise ValueError("Crew names cannot be empty") clean_crews.append(crew_name) slot_minutes = int(data["slot_minutes"]) if slot_minutes <= 0: raise ValueError("'slot_minutes' must be greater than zero") return { "start_time": parse_time(data["start_time"]), "end_time": parse_time(data["end_time"]), "slot_minutes": slot_minutes, "crews": clean_crews, } def generate_time_slots(start: time, end: time, slot_minutes: int) -> list[str]: start_dt = datetime.combine(date.today(), start) end_dt = datetime.combine(date.today(), end) if end_dt <= start_dt: raise ValueError("'end_time' must be later than 'start_time'") slots: list[str] = [] current = start_dt while current < end_dt: next_slot = current + timedelta(minutes=slot_minutes) slots.append( f"{current.strftime('%I:%M %p')} - {next_slot.strftime('%I:%M %p')}" ) current = next_slot return slots def apply_page_setup(worksheet) -> None: worksheet.page_setup.orientation = "landscape" worksheet.page_setup.fitToWidth = 1 worksheet.page_setup.fitToHeight = 0 worksheet.print_options.horizontalCentered = True worksheet.sheet_view.showGridLines = False worksheet.freeze_panes = "B4" def style_sheet(worksheet, schedule_date: date, crews: list[str], time_slots: list[str]) -> None: thin_side = Side(style="thin", color="000000") border = Border(left=thin_side, right=thin_side, top=thin_side, bottom=thin_side) title_fill = PatternFill(fill_type="solid", fgColor="D9EAF7") header_fill = PatternFill(fill_type="solid", fgColor="EDEDED") title_font = Font(size=18, bold=True) header_font = Font(size=11, bold=True) cell_font = Font(size=10) center = Alignment(horizontal="center", vertical="center", wrap_text=True) top_wrap = Alignment(horizontal="left", vertical="top", wrap_text=True) sheet_title = schedule_date.strftime("%A").upper() sheet_date = schedule_date.strftime("%B %d, %Y") last_col = 1 + len(crews) last_col_letter = get_column_letter(last_col) worksheet.merge_cells(f"A1:{last_col_letter}1") worksheet["A1"] = f"{sheet_title} - {sheet_date}" worksheet["A1"].font = title_font worksheet["A1"].alignment = center worksheet["A1"].fill = title_fill worksheet["A1"].border = border worksheet.row_dimensions[1].height = 28 worksheet["A3"] = "Time Slot" worksheet["A3"].font = header_font worksheet["A3"].alignment = center worksheet["A3"].fill = header_fill worksheet["A3"].border = border for crew_index, crew_name in enumerate(crews, start=2): cell = worksheet.cell(row=3, column=crew_index) cell.value = crew_name cell.font = header_font cell.alignment = center cell.fill = header_fill cell.border = border worksheet.column_dimensions["A"].width = 18 for crew_index in range(2, last_col + 1): worksheet.column_dimensions[get_column_letter(crew_index)].width = 28 row_index = 4 for slot in time_slots: time_cell = worksheet.cell(row=row_index, column=1) time_cell.value = slot time_cell.font = header_font time_cell.alignment = center time_cell.border = border for crew_index in range(2, last_col + 1): cell = worksheet.cell(row=row_index, column=crew_index) cell.value = "" cell.font = cell_font cell.alignment = top_wrap cell.border = border worksheet.row_dimensions[row_index].height = 70 row_index += 1 apply_page_setup(worksheet) def build_workbook(monday: date, crews: list[str], time_slots: list[str]) -> Workbook: workbook = Workbook() default_sheet = workbook.active workbook.remove(default_sheet) for day_offset in range(5): schedule_date = monday + timedelta(days=day_offset) sheet_name = schedule_date.strftime("%A") worksheet = workbook.create_sheet(title=sheet_name) style_sheet(worksheet, schedule_date, crews, time_slots) return workbook def main() -> int: try: input_date = parse_date(sys.argv[1]) if len(sys.argv) > 1 else date.today() monday = get_monday(input_date) config = load_config(CONFIG_FILE) time_slots = generate_time_slots( config["start_time"], config["end_time"], config["slot_minutes"], ) workbook = build_workbook(monday, config["crews"], time_slots) output_path = Path(f"weekly_schedule_{monday.isoformat()}.xlsx") workbook.save(output_path) print(f"Created: {output_path.resolve()}") return 0 except Exception as exc: print(f"Error: {exc}", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())