add ics export support
This commit is contained in:
@@ -1,6 +1,26 @@
|
|||||||
html {
|
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 {
|
table {
|
||||||
@@ -14,7 +34,8 @@ table {
|
|||||||
position: relative; */
|
position: relative; */
|
||||||
border-collapse: separate;
|
border-collapse: separate;
|
||||||
border-spacing: 0;
|
border-spacing: 0;
|
||||||
|
background-color: var(--table-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
#main-table {
|
#main-table {
|
||||||
@@ -72,12 +93,12 @@ th {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.table-div tr:hover {
|
.table-div tr:hover {
|
||||||
background-color: lightblue;
|
background-color: var(--highlight-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-div tr:hover .unavailable {
|
.table-div tr:hover .unavailable {
|
||||||
|
|
||||||
background-color: lightgray;
|
background-color: var(--worker-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.Sat {
|
.Sat {
|
||||||
|
|||||||
@@ -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 = $('<div id="ics-export-buttons" style="margin-top: 20px;"><h3>Export ICS Files</h3></div>');
|
||||||
|
const setTimesButton = $(`<button id="set-shift-times" style="margin: 5px;">Set Shift Times</button>`);
|
||||||
|
icsButtonsDiv.append(setTimesButton);
|
||||||
|
|
||||||
|
// Shift type exports (with worker info)
|
||||||
|
const shiftExportsDiv = $('<div style="margin-bottom: 20px;"><h4>Export by Shift Type</h4></div>');
|
||||||
|
Object.keys(shiftDates).sort().forEach(shiftType => {
|
||||||
|
const button = $(`<button style="margin: 5px;">Download ${shiftType.replace(/_/g, ' ')} ICS</button>`);
|
||||||
|
button.on('click', () => {
|
||||||
|
const ics = generateICS(shiftType, shiftDates[shiftType]);
|
||||||
|
downloadICS(`${shiftType}.ics`, ics);
|
||||||
|
});
|
||||||
|
shiftExportsDiv.append(button);
|
||||||
|
});
|
||||||
|
icsButtonsDiv.append(shiftExportsDiv);
|
||||||
|
|
||||||
|
// Worker exports
|
||||||
|
const workerExportsDiv = $('<div style="margin-bottom: 20px;"><h4>Export by Worker</h4></div>');
|
||||||
|
Object.keys(workerShifts).sort().forEach(workerName => {
|
||||||
|
const workerShiftTypes = Object.keys(workerShifts[workerName]);
|
||||||
|
if (workerShiftTypes.length > 0) {
|
||||||
|
const button = $(`<button style="margin: 5px;">Download ${workerName} ICS</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 = $(`
|
||||||
|
<div style="margin: 8px 0;">
|
||||||
|
<input type="checkbox" id="${checkboxId}" checked>
|
||||||
|
<label for="${checkboxId}" style="margin-left: 8px;">
|
||||||
|
${shiftType.replace(/_/g, ' ').toUpperCase()} (${dates.length} shift${dates.length !== 1 ? 's' : ''})
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
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 = $(`
|
||||||
|
<div id="worker-shift-modal" style="display:none; position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); z-index:10000; background:#fff; border:2px solid #888; border-radius:8px; box-shadow:0 4px 24px #0002; padding:24px; min-width:400px; max-height:80vh; overflow-y:auto;">
|
||||||
|
<h3 style="margin-top:0;" id="worker-shift-modal-title">Select Shifts for Export</h3>
|
||||||
|
<div id="worker-shift-checkboxes" style="margin: 16px 0;"></div>
|
||||||
|
<div style="text-align:right; margin-top:16px;">
|
||||||
|
<button id="worker-shift-cancel" style="margin-right:8px;">Cancel</button>
|
||||||
|
<button id="worker-shift-export">Export Selected</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
$('body').append(workerShiftModal);
|
||||||
|
|
||||||
|
// Modal for setting shift times
|
||||||
|
const shiftTimesModal = $(`
|
||||||
|
<div id="shift-times-modal" style="display:none; position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); z-index:10000; background:#fff; border:2px solid #888; border-radius:8px; box-shadow:0 4px 24px #0002; padding:24px; min-width:400px; max-height:80vh; overflow-y:auto;">
|
||||||
|
<h3 style="margin-top:0;">Set Shift Times</h3>
|
||||||
|
<div id="shift-times-controls"></div>
|
||||||
|
<div style="text-align:right; margin-top:16px;">
|
||||||
|
<button id="save-shift-times">Save</button>
|
||||||
|
<button id="close-shift-times">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
$("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 = $(`
|
||||||
|
<div style="margin: 10px 0; padding: 10px; border: 1px solid #ccc; border-radius: 4px;">
|
||||||
|
<label><strong>${shiftType.replace(/_/g, ' ')}</strong></label><br>
|
||||||
|
<label>Start: <input type="time" class="shift-start" data-shift="${shiftType}" value="${time.start}"></label>
|
||||||
|
<label>End: <input type="time" class="shift-end" data-shift="${shiftType}" value="${time.end}"></label>
|
||||||
|
<label><input type="checkbox" class="shift-nextday" data-shift="${shiftType}" ${time.nextDay ? 'checked' : ''}> Ends next day</label>
|
||||||
|
</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) {
|
function hsv2rgb(h, s, v) {
|
||||||
// adapted from http://schinckel.net/2012/01/10/hsv-to-rgb-in-javascript/
|
// adapted from http://schinckel.net/2012/01/10/hsv-to-rgb-in-javascript/
|
||||||
var rgb, i, data = [];
|
var rgb, i, data = [];
|
||||||
|
|||||||
+25
-4
@@ -1,6 +1,26 @@
|
|||||||
html {
|
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 {
|
table {
|
||||||
@@ -14,7 +34,8 @@ table {
|
|||||||
position: relative; */
|
position: relative; */
|
||||||
border-collapse: separate;
|
border-collapse: separate;
|
||||||
border-spacing: 0;
|
border-spacing: 0;
|
||||||
|
background-color: var(--table-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
#main-table {
|
#main-table {
|
||||||
@@ -72,12 +93,12 @@ th {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#main-table tr:hover {
|
#main-table tr:hover {
|
||||||
background-color: lightblue;
|
background-color: var(--highlight-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
#main-table tr:hover .unavailable {
|
#main-table tr:hover .unavailable {
|
||||||
|
|
||||||
background-color: lightgray;
|
background-color: var(--worker-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.Sat {
|
.Sat {
|
||||||
|
|||||||
@@ -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 = $('<div id="ics-export-buttons" style="margin-top: 20px;"><h3>Export ICS Files</h3></div>');
|
||||||
|
const setTimesButton = $(`<button id="set-shift-times" style="margin: 5px;">Set Shift Times</button>`);
|
||||||
|
icsButtonsDiv.append(setTimesButton);
|
||||||
|
|
||||||
|
// Shift type exports (with worker info)
|
||||||
|
const shiftExportsDiv = $('<div style="margin-bottom: 20px;"><h4>Export by Shift Type</h4></div>');
|
||||||
|
Object.keys(shiftDates).sort().forEach(shiftType => {
|
||||||
|
const button = $(`<button style="margin: 5px;">Download ${shiftType.replace(/_/g, ' ')} ICS</button>`);
|
||||||
|
button.on('click', () => {
|
||||||
|
const ics = generateICS(shiftType, shiftDates[shiftType]);
|
||||||
|
downloadICS(`${shiftType}.ics`, ics);
|
||||||
|
});
|
||||||
|
shiftExportsDiv.append(button);
|
||||||
|
});
|
||||||
|
icsButtonsDiv.append(shiftExportsDiv);
|
||||||
|
|
||||||
|
// Worker exports
|
||||||
|
const workerExportsDiv = $('<div style="margin-bottom: 20px;"><h4>Export by Worker</h4></div>');
|
||||||
|
Object.keys(workerShifts).sort().forEach(workerName => {
|
||||||
|
const workerShiftTypes = Object.keys(workerShifts[workerName]);
|
||||||
|
if (workerShiftTypes.length > 0) {
|
||||||
|
const button = $(`<button style="margin: 5px;">Download ${workerName} ICS</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 = $(`
|
||||||
|
<div style="margin: 8px 0;">
|
||||||
|
<input type="checkbox" id="${checkboxId}" checked>
|
||||||
|
<label for="${checkboxId}" style="margin-left: 8px;">
|
||||||
|
${shiftType.replace(/_/g, ' ').toUpperCase()} (${dates.length} shift${dates.length !== 1 ? 's' : ''})
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
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 = $(`
|
||||||
|
<div id="worker-shift-modal" style="display:none; position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); z-index:10000; background:#fff; border:2px solid #888; border-radius:8px; box-shadow:0 4px 24px #0002; padding:24px; min-width:400px; max-height:80vh; overflow-y:auto;">
|
||||||
|
<h3 style="margin-top:0;" id="worker-shift-modal-title">Select Shifts for Export</h3>
|
||||||
|
<div id="worker-shift-checkboxes" style="margin: 16px 0;"></div>
|
||||||
|
<div style="text-align:right; margin-top:16px;">
|
||||||
|
<button id="worker-shift-cancel" style="margin-right:8px;">Cancel</button>
|
||||||
|
<button id="worker-shift-export">Export Selected</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
$('body').append(workerShiftModal);
|
||||||
|
|
||||||
|
// Modal for setting shift times
|
||||||
|
const shiftTimesModal = $(`
|
||||||
|
<div id="shift-times-modal" style="display:none; position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); z-index:10000; background:#fff; border:2px solid #888; border-radius:8px; box-shadow:0 4px 24px #0002; padding:24px; min-width:400px; max-height:80vh; overflow-y:auto;">
|
||||||
|
<h3 style="margin-top:0;">Set Shift Times</h3>
|
||||||
|
<div id="shift-times-controls"></div>
|
||||||
|
<div style="text-align:right; margin-top:16px;">
|
||||||
|
<button id="save-shift-times">Save</button>
|
||||||
|
<button id="close-shift-times">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
$("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 = $(`
|
||||||
|
<div style="margin: 10px 0; padding: 10px; border: 1px solid #ccc; border-radius: 4px;">
|
||||||
|
<label><strong>${shiftType.replace(/_/g, ' ')}</strong></label><br>
|
||||||
|
<label>Start: <input type="time" class="shift-start" data-shift="${shiftType}" value="${time.start}"></label>
|
||||||
|
<label>End: <input type="time" class="shift-end" data-shift="${shiftType}" value="${time.end}"></label>
|
||||||
|
<label><input type="checkbox" class="shift-nextday" data-shift="${shiftType}" ${time.nextDay ? 'checked' : ''}> Ends next day</label>
|
||||||
|
</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) {
|
function hsv2rgb(h, s, v) {
|
||||||
// adapted from http://schinckel.net/2012/01/10/hsv-to-rgb-in-javascript/
|
// adapted from http://schinckel.net/2012/01/10/hsv-to-rgb-in-javascript/
|
||||||
var rgb, i, data = [];
|
var rgb, i, data = [];
|
||||||
@@ -1398,4 +1703,26 @@ daysOfWeek.forEach(day => {
|
|||||||
$(function() {
|
$(function() {
|
||||||
updateAllHighlights();
|
updateAllHighlights();
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Dark Mode Toggle ---
|
||||||
|
$(function() {
|
||||||
|
// Add dark mode toggle button to the page
|
||||||
|
const darkModeButton = $('<button id="dark-mode-toggle" style="position: fixed; top: 10px; right: 10px; z-index: 1000; padding: 8px 12px; background: #333; color: white; border: none; border-radius: 4px; cursor: pointer;">🌙 Dark Mode</button>');
|
||||||
|
$('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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+158
@@ -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()
|
||||||
Reference in New Issue
Block a user