feat: kanban touch drag, swipe undo, dashboard task deep-link

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ulas Kalayci
2026-04-20 09:44:50 +02:00
parent c8e20b22c8
commit b867917995
8 changed files with 169 additions and 9 deletions
+10
View File
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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=<id>`)
### 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 ## [0.20.21] - 2026-04-20
### Changed ### Changed
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "oikos", "name": "oikos",
"version": "0.20.20", "version": "0.20.22",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "oikos", "name": "oikos",
"version": "0.20.20", "version": "0.20.22",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "oikos", "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.", "description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.",
"main": "server/index.js", "main": "server/index.js",
"type": "module", "type": "module",
+3 -1
View File
@@ -152,7 +152,9 @@
"kanbanMoveToOpen": "Erneut öffnen", "kanbanMoveToOpen": "Erneut öffnen",
"recurring": "Wiederkehrend", "recurring": "Wiederkehrend",
"listView": "Listenansicht", "listView": "Listenansicht",
"kanbanView": "Kanban-Ansicht" "kanbanView": "Kanban-Ansicht",
"swipedDoneToast": "Als erledigt markiert.",
"swipedOpenToast": "Als offen markiert."
}, },
"shopping": { "shopping": {
"title": "Einkauf", "title": "Einkauf",
+1 -1
View File
@@ -197,7 +197,7 @@ function renderUrgentTasks(tasks) {
const items = tasks.map((t) => { const items = tasks.map((t) => {
const due = formatDueDate(t.due_date); const due = formatDueDate(t.due_date);
return ` return `
<div class="task-item" data-route="/tasks" role="button" tabindex="0"> <div class="task-item" data-route="/tasks?open=${t.id}" role="button" tabindex="0">
${t.priority !== 'none' ? `<div class="task-item__priority task-item__priority--${t.priority}" aria-hidden="true"></div>` : ''} ${t.priority !== 'none' ? `<div class="task-item__priority task-item__priority--${t.priority}" aria-hidden="true"></div>` : ''}
<span class="sr-only">${PRIORITY_LABELS()[t.priority] ?? t.priority}</span> <span class="sr-only">${PRIORITY_LABELS()[t.priority] ?? t.priority}</span>
<div class="task-item__content"> <div class="task-item__content">
+138 -1
View File
@@ -645,6 +645,7 @@ function renderKanban(container) {
if (window.lucide) window.lucide.createIcons(); if (window.lucide) window.lucide.createIcons();
wireKanbanDrag(container); wireKanbanDrag(container);
wireKanbanTouch(container);
updateOverdueBadge(); 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 // Partielle DOM-Updates
// -------------------------------------------------------- // --------------------------------------------------------
@@ -952,11 +1062,26 @@ function wireSwipeGestures(container) {
card.style.transition = 'transform 0.2s ease'; card.style.transition = 'transform 0.2s ease';
card.style.transform = 'translateX(-110%)'; card.style.transform = 'translateX(-110%)';
vibrate(40); vibrate(40);
const capturedStatus = status;
const nextStatus = capturedStatus === 'done' ? 'open' : 'done';
setTimeout(async () => { setTimeout(async () => {
resetCard(false); resetCard(false);
try { try {
await toggleTaskStatus(taskId, status); await toggleTaskStatus(taskId, capturedStatus);
await loadTasks(container); 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) { } catch (err) {
window.oikos.showToast(err.message, 'danger'); window.oikos.showToast(err.message, 'danger');
await loadTasks(container); await loadTasks(container);
@@ -1212,4 +1337,16 @@ export async function render(container, { user }) {
wireTaskList(container); wireTaskList(container);
renderFilters(container); renderFilters(container);
renderTaskList(container); renderTaskList(container);
// Deep-Link: ?open=<id> ö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 */ }
}
} }
+4 -3
View File
@@ -155,9 +155,10 @@ async function navigate(path, userOrPushState = true, pushState = true) {
// Alten Pfad merken, bevor currentPath aktualisiert wird - für Richtungsberechnung // Alten Pfad merken, bevor currentPath aktualisiert wird - für Richtungsberechnung
const previousPath = currentPath; 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 // Auth-Guard
if (route.requiresAuth && !currentUser) { if (route.requiresAuth && !currentUser) {
@@ -188,7 +189,7 @@ async function navigate(path, userOrPushState = true, pushState = true) {
document.documentElement.style.setProperty('--active-module-accent', accent); document.documentElement.style.setProperty('--active-module-accent', accent);
await renderPage(route, previousPath); await renderPage(route, previousPath);
updateNav(path); updateNav(basePath);
updateThemeColorForRoute(route); updateThemeColorForRoute(route);
} finally { } finally {
isNavigating = false; isNavigating = false;
+10
View File
@@ -557,6 +557,16 @@
transform: rotate(1.5deg); 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 { .kanban-card--done {
opacity: 0.6; opacity: 0.6;
} }