Skip to content

Commit cb34e5f

Browse files
committed
feat: allow manual task reordering via HTML5 drag-and-drop
Users can reorder tasks by dragging the grip handle; order is stored in IndexedDB and used when loading and filtering.
1 parent 2e80d72 commit cb34e5f

3 files changed

Lines changed: 146 additions & 34 deletions

File tree

js/app.js

Lines changed: 118 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
initFilters,
1010
setTasks,
1111
getFilteredTasks,
12+
getOrderedCachedTasks,
1213
getCurrentFilter,
1314
getTaskCounts,
1415
getCompletedThisWeekCount,
@@ -34,11 +35,21 @@ const sortModeSelect = document.getElementById("sort-mode");
3435
const themeToggleBtn = document.getElementById("theme-toggle");
3536
const installBtn = document.getElementById("install-btn");
3637
const weeklySummaryEl = document.getElementById("task-weekly-summary");
37-
const advancedFieldsToggleBtn = document.getElementById("toggle-advanced-fields");
38-
const advancedFieldsContainer = document.getElementById("advanced-task-options");
39-
const advancedFiltersToggleBtn = document.getElementById("toggle-advanced-filters");
40-
const advancedFiltersContainer = document.getElementById("advanced-filters-container");
41-
const advancedFiltersToggleContainer = document.querySelector(".advanced-filters-toggle-container");
38+
const advancedFieldsToggleBtn = document.getElementById(
39+
"toggle-advanced-fields",
40+
);
41+
const advancedFieldsContainer = document.getElementById(
42+
"advanced-task-options",
43+
);
44+
const advancedFiltersToggleBtn = document.getElementById(
45+
"toggle-advanced-filters",
46+
);
47+
const advancedFiltersContainer = document.getElementById(
48+
"advanced-filters-container",
49+
);
50+
const advancedFiltersToggleContainer = document.querySelector(
51+
".advanced-filters-toggle-container",
52+
);
4253

4354
// Global Variables
4455
let activeTab = "schedule"; // or: no-time
@@ -74,9 +85,7 @@ submitFormBtn.addEventListener("click", submitForm);
7485
// toggle advanced (optional) fields visibility
7586
if (advancedFieldsToggleBtn && advancedFieldsContainer) {
7687
// start collapsed
77-
advancedFieldsContainer.classList.remove(
78-
"advanced-task-options--open",
79-
);
88+
advancedFieldsContainer.classList.remove("advanced-task-options--open");
8089
advancedFieldsToggleBtn.setAttribute("aria-expanded", "false");
8190

8291
advancedFieldsToggleBtn.addEventListener("click", () => {
@@ -93,19 +102,18 @@ if (advancedFieldsToggleBtn && advancedFieldsContainer) {
93102
"aria-expanded",
94103
!isOpen ? "true" : "false",
95104
);
96-
document.querySelector(".advanced-fields-toggle-text").textContent = !isOpen
97-
? "Hide additional options"
98-
: "Show additional options";
99-
document.querySelector(".advanced-fields-toggle i").classList.toggle("active", !isOpen);
105+
document.querySelector(".advanced-fields-toggle-text").textContent =
106+
!isOpen ? "Hide additional options" : "Show additional options";
107+
document
108+
.querySelector(".advanced-fields-toggle i")
109+
.classList.toggle("active", !isOpen);
100110
});
101111
}
102112

103113
// toggle advanced filters visibility
104114
if (advancedFiltersToggleBtn && advancedFiltersContainer) {
105115
// start collapsed
106-
advancedFiltersContainer.classList.remove(
107-
"tasks-advanced-filters--open",
108-
);
116+
advancedFiltersContainer.classList.remove("tasks-advanced-filters--open");
109117
advancedFiltersToggleBtn.setAttribute("aria-expanded", "false");
110118

111119
advancedFiltersToggleBtn.addEventListener("click", () => {
@@ -122,13 +130,44 @@ if (advancedFiltersToggleBtn && advancedFiltersContainer) {
122130
"aria-expanded",
123131
!isOpen ? "true" : "false",
124132
);
125-
document.querySelector(".advanced-filters-toggle-text").textContent = !isOpen
126-
? "Hide advanced filters"
127-
: "Show advanced filters";
128-
document.querySelector("#toggle-advanced-filters i").classList.toggle("active", !isOpen);
133+
document.querySelector(".advanced-filters-toggle-text").textContent =
134+
!isOpen ? "Hide advanced filters" : "Show advanced filters";
135+
document
136+
.querySelector("#toggle-advanced-filters i")
137+
.classList.toggle("active", !isOpen);
129138
});
130139
}
131140

