`;
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 = `
`;
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