Kumi
38d9d36312
Introduced the Kanblendar project, which combines a Kanban board and a daily calendar. Users can create, edit, and organize tasks in columns and time slots, with notification support for upcoming tasks. Added README.md with detailed usage instructions and LICENSE file for licensing under MIT. Created example files (HTML, CSS, JS) to illustrate the usage of Kanblendar, and essential styles and scripts for its functionality.
411 lines
17 KiB
JavaScript
411 lines
17 KiB
JavaScript
class Kanblendar {
|
|
constructor(config = {}) {
|
|
this.kanbanSection = document.getElementById('kanblendar');
|
|
this.tasks = new Map();
|
|
this.notificationTimeouts = new Map();
|
|
this.currentTask = null;
|
|
|
|
// Read configuration from data attributes
|
|
const dataConfig = {
|
|
columns: this.kanbanSection.dataset.columns ? this.kanbanSection.dataset.columns.split(',') : undefined,
|
|
startTime: this.kanbanSection.dataset.startTime,
|
|
endTime: this.kanbanSection.dataset.endTime,
|
|
interval: this.kanbanSection.dataset.interval ? parseInt(this.kanbanSection.dataset.interval, 10) : undefined
|
|
};
|
|
|
|
// Default configuration options
|
|
const defaultConfig = {
|
|
columns: ['Backlog', 'In Progress', 'Done'],
|
|
timeSlots: null,
|
|
startTime: '08:00',
|
|
endTime: '18:00',
|
|
interval: 60, // in minutes
|
|
generateModal: true, // Option to generate modal or not
|
|
currentDate: new Date().toISOString().split('T')[0] // Default to today's date
|
|
};
|
|
|
|
// Merge configurations
|
|
this.config = { ...defaultConfig, ...config, ...dataConfig };
|
|
this.timeSlots = this.config.timeSlots || this.generateTimeSlots(this.config.startTime, this.config.endTime, this.config.interval);
|
|
this.columns = this.config.columns;
|
|
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
this.createTaskBtn = document.getElementById('createTaskBtn');
|
|
this.kanbanSection = document.getElementById('kanblendar');
|
|
|
|
if (this.config.generateModal) {
|
|
this.createModal();
|
|
} else {
|
|
this.taskModal = document.getElementById('kanblendar-taskModal');
|
|
this.closeModal = document.querySelector('.kanblendar-close');
|
|
this.taskForm = document.getElementById('kanblendar-taskForm');
|
|
}
|
|
|
|
this.createTaskBtn.addEventListener('click', () => this.openModal());
|
|
this.closeModal.addEventListener('click', () => this.closeModalFunc());
|
|
window.addEventListener('click', (event) => {
|
|
if (event.target === this.taskModal) {
|
|
this.closeModalFunc();
|
|
}
|
|
});
|
|
this.taskForm.addEventListener('submit', (event) => this.saveTask(event));
|
|
|
|
document.addEventListener('taskMoved', (e) => {
|
|
console.log(`Task ${e.detail.taskId} moved to ${e.detail.newParent}`);
|
|
});
|
|
|
|
this.generateKanbanColumns();
|
|
this.initDragAndDrop();
|
|
this.requestNotificationPermission();
|
|
}
|
|
|
|
createModal() {
|
|
const modalHTML = `
|
|
<div id="kanblendar-taskModal" class="kanblendar-modal">
|
|
<div class="kanblendar-modal-content">
|
|
<span class="kanblendar-close">×</span>
|
|
<h2 id="kanblendar-modalTitle">Create Task</h2>
|
|
<form id="kanblendar-taskForm">
|
|
<label for="kanblendar-taskTitle">Title:</label>
|
|
<input type="text" id="kanblendar-taskTitle" name="kanblendar-taskTitle" required>
|
|
<label for="kanblendar-taskDescription">Description:</label>
|
|
<textarea id="kanblendar-taskDescription" name="kanblendar-taskDescription" required></textarea>
|
|
<label for="kanblendar-taskDueTime">Due Time:</label>
|
|
<input type="datetime-local" id="kanblendar-taskDueTime" name="kanblendar-taskDueTime">
|
|
<label for="kanblendar-taskColumn">Column:</label>
|
|
<select id="kanblendar-taskColumn" name="kanblendar-taskColumn"></select>
|
|
<label for="kanblendar-taskNotify">Notify Before (minutes):</label>
|
|
<input type="number" id="kanblendar-taskNotify" name="kanblendar-taskNotify" min="0">
|
|
<button type="submit">Save</button>
|
|
<button type="button" id="kanblendar-deleteTaskBtn" class="kanblendar-delete-task" style="display: none;">Delete</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.body.insertAdjacentHTML('beforeend', modalHTML);
|
|
this.taskModal = document.getElementById('kanblendar-taskModal');
|
|
this.closeModal = document.querySelector('.kanblendar-close');
|
|
this.taskForm = document.getElementById('kanblendar-taskForm');
|
|
this.deleteTaskBtn = document.getElementById('kanblendar-deleteTaskBtn');
|
|
|
|
// Populate the column dropdown
|
|
const taskColumnSelect = document.getElementById('kanblendar-taskColumn');
|
|
this.columns.forEach(column => {
|
|
const option = document.createElement('option');
|
|
option.value = column.toLowerCase().replace(/\s+/g, '-');
|
|
option.text = column;
|
|
taskColumnSelect.appendChild(option);
|
|
});
|
|
|
|
this.deleteTaskBtn.addEventListener('click', () => {
|
|
if (this.currentTask) {
|
|
this.deleteTask(this.currentTask);
|
|
this.closeModalFunc();
|
|
}
|
|
});
|
|
}
|
|
|
|
generateTimeSlots(startTime, endTime, interval) {
|
|
const timeSlots = [];
|
|
let currentTime = this.parseTime(startTime);
|
|
const end = this.parseTime(endTime);
|
|
|
|
while (currentTime < end) {
|
|
timeSlots.push({
|
|
display: this.formatTime(currentTime),
|
|
value: currentTime.toTimeString().slice(0, 5) // HH:MM format
|
|
});
|
|
currentTime = this.addMinutes(currentTime, interval);
|
|
}
|
|
|
|
return timeSlots;
|
|
}
|
|
|
|
parseTime(time) {
|
|
const [hours, minutes] = time.split(':').map(Number);
|
|
return new Date(1970, 0, 1, hours, minutes);
|
|
}
|
|
|
|
formatTime(date) {
|
|
return new Intl.DateTimeFormat(navigator.language, {
|
|
hour: 'numeric',
|
|
minute: 'numeric'
|
|
}).format(date);
|
|
}
|
|
|
|
addMinutes(date, minutes) {
|
|
return new Date(date.getTime() + minutes * 60000);
|
|
}
|
|
|
|
generateKanbanColumns() {
|
|
this.columns.forEach(column => {
|
|
const columnElement = document.createElement('div');
|
|
columnElement.classList.add('kanblendar-column');
|
|
columnElement.id = column.toLowerCase().replace(/\s+/g, '-');
|
|
columnElement.innerHTML = `<h2>${column}</h2><div class="kanblendar-non-timed-tasks" id="${column.toLowerCase().replace(/\s+/g, '-')}-tasks"></div>`;
|
|
|
|
this.timeSlots.forEach(timeSlot => {
|
|
const timeSlotElement = document.createElement('div');
|
|
timeSlotElement.classList.add('kanblendar-time-slot');
|
|
timeSlotElement.innerText = timeSlot.display;
|
|
timeSlotElement.dataset.startTime = timeSlot.value;
|
|
columnElement.appendChild(timeSlotElement);
|
|
});
|
|
|
|
this.kanbanSection.appendChild(columnElement);
|
|
});
|
|
}
|
|
|
|
initDragAndDrop() {
|
|
const timeSlots = document.querySelectorAll('.kanblendar-time-slot, .kanblendar-non-timed-tasks');
|
|
timeSlots.forEach(slot => {
|
|
slot.addEventListener('dragover', (e) => this.dragOver(e));
|
|
slot.addEventListener('drop', (e) => this.drop(e));
|
|
slot.addEventListener('dragenter', (e) => this.dragEnter(e));
|
|
slot.addEventListener('dragleave', (e) => this.dragLeave(e));
|
|
});
|
|
|
|
const tasks = document.querySelectorAll('.kanblendar-task');
|
|
tasks.forEach(task => {
|
|
task.addEventListener('dragstart', (e) => this.dragStart(e));
|
|
task.addEventListener('click', (e) => this.openModal(task)); // Add click event to open modal for editing
|
|
});
|
|
}
|
|
|
|
dragStart(e) {
|
|
e.dataTransfer.setData('text/plain', e.target.id);
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
}
|
|
|
|
dragOver(e) {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'move';
|
|
}
|
|
|
|
dragEnter(e) {
|
|
e.preventDefault();
|
|
if (e.target.classList.contains('kanblendar-task')) {
|
|
e.target.parentElement.classList.add('kanblendar-drag-over');
|
|
} else if (e.target.parentElement.classList.contains('kanblendar-task')) {
|
|
e.target.parentElement.parentElement.classList.add('kanblendar-drag-over');
|
|
} else {
|
|
e.target.classList.add('kanblendar-drag-over');
|
|
}
|
|
}
|
|
|
|
dragLeave(e) {
|
|
if (e.target.classList.contains('kanblendar-task')) {
|
|
e.target.parentElement.classList.remove('kanblendar-drag-over');
|
|
} else if (e.target.parentElement.classList.contains('kanblendar-task')) {
|
|
e.target.parentElement.parentElement.classList.remove('kanblendar-drag-over');
|
|
} else {
|
|
e.target.classList.remove('kanblendar-drag-over');
|
|
}
|
|
}
|
|
|
|
drop(e) {
|
|
e.preventDefault();
|
|
if (e.target.classList.contains('kanblendar-task')) {
|
|
e.target.parentElement.classList.remove('kanblendar-drag-over');
|
|
} else if (e.target.parentElement.classList.contains('kanblendar-task')) {
|
|
e.target.parentElement.parentElement.classList.remove('kanblendar-drag-over');
|
|
} else {
|
|
e.target.classList.remove('kanblendar-drag-over');
|
|
}
|
|
|
|
const id = e.dataTransfer.getData('text/plain');
|
|
const task = document.getElementById(id);
|
|
|
|
// Check if the drop target is a valid drop zone (time slot or non-timed tasks area)
|
|
let dropTarget = e.target;
|
|
if (dropTarget.classList.contains('kanblendar-task')) {
|
|
dropTarget = dropTarget.parentElement;
|
|
} else if (dropTarget.parentElement.classList.contains('kanblendar-task')) {
|
|
dropTarget = dropTarget.parentElement.parentElement;
|
|
}
|
|
|
|
if (dropTarget.classList.contains('kanblendar-time-slot') || dropTarget.classList.contains('kanblendar-non-timed-tasks')) {
|
|
dropTarget.appendChild(task);
|
|
this.emitTaskMovedEvent(task, dropTarget);
|
|
}
|
|
|
|
// Update the task's due time if dropped in a time slot and the current due time is not valid for that slot
|
|
if (dropTarget.classList.contains('kanblendar-time-slot')) {
|
|
const startTime = dropTarget.dataset.startTime;
|
|
const dueTime = new Date(task.dataset.dueTime);
|
|
const slotStartTime = new Date(`${this.config.currentDate}T${startTime}:00`);
|
|
const slotEndTime = this.addMinutes(slotStartTime, this.config.interval);
|
|
|
|
if (!(dueTime >= slotStartTime && dueTime <= slotEndTime)) {
|
|
task.dataset.dueTime = slotStartTime.toISOString();
|
|
}
|
|
}
|
|
}
|
|
|
|
openModal(task = null) {
|
|
this.currentTask = task;
|
|
if (task) {
|
|
document.getElementById('kanblendar-modalTitle').innerText = 'Edit Task';
|
|
document.getElementById('kanblendar-taskTitle').value = task.querySelector('.kanblendar-task-title').innerText;
|
|
document.getElementById('kanblendar-taskDescription').value = task.querySelector('.kanblendar-task-desc').innerText;
|
|
document.getElementById('kanblendar-taskDueTime').value = task.dataset.dueTime || '';
|
|
document.getElementById('kanblendar-taskColumn').value = task.dataset.column || '';
|
|
document.getElementById('kanblendar-taskNotify').value = task.dataset.notifyBefore || '';
|
|
this.deleteTaskBtn.style.display = 'block'; // Show delete button when editing
|
|
} else {
|
|
document.getElementById('kanblendar-modalTitle').innerText = 'Create Task';
|
|
this.taskForm.reset();
|
|
this.deleteTaskBtn.style.display = 'none'; // Hide delete button when creating
|
|
}
|
|
this.taskModal.style.display = 'flex';
|
|
}
|
|
|
|
closeModalFunc() {
|
|
this.taskModal.style.display = 'none';
|
|
}
|
|
|
|
saveTask(event) {
|
|
event.preventDefault();
|
|
const title = document.getElementById('kanblendar-taskTitle').value;
|
|
const description = document.getElementById('kanblendar-taskDescription').value;
|
|
const dueTime = document.getElementById('kanblendar-taskDueTime').value;
|
|
const column = document.getElementById('kanblendar-taskColumn').value;
|
|
const notifyBefore = parseInt(document.getElementById('kanblendar-taskNotify').value, 10);
|
|
|
|
let newTask = null;
|
|
|
|
if (this.currentTask) {
|
|
this.currentTask.querySelector('.kanblendar-task-title').innerText = title;
|
|
this.currentTask.querySelector('.kanblendar-task-desc').innerText = description;
|
|
this.currentTask.dataset.dueTime = dueTime;
|
|
this.currentTask.dataset.column = column;
|
|
this.currentTask.dataset.notifyBefore = notifyBefore;
|
|
this.moveTaskToColumn(this.currentTask, column);
|
|
this.updateTaskLocation(this.currentTask, dueTime);
|
|
this.cancelNotification(this.currentTask.id);
|
|
} else {
|
|
newTask = this.createTaskElement(title, description, dueTime, column, notifyBefore);
|
|
this.moveTaskToColumn(newTask, column); // Correctly move to the selected column
|
|
this.updateTaskLocation(newTask, dueTime);
|
|
}
|
|
|
|
if (dueTime && notifyBefore >= 0) {
|
|
this.scheduleNotification(title, description, new Date(dueTime), notifyBefore, this.currentTask ? this.currentTask.id : newTask.id);
|
|
}
|
|
|
|
this.closeModalFunc();
|
|
}
|
|
|
|
createTaskElement(title, description, dueTime, column, notifyBefore) {
|
|
const id = `task-${Date.now()}`;
|
|
const newTask = document.createElement('div');
|
|
newTask.classList.add('kanblendar-task');
|
|
newTask.setAttribute('draggable', 'true');
|
|
newTask.setAttribute('id', id);
|
|
newTask.dataset.dueTime = dueTime;
|
|
newTask.dataset.column = column;
|
|
newTask.dataset.notifyBefore = notifyBefore;
|
|
newTask.innerHTML = `
|
|
<div class="kanblendar-task-title">${title}</div>
|
|
<div class="kanblendar-task-desc">${description}</div>
|
|
`;
|
|
newTask.addEventListener('dragstart', (e) => this.dragStart(e));
|
|
newTask.addEventListener('click', (e) => this.openModal(newTask)); // Add click event to open modal for editing
|
|
this.tasks.set(id, { title, description, dueTime, notifyBefore });
|
|
return newTask;
|
|
}
|
|
|
|
moveTaskToColumn(task, column) {
|
|
const columnTasks = document.getElementById(`${column}-tasks`);
|
|
if (columnTasks) {
|
|
columnTasks.appendChild(task);
|
|
}
|
|
}
|
|
|
|
updateTaskLocation(task, dueTime) {
|
|
const taskDate = new Date(dueTime).toISOString().split('T')[0];
|
|
if (taskDate === this.config.currentDate) {
|
|
const taskTime = new Date(dueTime).toTimeString().slice(0, 5); // HH:MM format
|
|
let placedInTimeSlot = false;
|
|
|
|
document.querySelectorAll(`#${task.dataset.column} .kanblendar-time-slot`).forEach(timeSlotElement => {
|
|
const startTime = timeSlotElement.dataset.startTime;
|
|
const endTime = this.addMinutes(this.parseTime(startTime), this.config.interval).toTimeString().slice(0, 5);
|
|
if (taskTime >= startTime && taskTime < endTime) {
|
|
timeSlotElement.appendChild(task);
|
|
placedInTimeSlot = true;
|
|
}
|
|
});
|
|
|
|
if (!placedInTimeSlot) {
|
|
const columnTasks = document.getElementById(`${task.dataset.column}-tasks`);
|
|
columnTasks.appendChild(task);
|
|
}
|
|
} else {
|
|
const columnTasks = document.getElementById(`${task.dataset.column}-tasks`);
|
|
columnTasks.appendChild(task);
|
|
}
|
|
}
|
|
|
|
deleteTask(task) {
|
|
this.cancelNotification(task.id);
|
|
task.remove();
|
|
this.tasks.delete(task.id);
|
|
}
|
|
|
|
scheduleNotification(title, description, dueTime, notifyBefore, taskId) {
|
|
const notifyTime = new Date(dueTime.getTime() - notifyBefore * 60000);
|
|
const now = new Date();
|
|
|
|
if (notifyTime > now) {
|
|
const timeout = notifyTime.getTime() - now.getTime();
|
|
const timeoutId = setTimeout(() => {
|
|
this.showNotification(title, description);
|
|
this.notificationTimeouts.delete(taskId);
|
|
}, timeout);
|
|
|
|
this.notificationTimeouts.set(taskId, timeoutId);
|
|
}
|
|
}
|
|
|
|
cancelNotification(taskId) {
|
|
if (this.notificationTimeouts.has(taskId)) {
|
|
clearTimeout(this.notificationTimeouts.get(taskId));
|
|
this.notificationTimeouts.delete(taskId);
|
|
}
|
|
}
|
|
|
|
showNotification(title, description) {
|
|
if (Notification.permission === 'granted') {
|
|
new Notification(title, { body: description });
|
|
} else {
|
|
alert(`Reminder: ${title}\n${description}`);
|
|
}
|
|
}
|
|
|
|
requestNotificationPermission() {
|
|
if ('Notification' in window) {
|
|
Notification.requestPermission().then(permission => {
|
|
if (permission === 'granted') {
|
|
console.log('Notification permission granted.');
|
|
} else {
|
|
console.log('Notification permission denied.');
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
emitTaskMovedEvent(task, target) {
|
|
const event = new CustomEvent('taskMoved', {
|
|
detail: {
|
|
taskId: task.id,
|
|
newParent: target.id
|
|
}
|
|
});
|
|
document.dispatchEvent(event);
|
|
}
|
|
}
|