141+
// Reorder tasks: move task with sourceId before task with targetId, persist order to IndexedDB
142+
async function reorderTasks(sourceId, targetId) {
143+
if (!sourceId || !targetId || sourceId === targetId) return;
144+
const ordered = getOrderedCachedTasks();
145+
const sourceIndex = ordered.findIndex((t) => t && t.id === sourceId);
146+
const targetIndex = ordered.findIndex((t) => t && t.id === targetId);
147+
if (sourceIndex === -1 || targetIndex === -1) return;
148+
149+
const [moved] = ordered.splice(sourceIndex, 1);
150+
ordered.splice(targetIndex, 0, moved);
151+
ordered.forEach((task, i) => {
152+
if (task) task.order = i;
153+
});
154+
155+
try {
156+
await Promise.all(
157+
ordered.map((task) => updateTask(task, task.id)),
158+
);
159+
setTasks(ordered);
160+
cachedTasks = ordered;
161+
const tasksToRender = getFilteredTasks();
162+
renderTasksInPage(tasksToRender);
163+
updateFilterBadges();
164+
updateWeeklySummary();
165+
showToast("✓ Task order updated", "success", 2000);
166+
} catch (err) {
167+
showToast("✗ Could not save order. Please try again.", "error", 3000);
168+
}
169+
}
170+
132171
// initialize filters module for filter buttons and advanced filters
133172
initFilters(
134173
filterButtons,
@@ -376,10 +415,7 @@ async function markTaskCompleted(id, iconEl) {
376415

377416
const icon = iconEl.querySelector("i");
378417
if (icon) {
379-
icon.classList.toggle(
380-
"fa-circle-check",
381-
newStatus === "completed",
382-
);
418+
icon.classList.toggle("fa-circle-check", newStatus === "completed");
383419
icon.classList.toggle("fa-circle", newStatus !== "completed");
384420
icon.classList.toggle("text-success", newStatus === "completed");
385421
}
@@ -407,13 +443,25 @@ async function markTaskCompleted(id, iconEl) {
407443
}
408444
}
409445

446+
// Ensure every task has an order; sort by order (tasks without order go to end)
447+
function normalizeTaskOrder(tasks) {
448+
if (!Array.isArray(tasks) || tasks.length === 0) return;
449+
const sorted = [...tasks].sort(
450+
(a, b) => (a.order ?? Infinity) - (b.order ?? Infinity),
451+
);
452+
sorted.forEach((task, i) => {
453+
if (task) task.order = i;
454+
});
455+
}
456+
410457
// Update Tasks Container
411458
async function updateUI() {
412459
try {
413460
// show loading state while fetching from IndexedDB
414461
showLoadingState();
415462

416-
const allTasks = await getAllTasksFromDB();
463+
let allTasks = await getAllTasksFromDB();
464+
normalizeTaskOrder(allTasks);
417465
cachedTasks = allTasks;
418466

419467
// Ensure any tasks whose endTime has passed are marked as overdue in DB
@@ -442,10 +490,10 @@ async function updateUI() {
442490
renderTasksInPage(tasksToRender);
443491
updateFilterBadges();
444492
updateWeeklySummary();
445-
493+
446494
// show advanced filters toggle button if there are tasks
447495
if (cachedTasks.length > 0 && advancedFiltersToggleContainer) {
448-
advancedFiltersToggleContainer.classList.remove('d-none');
496+
advancedFiltersToggleContainer.classList.remove("d-none");
449497
}
450498
} catch (error) {
451499
showToast(
@@ -479,7 +527,7 @@ async function submitForm(e) {
479527
// validate form data
480528
if (!checkInputValidity(taskTitle, taskTime, taskDescription)) return;
481529

482-
// create task object
530+
// create task object (order = append at end)
483531
const task = {
484532
id: `${taskTitle} - ${Date.now()} - ${Math.random()}`, // generate unique id for each task
485533
title: taskTitle,
@@ -488,6 +536,7 @@ async function submitForm(e) {
488536
status: "pending", // pending, completed, overdue
489537
priority: taskPriority || "Medium",
490538
tags,
539+
order: getOrderedCachedTasks().length,
491540
};
492541

493542
// store form data in IndexedDB
@@ -924,6 +973,26 @@ function showTaskLists(tasks) {
924973
"justify-content-between",
925974
);
926975

976+
// Drag handle (only this starts drag to avoid conflicting with buttons)
977+
const dragHandle = document.createElement("span");
978+
dragHandle.classList.add("task-drag-handle", "icon-btn");
979+
dragHandle.setAttribute("draggable", "true");
980+
dragHandle.setAttribute("aria-label", "Drag to reorder");
981+
dragHandle.setAttribute("title", "Drag to reorder");
982+
const gripIcon = document.createElement("i");
983+
gripIcon.classList.add("fa-solid", "fa-grip-vertical", "text-muted");
984+
dragHandle.appendChild(gripIcon);
985+
dragHandle.addEventListener("dragstart", (e) => {
986+
e.stopPropagation();
987+
e.dataTransfer.setData("text/plain", task.id);
988+
e.dataTransfer.effectAllowed = "move";
989+
e.dataTransfer.setDragImage(li, 0, 0);
990+
li.classList.add("task-dragging");
991+
});
992+
dragHandle.addEventListener("dragend", () => {
993+
li.classList.remove("task-dragging");
994+
});
995+
927996
// Content
928997
const content = document.createElement("div");
929998
content.classList.add("content");
@@ -1031,10 +1100,7 @@ function showTaskLists(tasks) {
10311100
deleteBtn.type = "button";
10321101
deleteBtn.classList.add("icon-btn", "task-delete-btn");
10331102
deleteBtn.setAttribute("data-id", task.id);
1034-
deleteBtn.setAttribute(
1035-
"aria-label",
1036-
`Delete task "${task.title}"`,
1037-
);
1103+
deleteBtn.setAttribute("aria-label", `Delete task "${task.title}"`);
10381104

10391105
const deleteIcon = document.createElement("i");
10401106
deleteIcon.classList.add(
@@ -1070,6 +1136,7 @@ function showTaskLists(tasks) {
10701136
actions.append(completeBtn);
10711137
}
10721138
actions.append(deleteBtn, toggleBtn);
1139+
wrapper.prepend(dragHandle);
10731140
wrapper.append(content, actions);
10741141

10751142
// Time / status
@@ -1110,6 +1177,26 @@ function showTaskLists(tasks) {
11101177
}
11111178

11121179
li.append(wrapper, timeSpan);
1180+
1181+
// Drag-and-drop: allow dropping on this item to reorder
1182+
li.addEventListener("dragover", (e) => {
1183+
e.preventDefault();
1184+
e.dataTransfer.dropEffect = "move";
1185+
if (!li.classList.contains("task-dragging")) {
1186+
li.classList.add("task-drag-over");
1187+
}
1188+
});
1189+
li.addEventListener("dragleave", () => {
1190+
li.classList.remove("task-drag-over");
1191+
});
1192+
li.addEventListener("drop", (e) => {
1193+
e.preventDefault();
1194+
li.classList.remove("task-drag-over");
1195+
const sourceId = e.dataTransfer.getData("text/plain");
1196+
if (!sourceId || sourceId === task.id) return;
1197+
reorderTasks(sourceId, task.id);
1198+
});
1199+
11131200
fragment.appendChild(li);
11141201
});
11151202

js/filters.js

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,19 @@ export function setTasks(tasks) {
7171
cachedTasks = Array.isArray(tasks) ? tasks : [];
7272
}
7373

74+
// Get all cached tasks in display order (by order field)
75+
export function getOrderedCachedTasks() {
76+
if (!Array.isArray(cachedTasks)) return [];
77+
return [...cachedTasks].sort(
78+
(a, b) => (a.order ?? 999999) - (b.order ?? 999999),
79+
);
80+
}
81+
7482
// Get tasks filtered by current filter / priority / tag, and sorted if needed
7583
export function getFilteredTasks() {
7684
if (!Array.isArray(cachedTasks)) return [];
7785

78-
let tasks = cachedTasks.slice();
86+
let tasks = getOrderedCachedTasks();
7987

8088
// status filter
8189
if (currentFilter !== "all") {
@@ -133,8 +141,8 @@ export function getFilteredTasks() {
133141

134142
if (aRank !== bRank) return aRank - bRank;
135143

136-
// tie-breaker: keep original order by not changing when ranks equal
137-
return 0;
144+
// tie-breaker: preserve manual order
145+
return (a.order ?? 999999) - (b.order ?? 999999);
138146
});
139147
}
140148

style.css

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,23 @@ main > .container {
391391
}
392392
}
393393

394+
/* Drag-and-drop reorder */
395+
.task-drag-handle {
396+
cursor: grab;
397+
align-self: center;
398+
padding: 0.25rem;
399+
}
400+
.task-drag-handle:active {
401+
cursor: grabbing;
402+
}
403+
.custom-list-item.task-dragging {
404+
opacity: 0.6;
405+
}
406+
.custom-list-item.task-drag-over {
407+
outline: 2px dashed var(--primary-color);
408+
outline-offset: 2px;
409+
}
410+
394411
.custom-list-item.task-overdue h4 {
395412
text-decoration: line-through;
396413
opacity: 0.6;

0 commit comments

Comments
 (0)