From 23529ba441e16dfe282355b5c0ee5b70f60ff84f Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 29 Dec 2025 22:22:29 +0000 Subject: [PATCH] add ics export support --- output/block_shifts/timetable.css | 29 ++- output/block_shifts/timetable.js | 305 ++++++++++++++++++++++++++++ output/timetable.css | 29 ++- output/timetable.js | 327 ++++++++++++++++++++++++++++++ test_rota.py | 158 +++++++++++++++ 5 files changed, 840 insertions(+), 8 deletions(-) create mode 100644 test_rota.py diff --git a/output/block_shifts/timetable.css b/output/block_shifts/timetable.css index 8fda8b5..11e56f8 100644 --- a/output/block_shifts/timetable.css +++ b/output/block_shifts/timetable.css @@ -1,6 +1,26 @@ html { + --bg-color: white; + --text-color: black; + --table-bg: #f9f9f9; + --border-color: #ddd; + --header-bg: #e0e0e0; + --highlight-color: #ffff99; + --worker-bg: #f0f0f0; +} - /* color: red; */ +html.dark-mode { + --bg-color: #121212; + --text-color: #e0e0e0; + --table-bg: #1e1e1e; + --border-color: #333; + --header-bg: #2a2a2a; + --highlight-color: #333300; + --worker-bg: #2a2a2a; +} + +body { + background-color: var(--bg-color); + color: var(--text-color); } table { @@ -14,7 +34,8 @@ table { position: relative; */ border-collapse: separate; border-spacing: 0; - + background-color: var(--table-bg); + border: 1px solid var(--border-color); } #main-table { @@ -72,12 +93,12 @@ th { } .table-div tr:hover { - background-color: lightblue; + background-color: var(--highlight-color); } .table-div tr:hover .unavailable { - background-color: lightgray; + background-color: var(--worker-bg); } .Sat { diff --git a/output/block_shifts/timetable.js b/output/block_shifts/timetable.js index a980ff9..112736a 100644 --- a/output/block_shifts/timetable.js +++ b/output/block_shifts/timetable.js @@ -513,6 +513,311 @@ $("#gen-table").each(function () { }); }); +// ICS Export functionality +let customShiftTimes = {}; // Will store user-defined times + +function getShiftTimes() { + // Merge default with custom + const defaultTimes = { + 'night_weekday': { start: '20:00', end: '08:15', nextDay: true }, + 'night_weekend': { start: '20:00', end: '08:15', nextDay: true }, + 'exeter_twilight': { start: '14:00', end: '02:30', nextDay: true }, + 'truro_twilight': { start: '14:00', end: '02:30', nextDay: true }, + 'torbay_twilight': { start: '14:00', end: '02:30', nextDay: true }, + 'plymouth_twilight': { start: '14:00', end: '02:30', nextDay: true }, + 'weekend_exeter': { start: '08:00', end: '20:30', nextDay: false }, + 'weekend_truro': { start: '08:00', end: '20:30', nextDay: false }, + 'weekend_torbay': { start: '08:00', end: '20:30', nextDay: false }, + 'weekend_plymouth1': { start: '08:00', end: '16:00', nextDay: false }, + 'weekend_plymouth2': { start: '16:00', end: '00:00', nextDay: false }, + 'plymouth_bank_holidays': { start: '08:00', end: '16:00', nextDay: false }, + }; + return { ...defaultTimes, ...customShiftTimes }; +} + +function generateICS(shiftType, shiftData) { + let icsContent = `BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Rota Generator//EN +`; + + const shiftTimes = getShiftTimes(); + const time = shiftTimes[shiftType] || { start: '09:00', end: '17:00', nextDay: false }; + + shiftData.forEach(entry => { + const date = entry.date; + const workerName = entry.worker; + + const startDate = new Date(date + 'T' + time.start + ':00'); + let endDate = new Date(date + 'T' + time.end + ':00'); + if (time.nextDay) { + endDate.setDate(endDate.getDate() + 1); + } + + const formatDate = (d) => d.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; + + const shiftTitle = shiftType.replace(/_/g, ' ').toUpperCase(); + const summary = workerName ? `${shiftTitle} - ${workerName}` : shiftTitle; + const description = workerName ? + `Shift: ${shiftType}\nWorker: ${workerName}` : + `Shift: ${shiftType}`; + + icsContent += `BEGIN:VEVENT +UID:${workerName ? workerName + '-' : ''}${shiftType}-${date}@rota +DTSTAMP:${formatDate(new Date())} +DTSTART:${formatDate(startDate)} +DTEND:${formatDate(endDate)} +SUMMARY:${summary} +DESCRIPTION:${description} +END:VEVENT +`; + }); + + icsContent += 'END:VCALENDAR'; + return icsContent; +} + +function downloadICS(filename, content) { + const blob = new Blob([content], { type: 'text/calendar;charset=utf-8' }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} + +// Collect shift dates and worker assignments +const shiftDates = {}; +const workerShifts = {}; + +$('#main-table tr.worker-row').each(function() { + const workerName = $(this).find('td.worker').attr('data-worker'); + if (workerName) { + workerShifts[workerName] = {}; + + $(this).find('td.rota-day').each(function() { + const shift = $(this).attr('data-shift'); + const date = $(this).attr('data-date'); + if (shift && shift !== '' && date) { + const shifts = shift.split(',').map(s => s.trim()).filter(s => s); + shifts.forEach(s => { + // Collect by shift type with worker info + if (!shiftDates[s]) { + shiftDates[s] = []; + } + // Check if we already have this date for this shift type + const existingEntry = shiftDates[s].find(entry => entry.date === date); + if (!existingEntry) { + shiftDates[s].push({ date: date, worker: workerName }); + } + + // Collect by worker + if (!workerShifts[workerName][s]) { + workerShifts[workerName][s] = []; + } + if (!workerShifts[workerName][s].includes(date)) { + workerShifts[workerName][s].push(date); + } + }); + } + }); + } +}); + +// Add ICS export buttons +const icsButtonsDiv = $('

Export ICS Files

'); +const setTimesButton = $(``); +icsButtonsDiv.append(setTimesButton); + +// Shift type exports (with worker info) +const shiftExportsDiv = $('

Export by Shift Type

'); +Object.keys(shiftDates).sort().forEach(shiftType => { + const button = $(``); + button.on('click', () => { + const ics = generateICS(shiftType, shiftDates[shiftType]); + downloadICS(`${shiftType}.ics`, ics); + }); + shiftExportsDiv.append(button); +}); +icsButtonsDiv.append(shiftExportsDiv); + +// Worker exports +const workerExportsDiv = $('

Export by Worker

'); +Object.keys(workerShifts).sort().forEach(workerName => { + const workerShiftTypes = Object.keys(workerShifts[workerName]); + if (workerShiftTypes.length > 0) { + const button = $(``); + button.on('click', () => { + showWorkerShiftSelectionModal(workerName, workerShifts[workerName]); + }); + workerExportsDiv.append(button); + } +}); +icsButtonsDiv.append(workerExportsDiv); + +$('#export-div').append(icsButtonsDiv); + +// Function to show worker shift selection modal +function showWorkerShiftSelectionModal(workerName, workerShiftData) { + const checkboxesDiv = $('#worker-shift-checkboxes'); + checkboxesDiv.empty(); + + $('#worker-shift-modal-title').text(`Select Shifts for ${workerName}`); + + // Create checkboxes for each shift type + Object.keys(workerShiftData).sort().forEach(shiftType => { + const dates = workerShiftData[shiftType]; + const checkboxId = `shift-${shiftType}`; + const checkbox = $(` +
+ + +
+ `); + checkboxesDiv.append(checkbox); + }); + + // Store worker data for export + $('#worker-shift-export').data('workerName', workerName); + $('#worker-shift-export').data('workerShiftData', workerShiftData); + + // Show modal + workerShiftModal.show(); +} + +// Modal for worker shift selection +const workerShiftModal = $(` + +`); +$('body').append(workerShiftModal); + +// Modal for setting shift times +const shiftTimesModal = $(` + +`); +$("body").append(shiftTimesModal); + +$('#set-shift-times').on('click', function() { + $('#shift-times-modal').show(); + populateShiftTimesModal(); +}); + +$('#close-shift-times').on('click', function() { + $('#shift-times-modal').hide(); +}); + +$('#save-shift-times').on('click', function() { + saveShiftTimes(); + $('#shift-times-modal').hide(); +}); + +// Worker shift modal event handlers +$('#worker-shift-cancel').on('click', function() { + workerShiftModal.hide(); +}); + +$('#worker-shift-export').on('click', function() { + const workerName = $(this).data('workerName'); + const workerShiftData = $(this).data('workerShiftData'); + + // Get selected shift types + const selectedShifts = []; + $('#worker-shift-checkboxes input[type="checkbox"]:checked').each(function() { + const shiftType = $(this).attr('id').replace('shift-', ''); + selectedShifts.push(shiftType); + }); + + if (selectedShifts.length === 0) { + alert('Please select at least one shift type to export.'); + return; + } + + // Generate ICS for selected shifts + let icsContent = `BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Rota Generator//EN +`; + + selectedShifts.forEach(shiftType => { + const dates = workerShiftData[shiftType]; + const shiftTimes = getShiftTimes(); + const time = shiftTimes[shiftType] || { start: '09:00', end: '17:00', nextDay: false }; + + dates.forEach(date => { + const startDate = new Date(date + 'T' + time.start + ':00'); + let endDate = new Date(date + 'T' + time.end + ':00'); + if (time.nextDay) { + endDate.setDate(endDate.getDate() + 1); + } + + const formatDate = (d) => d.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; + + const shiftTitle = shiftType.replace(/_/g, ' ').toUpperCase(); + + icsContent += `BEGIN:VEVENT +UID:${workerName}-${shiftType}-${date}@rota +DTSTAMP:${formatDate(new Date())} +DTSTART:${formatDate(startDate)} +DTEND:${formatDate(endDate)} +SUMMARY:${shiftTitle} - ${workerName} +DESCRIPTION:Shift: ${shiftType}\nWorker: ${workerName} +END:VEVENT +`; + }); + }); + + icsContent += 'END:VCALENDAR'; + downloadICS(`${workerName.replace(/[^a-zA-Z0-9]/g, '_')}.ics`, icsContent); + + workerShiftModal.hide(); +}); + +function populateShiftTimesModal() { + const controls = $('#shift-times-controls'); + controls.empty(); + const shiftTimes = getShiftTimes(); + Object.keys(shiftDates).sort().forEach(shiftType => { + const time = shiftTimes[shiftType] || { start: '09:00', end: '17:00', nextDay: false }; + const div = $(` +
+
+ + + +
+ `); + controls.append(div); + }); +} + +function saveShiftTimes() { + customShiftTimes = {}; + $('#shift-times-controls .shift-start').each(function() { + const shift = $(this).data('shift'); + const start = $(this).val(); + const end = $(`input.shift-end[data-shift="${shift}"]`).val(); + const nextDay = $(`input.shift-nextday[data-shift="${shift}"]`).is(':checked'); + customShiftTimes[shift] = { start, end, nextDay }; + }); +} + function hsv2rgb(h, s, v) { // adapted from http://schinckel.net/2012/01/10/hsv-to-rgb-in-javascript/ var rgb, i, data = []; diff --git a/output/timetable.css b/output/timetable.css index fe8a251..cfed804 100644 --- a/output/timetable.css +++ b/output/timetable.css @@ -1,6 +1,26 @@ html { + --bg-color: white; + --text-color: black; + --table-bg: #f9f9f9; + --border-color: #ddd; + --header-bg: #e0e0e0; + --highlight-color: #ffff99; + --worker-bg: #f0f0f0; +} - /* color: red; */ +html.dark-mode { + --bg-color: #121212; + --text-color: #e0e0e0; + --table-bg: #1e1e1e; + --border-color: #333; + --header-bg: #2a2a2a; + --highlight-color: #333300; + --worker-bg: #2a2a2a; +} + +body { + background-color: var(--bg-color); + color: var(--text-color); } table { @@ -14,7 +34,8 @@ table { position: relative; */ border-collapse: separate; border-spacing: 0; - + background-color: var(--table-bg); + border: 1px solid var(--border-color); } #main-table { @@ -72,12 +93,12 @@ th { } #main-table tr:hover { - background-color: lightblue; + background-color: var(--highlight-color); } #main-table tr:hover .unavailable { - background-color: lightgray; + background-color: var(--worker-bg); } .Sat { diff --git a/output/timetable.js b/output/timetable.js index 1d54b9f..89547a7 100644 --- a/output/timetable.js +++ b/output/timetable.js @@ -1162,6 +1162,311 @@ $("#gen-table").each(function () { }); }); +// ICS Export functionality +let customShiftTimes = {}; // Will store user-defined times + +function getShiftTimes() { + // Merge default with custom + const defaultTimes = { + 'night_weekday': { start: '20:00', end: '08:15', nextDay: true }, + 'night_weekend': { start: '20:00', end: '08:15', nextDay: true }, + 'exeter_twilight': { start: '14:00', end: '02:30', nextDay: true }, + 'truro_twilight': { start: '14:00', end: '02:30', nextDay: true }, + 'torbay_twilight': { start: '14:00', end: '02:30', nextDay: true }, + 'plymouth_twilight': { start: '14:00', end: '02:30', nextDay: true }, + 'weekend_exeter': { start: '08:00', end: '20:30', nextDay: false }, + 'weekend_truro': { start: '08:00', end: '20:30', nextDay: false }, + 'weekend_torbay': { start: '08:00', end: '20:30', nextDay: false }, + 'weekend_plymouth1': { start: '08:00', end: '16:00', nextDay: false }, + 'weekend_plymouth2': { start: '16:00', end: '00:00', nextDay: false }, + 'plymouth_bank_holidays': { start: '08:00', end: '16:00', nextDay: false }, + }; + return { ...defaultTimes, ...customShiftTimes }; +} + +function generateICS(shiftType, shiftData) { + let icsContent = `BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Rota Generator//EN +`; + + const shiftTimes = getShiftTimes(); + const time = shiftTimes[shiftType] || { start: '09:00', end: '17:00', nextDay: false }; + + shiftData.forEach(entry => { + const date = entry.date; + const workerName = entry.worker; + + const startDate = new Date(date + 'T' + time.start + ':00'); + let endDate = new Date(date + 'T' + time.end + ':00'); + if (time.nextDay) { + endDate.setDate(endDate.getDate() + 1); + } + + const formatDate = (d) => d.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; + + const shiftTitle = shiftType.replace(/_/g, ' ').toUpperCase(); + const summary = workerName ? `${shiftTitle} - ${workerName}` : shiftTitle; + const description = workerName ? + `Shift: ${shiftType}\nWorker: ${workerName}` : + `Shift: ${shiftType}`; + + icsContent += `BEGIN:VEVENT +UID:${workerName ? workerName + '-' : ''}${shiftType}-${date}@rota +DTSTAMP:${formatDate(new Date())} +DTSTART:${formatDate(startDate)} +DTEND:${formatDate(endDate)} +SUMMARY:${summary} +DESCRIPTION:${description} +END:VEVENT +`; + }); + + icsContent += 'END:VCALENDAR'; + return icsContent; +} + +function downloadICS(filename, content) { + const blob = new Blob([content], { type: 'text/calendar;charset=utf-8' }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} + +// Collect shift dates and worker assignments +const shiftDates = {}; +const workerShifts = {}; + +$('#main-table tr.worker-row').each(function() { + const workerName = $(this).find('td.worker').attr('data-worker'); + if (workerName) { + workerShifts[workerName] = {}; + + $(this).find('td.rota-day').each(function() { + const shift = $(this).attr('data-shift'); + const date = $(this).attr('data-date'); + if (shift && shift !== '' && date) { + const shifts = shift.split(',').map(s => s.trim()).filter(s => s); + shifts.forEach(s => { + // Collect by shift type with worker info + if (!shiftDates[s]) { + shiftDates[s] = []; + } + // Check if we already have this date for this shift type + const existingEntry = shiftDates[s].find(entry => entry.date === date); + if (!existingEntry) { + shiftDates[s].push({ date: date, worker: workerName }); + } + + // Collect by worker + if (!workerShifts[workerName][s]) { + workerShifts[workerName][s] = []; + } + if (!workerShifts[workerName][s].includes(date)) { + workerShifts[workerName][s].push(date); + } + }); + } + }); + } +}); + +// Add ICS export buttons +const icsButtonsDiv = $('

Export ICS Files

'); +const setTimesButton = $(``); +icsButtonsDiv.append(setTimesButton); + +// Shift type exports (with worker info) +const shiftExportsDiv = $('

Export by Shift Type

'); +Object.keys(shiftDates).sort().forEach(shiftType => { + const button = $(``); + button.on('click', () => { + const ics = generateICS(shiftType, shiftDates[shiftType]); + downloadICS(`${shiftType}.ics`, ics); + }); + shiftExportsDiv.append(button); +}); +icsButtonsDiv.append(shiftExportsDiv); + +// Worker exports +const workerExportsDiv = $('

Export by Worker

'); +Object.keys(workerShifts).sort().forEach(workerName => { + const workerShiftTypes = Object.keys(workerShifts[workerName]); + if (workerShiftTypes.length > 0) { + const button = $(``); + button.on('click', () => { + showWorkerShiftSelectionModal(workerName, workerShifts[workerName]); + }); + workerExportsDiv.append(button); + } +}); +icsButtonsDiv.append(workerExportsDiv); + +$('#export-div').append(icsButtonsDiv); + +// Function to show worker shift selection modal +function showWorkerShiftSelectionModal(workerName, workerShiftData) { + const checkboxesDiv = $('#worker-shift-checkboxes'); + checkboxesDiv.empty(); + + $('#worker-shift-modal-title').text(`Select Shifts for ${workerName}`); + + // Create checkboxes for each shift type + Object.keys(workerShiftData).sort().forEach(shiftType => { + const dates = workerShiftData[shiftType]; + const checkboxId = `shift-${shiftType}`; + const checkbox = $(` +
+ + +
+ `); + checkboxesDiv.append(checkbox); + }); + + // Store worker data for export + $('#worker-shift-export').data('workerName', workerName); + $('#worker-shift-export').data('workerShiftData', workerShiftData); + + // Show modal + workerShiftModal.show(); +} + +// Modal for worker shift selection +const workerShiftModal = $(` + +`); +$('body').append(workerShiftModal); + +// Modal for setting shift times +const shiftTimesModal = $(` + +`); +$("body").append(shiftTimesModal); + +$('#set-shift-times').on('click', function() { + $('#shift-times-modal').show(); + populateShiftTimesModal(); +}); + +$('#close-shift-times').on('click', function() { + $('#shift-times-modal').hide(); +}); + +$('#save-shift-times').on('click', function() { + saveShiftTimes(); + $('#shift-times-modal').hide(); +}); + +// Worker shift modal event handlers +$('#worker-shift-cancel').on('click', function() { + workerShiftModal.hide(); +}); + +$('#worker-shift-export').on('click', function() { + const workerName = $(this).data('workerName'); + const workerShiftData = $(this).data('workerShiftData'); + + // Get selected shift types + const selectedShifts = []; + $('#worker-shift-checkboxes input[type="checkbox"]:checked').each(function() { + const shiftType = $(this).attr('id').replace('shift-', ''); + selectedShifts.push(shiftType); + }); + + if (selectedShifts.length === 0) { + alert('Please select at least one shift type to export.'); + return; + } + + // Generate ICS for selected shifts + let icsContent = `BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Rota Generator//EN +`; + + selectedShifts.forEach(shiftType => { + const dates = workerShiftData[shiftType]; + const shiftTimes = getShiftTimes(); + const time = shiftTimes[shiftType] || { start: '09:00', end: '17:00', nextDay: false }; + + dates.forEach(date => { + const startDate = new Date(date + 'T' + time.start + ':00'); + let endDate = new Date(date + 'T' + time.end + ':00'); + if (time.nextDay) { + endDate.setDate(endDate.getDate() + 1); + } + + const formatDate = (d) => d.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; + + const shiftTitle = shiftType.replace(/_/g, ' ').toUpperCase(); + + icsContent += `BEGIN:VEVENT +UID:${workerName}-${shiftType}-${date}@rota +DTSTAMP:${formatDate(new Date())} +DTSTART:${formatDate(startDate)} +DTEND:${formatDate(endDate)} +SUMMARY:${shiftTitle} - ${workerName} +DESCRIPTION:Shift: ${shiftType}\nWorker: ${workerName} +END:VEVENT +`; + }); + }); + + icsContent += 'END:VCALENDAR'; + downloadICS(`${workerName.replace(/[^a-zA-Z0-9]/g, '_')}.ics`, icsContent); + + workerShiftModal.hide(); +}); + +function populateShiftTimesModal() { + const controls = $('#shift-times-controls'); + controls.empty(); + const shiftTimes = getShiftTimes(); + Object.keys(shiftDates).sort().forEach(shiftType => { + const time = shiftTimes[shiftType] || { start: '09:00', end: '17:00', nextDay: false }; + const div = $(` +
+
+ + + +
+ `); + controls.append(div); + }); +} + +function saveShiftTimes() { + customShiftTimes = {}; + $('#shift-times-controls .shift-start').each(function() { + const shift = $(this).data('shift'); + const start = $(this).val(); + const end = $(`input.shift-end[data-shift="${shift}"]`).val(); + const nextDay = $(`input.shift-nextday[data-shift="${shift}"]`).is(':checked'); + customShiftTimes[shift] = { start, end, nextDay }; + }); +} + function hsv2rgb(h, s, v) { // adapted from http://schinckel.net/2012/01/10/hsv-to-rgb-in-javascript/ var rgb, i, data = []; @@ -1398,4 +1703,26 @@ daysOfWeek.forEach(day => { $(function() { updateAllHighlights(); +}); + +// --- Dark Mode Toggle --- +$(function() { + // Add dark mode toggle button to the page + const darkModeButton = $(''); + $('body').append(darkModeButton); + + // Check for saved theme preference or default to light mode + const currentTheme = localStorage.getItem('theme') || 'light'; + if (currentTheme === 'dark') { + $('html').addClass('dark-mode'); + darkModeButton.text('☀️ Light Mode'); + } + + // Toggle dark mode + darkModeButton.on('click', function() { + $('html').toggleClass('dark-mode'); + const isDark = $('html').hasClass('dark-mode'); + localStorage.setItem('theme', isDark ? 'dark' : 'light'); + $(this).text(isDark ? '☀️ Light Mode' : '🌙 Dark Mode'); + }); }); \ No newline at end of file diff --git a/test_rota.py b/test_rota.py new file mode 100644 index 0000000..e5bc94b --- /dev/null +++ b/test_rota.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +""" +Test script to generate a small rota for testing ICS export functionality. +""" + +import datetime +import os +import sys +from pathlib import Path + +# Add the project root to Python path +sys.path.insert(0, str(Path(__file__).parent)) + +from rota_generator.shifts import RotaBuilder, SingleShift, WorkerRequirement, days, NightConstraint, PreShiftConstraint +from rota_generator.workers import Worker, NotAvailableToWork, NonWorkingDays + +def create_test_rota(): + """Create a small test rota with a few shifts and workers.""" + + # Define sites + sites = ("truro", "exeter", "plymouth") + + # Create rota builder + rota_start_date = datetime.date(2025, 12, 29) # Start from today-ish + Rota = RotaBuilder( + rota_start_date, + weeks_to_rota=4, # Small rota for testing + balance_offset_modifier=2, + name="test_rota", + ) + + # Add some shifts + Rota.add_shifts( + SingleShift( + sites=("truro", "exeter", "plymouth"), + name="night_weekday", + length=12.25, + days=days[:5], # Mon-Fri + balance_offset=3.9, + workers_required=1, + force_as_block=True, + constraints=[NightConstraint(), PreShiftConstraint(days=1)], + ), + SingleShift( + sites=("truro", "exeter", "plymouth"), + name="weekend_truro", + length=12.5, + days=days[5:], # Sat-Sun + balance_offset=3, + workers_required=1, + force_as_block=True, + constraints=[PreShiftConstraint(days=1)], + ), + SingleShift( + sites=("plymouth",), + name="plymouth_twilight", + length=12.5, + days=days[:5], + balance_offset=4, + workers_required=1, + ), + ) + + # Add some test workers + workers = [ + Worker( + name="Alice", + site="truro", + fte=100, # 1.0 FTE + start_date=rota_start_date, + end_date=None, + grade=1, + initials="AL", + ), + Worker( + name="Bob", + site="exeter", + fte=100, # 1.0 FTE + start_date=rota_start_date, + end_date=None, + grade=1, + initials="BO", + ), + Worker( + name="Charlie", + site="plymouth", + fte=100, # 1.0 FTE + start_date=rota_start_date, + end_date=None, + grade=1, + initials="CH", + ), + Worker( + name="Chester", + site="plymouth", + fte=100, # 1.0 FTE + start_date=rota_start_date, + end_date=None, + grade=1, + initials="CH", + ), + Worker( + name="Dino", + site="truro", + fte=100, # 0.8 FTE + start_date=rota_start_date, + end_date=None, + grade=2, + initials="DI", + ), + Worker( + name="Diana", + site="truro", + fte=80, # 0.8 FTE + start_date=rota_start_date, + end_date=None, + grade=2, + initials="DI", + ), + ] + + Rota.add_workers(workers) + + # Build and solve + Rota.build_and_solve(options={"ratio": 0.1, "seconds": 30, "threads": 10}) + + return Rota + +def generate_html_output(rota, output_dir="output/test"): + """Generate HTML output for the rota.""" + + os.makedirs(output_dir, exist_ok=True) + + # Use the built-in export method + rota.export_rota_to_html(filename="index") + + html_path = os.path.join("output", "test", "index.html") + + print(f"Test rota HTML generated at: {html_path}") + print("Open this file in a web browser to test the ICS export functionality.") + + return html_path + +if __name__ == "__main__": + print("Generating test rota...") + + try: + rota = create_test_rota() + html_path = generate_html_output(rota) + + print("\nTest rota generation complete!") + print(f"To test ICS export, open: file://{os.path.abspath(html_path)}") + print("Then use the 'Set Shift Times' button to configure times and download ICS files.") + + except Exception as e: + print(f"Error generating test rota: {e}") + import traceback + traceback.print_exc() \ No newline at end of file