Smart Repetitive Task Scheduler

Smart Repetitive Task Scheduler

Define New Repetitive Task

Recurrence Rule

day(s)

End Recurrence

Defined Task Series

Priority: ${series.priority} Starts: ${new Date(series.startDate).toLocaleDateString()} ${recurrenceDesc} ${series.nextDueDate ? `Next Due: ${new Date(series.nextDueDate).toLocaleString()}` : ''}

`; taskSeriesListUL.appendChild(li); }); document.querySelectorAll('.edit-series-btn').forEach(btn => btn.addEventListener('click', (e) => populateSeriesFormForEdit(e.target.dataset.id))); document.querySelectorAll('.delete-series-btn').forEach(btn => btn.addEventListener('click', (e) => deleteTaskSeries(e.target.dataset.id))); } // --- Date Calculation Logic --- // Helper to get Nth weekday of a month. `n` can be 1-4 or 'last'. `dayOfWeek` is 0(Sun)-6(Sat). function getNthWeekdayOfMonth(year, month, n, dayOfWeekVal) { const targetDay = parseInt(dayOfWeekVal); let count = 0; let resultDate = null; if (n === 'last') { const d = new Date(year, month + 1, 0); // Last day of current month for (let i = d.getDate(); i >= 1; i--) { d.setDate(i); if (d.getDay() === targetDay) { resultDate = new Date(d); break; } } } else { const numN = parseInt(n); const d = new Date(year, month, 1); while (d.getMonth() === month) { if (d.getDay() === targetDay) { count++; if (count === numN) { resultDate = new Date(d); break; } } d.setDate(d.getDate() + 1); } } return resultDate; } function calculateNextDueDate(baseDateStr, series, isInitialCalculation = false) { let baseDate = new Date(baseDateStr); // This is the reference point (either startDate or lastCompletedInstanceDate's due time) let nextDate = new Date(baseDate); // Start with a copy const rule = series.recurrence; // If it's not the initial calculation, we need to advance from the baseDate at least once. // For initial calculation, we might need to adjust baseDate if it doesn't fit the rule (e.g., weekly on Wednesday, but startDate is Monday) if (!isInitialCalculation) { // Standard advancement switch (rule.type) { case 'daily': nextDate.setDate(baseDate.getDate() + rule.interval); break; case 'weekly': // Advance by interval weeks first, then find the next valid dayOfWeek // This is complex because the baseDate itself might be one of the daysOfWeek // Simplification: add 1 day, then find next suitable day within the interval weeks nextDate.setDate(baseDate.getDate() + 1); // Ensure we move past current baseDate let weeksToAdd = 0; let attempts = 0; // Safety break while (attempts < (rule.daysOfWeek.length * rule.interval + 7)) { // Search limit const currentDayOfWeek = nextDate.getDay(); const dayIsValid = rule.daysOfWeek.includes(currentDayOfWeek); if (dayIsValid) { // Check if this date is truly after baseDate + appropriate interval for weekly // This logic requires more refinement to correctly handle "every X weeks on Mon, Wed" // For now, this finds the next valid day. Interval needs to be applied correctly on top. // Simplified for "next available dayOfWeek": let potentialNextDate = new Date(nextDate); let dateToCompareAgainst = new Date(baseDate); if(isInitialCalculation) dateToCompareAgainst.setDate(dateToCompareAgainst.getDate()-1); // for initial, allow start date itself if(potentialNextDate > dateToCompareAgainst) { // ensure it is in the future if (rule.interval > 1) { // How to check if it's in the "correct" week of the interval? // This part is tricky. For a robust solution for "every X weeks", one might // determine the "start of the week" for baseDate, add X weeks, then find the first valid day. // For now, this finds the next instance of a valid day, interval not perfectly handled. } break; // Found a valid day } } nextDate.setDate(nextDate.getDate() + 1); attempts++; } // A more correct way for interval > 1 for weekly might be: if (rule.interval > 1 && !isInitialCalculation) { // Calculate start of current week, add interval, then find next day of week // This is skipped for brevity in this example due to complexity. } break; case 'monthly': let currentMonth = baseDate.getMonth(); let currentYear = baseDate.getFullYear(); let newMonth = currentMonth + rule.interval; // Advance by interval of months nextDate.setFullYear(currentYear + Math.floor(newMonth / 12)); nextDate.setMonth(newMonth % 12); if (rule.monthlyRepeatBy === 'dayOfMonth') { nextDate.setDate(rule.dayOfMonth); // If dayOfMonth results in month overflow (e.g. Feb 30), Date obj handles it by moving to next month. // We need to ensure it's for the *correct* month if day is too large. if (nextDate.getMonth() !== (newMonth % 12)) { // It overflowed to a later month nextDate.setMonth(newMonth % 12 + 1, 0); // Go to last day of intended month } } else { // dayOfWeek const nthDate = getNthWeekdayOfMonth(nextDate.getFullYear(), nextDate.getMonth(), rule.nthInstance, rule.whichDay); if (nthDate) { nextDate = new Date(nthDate); nextDate.setHours(baseDate.getHours(), baseDate.getMinutes(), baseDate.getSeconds()); } else { // Couldn't find Nth weekday (e.g. 5th Friday in a month with 4) - this series might end or skip month // This case needs careful handling: skip to next interval month and try again console.warn("Could not find Nth weekday for series:", series.id, "for month:", nextDate.getMonth()); // For now, just advance month again (simplification) nextDate.setMonth(nextDate.getMonth() + rule.interval); // And re-attempt to find nth weekday (this could recurse or loop) - very complex // Fallback: return null or a very far date if this happens, or log error. return null; } } // If calculated nextDate is <= baseDate, it means we are in the same month or past, need to advance month again if (nextDate <= baseDate) { newMonth = nextDate.getMonth() + rule.interval; nextDate.setFullYear(nextDate.getFullYear() + Math.floor(newMonth / 12)); nextDate.setMonth(newMonth % 12); // Recalculate day based on rule for new month if (rule.monthlyRepeatBy === 'dayOfMonth') { nextDate.setDate(rule.dayOfMonth); if (nextDate.getMonth() !== (newMonth % 12)) { nextDate.setMonth(newMonth % 12 + 1, 0); } } else { const nthDate = getNthWeekdayOfMonth(nextDate.getFullYear(), nextDate.getMonth(), rule.nthInstance, rule.whichDay); if(nthDate) { nextDate = new Date(nthDate); nextDate.setHours(baseDate.getHours(), baseDate.getMinutes(), baseDate.getSeconds()); } else return null; // Error case } } break; case 'yearly': nextDate.setFullYear(baseDate.getFullYear() + rule.interval); // Day and Month are taken from the original baseDate (or series.startDate) break; } } else { // Initial Calculation: Adjust startDate if it doesn't fit rules like weekly if (rule.type === 'weekly') { let attempts = 0; while (!rule.daysOfWeek.includes(nextDate.getDay()) && attempts < 7) { nextDate.setDate(nextDate.getDate() + 1); attempts++; } // If after 7 attempts, no valid day is found (should not happen if daysOfWeek is populated) } else if (rule.type === 'monthly' && rule.monthlyRepeatBy === 'dayOfWeek') { const nthDate = getNthWeekdayOfMonth(nextDate.getFullYear(), nextDate.getMonth(), rule.nthInstance, rule.whichDay); if(nthDate) { nextDate = new Date(nthDate); nextDate.setHours(baseDate.getHours(), baseDate.getMinutes(), baseDate.getSeconds()); } else return null; } // For daily and yearly, initial startDate is usually fine unless explicitly before "today" } // Apply end conditions if (series.endCondition.type === 'onDate' && new Date(nextDate.toISOString().slice(0,10)) > new Date(series.endCondition.endDate)) { return null; // Ended } if (series.endCondition.type === 'after' && series.completedOccurrences >= series.endCondition.occurrences) { return null; // Ended } return nextDate.toISOString().slice(0,16); // Return as YYYY-MM-DDTHH:mm } // --- Upcoming Instances View Logic --- viewPeriodSelect.addEventListener('change', () => { customRangeControlsDiv.classList.toggle('hidden', viewPeriodSelect.value !== 'customRange'); if(viewPeriodSelect.value !== 'customRange') generateAndDisplayInstances(); }); refreshInstanceViewBtn.addEventListener('click', generateAndDisplayInstances); // Initialize custom range dates function initializeCustomRangeDates() { const todayStr = new Date(today.getTime() - today.getTimezoneOffset() * 60000).toISOString().slice(0, 10); const sevenDaysLater = new Date(today); sevenDaysLater.setDate(today.getDate() + 7); const sevenDaysLaterStr = new Date(sevenDaysLater.getTime() - sevenDaysLater.getTimezoneOffset() * 60000).toISOString().slice(0, 10); customStartDateInput.value = todayStr; customEndDateInput.value = sevenDaysLaterStr; } function generateAndDisplayInstances() { today = new Date(); // Refresh 'today' taskInstancesViewDiv.innerHTML = ''; let viewStart, viewEnd; const period = viewPeriodSelect.value; if (period === 'customRange') { if (!customStartDateInput.value || !customEndDateInput.value) { alert("Please select a start and end date for the custom range."); return; } viewStart = new Date(customStartDateInput.value + "T00:00:00"); viewEnd = new Date(customEndDateInput.value + "T23:59:59"); } else { viewStart = new Date(today); viewStart.setHours(0,0,0,0); viewEnd = new Date(viewStart); if (period === 'next7days') viewEnd.setDate(viewStart.getDate() + 7); else if (period === 'next30days') viewEnd.setDate(viewStart.getDate() + 30); viewEnd.setHours(23,59,59,999); } let allInstances = []; taskSeriesArray.forEach(series => { let currentInstanceDateStr = series.nextDueDate; let occurrencesGenerated = 0; const maxInstancesToGenerate = 100; // Safety break for 'never' ending tasks in a long range while (currentInstanceDateStr && occurrencesGenerated < maxInstancesToGenerate) { const currentInstanceDate = new Date(currentInstanceDateStr); if (currentInstanceDate > viewEnd) break; // Instance is past the view window if (currentInstanceDate >= viewStart) { // Check end conditions again here for this specific instance let shouldAdd = true; if (series.endCondition.type === 'onDate' && new Date(currentInstanceDate.toISOString().slice(0,10)) > new Date(series.endCondition.endDate)) { shouldAdd = false; } // For 'after_occurrences', we use series.completedOccurrences + generated for this view so far // This can be tricky; simpler to let calculateNextDueDate handle this primarily if (shouldAdd) { allInstances.push({ seriesId: series.id, description: series.description, priority: series.priority, duration: series.duration, dueDate: currentInstanceDateStr, isOverdue: currentInstanceDate < today && currentInstanceDateStr !== series.lastCompletedInstanceDate, isToday: currentInstanceDate.toDateString() === today.toDateString() }); } } // Calculate the *next* potential instance date for THIS loop, from the current one // This is a projection, not changing the series' actual nextDueDate yet. const nextProjectedDateStr = calculateNextDueDate(currentInstanceDateStr, series, false); if (!nextProjectedDateStr || nextProjectedDateStr === currentInstanceDateStr) break; // No more or stuck currentInstanceDateStr = nextProjectedDateStr; occurrencesGenerated++; } }); allInstances.sort((a,b) => new Date(a.dueDate) - new Date(b.dueDate)); if (allInstances.length === 0) { taskInstancesViewDiv.innerHTML = '

No upcoming task instances in the selected period.

'; return; } const ul = document.createElement('ul'); ul.className = 'item-list'; allInstances.forEach(instance => { const li = document.createElement('li'); li.className = `task-instance-item priority-${instance.priority}`; if (instance.isOverdue) li.classList.add('instance-overdue'); else if (instance.isToday) li.classList.add('instance-today'); li.innerHTML = `

