feat(kanban): combine the columns of all lists by name in "Alle Aufgaben"; block drops into columns a task's list lacks
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m37s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-01 14:32:51 +01:00
1 parent 7860609201
commit 3e4eb9b14f
5 files changed
+124 -49

No files matched your search

+109 -44
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState, useRef, useMemo } from 'react';
import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import {
Check,
@@ -312,7 +312,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
return () => window.removeEventListener('mindsift:ws', handleWs);
}, []);
const safeTaskLists = Array.isArray(taskLists) ? taskLists : [];
const safeTaskLists = useMemo(() => (Array.isArray(taskLists) ? taskLists : []), [taskLists]);
const currentSelectedList = safeTaskLists.find((l) => l.id === selectedListId);
const isSelectedListReadOnly = currentSelectedList?.permission === 'viewer';
const isTaskReadOnly = (task: Thought) =>
@@ -367,10 +367,65 @@ export const TasksView: React.FC<TasksViewProps> = ({
}, [currentSelectedList, globalColumns]);
// The columns of the list a task belongs to: in "Alle Aufgaben" every task keeps its own list's columns
const columnsFor = (task: Thought): KanbanColumn[] => {
const own = safeTaskLists.find((l) => l.id === task.taskListId)?.columns;
return Array.isArray(own) && own.length > 0 ? own : activeColumns;
};
const columnsFor = useCallback(
(task: Thought): KanbanColumn[] => {
const own = safeTaskLists.find((l) => l.id === task.taskListId)?.columns;
return Array.isArray(own) && own.length > 0 ? own : activeColumns;
},
[safeTaskLists, activeColumns]
);
// The column of its own list a task stands in; a status whose column is gone counts as the first open one
const ownColumnOf = useCallback(
(task: Thought): KanbanColumn | undefined => {
const cols = columnsFor(task);
if (task.isCompleted) return cols.find((c) => c.isDone && c.id === task.kanbanStatus) || cols.find((c) => c.isDone);
return (
cols.find((c) => c.id === task.kanbanStatus) ||
cols.find((c) => c.id === 'todo') ||
cols.find((c) => !c.isDone)
);
},
[columnsFor]
);
// Board columns: those of the chosen list; in "Alle Aufgaben" the columns of all lists joined by name
// (case aside), in the order and colour of the first list that has them. Their IDs are then "name:…".
const isCombinedBoard = selectedListId === 'all';
const boardIdOf = useCallback(
(col: KanbanColumn) => (isCombinedBoard ? `name:${col.name.trim().toLowerCase()}` : col.id),
[isCombinedBoard]
);
const boardColumns: KanbanColumn[] = useMemo(() => {
if (!isCombinedBoard) return activeColumns;
const out: KanbanColumn[] = [];
const sources = [
...safeTaskLists.map((l) => (Array.isArray(l.columns) && l.columns.length > 0 ? l.columns : globalColumns)),
globalColumns, // personal tasks
];
const idOf = (col: KanbanColumn) => `name:${col.name.trim().toLowerCase()}`;
for (const cols of sources) {
cols.forEach((col, i) => {
const id = idOf(col);
if (out.some((c) => c.id === id)) return;
// A new column goes right before the next column of its list that is already on the board
const next = cols.slice(i + 1).map((c) => out.findIndex((o) => o.id === idOf(c))).find((at) => at >= 0);
out.splice(next ?? out.length, 0, { ...col, id });
});
}
return out;
}, [isCombinedBoard, activeColumns, safeTaskLists, globalColumns]);
// The task's own column behind a board column, or null if its list has no column of that name
const ownColumnIdFor = (task: Thought | undefined, boardColId: string): string | null =>
task ? (columnsFor(task).find((c) => boardIdOf(c) === boardColId)?.id ?? null) : null;
const inBoardColumn = useCallback(
(task: Thought, boardCol: KanbanColumn) => {
const own = ownColumnOf(task);
return !!own && boardIdOf(own) === boardCol.id;
},
[ownColumnOf, boardIdOf]
);
const safeTasks = useMemo(() => {
if (!Array.isArray(tasks)) return [];
@@ -484,11 +539,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
groups.push({ key: 'tag:none', title: 'Ohne Hashtag', count: withoutTag.length, tasks: withoutTag });
}
} else if (groupBy === 'status') {
activeColumns.forEach((col) => {
const list = filteredTasks.filter((t) => {
if (col.isDone) return t.isCompleted || t.kanbanStatus === col.id;
return !t.isCompleted && (t.kanbanStatus === col.id || (!t.kanbanStatus && col.id === 'todo'));
});
boardColumns.forEach((col) => {
const list = filteredTasks.filter((t) => inBoardColumn(t, col));
groups.push({ key: `status:${col.id}`, title: col.name, count: list.length, tasks: list });
});
} else if (groupBy === 'priority') {
@@ -511,7 +563,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
if (noDue.length > 0) groups.push({ key: 'due:none', title: 'Ohne Fälligkeitsdatum', count: noDue.length, tasks: noDue });
}
return groups;
}, [filteredTasks, groupBy, activeColumns]);
}, [filteredTasks, groupBy, boardColumns, inBoardColumn]);
// --- TASK ACTIONS ---
@@ -797,8 +849,10 @@ export const TasksView: React.FC<TasksViewProps> = ({
const title = prompt('Neue Aufgabe für diese Spalte:');
if (!title || !title.trim()) return;
triggerHaptic(10);
const targetCol = activeColumns.find((c) => c.id === columnId);
const isCompleted = targetCol ? !!targetCol.isDone : columnId === 'done';
const targetCol = activeColumns.find((c) => boardIdOf(c) === columnId);
if (!targetCol) return;
columnId = targetCol.id;
const isCompleted = !!targetCol.isDone;
try {
const created = await api.captureThought(title.trim(), 'normal', 'manual', undefined, {
target: 'task',
@@ -882,7 +936,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
const updatedSourceTask: Thought = {
...sourceTask,
kanbanStatus: newKanbanCol || targetTask.kanbanStatus || sourceTask.kanbanStatus,
isCompleted: newKanbanCol ? newKanbanCol === 'done' : targetTask.isCompleted !== undefined ? targetTask.isCompleted : sourceTask.isCompleted,
isCompleted: newKanbanCol
? !!columnsFor(sourceTask).find((c) => c.id === newKanbanCol)?.isDone || newKanbanCol === 'done'
: targetTask.isCompleted !== undefined ? targetTask.isCompleted : sourceTask.isCompleted,
};
const newTasks = [...remainingTasks];
@@ -941,8 +997,10 @@ export const TasksView: React.FC<TasksViewProps> = ({
);
const handleKanbanColDragOver = (e: React.DragEvent<HTMLDivElement>, colId: string) => {
e.preventDefault();
if (!draggingTaskId) return;
// A column the card's list does not have takes nothing (no preventDefault = no drop allowed)
if (!ownColumnIdFor(tasks.find((t) => t.id === draggingTaskId), colId)) return;
e.preventDefault();
if (dragOverKanbanCol !== colId) setDragOverKanbanCol(colId);
// Slot = number of cards (without the dragged one) whose middle lies above the pointer
const cards = Array.from(e.currentTarget.querySelectorAll<HTMLElement>('[data-kanban-task]')).filter(
@@ -974,16 +1032,18 @@ export const TasksView: React.FC<TasksViewProps> = ({
const slot = kanbanSlot;
resetKanbanDrag();
if (!sourceId) return;
const ownColId = ownColumnIdFor(tasks.find((t) => t.id === sourceId), colId);
if (!ownColId) return;
// Set with the move in one render: the card shows up marked in its new place, not a moment later
setJustDroppedId(sourceId);
window.setTimeout(() => setJustDroppedId((cur) => (cur === sourceId ? null : cur)), 900);
const others = colTasks.filter((t) => t.id !== sourceId);
if (others.length === 0 || !slot || slot.col !== colId) {
await moveTaskToColumn(sourceId, colId);
await moveTaskToColumn(sourceId, ownColId);
} else if (slot.index < others.length) {
await moveTaskNextTo(sourceId, others[slot.index], 'before', colId);
await moveTaskNextTo(sourceId, others[slot.index], 'before', ownColId);
} else {
await moveTaskNextTo(sourceId, others[others.length - 1], 'after', colId);
await moveTaskNextTo(sourceId, others[others.length - 1], 'after', ownColId);
}
};
@@ -1009,7 +1069,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
const sourceTask = tasks.find((t) => t.id === sourceId);
if (!sourceTask) return;
const isDone = colId === 'done';
const targetCol = columnsFor(sourceTask).find((c) => c.id === colId);
const isDone = targetCol ? !!targetCol.isDone : colId === 'done';
const updatedSourceTask: Thought = {
...sourceTask,
kanbanStatus: colId,
@@ -1053,14 +1114,17 @@ export const TasksView: React.FC<TasksViewProps> = ({
enabled: !canDragTasks && viewMode === 'kanban',
onStart: (taskId) => setDraggingTaskId(taskId),
onHover: (colId, target) => {
setDragOverKanbanCol(colId);
setDragOverTaskTarget(target);
const allowed = !!colId && !!ownColumnIdFor(tasks.find((t) => t.id === draggingTaskId), colId);
setDragOverKanbanCol(allowed ? colId : null);
setDragOverTaskTarget(allowed ? target : null);
},
onDrop: (taskId, colId, target) => {
clearDragState();
const ownColId = colId ? ownColumnIdFor(tasks.find((t) => t.id === taskId), colId) : null;
if (!ownColId) return;
const targetTask = target ? tasks.find((t) => t.id === target.taskId) : undefined;
if (targetTask && target) void moveTaskNextTo(taskId, targetTask, target.position, colId ?? undefined);
else if (colId) void moveTaskToColumn(taskId, colId);
if (targetTask && target) void moveTaskNextTo(taskId, targetTask, target.position, ownColId);
else void moveTaskToColumn(taskId, ownColId);
},
onCancel: clearDragState,
});
@@ -2414,17 +2478,13 @@ export const TasksView: React.FC<TasksViewProps> = ({
className={`grid grid-cols-1 md:grid-cols-2 gap-3 pt-1 lg:w-[var(--kanban-w)] lg:ml-[var(--kanban-ml)] transition-[width,margin] duration-300 ease-out ${
isKanbanWide ? 'lg:grid-cols-[repeat(var(--kanban-cols),minmax(0,1fr))]' : 'lg:grid-cols-4'
}`}
style={kanbanBoardStyle(isKanbanWide, activeColumns.length)}
style={kanbanBoardStyle(isKanbanWide, boardColumns.length)}
>
{activeColumns.map((col, colIdx) => {
const colTasks = kanbanTasks.filter((t) => {
if (col.isDone) {
return t.isCompleted || t.kanbanStatus === col.id;
}
if (t.isCompleted) return false;
if (t.kanbanStatus) return t.kanbanStatus === col.id;
return col.id === 'todo';
});
{boardColumns.map((col) => {
const colTasks = kanbanTasks.filter((t) => inBoardColumn(t, col));
// While dragging: a column the card's list does not have is greyed out and takes nothing
const blockedForDrag =
!!draggingTaskId && !ownColumnIdFor(tasks.find((t) => t.id === draggingTaskId), col.id);
// Cards that take part in the slot count (the dragged one has left its place)
const visibleIds = colTasks.filter((t) => t.id !== kanbanHiddenId).map((t) => t.id);
@@ -2439,7 +2499,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
data-kanban-col={col.id}
className={`bg-canvas border border-border flex flex-col min-h-[420px] overflow-hidden transition-all ${
dragOverKanbanCol === col.id ? 'ring-2 ring-accent/60 bg-accent/5' : ''
}`}
} ${blockedForDrag ? 'opacity-40' : ''}`}
title={blockedForDrag ? 'Diese Spalte gibt es in der Liste der Aufgabe nicht' : undefined}
>
{/* Column Header */}
<div className="px-3 py-2 bg-panel border-b border-border flex items-center justify-between">
@@ -2456,6 +2517,10 @@ export const TasksView: React.FC<TasksViewProps> = ({
const isDragging = draggingTaskId === task.id;
const readOnly = isTaskReadOnly(task);
const visibleIdx = visibleIds.indexOf(task.id);
const ownCols = columnsFor(task);
const ownIdx = ownCols.findIndex((c) => c.id === ownColumnOf(task)?.id);
const prevCol = ownIdx > 0 ? ownCols[ownIdx - 1] : undefined;
const nextCol = ownIdx >= 0 ? ownCols[ownIdx + 1] : undefined;
return (
<React.Fragment key={task.id}>
@@ -2606,23 +2671,23 @@ export const TasksView: React.FC<TasksViewProps> = ({
>
<button
type="button"
disabled={colIdx === 0}
onClick={() => handleSetKanbanStatus(task, activeColumns[colIdx - 1].id)}
disabled={!prevCol}
onClick={() => prevCol && handleSetKanbanStatus(task, prevCol.id)}
className="text-faint hover:text-main disabled:opacity-20 flex items-center gap-0.5"
title={colIdx > 0 ? `Nach "${activeColumns[colIdx - 1].name}"` : ''}
title={prevCol ? `Nach "${prevCol.name}"` : ''}
>
<ArrowLeft className="w-3 h-3" />
<span className="hidden sm:inline">{colIdx > 0 ? activeColumns[colIdx - 1].name : ''}</span>
<span className="hidden sm:inline">{prevCol?.name ?? ''}</span>
</button>
<button
type="button"
disabled={colIdx === activeColumns.length - 1}
onClick={() => handleSetKanbanStatus(task, activeColumns[colIdx + 1].id)}
disabled={!nextCol}
onClick={() => nextCol && handleSetKanbanStatus(task, nextCol.id)}
className="text-faint hover:text-main disabled:opacity-20 flex items-center gap-0.5"
title={colIdx < activeColumns.length - 1 ? `Nach "${activeColumns[colIdx + 1].name}"` : ''}
title={nextCol ? `Nach "${nextCol.name}"` : ''}
>
<span className="hidden sm:inline">{colIdx < activeColumns.length - 1 ? activeColumns[colIdx + 1].name : ''}</span>
<span className="hidden sm:inline">{nextCol?.name ?? ''}</span>
<ArrowRight className="w-3 h-3" />
</button>
</div>
@@ -2643,7 +2708,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
</div>
{/* Quick Add Task to Column */}
{!isSelectedListReadOnly && (
{!isSelectedListReadOnly && activeColumns.some((c) => boardIdOf(c) === col.id) && (
<div className="p-2 border-t border-border bg-panel">
<button
type="button"