diff --git a/CHANGELOG.md b/CHANGELOG.md index c383365..7b70b3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.20.22] - 2026-04-20 + +### Added +- Tasks: Kanban board now supports touch drag-and-drop on mobile — a ghost card follows the finger and drops into the target column on release +- Tasks: swipe-left to mark done/open now shows a 5-second undo toast that reverts the status change +- Tasks: opening a task card from the Dashboard now navigates to `/tasks` and immediately opens the edit modal for that task (deep-link via `?open=`) + +### Fixed +- Router: query parameters (e.g. `?open=123`) are now stripped before route matching, so parameterised URLs resolve correctly without falling back to the home page + ## [0.20.21] - 2026-04-20 ### Changed diff --git a/package-lock.json b/package-lock.json index 73b5bae..156d67f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "oikos", - "version": "0.20.20", + "version": "0.20.22", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "oikos", - "version": "0.20.20", + "version": "0.20.22", "license": "MIT", "dependencies": { "bcrypt": "^6.0.0", diff --git a/package.json b/package.json index e3ae63f..4e66b26 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oikos", - "version": "0.20.21", + "version": "0.20.22", "description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.", "main": "server/index.js", "type": "module", diff --git a/public/locales/de.json b/public/locales/de.json index a207970..93b3c20 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -152,7 +152,9 @@ "kanbanMoveToOpen": "Erneut öffnen", "recurring": "Wiederkehrend", "listView": "Listenansicht", - "kanbanView": "Kanban-Ansicht" + "kanbanView": "Kanban-Ansicht", + "swipedDoneToast": "Als erledigt markiert.", + "swipedOpenToast": "Als offen markiert." }, "shopping": { "title": "Einkauf", diff --git a/public/pages/dashboard.js b/public/pages/dashboard.js index 3af080d..8c66f13 100644 --- a/public/pages/dashboard.js +++ b/public/pages/dashboard.js @@ -197,7 +197,7 @@ function renderUrgentTasks(tasks) { const items = tasks.map((t) => { const due = formatDueDate(t.due_date); return ` -
+
${t.priority !== 'none' ? `` : ''} ${PRIORITY_LABELS()[t.priority] ?? t.priority}
diff --git a/public/pages/tasks.js b/public/pages/tasks.js index 81e5e1d..c45cc27 100644 --- a/public/pages/tasks.js +++ b/public/pages/tasks.js @@ -645,6 +645,7 @@ function renderKanban(container) { if (window.lucide) window.lucide.createIcons(); wireKanbanDrag(container); + wireKanbanTouch(container); updateOverdueBadge(); } @@ -752,6 +753,115 @@ function wireKanbanDrag(container) { }); } +// -------------------------------------------------------- +// Kanban-Touch-Drag (Mobile) +// -------------------------------------------------------- + +function wireKanbanTouch(container) { + const board = container.querySelector('.kanban-board'); + if (!board) return; + + let dragging = null; + let ghost = null; + let taskId = null; + let originX = 0, originY = 0; + let originLeft = 0, originTop = 0; + let activeZone = null; + let started = false; + + function cleanup() { + ghost?.remove(); + ghost = null; + if (dragging) { + dragging.classList.remove('kanban-card--dragging'); + dragging = null; + } + board.querySelectorAll('.kanban-col__body--over').forEach((el) => + el.classList.remove('kanban-col__body--over') + ); + activeZone = null; + started = false; + taskId = null; + } + + board.addEventListener('touchstart', (e) => { + const card = e.target.closest('.kanban-card[data-task-id]'); + if (!card || e.target.closest('[data-next-status]')) return; + dragging = card; + taskId = card.dataset.taskId; + const touch = e.touches[0]; + originX = touch.clientX; + originY = touch.clientY; + const rect = card.getBoundingClientRect(); + originLeft = rect.left; + originTop = rect.top; + started = false; + }, { passive: true }); + + board.addEventListener('touchmove', (e) => { + if (!dragging) return; + const touch = e.touches[0]; + const dx = touch.clientX - originX; + const dy = touch.clientY - originY; + + if (!started && Math.sqrt(dx * dx + dy * dy) < 8) return; + + if (!started) { + started = true; + ghost = dragging.cloneNode(true); + ghost.className = 'kanban-card kanban-card--ghost'; + ghost.style.width = dragging.getBoundingClientRect().width + 'px'; + ghost.style.left = originLeft + 'px'; + ghost.style.top = originTop + 'px'; + document.body.appendChild(ghost); + dragging.classList.add('kanban-card--dragging'); + } + + e.preventDefault(); + ghost.style.left = (originLeft + dx) + 'px'; + ghost.style.top = (originTop + dy) + 'px'; + + ghost.style.visibility = 'hidden'; + const el = document.elementFromPoint(touch.clientX, touch.clientY); + ghost.style.visibility = ''; + + const zone = el?.closest('[data-drop-zone]'); + board.querySelectorAll('.kanban-col__body--over').forEach((z) => + z.classList.remove('kanban-col__body--over') + ); + if (zone) { + zone.classList.add('kanban-col__body--over'); + activeZone = zone; + } else { + activeZone = null; + } + }, { passive: false }); + + board.addEventListener('touchend', async () => { + if (!dragging) return; + const zone = activeZone; + const tid = taskId; + const task = state.tasks.find((tk) => String(tk.id) === String(tid)); + cleanup(); + + if (!zone || !task) return; + const newStatus = zone.dataset.dropZone; + if (task.status === newStatus) return; + + task.status = newStatus; + renderKanban(container); + try { + await api.patch(`/tasks/${tid}/status`, { status: newStatus }); + await loadTasks(container); + } catch (err) { + window.oikos.showToast(err.message, 'danger'); + await loadTasks(container); + } + }, { passive: true }); + + board.addEventListener('touchcancel', cleanup, { passive: true }); +} + // -------------------------------------------------------- // Partielle DOM-Updates // -------------------------------------------------------- @@ -952,11 +1062,26 @@ function wireSwipeGestures(container) { card.style.transition = 'transform 0.2s ease'; card.style.transform = 'translateX(-110%)'; vibrate(40); + const capturedStatus = status; + const nextStatus = capturedStatus === 'done' ? 'open' : 'done'; setTimeout(async () => { resetCard(false); try { - await toggleTaskStatus(taskId, status); + await toggleTaskStatus(taskId, capturedStatus); await loadTasks(container); + window.oikos.showToast( + t(nextStatus === 'done' ? 'tasks.swipedDoneToast' : 'tasks.swipedOpenToast'), + 'default', + 5000, + async () => { + try { + await toggleTaskStatus(taskId, nextStatus); + await loadTasks(container); + } catch (err) { + window.oikos.showToast(err.message, 'danger'); + } + }, + ); } catch (err) { window.oikos.showToast(err.message, 'danger'); await loadTasks(container); @@ -1212,4 +1337,16 @@ export async function render(container, { user }) { wireTaskList(container); renderFilters(container); renderTaskList(container); + + // Deep-Link: ?open= öffnet direkt das Edit-Modal + const openId = new URLSearchParams(window.location.search).get('open'); + if (openId) { + try { + const [task, reminder] = await Promise.all([ + loadTaskForEdit(openId), + loadReminderForTask(openId), + ]); + openTaskModal({ task, users: state.users, reminder }, container); + } catch { /* Task existiert nicht oder kein Zugriff */ } + } } diff --git a/public/router.js b/public/router.js index 6d15ae4..d65c797 100644 --- a/public/router.js +++ b/public/router.js @@ -155,9 +155,10 @@ async function navigate(path, userOrPushState = true, pushState = true) { // Alten Pfad merken, bevor currentPath aktualisiert wird - für Richtungsberechnung const previousPath = currentPath; - currentPath = path; + const basePath = path.split('?')[0]; + currentPath = basePath; - const route = ROUTES.find((r) => r.path === path) ?? ROUTES.find((r) => r.path === '/'); + const route = ROUTES.find((r) => r.path === basePath) ?? ROUTES.find((r) => r.path === '/'); // Auth-Guard if (route.requiresAuth && !currentUser) { @@ -188,7 +189,7 @@ async function navigate(path, userOrPushState = true, pushState = true) { document.documentElement.style.setProperty('--active-module-accent', accent); await renderPage(route, previousPath); - updateNav(path); + updateNav(basePath); updateThemeColorForRoute(route); } finally { isNavigating = false; diff --git a/public/styles/tasks.css b/public/styles/tasks.css index 96fc56e..b002023 100644 --- a/public/styles/tasks.css +++ b/public/styles/tasks.css @@ -557,6 +557,16 @@ transform: rotate(1.5deg); } +.kanban-card--ghost { + position: fixed; + z-index: 1000; + pointer-events: none; + opacity: 0.88; + box-shadow: var(--shadow-xl); + transform: rotate(2deg) scale(1.03); + transition: none; +} + .kanban-card--done { opacity: 0.6; }