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

+11 -1
View File
@@ -6,6 +6,15 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased]
## [0.40.0] - 2026-11-01
### Changed
- Kanban in „Alle Aufgaben“: Die Spalten aller Aufgabenlisten werden nach Namen zusammengeführt (Groß- und Kleinschreibung egal), in Reihenfolge und Farbe der ersten Liste, die eine Spalte hat. Gleich benannte Spalten erscheinen einmal, verschieden benannte getrennt, auch bei den Erledigt-Spalten.
- Beim Ziehen sind Spalten, die die Liste der Karte nicht hat, ausgegraut und nehmen die Karte nicht an. Die Pfeile auf einer Karte führen zu den Nachbarspalten ihrer eigenen Liste; Status-Auswahl und Detailansicht zeigen wie bisher nur die Spalten der eigenen Liste.
### Fixed
- Kanban in „Alle Aufgaben“: Aufgaben in eigenen Spalten einer Liste (z. B. „Review“) fehlten dort ganz, weil nur die Standardspalten angezeigt wurden. Ebenso tauchen Aufgaben, deren Spalte gelöscht wurde, jetzt in der ersten offenen Spalte auf statt nirgends.
## [0.39.0] - 2026-11-01
### Added
@@ -659,7 +668,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.39.0...dev
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.40.0...dev
[0.40.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.39.0...v0.40.0
[0.39.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.38.1...v0.39.0
[0.38.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.38.0...v0.38.1
[0.38.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.37.2...v0.38.0
+1 -1
View File
@@ -6,7 +6,7 @@
[![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#)
[![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart)
[![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir)
[![Version](https://img.shields.io/badge/Version-v0.39.0-16a34a?style=flat-square)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/Version-v0.40.0-16a34a?style=flat-square)](CHANGELOG.md)
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mindsift-frontend",
"version": "0.39.0",
"version": "0.40.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mindsift-frontend",
"version": "0.39.0",
"version": "0.40.0",
"license": "Unlicense",
"dependencies": {
"@fontsource-variable/inter": "5.3.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mindsift-frontend",
"private": true,
"version": "0.39.0",
"version": "0.40.0",
"license": "Unlicense",
"type": "module",
"scripts": {
+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"