${instance.description}

Due: ${new Date(instance.dueDate).toLocaleString()} ${instance.duration ? `Duration: ${instance.duration}` : ''} Priority: ${instance.priority}

`; ul.appendChild(li); }); taskInstancesViewDiv.appendChild(ul); // Add event listeners for Mark Done / Skip document.querySelectorAll('.mark-done-btn, .skip-instance-btn').forEach(btn => { btn.addEventListener('click', function() { handleInstanceAction(this.dataset.seriesId, this.dataset.instanceDate, this.classList.contains('mark-done-btn')); }); }); } function handleInstanceAction(seriesId, instanceDateStr, isMarkDone) { const seriesIndex = taskSeriesArray.findIndex(s => s.id === seriesId); if (seriesIndex === -1) return; const series = taskSeriesArray[seriesIndex]; // Ensure we are acting on the *current* nextDueDate of the series if (series.nextDueDate !== instanceDateStr) { // This can happen if user clicks on an older projected instance. // For simplicity, "Mark Done" / "Skip" always operate on the series' current `nextDueDate`. // A more complex system might allow marking historical instances done. // For now, if it's not the current nextDueDate, we could just refresh or give a message. // Let's assume for now it should be the current one to avoid complications with out-of-order completions. if (new Date(instanceDateStr) > new Date(series.nextDueDate)) { alert("You can only mark the current or next upcoming instance as done/skipped for this series from this view. This instance is further in the future."); return; } else if (new Date(instanceDateStr) < new Date(series.nextDueDate) && instanceDateStr !== series.lastCompletedInstanceDate) { // If user tries to mark an older instance as done (that is not the last completed one) // This implies they want to "catch up". // For now, let's just increment completed count and set this as last completed. if(isMarkDone) series.completedOccurrences++; series.lastCompletedInstanceDate = instanceDateStr; } } else { // Acting on the current `series.nextDueDate` if(isMarkDone) series.completedOccurrences++; series.lastCompletedInstanceDate = series.nextDueDate; } const newNextDueDate = calculateNextDueDate(series.lastCompletedInstanceDate, series, false); if (newNextDueDate) { series.nextDueDate = newNextDueDate; } else { // Task series has ended (either by condition or no more valid dates) series.nextDueDate = null; // Mark as no more due dates // Optionally: Could move to an "archived" state or flag as "completed series" alert(`Task series "${series.description}" has now ended based on its rules.`); } taskSeriesArray[seriesIndex] = series; saveTaskSeries(); renderTaskSeriesList(); // Update the defined series list (e.g. its next due date) generateAndDisplayInstances(); // Refresh the current view } // --- PDF Download --- downloadInstancesPdfBtn.addEventListener('click', () => { const instancesUl = taskInstancesViewDiv.querySelector('.item-list'); if (!instancesUl || !instancesUl.children.length) { alert('No instances to download for the current view.'); return; } const { jsPDF } = window.jspdf; const doc = new jsPDF(); let viewStartStr, viewEndStr; const period = viewPeriodSelect.value; if (period === 'customRange') { viewStartStr = new Date(customStartDateInput.value + "T00:00:00").toLocaleDateString(); viewEndStr = new Date(customEndDateInput.value + "T23:59:59").toLocaleDateString(); } else { const tempStart = new Date(today); tempStart.setHours(0,0,0,0); const tempEnd = new Date(tempStart); if (period === 'next7days') tempEnd.setDate(tempStart.getDate() + 6); // 0-6 is 7 days else if (period === 'next30days') tempEnd.setDate(tempStart.getDate() + 29); viewStartStr = tempStart.toLocaleDateString(); viewEndStr = tempEnd.toLocaleDateString(); } doc.setFontSize(18); doc.setTextColor(parseInt(getComputedStyle(document.documentElement).getPropertyValue('--primary-color').substring(1,3),16), parseInt(getComputedStyle(document.documentElement).getPropertyValue('--primary-color').substring(3,5),16), parseInt(getComputedStyle(document.documentElement).getPropertyValue('--primary-color').substring(5,7),16)); doc.text('Scheduled Task Instances', 14, 22); doc.setFontSize(12); doc.setTextColor(100); doc.text(`Period: ${viewStartStr} - ${viewEndStr}`, 14, 30); doc.setFontSize(10); doc.text(`Generated on: ${new Date().toLocaleString()}`, 14, 36); const body = []; Array.from(instancesUl.children).forEach(li => { const description = li.querySelector('.task-description').textContent; // Extracting from textContent, so need to be careful with labels const metaText = li.querySelector('.task-meta').textContent; const dueDateMatch = metaText.match(/Due: (.*?)(?:Duration:|Priority:|$)/); const durationMatch = metaText.match(/Duration: (.*?)(?:Priority:|$)/); const priorityMatch = metaText.match(/Priority: (.*)/); const dueDate = dueDateMatch ? dueDateMatch[1].trim() : 'N/A'; const duration = durationMatch ? durationMatch[1].trim() : 'N/A'; const priority = priorityMatch ? priorityMatch[1].trim() : 'N/A'; body.push([dueDate, description, priority, duration]); }); if (body.length === 0) { doc.text("No instances in the selected period.", 14, 45); } else { doc.autoTable({ startY: 42, head: [['Due Date & Time', 'Description', 'Priority', 'Est. Duration']], body: body, theme: 'grid', headStyles: { fillColor: [0, 123, 255], textColor: 255 }, didParseCell: function (data) { const priorityColorsPDF = { high: getComputedStyle(document.documentElement).getPropertyValue('--danger-color').trim(), medium: getComputedStyle(document.documentElement).getPropertyValue('--warning-color').trim(), low: getComputedStyle(document.documentElement).getPropertyValue('--info-color').trim(), }; if (data.column.index === 2 && data.cell.section === 'body') { // Priority column const priorityValue = data.cell.raw.toString().toLowerCase(); if (priorityColorsPDF[priorityValue]) { data.cell.styles.textColor = priorityColorsPDF[priorityValue]; data.cell.styles.fontStyle = 'bold'; } } } }); } doc.save(`Task_Instances_${viewStartStr}_to_${viewEndStr}.pdf`); }); // --- Initializations --- setDateInputMin(); updateRecurrenceOptionsVisibility(); updateEndRecurrenceOptionsVisibility(); renderTaskSeriesList(); initializeCustomRangeDates(); // Set default custom range dates generateAndDisplayInstances(); // Initial load of instances for default view (next 7 days) })(); // End IIFE
Scroll to Top