commit 38d9d36312b8e12673f5585dffd1dd8e269a2432 Author: Kumi Date: Tue Jul 30 10:52:03 2024 +0200 feat: add initial Kanblendar project with core features 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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9bc3d2b --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2024 Kumi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7644295 --- /dev/null +++ b/README.md @@ -0,0 +1,150 @@ +# Kanblendar + +[![Support Private.coffee!](https://shields.private.coffee/badge/private.coffee-support%20us!-pink?logo=coffeescript)](https://private.coffee) + +Kanblendar is a JavaScript class that blends the functionality of a Kanban board and a daily calendar. Users can create, edit, and organize tasks within columns and time slots, and receive notifications for upcoming tasks. + +## Features + +- Plain JavaScript class with no dependencies +- Create, edit, and delete tasks +- Organize tasks into columns and time slots +- Drag and drop tasks between columns and time slots +- Set due times and receive notifications +- Visual feedback during drag-and-drop operations + +## Installation + +1. Clone the repository: + ```sh + git clone https://git.private.coffee/kumi/kanblendar.git + ``` + +2. Navigate to the project directory: + ```sh + cd kanblendar + ``` + +3. Open `example.html` in your web browser to start the example application. + +## Usage + +### Creating a Task + +1. Click the "Create Task" button. +2. Fill out the task form with the title, description, due time, column, and notification time. +3. Click "Save" to create the task. + +### Editing a Task + +1. Click on an existing task to open the edit form. +2. Update the task details as needed. +3. Click "Save" to save the changes. + +### Deleting a Task + +1. Click on an existing task to open the edit form. +2. Click the "Delete" button to remove the task. + +### Drag and Drop + +- Drag tasks between columns or into specific time slots. +- Tasks will update their due time if moved into a time slot where the current due time is not valid. + +## Notifications + +- Kanblendar requests notification permissions on load. +- Notifications are shown for tasks based on the specified notification time. +- Notifications are automatically unscheduled if a task is modified or deleted. +- If notifications are not supported or denied, a alert will be shown instead. + +## Programmatically Modifying the Kanblendar + +You can programmatically create, edit, and delete tasks using JavaScript. Here are some examples: + +### Creating a Task + +```javascript +const kanblendar = new Kanblendar(); + +const title = 'New Task'; +const description = 'This is a new task.'; +const dueTime = '2023-10-31T14:00'; +const column = 'backlog'; +const notifyBefore = 10; // Notify 10 minutes before the due time + +const newTask = kanblendar.createTaskElement(title, description, dueTime, column, notifyBefore); +kanblendar.moveTaskToColumn(newTask, column); +kanblendar.updateTaskLocation(newTask, dueTime); +kanblendar.scheduleNotification(title, description, new Date(dueTime), notifyBefore, newTask.id); +``` + +### Editing a Task + +```javascript +const taskId = 'task-1234567890'; // Replace with the actual task ID +const task = document.getElementById(taskId); + +const newTitle = 'Updated Task'; +const newDescription = 'This is an updated task.'; +const newDueTime = '2023-10-31T15:00'; +const newColumn = 'in-progress'; +const newNotifyBefore = 15; // Notify 15 minutes before the due time + +task.querySelector('.kanblendar-task-title').innerText = newTitle; +task.querySelector('.kanblendar-task-desc').innerText = newDescription; +task.dataset.dueTime = newDueTime; +task.dataset.column = newColumn; +task.dataset.notifyBefore = newNotifyBefore; + +kanblendar.moveTaskToColumn(task, newColumn); +kanblendar.updateTaskLocation(task, newDueTime); +kanblendar.cancelNotification(task.id); +kanblendar.scheduleNotification(newTitle, newDescription, new Date(newDueTime), newNotifyBefore, task.id); +``` + +### Deleting a Task + +```javascript +const taskId = 'task-1234567890'; // Replace with the actual task ID +const task = document.getElementById(taskId); + +kanblendar.deleteTask(task); +``` + +## Development + +### Project Structure + +- `kanblendar.css` - CSS styles +- `kanblendar.js` - JavaScript functionality +- `example.html` - Example HTML file +- `example.js` - Additional JavaScript code for the example +- `example.css` - Additional CSS styles for the example + +### Adding Features + +1. Clone the repository and create a new branch: + ```sh + git checkout -b feature-branch + ``` + +2. Make your changes and commit them: + ```sh + git commit -m "Add new feature" + ``` + +3. Push the changes to the remote repository: + ```sh + git push origin feature-branch + ``` + +4. Open a pull request to the main branch. + +## Contributing + +Contributions are welcome! Please open an issue or submit a pull request for any improvements or bug fixes. + +## License + +This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. \ No newline at end of file diff --git a/example.css b/example.css new file mode 100644 index 0000000..681b869 --- /dev/null +++ b/example.css @@ -0,0 +1,43 @@ +body { + font-family: Arial, sans-serif; + margin: 0; + padding: 0; + box-sizing: border-box; +} + +header { + background-color: #4CAF50; + color: white; + text-align: center; + padding: 1em 0; +} + +header button { + margin: 1em; + padding: 0.5em 1em; + background-color: #fff; + border: 1px solid #4CAF50; + border-radius: 5px; + cursor: pointer; +} + +main { + display: flex; + padding: 1em; + padding-bottom: 4em; +} + + +footer { + background-color: #4CAF50; + color: white; + text-align: center; + padding: 1em 0; + position: fixed; + bottom: 0; + width: 100%; +} + +footer a { + color: white; +} \ No newline at end of file diff --git a/example.html b/example.html new file mode 100644 index 0000000..ab41210 --- /dev/null +++ b/example.html @@ -0,0 +1,33 @@ + + + + + + Kanblendar + + + + +
+

