Files
CrewChronicle/generate_week_schedule_xlsx.py
2026-03-10 11:55:30 -04:00

206 lines
6.2 KiB
Python

#!/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())