Time Budgeting Tool
Setup & Budget Allocation
Track Actuals & View Report
Budget Period & Settings
Manage Categories
My Categories:
Allocate Budgeted Time for
Total Budgeted: 0h 0m / Available: 8h 0m
Track Actual Time Spent for
Time Budget Report for
Total Available for Budgeting: 0h 0m
Total Budgeted Time: 0h 0m
Total Actual Time Spent: 0h 0m
Overall Variance (Actual - Budgeted): 0h 0m
Budgeted vs. Actual Time per Category
Detailed Breakdown:
| Category | Budgeted | Actual | Variance |
|---|
Select a date and allocate budget on the first tab, then track actuals here to generate a report.
h
m
`;
actualsTrackingListDiv.appendChild(itemDiv);
});
actualsTrackingListDiv.querySelectorAll('.actual-hours-input, .actual-minutes-input').forEach(input => {
input.addEventListener('change', (e) => {
const catId = e.target.dataset.catId;
const catBudget = timeBudgets[currentBudgetDateStr].categories.find(c => c.id === catId);
if (catBudget) {
const container = e.target.closest('.time-input');
const hoursInputEl = container.querySelector('.actual-hours-input');
const minutesInputEl = container.querySelector('.actual-minutes-input');
catBudget.actualHours = timeToHours(hoursInputEl.value, minutesInputEl.value);
// Forcing save via button for actuals: saveTimeBudgets();
// generateAndDisplayReportSummary(); // Live update report as actuals change
}
});
});
// If any category has actuals or budget, try to display report
if (currentBudget.categories.some(c => c.budgetedHours > 0 || c.actualHours > 0)) {
generateAndDisplayReportSummary();
}
}
saveActualsBtn.addEventListener('click', () => {
// Trigger save for any pending changes from input fields
actualsTrackingListDiv.querySelectorAll('.actual-hours-input').forEach(input => {
input.dispatchEvent(new Event('change', {bubbles:true})); // Ensure last changes are captured
});
saveTimeBudgets();
generateAndDisplayReportSummary();
alert('Actual times saved and report updated!');
});
function generateAndDisplayReportSummary() {
const currentBudget = timeBudgets[currentBudgetDateStr];
if (!currentBudget) {
reportOutputSection.style.display = 'none';
noReportDataMsg.style.display = 'block';
return;
}
let totalBudgetedH = 0;
let totalActualH = 0;
const chartData = [];
const tableRowsData = [];
currentBudget.categories.forEach(cat => {
if (cat.budgetedHours > 0 || cat.actualHours > 0) {
totalBudgetedH += (cat.budgetedHours || 0);
totalActualH += (cat.actualHours || 0);
const variance = (cat.actualHours || 0) - (cat.budgetedHours || 0);
chartData.push({ name: cat.name, budgeted: (cat.budgetedHours || 0), actual: (cat.actualHours || 0) });
tableRowsData.push({ name: cat.name, budgeted: (cat.budgetedHours || 0), actual: (cat.actualHours || 0), variance: variance });
}
});
if (chartData.length === 0) {
reportOutputSection.style.display = 'none';
noReportDataMsg.style.display = 'block';
return;
}
reportTotalAvailableSpan.textContent = formatHoursToHM(currentBudget.totalAvailableHours);
reportTotalBudgetedSpan.textContent = formatHoursToHM(totalBudgetedH);
reportTotalActualSpan.textContent = formatHoursToHM(totalActualH);
const overallVariance = totalActualH - totalBudgetedH;
reportOverallVarianceSpan.textContent = formatHoursToHM(overallVariance);
reportOverallVarianceSpan.style.color = overallVariance === 0 ? 'var(--dark-text)' : (overallVariance > 0 ? 'var(--danger-color)' : 'var(--accent-color)'); // Positive variance (more actual) is red, negative (less actual) is green
renderComparisonChart(chartData);
renderDetailedBudgetTable(tableRowsData);
reportOutputSection.style.display = 'block';
noReportDataMsg.style.display = 'none';
}
function renderComparisonChart(data) {
comparisonChartDiv.innerHTML = '';
if (data.length === 0) {
comparisonChartDiv.innerHTML = 'No data to display in chart.
'; return; } const maxTime = Math.max(...data.map(d => Math.max(d.budgeted, d.actual)), 0.1); data.sort((a,b) => a.name.localeCompare(b.name)).forEach(item => { const rowDiv = document.createElement('div'); rowDiv.className = 'chart-row'; const labelDiv = document.createElement('div'); labelDiv.className = 'chart-row-label'; labelDiv.textContent = item.name; labelDiv.title = item.name; rowDiv.appendChild(labelDiv); const barsContainer = document.createElement('div'); barsContainer.className = 'chart-bars-container'; const budgetedBar = document.createElement('div'); budgetedBar.className = 'chart-bar-budgeted'; budgetedBar.style.width = `${(item.budgeted / maxTime) * 100}%`; budgetedBar.textContent = item.budgeted > 0 ? formatHoursToHM(item.budgeted) : ''; budgetedBar.title = `Budgeted: ${formatHoursToHM(item.budgeted)}`; barsContainer.appendChild(budgetedBar); const actualBar = document.createElement('div'); actualBar.className = 'chart-bar-actual'; actualBar.style.width = `${(item.actual / maxTime) * 100}%`; actualBar.textContent = item.actual > 0 ? formatHoursToHM(item.actual) : ''; actualBar.title = `Actual: ${formatHoursToHM(item.actual)}`; barsContainer.appendChild(actualBar); rowDiv.appendChild(barsContainer); comparisonChartDiv.appendChild(rowDiv); }); } function renderDetailedBudgetTable(data) { detailedBudgetTbody.innerHTML = ''; if (data.length === 0) return; data.sort((a,b) => a.name.localeCompare(b.name)).forEach(item => { const row = detailedBudgetTbody.insertRow(); row.insertCell().textContent = item.name; row.insertCell().textContent = formatHoursToHM(item.budgeted); row.insertCell().textContent = formatHoursToHM(item.actual); const varianceCell = row.insertCell(); varianceCell.textContent = formatHoursToHM(item.variance); varianceCell.className = item.variance === 0 ? '' : (item.variance > 0 ? 'variance-negative' : 'variance-positive'); // Negative variance means actual < budget (good), Positive means actual > budget (bad for 'time spent') }); } // --- PDF Download --- downloadBudgetReportPdfBtn.addEventListener('click', async () => { const currentBudget = timeBudgets[currentBudgetDateStr]; if (!currentBudget || reportOutputSection.style.display === 'none') { alert('Please generate a report by tracking actuals 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('Time Budget Report', pageWidth / 2, yPos, { align: 'center' }); yPos += 20; doc.setFontSize(12); doc.setTextColor(100); doc.text(`For Date: ${formatDateForDisplay(currentBudget.date)}`, pageWidth / 2, yPos, { align: 'center' }); yPos += 15; doc.setFontSize(10); doc.text(`Generated: ${new Date().toLocaleString()}`, pageWidth / 2, yPos, { align: 'center' }); yPos += 25; const summaryPdfData = [ ['Total Available for Budgeting:', reportTotalAvailableSpan.textContent], ['Total Budgeted Time:', reportTotalBudgetedSpan.textContent], ['Total Actual Time Spent:', reportTotalActualSpan.textContent], ['Overall Variance (Actual - Budgeted):', reportOverallVarianceSpan.textContent] ]; doc.autoTable({ startY: yPos, body: summaryPdfData, theme: 'plain', styles: {fontSize:10}, columnStyles: {0:{fontStyle:'bold'}}}); yPos = doc.lastAutoTable.finalY + 15; if (html2canvas && comparisonChartDiv && comparisonChartDiv.offsetHeight > 0) { if (yPos > doc.internal.pageSize.getHeight() - 150) { doc.addPage(); yPos = margin; } doc.setFontSize(12); doc.setTextColor(varToRGB('--primary-color').r, varToRGB('--primary-color').g, varToRGB('--primary-color').b); doc.text("Budgeted vs. Actual Time per Category", margin, yPos); yPos += 15; try { const canvas = await html2canvas(comparisonChartDiv, { scale: 1.5, backgroundColor: getComputedStyle(document.documentElement).getPropertyValue('--white-bg').trim() }); const imgData = canvas.toDataURL('image/png'); const imgProps = doc.getImageProperties(imgData); const pdfImgWidth = pageWidth - 2 * margin; const pdfImgHeight = (imgProps.height * pdfImgWidth) / imgProps.width; doc.addImage(imgData, 'PNG', margin, yPos, pdfImgWidth, pdfImgHeight); yPos += pdfImgHeight + 10; } catch (e) { console.error("Chart to PDF error:", e); doc.setTextColor(255,0,0).text("Chart could not be rendered.", margin, yPos); yPos+=15; } } if (yPos > doc.internal.pageSize.getHeight() - 80) { doc.addPage(); yPos = margin; } doc.setFontSize(12); doc.setTextColor(varToRGB('--primary-color').r, varToRGB('--primary-color').g, varToRGB('--primary-color').b); doc.text('Detailed Breakdown:', margin, yPos); yPos += 15; const detailedPdfBody = []; detailedBudgetTbody.querySelectorAll('tr').forEach(tr => { const cells = Array.from(tr.querySelectorAll('td')).map(td => td.textContent); detailedPdfBody.push(cells); }); if (detailedPdfBody.length > 0) { doc.autoTable({ startY: yPos, head: [['Category', 'Budgeted', 'Actual', 'Variance']], body: detailedPdfBody, theme: 'grid', headStyles: { fillColor: varToRGB('--primary-color', true), textColor: varToRGB('--light-text', true) }, styles:{fontSize:9} }); } else { doc.text("No detailed data to show.", margin, yPos); } doc.save(`Time_Budget_Report_${currentBudget.date}.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 --- renderDefinedCategoriesList(); handleDateOrAvailableHoursChange(); // Load or create budget for the default date & available hours })();