`;
forecastTasksListUL.appendChild(li);
});
forecastTasksListUL.querySelectorAll('.edit-forecast-task-btn').forEach(btn => btn.addEventListener('click', (e) => populateForecastTaskFormForEdit(e.target.dataset.id)));
forecastTasksListUL.querySelectorAll('.delete-forecast-task-btn').forEach(btn => btn.addEventListener('click', (e) => deleteForecastTask(e.target.dataset.id)));
}
// --- Forecast Calculation & Display ---
calculateForecastBtn.addEventListener('click', () => {
if (forecastTasks.length === 0) {
alert('No tasks to forecast. Please add tasks and their estimates first.');
forecastResultsDiv.style.display = 'none';
return;
}
const startDateStr = projectStartDateInput.value;
if (!startDateStr) {
alert('Please select a Project Start Date.');
return;
}
const hoursPerDay = parseFloat(hoursPerWorkDayInput.value) || 8;
if (hoursPerDay <=0) { alert("Working hours per day must be positive."); return; }
const considerWeekdays = considerWeekdaysOnlyToggle.checked;
let totalAdjustedEffortHours = 0;
forecastTasks.forEach(task => {
totalAdjustedEffortHours += calculateAdjustedEffort(task);
});
const totalWorkDays = totalAdjustedEffortHours / hoursPerDay;
let completionDate = new Date(startDateStr + "T00:00:00"); // Ensure it's parsed as local
let daysToAdd = totalWorkDays;
if (considerWeekdays) {
let workDaysAdded = 0;
// Adjust daysToAdd to be actual calendar days considering only weekdays
let calendarDaysElapsed = 0;
let tempDate = new Date(completionDate); // Use a temporary date for iteration
while(workDaysAdded < daysToAdd) {
tempDate.setDate(tempDate.getDate() + 1); // Move to the next calendar day
calendarDaysElapsed++;
const dayOfWeek = tempDate.getDay(); // 0 (Sun) to 6 (Sat)
if (dayOfWeek !== 0 && dayOfWeek !== 6) { // It's a weekday
workDaysAdded++;
}
if (calendarDaysElapsed > daysToAdd * 3 && daysToAdd > 5) { break;} // Safety break for very long periods
}
completionDate.setDate(new Date(startDateStr + "T00:00:00").getDate() + calendarDaysElapsed -1); // -1 because loop adds one extra if daysToAdd is integer
// If daysToAdd is fractional, the last day will be partial.
// The date calculation should give the END of the last full work day, or part way into next if fractional
if (daysToAdd > 0) { // Recalculate completionDate based on calendar days to add
let currentCalcDate = new Date(startDateStr + "T00:00:00");
let remainingWorkDays = daysToAdd;
while (remainingWorkDays > 0) {
const dayOfWeek = currentCalcDate.getDay();
if (considerWeekdays && (dayOfWeek === 0 || dayOfWeek === 6)) {
// Skip weekend
} else {
remainingWorkDays -= 1; // Consume one workday
}
if (remainingWorkDays <= 0) break; // Stop if all workdays are accounted for
currentCalcDate.setDate(currentCalcDate.getDate() + 1); // Move to next calendar day
}
completionDate = currentCalcDate;
}
} else { // Consider all calendar days
completionDate.setDate(completionDate.getDate() + Math.ceil(daysToAdd) -1 ); // If 1 day, it's startDate. If 1.5 days, it's next day.
if (daysToAdd > 0) {
completionDate = new Date(startDateStr + "T00:00:00");
completionDate.setDate(completionDate.getDate() + Math.ceil(daysToAdd) -1 );
} else {
completionDate = new Date(startDateStr + "T00:00:00"); // if 0 days, it's start date
}
}
resultStartDateSpan.textContent = formatDate(startDateStr);
resultTotalEffortHoursSpan.textContent = `${totalAdjustedEffortHours.toFixed(2)} hours`;
resultTotalWorkDaysSpan.textContent = `${totalWorkDays.toFixed(2)} days`;
resultHoursPerDayInfoSpan.textContent = `${hoursPerDay}h`;
resultWeekdaysInfoSpan.textContent = considerWeekdays ? 'weekdays only' : 'all days';
resultCompletionDateSpan.textContent = formatDate(new Date(completionDate.getTime() - completionDate.getTimezoneOffset() * 60000).toISOString().slice(0,10));
forecastResultsDiv.style.display = 'block';
});
// --- PDF Download ---
downloadForecastPdfBtn.addEventListener('click', () => {
if (forecastResultsDiv.style.display === 'none' || forecastTasks.length === 0) {
alert('Please calculate a forecast first.'); return;
}
const { jsPDF } = window.jspdf;
const doc = new jsPDF('p', 'pt', 'a4');
const margin = 40; let yPos = margin; const pageWidth = doc.internal.pageSize.getWidth();
doc.setFontSize(18); doc.setTextColor(varToRGB('--primary-color').r, varToRGB('--primary-color').g, varToRGB('--primary-color').b);
doc.text('Work-Time Forecast Report', pageWidth / 2, yPos, { align: 'center' }); yPos += 20;
doc.setFontSize(10); doc.setTextColor(100);
doc.text(`Report Generated: ${new Date().toLocaleString()}`, pageWidth / 2, yPos, { align: 'center' }); yPos += 25;
const summaryData = [
['Project Start Date:', resultStartDateSpan.textContent],
['Total Adjusted Effort (Hours):', resultTotalEffortHoursSpan.textContent],
['Total Work-Days:', `${resultTotalWorkDaysSpan.textContent} (${resultHoursPerDayInfoSpan.textContent}/day, ${resultWeekdaysInfoSpan.textContent})`],
['Projected Completion Date:', resultCompletionDateSpan.textContent],
];
doc.autoTable({ startY: yPos, body: summaryData, theme: 'plain', styles: {fontSize:10}, columnStyles: {0:{fontStyle:'bold', cellWidth:180}}});
yPos = doc.lastAutoTable.finalY + 20;
doc.setFontSize(12); doc.setTextColor(varToRGB('--primary-color').r, varToRGB('--primary-color').g, varToRGB('--primary-color').b);
doc.text('Detailed Task Estimates:', margin, yPos); yPos += 18;
const taskTableBody = forecastTasks.map(task => [
task.description,
task.baseEffortHours.toFixed(1),
`${task.complexityFactor}x`,
`${task.contingencyPercent}%`,
calculateAdjustedEffort(task).toFixed(2)
]);
if (taskTableBody.length > 0) {
doc.autoTable({
startY: yPos,
head: [['Task Description', 'Base Effort (H)', 'Complexity', 'Contingency', 'Adjusted Effort (H)']],
body: taskTableBody, theme: 'grid',
headStyles: { fillColor: varToRGB('--primary-color', true), textColor: varToRGB('--light-text', true) },
styles: { fontSize: 9, cellPadding: 4 },
columnStyles: { 0: {cellWidth: 'auto'} }
});
} else {
doc.text('No tasks were included in this forecast.', margin, yPos);
}
doc.save(`Work_Time_Forecast_${forecastSettings.projectStartDate || todayStr}.pdf`);
});
function varToRGB(varName, asArray = false) {
const colorHex = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
if (!colorHex.startsWith('#')) { return asArray ? [0,0,0] : {r:0,g:0,b:0}; }
const r = parseInt(colorHex.slice(1, 3), 16); const g = parseInt(colorHex.slice(3, 5), 16); const b = parseInt(colorHex.slice(5, 7), 16);
return asArray ? [r,g,b] : {r,g,b};
}
// --- Initializations ---
initSettingsUI();
renderForecastTasksList();
resetForecastTaskForm(); // Also sets min date for new tasks
// Set project start date input default (done by initSettingsUI if settings exist, or set here)
if(!projectStartDateInput.value) projectStartDateInput.value = todayStr;
})();