Kanblendar

+ +
+
+
+ +
+
+
+

Brought to you by Kumi

+
+ + + + + + + \ No newline at end of file diff --git a/example.js b/example.js new file mode 100644 index 0000000..e1fb0ae --- /dev/null +++ b/example.js @@ -0,0 +1,3 @@ +document.addEventListener('DOMContentLoaded', () => { + new Kanblendar(); +}); \ No newline at end of file diff --git a/kanblendar.css b/kanblendar.css new file mode 100644 index 0000000..4e3d731 --- /dev/null +++ b/kanblendar.css @@ -0,0 +1,129 @@ +.kanblendar { + display: flex; + flex: 1; + gap: 1em; + padding: 1em; +} + +.kanblendar-column { + flex: 1; + background-color: #f4f4f4; + padding: 1em; + border-radius: 5px; + border: 1px solid #ddd; + display: flex; + flex-direction: column; +} + +.kanblendar-column h2 { + text-align: center; + border-bottom: 1px solid #ddd; + padding-bottom: 0.5em; +} + +.kanblendar-non-timed-tasks { + margin-bottom: 1em; + min-height: 50px; + /* Ensure it has a minimum height */ + background-color: #fafafa; + border: 1px dashed #ddd; +} + +.kanblendar-task { + background-color: #fff; + margin: 0.5em 0; + padding: 0.5em; + border: 1px solid #ddd; + border-radius: 3px; + box-shadow: 0 2px 2px rgba(0, 0, 0, 0.1); + cursor: grab; +} + +.kanblendar-time-slot { + background-color: #e9ecef; + margin: 0.5em 0; + padding: 1em; + border-radius: 5px; + border: 1px solid #ddd; + text-align: center; + min-height: 2em; + position: relative; +} + +.kanblendar-time-slot::after { + content: ''; + display: block; + height: 100%; + width: 100%; + position: absolute; + top: 0; + left: 0; + z-index: -1; +} + +.kanblendar-drag-over { + background-color: #cce5ff; + /* Highlight color */ + border-color: #004085; +} + +/* Modal Styles */ +.kanblendar-modal { + display: none; + position: fixed; + z-index: 1; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgba(0, 0, 0, 0.5); + justify-content: center; + align-items: center; +} + +.kanblendar-modal-content { + background-color: #fff; + padding: 2em; + border-radius: 5px; + width: 80%; + max-width: 500px; + margin: auto; +} + +.kanblendar-close { + color: #aaa; + float: right; + font-size: 28px; + font-weight: bold; +} + +.kanblendar-close:hover, +.kanblendar-close:focus { + color: black; + text-decoration: none; + cursor: pointer; +} + +/* Form Styles */ +.kanblendar-modal-content form { + display: flex; + flex-direction: column; +} + +.kanblendar-modal-content label, +.kanblendar-modal-content input, +.kanblendar-modal-content textarea, +.kanblendar-modal-content select, +.kanblendar-modal-content button { + margin-bottom: 1em; +} + +.kanblendar-delete-task { + background-color: #ff4c4c; + color: white; + border: none; + padding: 0.5em 1em; + border-radius: 5px; + cursor: pointer; +} \ No newline at end of file diff --git a/kanblendar.js b/kanblendar.js new file mode 100644 index 0000000..96fdf68 --- /dev/null +++ b/kanblendar.js @@ -0,0 +1,411 @@ +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 = ` +
+
+ × +

Create Task

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

${column}

`; + + 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 = ` +
${title}
+
${description}
+ `; + 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); + } +}