Files
MindSift/frontend/src/features/triage/TasksView.tsx
T
sandrodachandClaude Opus 5.5 2bb0dec744
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m40s
fix(tasks): a task opened by a link does not open again on every return to the tab
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 18:18:56 +01:00

3039 lines
129 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import {
Check,
Plus,
Calendar,
Trash2,
RotateCcw,
FileText,
LayoutList,
Columns,
Maximize2,
Minimize2,
ArrowRight,
ArrowLeft,
Edit3,
X,
Layers,
Users,
CheckSquare,
User as
Lock,
Hash,
AlertTriangle,
Flame,
Settings,
Folder,
Filter,
Repeat,
} from 'lucide-react';
import { TaskList, Thought, SubTask, Team, User, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS, TrashedTask } from '../../lib/types';
import { formatDeletedAt, formatDue, isDueToday, isOverdue, localDay, splitDue } from '../../lib/due';
import { readViewSession, writeViewSession } from '../../lib/viewSession';
import { useKanbanTouchDrag } from './useKanbanTouchDrag';
import { api, triggerHaptic } from '../../lib/api';
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { TaskDetailModal } from './TaskDetailModal';
import { OriginBadge } from '../../components/OriginBadge';
import { VoiceInputButton } from '../../components/VoiceInputButton';
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS, columnDotClass } from './ManageColumnsModal';
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
import { CountBadge } from '../../components/CountBadge';
import { FormSheet } from '../../components/FormSheet';
import { EmojiIconButton } from '../../components/EmojiIconButton';
import { useMobileActions } from '../../context/MobileActionContext';
// Drag & drop needs a mouse or pen; on touch-only devices a draggable row can swallow a slightly
// longer tap, so the row would not open
const canDragTasks =
typeof window !== 'undefined' && !!window.matchMedia?.('(any-pointer: fine)').matches;
// Rows inside a task card keep taps on their own controls; taps on empty space or plain labels
// (due date, webhook) reach the card and open the task
const stopOnControl = (e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest('button, select, a, input')) e.stopPropagation();
};
// Same rule as the server's task query: a fresh Quick Dump note (status inbox) is no task yet
const isTaskThought = (t: Thought) =>
t.status === 'kept' || (t.status === 'converted' && t.convertedType === 'task');
interface TasksViewProps {
currentUser?: User | null;
onRefreshStats: () => void;
onOpenDocument: (docId: string, blockId?: string) => void;
refreshTrigger?: number;
initialSelectedTaskId?: string | null;
// The task from initialSelectedTaskId was opened; the parent forgets it, so returning to the tab does
// not open it again
onInitialTaskShown?: () => void;
openTasksCount?: number; // all open tasks incl. shared lists (same as the nav bar counter)
}
const KANBAN_WIDE_KEY = 'mindsift:kanban_wide';
// Wide Kanban: a column grows to at most this width, the board stays centred
const KANBAN_COL_MAX = 380;
const KANBAN_GAP = 12; // gap-3
// Only the board leaves the page column (header and "new task" keep their width): its width follows
// the columns (at most KANBAN_COL_MAX each, at most the window) and a margin keeps it centred – a
// negative one when it is wider than the page column. Width and margin are animated.
const kanbanBoardStyle = (wide: boolean, columns: number): React.CSSProperties => {
if (!wide) return { '--kanban-w': '100%', '--kanban-ml': '0px' } as React.CSSProperties;
const w = `min(100vw - 4rem, ${columns * KANBAN_COL_MAX + (columns - 1) * KANBAN_GAP}px)`;
return {
'--kanban-cols': columns,
'--kanban-w': w,
'--kanban-ml': `calc((100% - ${w}) / 2)`,
} as React.CSSProperties;
};
type TasksSession = { listId: string; viewMode: 'list' | 'kanban' };
export const TasksView: React.FC<TasksViewProps> = ({
currentUser,
onRefreshStats,
onOpenDocument,
refreshTrigger = 0,
initialSelectedTaskId,
onInitialTaskShown,
openTasksCount,
}) => {
// Open list and view survive switching to other tabs (like the workspace's open note)
const [viewMode, setViewMode] = useState<'list' | 'kanban'>(() =>
readViewSession<TasksSession>('tasks').viewMode === 'kanban' ? 'kanban' : 'list'
);
// Wide Kanban: the board takes the whole window and shows every column in one row
const [kanbanWide, setKanbanWide] = useState(() => {
try {
return localStorage.getItem(KANBAN_WIDE_KEY) === '1';
} catch {
return false;
}
});
const toggleKanbanWide = () => {
const next = !kanbanWide;
setKanbanWide(next);
try {
localStorage.setItem(KANBAN_WIDE_KEY, next ? '1' : '0');
} catch {
// only a convenience
}
};
const isKanbanWide = viewMode === 'kanban' && kanbanWide;
const [filter, setFilter] = useState<'open' | 'done' | 'discarded'>('open');
const [tasks, setTasks] = useState<Thought[]>([]);
const [taskLists, setTaskLists] = useState<TaskList[]>([]);
const [selectedListId, setSelectedListId] = useState<string>(
() => readViewSession<TasksSession>('tasks').listId || 'all'
);
useEffect(() => {
writeViewSession<TasksSession>('tasks', { listId: selectedListId, viewMode });
}, [selectedListId, viewMode]);
const [teams, setTeams] = useState<Team[]>([]);
const [collaborators, setCollaborators] = useState<User[]>([]);
// Task trash of the selected list (or all): deleted tasks and cleared or cleaned-up completed ones
const [taskTrash, setTaskTrash] = useState<TrashedTask[]>([]);
const [loading, setLoading] = useState(true);
// Drag and Drop state
const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null);
const [dragOverTaskTarget, setDragOverTaskTarget] = useState<{ taskId: string; position: 'before' | 'after' } | null>(null);
const [dragOverKanbanCol, setDragOverKanbanCol] = useState<string | null>(null);
// Kanban drag: the column works out the slot from the pointer; a gap of the card's height opens there
// (no per-card enter/leave flicker), the dragged card leaves its old place, and lights up where it lands
const [kanbanSlot, setKanbanSlot] = useState<{ col: string; index: number } | null>(null);
const [kanbanHiddenId, setKanbanHiddenId] = useState<string | null>(null);
const [justDroppedId, setJustDroppedId] = useState<string | null>(null);
const dragHeightRef = useRef(64);
// List tab a dragged task hovers over (dropping moves it into that list)
const [dragOverListTab, setDragOverListTab] = useState<string | null>(null);
// Quick Task Add state
const [quickTitle, setQuickTitle] = useState('');
const [quickVoiceError, setQuickVoiceError] = useState<string | null>(null);
const [quickDueDate, setQuickDueDate] = useState('');
const quickInputRef = useRef<HTMLInputElement | null>(null);
const { setGroups, setPrimaryAction } = useMobileActions();
// Mobile task creation & filter modal state
const [showMobileCreateModal, setShowMobileCreateModal] = useState(false);
const [mobileCreateTitle, setMobileCreateTitle] = useState('');
const [mobileVoiceError, setMobileVoiceError] = useState<string | null>(null);
const mobileCreateInputRef = useRef<HTMLInputElement | null>(null);
const [mobileCreateDueDate, setMobileCreateDueDate] = useState('');
const [mobileCreatePriority, setMobileCreatePriority] = useState<PriorityLevel>('normal');
const [mobileCreateListId, setMobileCreateListId] = useState<string>('');
const [showFilterModal, setShowFilterModal] = useState(false);
// New TaskList modal state
const [showNewListModal, setShowNewListModal] = useState(false);
const [newListTitle, setNewListTitle] = useState('');
const [newListIcon, setNewListIcon] = useState('📋');
// Share TaskList modal state
const [showShareListModal, setShowShareListModal] = useState(false);
const [sharingTaskList, setSharingTaskList] = useState<TaskList | null>(null);
// Detail modal state
const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
const [showDetailModal, setShowDetailModal] = useState(false);
const [extraTask, setExtraTask] = useState<Thought | null>(null);
const onInitialTaskShownRef = useRef(onInitialTaskShown);
onInitialTaskShownRef.current = onInitialTaskShown;
useEffect(() => {
if (initialSelectedTaskId) {
setDetailTaskId(initialSelectedTaskId);
setShowDetailModal(true);
setFilter('open');
onInitialTaskShownRef.current?.();
api.getThought(initialSelectedTaskId).then((th) => {
if (th) setExtraTask(th);
}).catch(() => {});
}
}, [initialSelectedTaskId]);
// Manage columns modal state
const [showManageColumnsModal, setShowManageColumnsModal] = useState(false);
const [globalColumns, setGlobalColumns] = useState<KanbanColumn[]>(() => {
try {
const stored = localStorage.getItem('mindsift:kanban_columns');
if (stored) {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed) && parsed.length > 0) return parsed;
}
} catch {}
return DEFAULT_KANBAN_COLUMNS;
});
// Hashtag filtering, grouping and sorting state
const [selectedTag, setSelectedTag] = useState<string | null>(null);
// Hashtag row on desktop: whether it is cut off, and whether it is opened up into several lines
const tagRowRef = useRef<HTMLDivElement>(null);
const [tagsExpanded, setTagsExpanded] = useState(false);
const [tagsOverflow, setTagsOverflow] = useState(false);
const [groupBy, setGroupBy] = useState<'none' | 'tag' | 'status' | 'priority' | 'due'>('none');
const [sortBy, setSortBy] = useState<'custom' | 'created' | 'due' | 'priority' | 'alpha'>('custom');
// Reloads happen after almost every action and on realtime events. Only the first load of a list shows
// the loading placeholder: replacing the rendered list on every reload swallowed clicks and taps that
// happened to fall into the reload (press and release then hit different elements).
const loadedListRef = useRef<string | null>(null);
const loadSeqRef = useRef(0);
const loadData = async () => {
const seq = ++loadSeqRef.current;
const listId = selectedListId;
if (loadedListRef.current !== listId) setLoading(true);
try {
const [allTasks, allLists, allTeams, allCollabs, trash] = await Promise.all([
api.getTasks(listId === 'all' ? undefined : listId).catch(() => []),
api.getTaskLists().catch(() => []),
api.getUserTeams().catch(() => []),
api.getCollaborators().catch(() => []),
api.getTaskTrash(listId === 'all' ? '' : listId).catch(() => []),
]);
if (seq !== loadSeqRef.current) return; // a newer reload is under way
loadedListRef.current = listId;
setTasks(Array.isArray(allTasks) ? allTasks : []);
setTaskLists(Array.isArray(allLists) ? allLists : []);
// The remembered list was deleted or unshared meanwhile
if (listId !== 'all' && Array.isArray(allLists) && !allLists.some((l) => l.id === listId)) {
setSelectedListId('all');
}
setTeams(Array.isArray(allTeams) ? allTeams : []);
setCollaborators(Array.isArray(allCollabs) ? allCollabs : []);
setTaskTrash(Array.isArray(trash) ? trash : []);
} catch {
// Prevent unhandled promise rejection from crashing view
} finally {
if (seq === loadSeqRef.current) setLoading(false);
}
};
useEffect(() => {
loadData();
}, [refreshTrigger, selectedListId]);
const selectedListIdRef = useRef(selectedListId);
selectedListIdRef.current = selectedListId;
const onRefreshStatsRef = useRef(onRefreshStats);
onRefreshStatsRef.current = onRefreshStats;
// The realtime handler is registered once; it must reload with the current list, not the first one
const loadDataRef = useRef(loadData);
loadDataRef.current = loadData;
// Live-Sync WebSocket listener for tasks (registered once on mount)
useEffect(() => {
const handleWs = (e: any) => {
const msg = e.detail;
if (!msg || !msg.event) return;
if (msg.event === 'TASK_CREATED') {
const created = msg.payload as Thought;
if (created && isTaskThought(created)) {
setTasks((prev) => {
if (prev.some((t) => t.id === created.id)) return prev;
if (selectedListIdRef.current !== 'all' && created.taskListId !== selectedListIdRef.current) return prev;
return [created, ...prev];
});
onRefreshStatsRef.current();
}
} else if (msg.event === 'TASK_UPDATED') {
const updated = msg.payload as Thought;
if (updated && updated.id) {
setTasks((prev) => {
const exists = prev.some((t) => t.id === updated.id);
if (!exists) {
if (
isTaskThought(updated) &&
(selectedListIdRef.current === 'all' || updated.taskListId === selectedListIdRef.current)
) {
return [updated, ...prev];
}
return prev;
}
// Undone back into the Quick Dump, discarded, or moved to another list
if (
!isTaskThought(updated) ||
(selectedListIdRef.current !== 'all' && updated.taskListId !== selectedListIdRef.current)
) {
return prev.filter((t) => t.id !== updated.id);
}
return prev.map((t) => (t.id === updated.id ? updated : t));
});
onRefreshStatsRef.current();
}
} else if (msg.event === 'TASK_DELETED') {
const deletedId = typeof msg.payload === 'string' ? msg.payload : msg.payload?.id;
if (deletedId) {
setTasks((prev) => prev.filter((t) => t.id !== deletedId));
onRefreshStatsRef.current();
}
} else if (msg.event === 'TASK_REORDERED') {
loadDataRef.current();
} else if (msg.event === 'TASK_LIST_CREATED' || msg.event === 'TASK_LIST_UPDATED' || msg.event === 'TASK_LIST_DELETED') {
// Payload only carries the list ID; reload so permissions reflect this user's view
api.getTaskLists().then((lists) => setTaskLists(Array.isArray(lists) ? lists : [])).catch(() => {});
}
};
window.addEventListener('mindsift:ws', handleWs);
return () => window.removeEventListener('mindsift:ws', handleWs);
}, []);
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) =>
!!task.taskListId && safeTaskLists.find((l) => l.id === task.taskListId)?.permission === 'viewer';
// Assigning only makes sense in shared lists (someone besides the owner); members also filter out
// assignees who lost access before the task payload caught up
const membersByList = useMemo(() => {
const m = new Map<string, TaskListMember[]>();
for (const tl of safeTaskLists) m.set(tl.id, tl.members || []);
return m;
}, [safeTaskLists]);
const assignableMembers = (task: Thought): TaskListMember[] => {
const members = (task.taskListId && membersByList.get(task.taskListId)) || [];
return members.length > 1 ? members : [];
};
const memberToPerson = (m: TaskListMember): AvatarPerson => ({
id: m.userId,
name: m.displayName || m.email,
avatarUrl: m.avatarUrl,
});
const getAssigneePeople = (task: Thought): AvatarPerson[] => {
const members = assignableMembers(task);
return (task.assignees || [])
.map((id) => members.find((m) => m.userId === id))
.filter((m): m is TaskListMember => !!m)
.map(memberToPerson);
};
const renderAssignees = (task: Thought, size: 'xxs' | 'xs') => {
const people = getAssigneePeople(task);
if (people.length === 0) return null;
return (
<AvatarStack
people={people}
size={size}
ringClass="ring-[var(--bg-panel)]"
title={`Zugewiesen: ${people.map((p) => p.name).join(', ')}`}
/>
);
};
// Active Kanban Columns (from selected list, or global custom, or default)
const activeColumns: KanbanColumn[] = useMemo(() => {
if (
currentSelectedList &&
Array.isArray(currentSelectedList.columns) &&
currentSelectedList.columns.length > 0
) {
return currentSelectedList.columns;
}
return globalColumns;
}, [currentSelectedList, globalColumns]);
// The columns of the list a task belongs to: in "Alle Aufgaben" every task keeps its own list's columns
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]
);
// Desktop Kanban in one row: the columns fill the screen height and scroll on their own, so the page
// itself does not scroll as well (like the workspace). Several rows of columns keep the page scroll.
const kanbanBoardRef = useRef<HTMLDivElement>(null);
const [kanbanColHeight, setKanbanColHeight] = useState<number | null>(null);
const kanbanOneRow = viewMode === 'kanban' && (isKanbanWide || boardColumns.length <= 4);
useEffect(() => {
if (!kanbanOneRow) {
setKanbanColHeight(null);
return;
}
const fit = () => {
const el = kanbanBoardRef.current;
if (!el || window.innerWidth < 1024) {
setKanbanColHeight(null);
return;
}
// From the board's top to the window's bottom; then take back what still overflows (footer, paddings)
const top = el.getBoundingClientRect().top + window.scrollY;
setKanbanColHeight(Math.max(320, window.innerHeight - top - 16));
requestAnimationFrame(() => {
const over = document.documentElement.scrollHeight - window.innerHeight;
if (over > 0) setKanbanColHeight((h) => (h ? Math.max(320, h - over) : h));
});
};
fit();
window.addEventListener('resize', fit);
return () => window.removeEventListener('resize', fit);
}, [kanbanOneRow, selectedListId, loading]);
const safeTasks = useMemo(() => {
if (!Array.isArray(tasks)) return [];
const seen = new Set<string>();
const unique: Thought[] = [];
for (const t of tasks) {
if (t && t.id && !seen.has(t.id)) {
seen.add(t.id);
unique.push(t);
}
}
return unique;
}, [tasks]);
const detailTask = useMemo(() => {
if (!detailTaskId) return null;
return tasks.find((t) => t.id === detailTaskId) || (extraTask?.id === detailTaskId ? extraTask : null);
}, [detailTaskId, tasks, extraTask]);
const safeTeams = Array.isArray(teams) ? teams : [];
const safeCollaborators = Array.isArray(collaborators) ? collaborators : [];
// Available unique hashtags from current tasks
const allAvailableTags = useMemo(() => {
const set = new Set<string>();
for (const t of safeTasks) {
if (Array.isArray(t.tags)) {
for (const tg of t.tags) {
if (tg) set.add(tg);
}
}
}
return Array.from(set).sort();
}, [safeTasks]);
// Cut off? Measured while the row is one line (on resize and when the tags change)
useEffect(() => {
const el = tagRowRef.current;
if (!el || tagsExpanded) return;
const measure = () => setTagsOverflow(el.scrollWidth > el.clientWidth + 1);
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [allAvailableTags, tagsExpanded, viewMode]);
// Filtered tasks for List view
const openTasks = safeTasks.filter((t) => !t.isCompleted);
const doneTasks = safeTasks.filter((t) => t.isCompleted);
const filteredTasks = useMemo(() => {
let result = safeTasks;
if (filter === 'open') {
result = result.filter((t) => !t.isCompleted);
} else if (filter === 'done') {
result = result.filter((t) => t.isCompleted);
}
if (selectedTag) {
result = result.filter((t) => Array.isArray(t.tags) && t.tags.includes(selectedTag));
}
if (sortBy === 'custom') {
return [...result].sort((a, b) => {
const orderA = a.sortOrder ?? 0;
const orderB = b.sortOrder ?? 0;
if (orderA !== orderB) return orderA - orderB;
return b.createdAt.localeCompare(a.createdAt);
});
}
return [...result].sort((a, b) => {
if (sortBy === 'due') {
if (!a.dueDate && !b.dueDate) return 0;
if (!a.dueDate) return 1;
if (!b.dueDate) return -1;
return a.dueDate.localeCompare(b.dueDate);
}
if (sortBy === 'priority') {
const pScore: Record<string, number> = { urgent: 3, high: 2, normal: 1, low: 0 };
const scoreA = pScore[a.priority] || 1;
const scoreB = pScore[b.priority] || 1;
return scoreB - scoreA;
}
if (sortBy === 'alpha') {
return a.content.localeCompare(b.content);
}
return b.createdAt.localeCompare(a.createdAt);
});
}, [safeTasks, filter, selectedTag, sortBy]);
// Tasks for Kanban board (shows all non-discarded matching tag)
const kanbanTasks = useMemo(() => {
let result = safeTasks;
if (selectedTag) {
result = result.filter((t) => Array.isArray(t.tags) && t.tags.includes(selectedTag));
}
return result;
}, [safeTasks, selectedTag]);
// Grouped tasks calculation
const groupedTasks = useMemo(() => {
if (groupBy === 'none') return null;
const groups: { key: string; title: string; count: number; tasks: Thought[] }[] = [];
if (groupBy === 'tag') {
const map = new Map<string, Thought[]>();
const withoutTag: Thought[] = [];
for (const t of filteredTasks) {
if (!t.tags || t.tags.length === 0) {
withoutTag.push(t);
} else {
for (const tg of t.tags) {
if (!map.has(tg)) map.set(tg, []);
map.get(tg)!.push(t);
}
}
}
Array.from(map.keys()).sort().forEach((tg) => {
const list = map.get(tg)!;
groups.push({ key: `tag:${tg}`, title: `#${tg}`, count: list.length, tasks: list });
});
if (withoutTag.length > 0) {
groups.push({ key: 'tag:none', title: 'Ohne Hashtag', count: withoutTag.length, tasks: withoutTag });
}
} else if (groupBy === 'status') {
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') {
const urgent = filteredTasks.filter((t) => t.priority === 'urgent');
const high = filteredTasks.filter((t) => t.priority === 'high');
const normal = filteredTasks.filter((t) => t.priority !== 'urgent' && t.priority !== 'high');
if (urgent.length > 0) groups.push({ key: 'p:urgent', title: '🔥 Dringend', count: urgent.length, tasks: urgent });
if (high.length > 0) groups.push({ key: 'p:high', title: '⚠️ Wichtig', count: high.length, tasks: high });
if (normal.length > 0) groups.push({ key: 'p:normal', title: 'Normal', count: normal.length, tasks: normal });
} else if (groupBy === 'due') {
const now = new Date();
const today = localDay(now);
const overdue = filteredTasks.filter((t) => isOverdue(t.dueDate, now));
const dueToday = filteredTasks.filter((t) => isDueToday(t.dueDate, now) && !isOverdue(t.dueDate, now));
const upcoming = filteredTasks.filter((t) => t.dueDate && splitDue(t.dueDate).date > today);
const noDue = filteredTasks.filter((t) => !t.dueDate);
if (overdue.length > 0) groups.push({ key: 'due:overdue', title: '⚠️ Überfällig', count: overdue.length, tasks: overdue });
if (dueToday.length > 0) groups.push({ key: 'due:today', title: '📅 Heute fällig', count: dueToday.length, tasks: dueToday });
if (upcoming.length > 0) groups.push({ key: 'due:upcoming', title: 'Demnächst', count: upcoming.length, tasks: upcoming });
if (noDue.length > 0) groups.push({ key: 'due:none', title: 'Ohne Fälligkeitsdatum', count: noDue.length, tasks: noDue });
}
return groups;
}, [filteredTasks, groupBy, boardColumns, inBoardColumn]);
// --- TASK ACTIONS ---
// --- UNDO (Strg+Z / Cmd+Z): deleting, moving and ticking off, newest first; deleted tasks come back
// from the task trash. Deleting also shows "Rückgängig" for phones.
type UndoEntry =
| { kind: 'delete'; ids: string[] }
| { kind: 'order'; snapshot: { id: string; sortOrder: number; kanbanStatus?: string; isCompleted: boolean }[] }
| { kind: 'status'; id: string; isCompleted: boolean; kanbanStatus?: string };
const undoStackRef = useRef<UndoEntry[]>([]);
const [toast, setToast] = useState<{ text: string; undo: boolean } | null>(null);
const pushUndo = (entry: UndoEntry) => {
undoStackRef.current = [...undoStackRef.current, entry].slice(-20);
setToast(null); // its "Rückgängig" would now undo this newer step
};
const orderSnapshot = () =>
tasks.map((t) => ({ id: t.id, sortOrder: t.sortOrder ?? 0, kanbanStatus: t.kanbanStatus, isCompleted: !!t.isCompleted }));
const toastTimerRef = useRef<number | null>(null);
const showToast = (text: string, undo = false) => {
setToast({ text, undo });
if (toastTimerRef.current !== null) window.clearTimeout(toastTimerRef.current);
toastTimerRef.current = window.setTimeout(() => setToast(null), undo ? 8000 : 3000);
};
useEffect(() => () => {
if (toastTimerRef.current !== null) window.clearTimeout(toastTimerRef.current);
}, []);
const undoLast = async () => {
const entry = undoStackRef.current.pop();
if (!entry) return;
setToast(null);
triggerHaptic(10);
try {
if (entry.kind === 'delete') {
for (const id of entry.ids) await api.restoreTrashedTask(id).catch(() => {});
showToast(entry.ids.length === 1 ? 'Aufgabe wiederhergestellt' : `${entry.ids.length} Aufgaben wiederhergestellt`);
} else if (entry.kind === 'order') {
const byId = new Map(entry.snapshot.map((x) => [x.id, x]));
setTasks((prev) =>
prev.map((t) => {
const old = byId.get(t.id);
return old ? { ...t, sortOrder: old.sortOrder, kanbanStatus: old.kanbanStatus, isCompleted: old.isCompleted } : t;
})
);
await api.reorderTasks(
entry.snapshot.map((x) => ({ id: x.id, sortOrder: x.sortOrder, kanbanStatus: x.kanbanStatus })),
selectedListId !== 'all' ? selectedListId : undefined
);
showToast('Verschieben rückgängig gemacht');
} else {
setTasks((prev) =>
prev.map((t) => (t.id === entry.id ? { ...t, isCompleted: entry.isCompleted, kanbanStatus: entry.kanbanStatus } : t))
);
await api.updateThought(entry.id, { isCompleted: entry.isCompleted, kanbanStatus: entry.kanbanStatus });
showToast('Rückgängig gemacht');
}
} finally {
await loadData();
onRefreshStats();
}
};
const undoLastRef = useRef(undoLast);
undoLastRef.current = undoLast;
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (!(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey || e.key.toLowerCase() !== 'z') return;
// Text fields keep their own undo
const el = e.target as HTMLElement | null;
if (el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName))) return;
if (undoStackRef.current.length === 0) return;
e.preventDefault();
void undoLastRef.current();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
const handleToggleDone = async (task: Thought) => {
triggerHaptic(15);
pushUndo({ kind: 'status', id: task.id, isCompleted: !!task.isCompleted, kanbanStatus: task.kanbanStatus });
const nextState = !task.isCompleted;
const cols = columnsFor(task);
const doneCol = cols.find((c) => c.isDone) || cols[cols.length - 1];
const todoCol = cols.find((c) => !c.isDone) || cols[0];
const nextKanban = nextState ? (doneCol ? doneCol.id : 'done') : (todoCol ? todoCol.id : 'todo');
setTasks((prev) =>
prev.map((t) =>
t.id === task.id ? { ...t, isCompleted: nextState, kanbanStatus: nextKanban } : t
)
);
await api.updateThought(task.id, { isCompleted: nextState, kanbanStatus: nextKanban });
onRefreshStats();
};
const handleSetKanbanStatus = async (task: Thought, nextStatus: string) => {
triggerHaptic(15);
pushUndo({ kind: 'status', id: task.id, isCompleted: !!task.isCompleted, kanbanStatus: task.kanbanStatus });
const targetCol = columnsFor(task).find((c) => c.id === nextStatus);
const isCompleted = targetCol ? !!targetCol.isDone : nextStatus === 'done';
setTasks((prev) =>
prev.map((t) => (t.id === task.id ? { ...t, kanbanStatus: nextStatus, isCompleted } : t))
);
await api.updateThought(task.id, { kanbanStatus: nextStatus, isCompleted });
onRefreshStats();
};
const handleSetPriority = async (task: Thought, nextPriority: string) => {
triggerHaptic(10);
const normalized = nextPriority === 'none' || nextPriority === '' ? 'normal' : nextPriority;
setTasks((prev) =>
prev.map((t) => (t.id === task.id ? { ...t, priority: normalized as any } : t))
);
await api.updateThought(task.id, { priority: normalized });
onRefreshStats();
};
const handleCyclePriority = async (task: Thought) => {
triggerHaptic(10);
const nextPriority =
task.priority === 'urgent'
? 'normal'
: task.priority === 'high'
? 'urgent'
: 'high';
await handleSetPriority(task, nextPriority);
};
const handleUpdateTaskTitle = async (task: Thought, newTitle: string) => {
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, content: newTitle } : t)));
await api.updateThought(task.id, { content: newTitle });
onRefreshStats();
};
const handleUpdateTaskDueDate = async (task: Thought, newDate: string) => {
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, dueDate: newDate } : t)));
await api.updateThought(task.id, { dueDate: newDate });
};
const handleUpdateTaskTaskList = async (task: Thought, newListId: string) => {
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, taskListId: newListId } : t)));
await api.updateThought(task.id, { taskListId: newListId });
if (selectedListId !== 'all' && selectedListId !== newListId) {
setTasks((prev) => prev.filter((t) => t.id !== task.id));
}
onRefreshStats();
};
// A task dragged onto a list tab lands in that list like a new task: it keeps its Kanban status
// if the list has that column, otherwise it starts in the list's first column
const handleDropOnListTab = async (e: React.DragEvent, list: TaskList) => {
e.preventDefault();
const sourceId = e.dataTransfer.getData('application/mindsift-task-id') || draggingTaskId;
setDraggingTaskId(null);
setDragOverTaskTarget(null);
setDragOverKanbanCol(null);
setDragOverListTab(null);
const task = tasks.find((t) => t.id === sourceId);
if (!task || task.taskListId === list.id || list.permission === 'viewer') return;
triggerHaptic(15);
const columns = Array.isArray(list.columns) && list.columns.length > 0 ? list.columns : globalColumns;
const keepStatus = !task.kanbanStatus || columns.some((c) => c.id === task.kanbanStatus);
const kanbanStatus = keepStatus ? task.kanbanStatus : (columns.find((c) => !c.isDone) || columns[0])?.id;
setTasks((prev) =>
selectedListId !== 'all'
? prev.filter((t) => t.id !== task.id)
: prev.map((t) => (t.id === task.id ? { ...t, taskListId: list.id, kanbanStatus } : t))
);
try {
await api.updateThought(task.id, { taskListId: list.id, ...(keepStatus ? {} : { kanbanStatus }) });
} catch {
loadData();
}
api.getTaskLists().then((lists) => setTaskLists(Array.isArray(lists) ? lists : [])).catch(() => {});
onRefreshStats();
};
const handleUpdateTaskNotes = async (task: Thought, newNotes: string) => {
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, notes: newNotes } : t)));
await api.updateThought(task.id, { notes: newNotes });
};
const handleUpdateTaskRecurrence = async (task: Thought, recurrence: Recurrence) => {
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, recurrence } : t)));
try {
await api.updateThought(task.id, { recurrence });
} catch {
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, recurrence: task.recurrence } : t)));
}
};
const handleUpdateTaskAssignees = async (task: Thought, assignees: string[]) => {
triggerHaptic(10);
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, assignees } : t)));
try {
const updated = await api.updateThought(task.id, { assignees });
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, assignees: updated.assignees || [] } : t)));
} catch {
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, assignees: task.assignees } : t)));
}
};
const handleUpdateTaskTags = async (task: Thought, newTags: string[]) => {
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, tags: newTags } : t)));
await api.updateThought(task.id, { tags: newTags });
onRefreshStats();
};
const handleToggleSubtask = async (task: Thought, subtaskId: string) => {
triggerHaptic(10);
const updatedSubtasks = (task.subtasks || []).map((s) =>
s.id === subtaskId ? { ...s, isCompleted: !s.isCompleted } : s
);
setTasks((prev) =>
prev.map((t) => (t.id === task.id ? { ...t, subtasks: updatedSubtasks } : t))
);
await api.updateThought(task.id, { subtasks: updatedSubtasks });
};
const handleAddSubtask = async (task: Thought, title: string) => {
triggerHaptic(10);
const newSub: SubTask = {
id: `st_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
title: title.trim(),
isCompleted: false,
createdAt: new Date().toISOString(),
};
const updatedSubtasks = [...(task.subtasks || []), newSub];
setTasks((prev) =>
prev.map((t) => (t.id === task.id ? { ...t, subtasks: updatedSubtasks } : t))
);
await api.updateThought(task.id, { subtasks: updatedSubtasks });
};
const handleDeleteSubtask = async (task: Thought, subtaskId: string) => {
triggerHaptic(10);
const updatedSubtasks = (task.subtasks || []).filter((s) => s.id !== subtaskId);
setTasks((prev) =>
prev.map((t) => (t.id === task.id ? { ...t, subtasks: updatedSubtasks } : t))
);
await api.updateThought(task.id, { subtasks: updatedSubtasks });
};
const handleConvertToDoc = async (task: Thought) => {
triggerHaptic(20);
// Already linked to a note: open it instead of creating another one
if (task.docId) {
onOpenDocument(task.docId, task.blockId);
return;
}
const updated = await api.triageAction(task.id, 'keep', { convertTo: 'document' });
onRefreshStats();
// The task stays in its list, linked to the note's first checkbox (docId); open that note
setTasks((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
const docId = updated.docId || (updated.convertedType === 'document' ? updated.convertedRefId : '');
if (docId) {
onOpenDocument(docId);
} else {
loadData();
}
};
// Deleting a task puts it into the task trash (task_trash.go)
const handleDeletePermanent = async (id: string) => {
triggerHaptic(20);
await api.deleteThought(id);
pushUndo({ kind: 'delete', ids: [id] });
showToast('Aufgabe im Papierkorb', true);
await loadData();
onRefreshStats();
};
const handleRestoreTrashed = async (item: TrashedTask) => {
triggerHaptic(15);
await api.restoreTrashedTask(item.id);
await loadData();
onRefreshStats();
};
const handlePurgeTrashed = async (item: TrashedTask) => {
triggerHaptic(20);
await api.purgeTrashedTask(item.id);
await loadData();
};
const handleClearDone = async () => {
triggerHaptic(20);
// The ones that will go (completed and editable here), for undo
const ids = doneTasks.filter((t) => !isTaskReadOnly(t)).map((t) => t.id);
await api.clearDoneTasks(selectedListId === 'all' ? '' : selectedListId);
if (ids.length > 0) {
pushUndo({ kind: 'delete', ids });
showToast(ids.length === 1 ? 'Aufgabe im Papierkorb' : `${ids.length} Aufgaben im Papierkorb`, true);
}
await loadData();
onRefreshStats();
};
const handleEmptyTaskTrash = async () => {
const scope = selectedListId === 'all' ? 'aller Listen, die du bearbeiten darfst,' : `von „${currentSelectedList?.title || 'dieser Liste'}“`;
if (!window.confirm(`Den Papierkorb ${scope} endgültig leeren?`)) return;
triggerHaptic(20);
await api.emptyTaskTrash(selectedListId === 'all' ? '' : selectedListId);
await loadData();
};
const handleQuickAddTask = async (e: React.FormEvent) => {
e.preventDefault();
const title = quickTitle.trim();
if (!title) return;
triggerHaptic(10);
setQuickTitle('');
try {
const created = await api.captureThought(title, 'normal', 'manual', quickDueDate, {
target: 'task',
taskListId: selectedListId === 'all' ? '' : selectedListId,
});
setTasks((prev) => {
if (prev.some((t) => t.id === created.id)) return prev;
return [created, ...prev];
});
onRefreshStats();
} catch {
loadData();
}
};
const handleMobileCreateTask = async (e: React.FormEvent) => {
e.preventDefault();
const title = mobileCreateTitle.trim();
if (!title) return;
triggerHaptic(12);
try {
const created = await api.captureThought(title, mobileCreatePriority, 'manual', mobileCreateDueDate || undefined, {
target: 'task',
taskListId: mobileCreateListId || (selectedListId === 'all' ? '' : selectedListId),
});
setTasks((prev) => {
if (prev.some((t) => t.id === created.id)) return prev;
return [created, ...prev];
});
setMobileCreateTitle('');
setMobileCreateDueDate('');
setMobileCreatePriority('normal');
setShowMobileCreateModal(false);
onRefreshStats();
} catch {
loadData();
}
};
const handleQuickAddTaskToColumn = async (columnId: string) => {
const title = prompt('Neue Aufgabe für diese Spalte:');
if (!title || !title.trim()) return;
triggerHaptic(10);
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',
taskListId: selectedListId === 'all' ? '' : selectedListId,
});
if (created && created.id) {
const updated = await api.updateThought(created.id, { kanbanStatus: columnId, isCompleted });
setTasks((prev) => [updated, ...prev]);
onRefreshStats();
}
} catch {
loadData();
}
};
const handleTaskDragStart = (e: React.DragEvent, task: Thought) => {
e.stopPropagation();
if (isTaskReadOnly(task)) {
e.preventDefault();
return;
}
e.dataTransfer.setData('application/mindsift-task-id', task.id);
e.dataTransfer.setData('text/plain', task.id);
e.dataTransfer.effectAllowed = 'move';
setDraggingTaskId(task.id);
};
const handleTaskDragOver = (e: React.DragEvent, targetTaskId: string) => {
e.preventDefault();
e.stopPropagation();
if (!draggingTaskId || draggingTaskId === targetTaskId) return;
const rect = e.currentTarget.getBoundingClientRect();
const position = e.clientY - rect.top < rect.height / 2 ? 'before' : 'after';
if (
!dragOverTaskTarget ||
dragOverTaskTarget.taskId !== targetTaskId ||
dragOverTaskTarget.position !== position
) {
setDragOverTaskTarget({ taskId: targetTaskId, position });
}
};
const handleTaskDragLeave = (e: React.DragEvent, targetTaskId: string) => {
e.stopPropagation();
if (dragOverTaskTarget?.taskId === targetTaskId) {
setDragOverTaskTarget(null);
}
};
const handleDropOnTask = async (e: React.DragEvent, targetTask: Thought, newKanbanCol?: string) => {
e.preventDefault();
e.stopPropagation();
const sourceId =
e.dataTransfer.getData('application/mindsift-task-id') ||
e.dataTransfer.getData('text/plain') ||
draggingTaskId;
const position = dragOverTaskTarget?.position || 'after';
setDraggingTaskId(null);
setDragOverTaskTarget(null);
setDragOverKanbanCol(null);
await moveTaskNextTo(sourceId, targetTask, position, newKanbanCol);
};
// Shared by mouse drag & drop and the touch drag on the Kanban board
const moveTaskNextTo = async (sourceId: string | null, targetTask: Thought, position: 'before' | 'after', newKanbanCol?: string) => {
if (!sourceId || sourceId === targetTask.id) return;
triggerHaptic(15);
const sourceTask = tasks.find((t) => t.id === sourceId);
if (!sourceTask) return;
const remainingTasks = tasks.filter((t) => t.id !== sourceId);
const targetIdx = remainingTasks.findIndex((t) => t.id === targetTask.id);
if (targetIdx === -1) return;
const insertIdx = position === 'before' ? targetIdx : targetIdx + 1;
const updatedSourceTask: Thought = {
...sourceTask,
kanbanStatus: newKanbanCol || targetTask.kanbanStatus || sourceTask.kanbanStatus,
isCompleted: newKanbanCol
? !!columnsFor(sourceTask).find((c) => c.id === newKanbanCol)?.isDone || newKanbanCol === 'done'
: targetTask.isCompleted !== undefined ? targetTask.isCompleted : sourceTask.isCompleted,
};
const newTasks = [...remainingTasks];
newTasks.splice(insertIdx, 0, updatedSourceTask);
const updatedWithSort = newTasks.map((t, idx) => ({
...t,
sortOrder: idx,
}));
pushUndo({ kind: 'order', snapshot: orderSnapshot() });
setTasks(updatedWithSort);
setSortBy('custom');
const reorderPayload = updatedWithSort.map((t) => ({
id: t.id,
sortOrder: t.sortOrder ?? 0,
kanbanStatus: t.kanbanStatus,
}));
try {
await api.reorderTasks(
reorderPayload,
selectedListId !== 'all' ? selectedListId : undefined
);
} catch {
loadData();
}
};
const resetKanbanDrag = () => {
setDraggingTaskId(null);
setDragOverTaskTarget(null);
setDragOverKanbanCol(null);
setDragOverListTab(null);
setKanbanSlot(null);
setKanbanHiddenId(null);
};
// The gap where the dragged card would land; it opens and closes smoothly, the cards below slide along
const renderDropSlot = (colId: string, index: number) => (
<motion.div
key={`slot-${colId}-${index}`}
variants={{
closed: { height: 0, opacity: 0 },
open: { height: dragHeightRef.current, opacity: 1 },
// On a drop the card takes the gap's place at once, so the gap goes without closing first
gone: (dropped: boolean) => ({ height: 0, opacity: 0, transition: { duration: dropped ? 0 : 0.2 } }),
}}
initial="closed"
animate="open"
exit="gone"
transition={{ duration: 0.2, ease: 'easeOut' }}
className="rounded-card border-2 border-dashed border-accent/50 bg-accent/5 pointer-events-none"
aria-hidden="true"
/>
);
const handleKanbanColDragOver = (e: React.DragEvent<HTMLDivElement>, colId: string) => {
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(
(el) => el.dataset.kanbanTask !== draggingTaskId
);
let index = cards.length;
for (let i = 0; i < cards.length; i++) {
const r = cards[i].getBoundingClientRect();
if (e.clientY < r.top + r.height / 2) {
index = i;
break;
}
}
if (!kanbanSlot || kanbanSlot.col !== colId || kanbanSlot.index !== index) setKanbanSlot({ col: colId, index });
};
const handleKanbanColDragLeave = (e: React.DragEvent<HTMLDivElement>, colId: string) => {
// Moving onto a child of the column is no leaving
if (e.currentTarget.contains(e.relatedTarget as Node)) return;
if (dragOverKanbanCol === colId) setDragOverKanbanCol(null);
setKanbanSlot((cur) => (cur?.col === colId ? null : cur));
};
const handleKanbanDrop = async (e: React.DragEvent, colId: string, colTasks: Thought[]) => {
e.preventDefault();
e.stopPropagation();
const sourceId =
e.dataTransfer.getData('application/mindsift-task-id') || e.dataTransfer.getData('text/plain') || draggingTaskId;
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, ownColId);
} else if (slot.index < others.length) {
await moveTaskNextTo(sourceId, others[slot.index], 'before', ownColId);
} else {
await moveTaskNextTo(sourceId, others[others.length - 1], 'after', ownColId);
}
};
// A drag that ends outside any column (Escape, dropped elsewhere) leaves nothing behind
useEffect(() => {
if (!draggingTaskId) return;
const end = () => {
setKanbanSlot(null);
setKanbanHiddenId(null);
};
window.addEventListener('dragend', end);
window.addEventListener('drop', end);
return () => {
window.removeEventListener('dragend', end);
window.removeEventListener('drop', end);
};
}, [draggingTaskId]);
const moveTaskToColumn = async (sourceId: string | null, colId: string) => {
if (!sourceId) return;
triggerHaptic(15);
const sourceTask = tasks.find((t) => t.id === sourceId);
if (!sourceTask) return;
const targetCol = columnsFor(sourceTask).find((c) => c.id === colId);
const isDone = targetCol ? !!targetCol.isDone : colId === 'done';
const updatedSourceTask: Thought = {
...sourceTask,
kanbanStatus: colId,
isCompleted: isDone,
};
const remainingTasks = tasks.filter((t) => t.id !== sourceId);
const newTasks = [updatedSourceTask, ...remainingTasks];
const updatedWithSort = newTasks.map((t, idx) => ({
...t,
sortOrder: idx,
}));
pushUndo({ kind: 'order', snapshot: orderSnapshot() });
setTasks(updatedWithSort);
setSortBy('custom');
const reorderPayload = updatedWithSort.map((t) => ({
id: t.id,
sortOrder: t.sortOrder ?? 0,
kanbanStatus: t.kanbanStatus,
}));
try {
await api.reorderTasks(
reorderPayload,
selectedListId !== 'all' ? selectedListId : undefined
);
} catch {
loadData();
}
};
// Kanban with a finger: hold a card, drag it, let go (mouse and pen use HTML5 drag & drop)
const clearDragState = () => {
setDraggingTaskId(null);
setDragOverTaskTarget(null);
setDragOverKanbanCol(null);
};
const kanbanTouch = useKanbanTouchDrag({
enabled: !canDragTasks && viewMode === 'kanban',
onStart: (taskId) => setDraggingTaskId(taskId),
onHover: (colId, 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, ownColId);
else void moveTaskToColumn(taskId, ownColId);
},
onCancel: clearDragState,
});
const handleCreateTaskList = async (e: React.FormEvent) => {
e.preventDefault();
const title = newListTitle.trim();
if (!title) return;
try {
const created = await api.createTaskList({
title,
icon: newListIcon,
});
const freshLists = await api.getTaskLists();
setTaskLists(freshLists);
setSelectedListId(created.id);
setShowNewListModal(false);
setNewListTitle('');
setNewListIcon('📋');
} catch (err: any) {
alert(err.message || 'Fehler beim Erstellen der Aufgabenliste');
}
};
const handleDeleteTaskList = async (listId: string) => {
if (
!window.confirm(
'Möchten Sie diese Aufgabenliste wirklich löschen? Die enthaltenen Aufgaben bleiben erhalten.'
)
)
return;
try {
await api.deleteTaskList(listId);
setTaskLists((prev) => prev.filter((l) => l.id !== listId));
setSelectedListId('all');
} catch (err: any) {
alert(err.message);
}
};
const handleOpenShareModal = async (tl: TaskList) => {
setSharingTaskList(tl);
setShowShareListModal(true);
try {
const [freshTeams, freshUsers, freshShares] = await Promise.all([
api.getUserTeams().catch(() => []),
api.getUsersDirectory ? api.getUsersDirectory().catch(() => api.getCollaborators().catch(() => [])) : api.getCollaborators().catch(() => []),
api.getTaskListShares(tl.id).catch(() => []),
]);
setTeams(freshTeams);
setCollaborators(freshUsers);
setSharingTaskList((prev) => (prev && prev.id === tl.id ? { ...prev, shares: freshShares } : prev));
} catch {
// ignore
}
};
const handleSaveColumns = async (newColumns: KanbanColumn[]) => {
triggerHaptic(15);
if (currentSelectedList && selectedListId !== 'all') {
const updated = await api.updateTaskList(currentSelectedList.id, { columns: newColumns });
setTaskLists((prev) => prev.map((l) => (l.id === updated.id ? updated : l)));
} else {
localStorage.setItem('mindsift:kanban_columns', JSON.stringify(newColumns));
setGlobalColumns(newColumns);
}
};
useEffect(() => {
setPrimaryAction(() => {
setMobileCreateListId(selectedListId === 'all' || isSelectedListReadOnly ? '' : selectedListId);
setShowMobileCreateModal(true);
});
const primaryItems: any[] = [
{
id: 'new-task',
label: 'Neue Aufgabe erstellen',
icon: <Plus className="w-4 h-4" />,
variant: 'accent',
onClick: () => {
setMobileCreateListId(selectedListId === 'all' || isSelectedListReadOnly ? '' : selectedListId);
setShowMobileCreateModal(true);
},
},
];
const filterSortActiveCount =
(selectedTag ? 1 : 0) + (groupBy !== 'none' ? 1 : 0) + (sortBy !== 'custom' ? 1 : 0);
const filterItems: any[] = [
{
id: 'tasks-filter-modal',
label: 'Filter, Hashtags & Sortierung',
icon: <Filter className="w-4 h-4 text-accent" />,
badge: filterSortActiveCount > 0 ? `${filterSortActiveCount} aktiv` : undefined,
active: filterSortActiveCount > 0,
onClick: () => setShowFilterModal(true),
},
];
const listManagementItems: any[] = [
{
id: 'new-task-list',
label: 'Neue Aufgabenliste anlegen',
icon: <Folder className="w-4 h-4" />,
onClick: () => setShowNewListModal(true),
},
];
if (currentSelectedList) {
listManagementItems.push({
id: 'share-task-list',
label: `Liste „${currentSelectedList.title}“ teilen`,
icon: <Users className="w-4 h-4" />,
onClick: () => handleOpenShareModal(currentSelectedList),
});
listManagementItems.push({
id: 'delete-task-list',
label: `Liste „${currentSelectedList.title}“ löschen`,
icon: <Trash2 className="w-4 h-4" />,
variant: 'danger',
onClick: () => handleDeleteTaskList(currentSelectedList.id),
});
}
const viewItems: any[] = [
{
id: 'filter-open',
label: 'Offene Aufgaben (Liste)',
icon: <LayoutList className="w-4 h-4" />,
badge: openTasks.length,
active: viewMode === 'list' && filter === 'open',
onClick: () => {
setViewMode('list');
setFilter('open');
},
},
{
id: 'filter-done',
label: 'Erledigte Aufgaben',
icon: <Check className="w-4 h-4" />,
badge: doneTasks.length,
active: viewMode === 'list' && filter === 'done',
onClick: () => {
setViewMode('list');
setFilter('done');
},
},
{
id: 'filter-discarded',
label: 'Papierkorb',
icon: <Trash2 className="w-4 h-4" />,
badge: taskTrash.length,
active: viewMode === 'list' && filter === 'discarded',
onClick: () => {
setViewMode('list');
setFilter('discarded');
},
},
{
id: 'view-kanban',
label: 'Kanban-Board Ansicht',
icon: <Columns className="w-4 h-4" />,
badge: kanbanTasks.length,
active: viewMode === 'kanban',
onClick: () => setViewMode('kanban'),
},
];
if (viewMode === 'kanban' && !isSelectedListReadOnly) {
viewItems.push({
id: 'manage-columns',
label: 'Kanban-Spalten verwalten',
icon: <Settings className="w-4 h-4" />,
onClick: () => setShowManageColumnsModal(true),
});
}
const listItems: any[] = [
{
id: 'list-all',
label: 'Alle Aufgaben',
icon: <Layers className="w-4 h-4" />,
badge: openTasksCount || undefined,
active: selectedListId === 'all',
onClick: () => setSelectedListId('all'),
},
...safeTaskLists.map((tl) => ({
id: `list-${tl.id}`,
label: tl.icon ? `${tl.icon} ${tl.title}` : tl.title,
badge: tl.tasksCount || undefined,
active: selectedListId === tl.id,
onClick: () => setSelectedListId(tl.id),
})),
];
setGroups([
{
id: 'tasks-primary',
items: primaryItems,
},
{
id: 'tasks-filters',
title: 'Filter & Sortierung',
items: filterItems,
},
{
id: 'tasks-list-management',
title: 'Listen-Verwaltung',
items: listManagementItems,
},
{
id: 'tasks-views',
title: 'Ansicht & Status',
items: viewItems,
},
{
id: 'tasks-lists',
title: 'Aufgabenlisten wechseln',
items: listItems,
},
]);
}, [
viewMode,
filter,
selectedListId,
selectedTag,
groupBy,
sortBy,
openTasks.length,
doneTasks.length,
taskTrash.length,
kanbanTasks.length,
safeTaskLists,
openTasksCount,
currentSelectedList,
isSelectedListReadOnly,
setPrimaryAction,
setGroups,
]);
const renderTaskProgressBar = (task: Thought) => {
if (!task.subtasks || task.subtasks.length === 0) return null;
const total = task.subtasks.length;
const done = task.subtasks.filter((s) => s.isCompleted).length;
const percent = Math.round((done / total) * 100);
return (
<div
className="space-y-0.5 mt-0.5 mb-0.5 select-none"
title={`${done} von ${total} Unteraufgaben erledigt (${percent}%)`}
>
<div className="flex items-center justify-between text-[9px] text-muted font-medium">
<span className="flex items-center gap-1">
<CheckSquare className="w-2.5 h-2.5 text-accent" />
<span>
{done}/{total} Unteraufgaben
</span>
</span>
<span className={percent === 100 ? 'text-status-ok font-bold' : 'text-muted'}>
{percent}%
</span>
</div>
<div className="w-full h-1 bg-border rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-300 ${
percent === 100 ? 'bg-status-ok' : 'bg-accent'
}`}
style={{ width: `${percent}%` }}
/>
</div>
</div>
);
};
// Clean Task Row in List View
const renderTaskRow = (task: Thought) => {
const isCompleted = task.isCompleted;
const readOnly = isTaskReadOnly(task);
const isDragging = draggingTaskId === task.id;
const isTarget = dragOverTaskTarget?.taskId === task.id;
return (
<div
key={task.id}
draggable={canDragTasks && !readOnly}
onDragStart={(e) => handleTaskDragStart(e, task)}
onDragEnd={() => {
setDraggingTaskId(null);
setDragOverTaskTarget(null);
setDragOverKanbanCol(null);
setDragOverListTab(null);
}}
onDragOver={(e) => handleTaskDragOver(e, task.id)}
onDragLeave={(e) => handleTaskDragLeave(e, task.id)}
onDrop={(e) => handleDropOnTask(e, task)}
onClick={() => {
setDetailTaskId(task.id);
setShowDetailModal(true);
}}
className={`px-3.5 py-3.5 sm:px-3 sm:py-2.5 flex items-center justify-between gap-3 transition-colors hover:bg-panel-hover group border-b border-border last:border-b-0 relative cursor-pointer select-none ${
readOnly ? '' : 'cursor-grab active:cursor-grabbing'
} ${
isCompleted ? 'opacity-65' : ''
} ${isDragging ? 'opacity-30 border border-dashed border-accent/40' : ''}`}
>
{/* Drop insertion line indicator */}
{isTarget && dragOverTaskTarget.position === 'before' && (
<div className="absolute top-0 left-0 right-0 h-0.5 bg-accent z-20 pointer-events-none rounded-full shadow-[0_0_8px_rgba(var(--accent-rgb),0.6)]" />
)}
{isTarget && dragOverTaskTarget.position === 'after' && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent z-20 pointer-events-none rounded-full shadow-[0_0_8px_rgba(var(--accent-rgb),0.6)]" />
)}
<div className="flex items-center gap-3 sm:gap-2.5 min-w-0 flex-1">
{/* Checkbox (larger touch target on mobile) */}
<button
type="button"
draggable={false}
disabled={readOnly}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
handleToggleDone(task);
}}
className={`w-5 h-5 sm:w-4 sm:h-4 checkbox-square flex items-center justify-center flex-shrink-0 transition-colors border disabled:cursor-default ${
isCompleted
? 'bg-status-ok border-status-ok text-on-accent'
: 'border-strong hover:border-accent text-transparent'
}`}
title={readOnly ? 'Nur Lesen' : isCompleted ? 'Als offen markieren' : 'Als erledigt abhaken'}
>
<Check className="w-3.5 h-3.5 sm:w-3 sm:h-3 stroke-[3]" />
</button>
{/* Clickable Title & Metadata */}
<div className="min-w-0 flex-1 cursor-pointer space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<p
className={`text-sm break-words leading-tight ${
isCompleted ? 'line-through text-faint' : 'text-main font-medium'
}`}
>
{task.content}
</p>
</div>
{/* Subtasks Progress Bar */}
{renderTaskProgressBar(task)}
{/* Interactive Badges Bar */}
<div
className="flex flex-wrap items-center gap-1.5 pt-0.5 empty:hidden"
onClick={stopOnControl}
>
{(task.convertedType === 'document_block' || task.docId) && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
const docId = task.docId || (task.convertedRefId ? task.convertedRefId.split(':')[0] : '');
const blockId = task.blockId || (task.convertedRefId ? task.convertedRefId.split(':')[1] : '');
if (docId) onOpenDocument(docId, blockId);
}}
className="h-5 px-1.5 text-[10px] rounded-btn border border-accent/40 bg-accent/10 text-accent hover:bg-accent/20 cursor-pointer flex items-center gap-1 font-medium"
title="In Notiz an der passenden Stelle öffnen"
>
<FileText className="w-2.5 h-2.5" />
<span>In Notiz</span>
</button>
)}
{/* Status: dot in the column colour; a selector on desktop, plain text on phones (changed in TaskDetailModal) */}
{(() => {
const statusId = task.kanbanStatus || (isCompleted ? 'done' : 'todo');
const taskColumns = columnsFor(task);
const statusCol = taskColumns.find((c) => c.id === statusId);
const dot = <span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${columnDotClass(statusCol)}`} aria-hidden="true" />;
return (
<>
<span className="hidden sm:inline-flex items-center gap-1">
{dot}
<select
value={statusId}
disabled={readOnly}
onChange={(e) => handleSetKanbanStatus(task, e.target.value)}
className="h-5 px-1.5 text-[10px] rounded-btn border border-border bg-panel text-muted hover:text-main cursor-pointer"
title="Status / Zustand ändern"
>
{taskColumns.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</span>
{statusCol && (
<span className="sm:hidden inline-flex items-center gap-1 text-[10px] text-muted" title="Status">
{dot}
{statusCol.name}
</span>
)}
</>
);
})()}
{/* Priority badge: click to cycle or clear */}
{task.priority === 'urgent' && (
<button
type="button"
disabled={readOnly}
onClick={() => handleCyclePriority(task)}
className="badge-accent text-[10px] cursor-pointer hover:opacity-85 disabled:cursor-default"
title="Klicken zum Ändern der Priorität (Dringend -> Normal)"
>
🔥 Dringend
</button>
)}
{task.priority === 'high' && (
<button
type="button"
disabled={readOnly}
onClick={() => handleCyclePriority(task)}
className="px-1.5 py-0.5 border border-status-warn text-status-warn rounded-btn text-[10px] font-medium cursor-pointer hover:opacity-85 disabled:cursor-default"
title="Klicken zum Ändern der Priorität (Wichtig -> Dringend)"
>
⚠️ Wichtig
</button>
)}
<OriginBadge origin={task.origin} createdAt={task.createdAt} />
{/* Note indicator */}
{task.notes && (
<button
type="button"
onClick={() => {
setDetailTaskId(task.id);
setShowDetailModal(true);
}}
className="inline-flex items-center gap-1 text-[10px] text-muted hover:text-main bg-canvas px-1.5 py-0.5 rounded border border-border"
title="Notiz anzeigen / bearbeiten"
>
<FileText className="w-2.5 h-2.5 text-accent" />
<span className="truncate max-w-[120px]">{task.notes}</span>
</button>
)}
{/* Due Date */}
{task.dueDate && (
<span
className={`inline-flex items-center gap-1 text-[10px] ${!task.isCompleted && isOverdue(task.dueDate) ? 'text-status-err font-semibold' : 'text-muted'}`}
title={`Fällig: ${task.dueDate.replace('T', ' ')}`}
>
<Calendar className="w-2.5 h-2.5 text-accent" />
<span>{formatDue(task.dueDate)}</span>
</span>
)}
{task.recurrence && (
<span className="inline-flex items-center text-muted" title={`Wiederholt sich ${RECURRENCE_LABELS[task.recurrence]}`}>
<Repeat className="w-3 h-3" />
</span>
)}
{/* Hashtag badges */}
{task.tags?.map((tag) => (
<button
key={tag}
type="button"
onClick={() => setSelectedTag(selectedTag === tag ? null : tag)}
className={`text-[10px] px-1.5 py-0.2 rounded-pill border transition-colors ${
selectedTag === tag
? 'bg-accent/15 border-accent text-accent font-semibold'
: 'badge-neutral hover:border-accent'
}`}
title={`Nach #${tag} filtern`}
>
#{tag}
</button>
))}
</div>
</div>
</div>
{renderAssignees(task, 'xs')}
{/* Action Controls (Desktop only - on mobile, tapping the task opens Detail Modal) */}
<div className="hidden sm:flex items-center gap-1 flex-shrink-0" onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
<button
type="button"
draggable={false}
onClick={() => {
setDetailTaskId(task.id);
setShowDetailModal(true);
}}
className="text-faint hover:text-accent p-1.5 transition-colors"
title={readOnly ? 'Aufgabe ansehen' : 'Aufgabe bearbeiten'}
>
{readOnly ? <Lock className="w-3.5 h-3.5" /> : <Edit3 className="w-3.5 h-3.5" />}
</button>
{!readOnly && (
<>
<button
type="button"
draggable={false}
onClick={() => handleConvertToDoc(task)}
title={task.docId ? 'Verknüpfte Notiz öffnen' : 'Als Notiz vertiefen'}
className={`${task.docId ? 'text-accent' : 'text-faint'} hover:text-accent p-1.5 transition-colors hidden sm:block`}
>
<FileText className="w-3.5 h-3.5" />
</button>
<button
type="button"
draggable={false}
onClick={() => handleDeletePermanent(task.id)}
title="Aufgabe löschen"
className="text-faint hover:text-status-err p-1.5 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</>
)}
</div>
</div>
);
};
return (
<div className="w-full space-y-3">
{/* Top Header: Task Lists Tabs & View Mode Switcher (Desktop) */}
<div className="hidden sm:flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2 !mt-0">
{/* Task Lists selector tabs */}
<div className="flex flex-wrap items-center gap-1">
<button
onClick={() => setSelectedListId('all')}
className={`h-8 px-3 text-xs font-medium border rounded-btn transition-colors ${
selectedListId === 'all'
? 'bg-panel border-accent text-main'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
{/* Counter on the label, like in the nav bar */}
<span className={`relative inline-flex items-center ${openTasksCount ? 'pr-3' : ''}`}>
Alle Aufgaben
<CountBadge count={openTasksCount} position="corner" className="-top-0.5 -right-2" />
</span>
</button>
{safeTaskLists.map((tl) => (
<div
key={tl.id}
onDragOver={(e) => {
const task = draggingTaskId ? tasks.find((t) => t.id === draggingTaskId) : null;
if (!task || task.taskListId === tl.id || tl.permission === 'viewer') return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (dragOverListTab !== tl.id) setDragOverListTab(tl.id);
}}
onDragLeave={() => {
if (dragOverListTab === tl.id) setDragOverListTab(null);
}}
onDrop={(e) => handleDropOnListTab(e, tl)}
className={`relative group h-8 flex items-center border rounded-btn transition-colors ${
selectedListId === tl.id ? 'bg-panel border-accent text-main pr-1' : 'bg-canvas border-border text-muted hover:text-main'
} ${dragOverListTab === tl.id ? 'ring-2 ring-accent/60 bg-accent/10 text-main' : ''}`}
>
<button
onClick={() => setSelectedListId(tl.id)}
className="h-full px-3 text-xs font-medium flex items-center gap-1.5 rounded-btn"
>
<span>{tl.icon}</span>
<span className={`relative inline-flex items-center ${tl.tasksCount ? 'pr-3' : ''}`}>
{tl.title}
<CountBadge count={tl.tasksCount} position="corner" className="-top-0.5 -right-2" />
</span>
{(() => {
const isShared = (tl.shares && tl.shares.length > 0) || (tl.members && tl.members.length > 1);
if (!isShared) return null;
const otherMembers = (tl.members || []).filter((m) => m.userId !== currentUser?.id);
if (otherMembers.length > 0) {
return (
<AvatarStack
people={otherMembers.map(memberToPerson)}
size="xxs"
ringClass={selectedListId === tl.id ? 'ring-[var(--bg-panel)]' : 'ring-[var(--bg-app)]'}
className="ml-0.5"
/>
);
}
return (
<span title="Geteilt mit Team oder Benutzern">
<Users className="w-2.5 h-2.5 text-accent ml-0.5" />
</span>
);
})()}
</button>
{selectedListId === tl.id && (
<button
onClick={() => handleDeleteTaskList(tl.id)}
title="Liste löschen"
className="h-6 w-6 text-faint hover:text-status-err hover:bg-status-err/10 rounded-btn flex items-center justify-center transition-colors cursor-pointer"
>
<X className="w-3 h-3" />
</button>
)}
</div>
))}
<button
onClick={() => setShowNewListModal(true)}
className="h-8 px-2.5 bg-canvas hover:bg-panel border border-border border-dashed rounded-btn text-xs text-muted hover:text-main flex items-center gap-1 transition-colors"
title="Neue Aufgabenliste anlegen"
>
<Plus className="w-3.5 h-3.5" />
<span>Neue Liste</span>
</button>
</div>
{/* Right Header Section: Sharing Badge, Icon-Only Button & View Switcher */}
<div className="flex items-center gap-2">
{currentSelectedList && (() => {
const listOwnerId = currentSelectedList.ownerId || currentSelectedList.userId;
const isOwner = currentSelectedList.isOwner !== false && (!listOwnerId || listOwnerId === currentUser?.id);
const sharesCount = (currentSelectedList.shares?.length || 0) > 0
? currentSelectedList.shares.length
: (currentSelectedList.members?.filter((m) => m.userId !== listOwnerId).length || 0);
const isShared = sharesCount > 0 || ((currentSelectedList.members?.length || 0) > 1);
const ownerMember = currentSelectedList.members?.find((m) => m.role === 'owner' || m.userId === listOwnerId);
const ownerDisplayName = currentSelectedList.ownerName || ownerMember?.displayName || ownerMember?.email || 'Eigentümer';
return (
<div className="flex items-center gap-1.5">
{isShared && (
isOwner ? (
<button
type="button"
onClick={() => handleOpenShareModal(currentSelectedList)}
className="px-2 py-0.5 rounded text-[10px] font-semibold bg-accent/15 text-accent border border-accent/30 flex items-center gap-1 hover:bg-accent/25 hover:border-accent/50 transition-colors cursor-pointer"
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt ({sharesCount > 0 ? sharesCount : currentSelectedList.members?.length || 1})</span>
</button>
) : (
<button
type="button"
onClick={() => handleOpenShareModal(currentSelectedList)}
className="px-2 py-0.5 rounded text-[10px] font-medium border border-border text-muted flex items-center gap-1 bg-surface-subtle hover:bg-panel-elevated hover:text-main transition-colors cursor-pointer"
title="Teilen & Berechtigungen öffnen"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt von {ownerDisplayName}</span>
</button>
)
)}
<button
type="button"
onClick={() => handleOpenShareModal(currentSelectedList)}
className="btn-icon"
title="Aufgabenliste teilen und Berechtigungen"
>
<Users className="w-3.5 h-3.5" />
</button>
</div>
);
})()}
{/* View Switcher: List vs Kanban */}
<div className="flex border border-border rounded-btn bg-canvas overflow-hidden">
<button
onClick={() => setViewMode('list')}
className={`h-8 px-2.5 flex items-center gap-1.5 text-xs transition-colors border-r border-border ${
viewMode === 'list' ? 'bg-panel text-main font-medium' : 'text-muted hover:text-main'
}`}
title="Listenansicht"
>
<LayoutList className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Liste</span>
</button>
<button
onClick={() => setViewMode('kanban')}
className={`h-8 px-2.5 flex items-center gap-1.5 text-xs transition-colors ${
viewMode === 'kanban'
? 'bg-panel text-main font-medium'
: 'text-muted hover:text-main'
}`}
title="Kanban-Board"
>
<Columns className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Kanban</span>
</button>
</div>
</div>
</div>
{/* Mobile Compact Context Bar (replaces cluttered button rows; options live in MindSift Logo Menu) */}
{/* Same height with or without the 36 px share button (Tasks, Shopping lists) */}
<div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 min-h-[54px] rounded-btn bg-panel border border-border !mt-0">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-semibold text-main truncate">
{currentSelectedList ? [currentSelectedList.icon, currentSelectedList.title].filter(Boolean).join(' ') : '📋 Alle Aufgaben'}
</span>
<span className="badge-neutral text-[11px] flex-shrink-0">
{viewMode === 'kanban'
? 'Kanban'
: filter === 'open'
? `Offen (${openTasks.length})`
: filter === 'done'
? `Erledigt (${doneTasks.length})`
: `Papierkorb (${taskTrash.length})`}
</span>
</div>
{currentSelectedList && (
<button
type="button"
onClick={() => handleOpenShareModal(currentSelectedList)}
className="btn-icon min-h-[36px] min-w-[36px] flex items-center justify-center flex-shrink-0"
title="Aufgabenliste teilen"
>
<Users className="w-4 h-4 text-accent" />
</button>
)}
</div>
{isSelectedListReadOnly && (
<div className="flex items-center gap-2 p-2.5 rounded-btn bg-panel-elevated border border-border text-xs text-muted">
<Lock className="w-3.5 h-3.5 text-faint shrink-0" />
<span>
Diese Liste wurde von {currentSelectedList?.ownerName || 'einem anderen Benutzer'} nur zum Lesen freigegeben.
</span>
</div>
)}
{/* Quick Add Task Input (Desktop only - on mobile, tapping the bottom '+' FAB opens mobile create sheet) */}
{!isSelectedListReadOnly && (
<form
onSubmit={handleQuickAddTask}
className="hidden sm:flex items-center gap-1.5 panel-surface p-2 sm:p-1.5"
>
<Plus className="w-4 h-4 text-accent ml-1 flex-shrink-0" />
<input
ref={quickInputRef}
type="text"
value={quickTitle}
onChange={(e) => setQuickTitle(e.target.value)}
placeholder={
quickVoiceError ||
(currentSelectedList ? `Aufgabe zu "${currentSelectedList.title}" hinzufügen…` : 'Neue Aufgabe anlegen…')
}
className="flex-1 bg-transparent px-2 py-1.5 sm:py-1 text-sm text-main placeholder-faint focus:outline-none min-w-0"
/>
<div className="flex items-center gap-1 flex-shrink-0">
<VoiceInputButton
value={quickTitle}
onChange={setQuickTitle}
focusRef={quickInputRef}
onError={(message) => {
setQuickVoiceError(message);
window.setTimeout(() => setQuickVoiceError(null), 4000);
}}
/>
<input
type="date"
value={quickDueDate}
onChange={(e) => setQuickDueDate(e.target.value)}
className="hidden sm:block h-7 px-2 input-field text-xs"
title="Fälligkeitsdatum"
/>
<button
type="submit"
disabled={!quickTitle.trim()}
className="btn-primary h-9 sm:h-7 px-3.5 sm:px-3 text-xs disabled:opacity-40"
>
Hinzufügen
</button>
</div>
</form>
)}
{/* Mobile Active Filter Badge Banner (minimal and clean) */}
{(selectedTag || groupBy !== 'none' || sortBy !== 'custom') && (
<div className="flex sm:hidden items-center justify-between gap-2 p-2 rounded-btn bg-accent/10 border border-accent/20 text-xs">
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
<Filter className="w-3.5 h-3.5 text-accent flex-shrink-0" />
{selectedTag && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-accent text-on-accent text-[11px] font-semibold">
#{selectedTag}
<button
type="button"
onClick={() => setSelectedTag(null)}
className="hover:opacity-75 cursor-pointer ml-0.5"
>
<X className="w-3 h-3" />
</button>
</span>
)}
{groupBy !== 'none' && (
<span className="text-muted text-[11px]">
Gruppe: <strong className="text-main">{groupBy}</strong>
</span>
)}
{sortBy !== 'custom' && (
<span className="text-muted text-[11px]">
Sortiert: <strong className="text-main">{sortBy}</strong>
</span>
)}
</div>
<button
type="button"
onClick={() => {
setSelectedTag(null);
setGroupBy('none');
setSortBy('custom');
}}
className="text-accent text-[11px] font-semibold flex-shrink-0 hover:underline cursor-pointer"
>
Zurücksetzen
</button>
</div>
)}
{/* Hashtag Filter & Grouping / Sorting Toolbar (Desktop only - on mobile accessible via Logo Menu Filter & Sort) */}
<div className="hidden sm:flex flex-wrap items-center justify-between gap-2 pt-1 pb-0.5">
{/* Hashtag filter chips: one line, cut off with "…" when too many; "…" opens them all in lines */}
<div
ref={tagRowRef}
className={`flex-1 min-w-0 flex items-center gap-1.5 pb-1 ${tagsExpanded ? 'flex-wrap' : 'overflow-hidden'}`}
>
<span className="text-[11px] font-semibold text-muted flex items-center gap-1 flex-shrink-0">
<Hash className="w-3 h-3 text-accent" />
<span>Hashtags:</span>
</span>
<button
onClick={() => setSelectedTag(null)}
className={`h-6 px-2 text-[11px] rounded-pill border transition-colors flex-shrink-0 ${
selectedTag === null
? 'bg-panel border-accent text-accent font-semibold'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
Alle ({safeTasks.length})
</button>
{allAvailableTags.map((tag) => {
const count = safeTasks.filter((t) => t.tags?.includes(tag)).length;
const isSelected = selectedTag === tag;
return (
<button
key={tag}
onClick={() => setSelectedTag(isSelected ? null : tag)}
className={`h-6 px-2 text-[11px] rounded-pill border transition-colors flex items-center gap-1 flex-shrink-0 ${
isSelected
? 'bg-accent/15 border-accent text-accent font-semibold'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<span>#{tag}</span>
<span className="text-[9px] opacity-70 font-mono">({count})</span>
</button>
);
})}
{tagsExpanded && (
<button
onClick={() => setTagsExpanded(false)}
className="h-6 px-2 text-[11px] rounded-pill text-accent hover:underline flex-shrink-0"
>
Weniger
</button>
)}
</div>
{!tagsExpanded && tagsOverflow && (
<button
onClick={() => setTagsExpanded(true)}
className="h-6 px-2 -ml-1 mb-1 text-xs rounded-pill border border-border bg-canvas text-muted hover:text-main flex-shrink-0"
title="Alle Hashtags zeigen"
aria-label="Alle Hashtags zeigen"
>
…
</button>
)}
{/* Kanban: manage columns and width (the list view has grouping and sorting above the list) */}
<div className="flex items-center gap-2 flex-shrink-0 ml-auto">
{viewMode === 'kanban' && (
<>
{!isSelectedListReadOnly && (
<button
onClick={() => setShowManageColumnsModal(true)}
className="btn-action h-7 px-2.5 text-xs flex items-center gap-1.5"
title="Kanban-Spalten hinzufügen, umbenennen oder neu anordnen"
>
<Columns className="w-3.5 h-3.5 text-accent" />
<span>Spalten anpassen</span>
</button>
)}
<button
type="button"
onClick={toggleKanbanWide}
aria-pressed={kanbanWide}
className="btn-action h-7 w-7 p-0 hidden lg:inline-flex items-center justify-center"
title={kanbanWide ? 'Board wieder normal breit' : 'Board auf volle Breite strecken'}
>
{kanbanWide ? (
<Minimize2 className="w-3.5 h-3.5 text-accent" />
) : (
<Maximize2 className="w-3.5 h-3.5 text-accent" />
)}
</button>
</>
)}
</div>
</div>
{/* MOBILE CREATE TASK BOTTOM SHEET MODAL */}
{showMobileCreateModal && (
<div
className="sm:hidden fixed inset-0 !m-0 z-50 flex items-end justify-center bg-black/60 backdrop-blur-xs animate-in fade-in duration-150"
onClick={() => setShowMobileCreateModal(false)}
>
<div
className="w-full max-h-[85vh] overflow-y-auto bg-panel border-t border-border rounded-t-2xl shadow-panel p-4 pb-[calc(1.5rem+env(safe-area-inset-bottom))] animate-in slide-in-from-bottom duration-200"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between pb-3 border-b border-border mb-3">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-btn bg-accent/15 text-accent flex items-center justify-center">
<CheckSquare className="w-4 h-4" />
</div>
<h3 className="text-sm font-semibold text-main">Neue Aufgabe anlegen</h3>
</div>
<button
type="button"
onClick={() => setShowMobileCreateModal(false)}
className="p-1.5 text-faint hover:text-main rounded-btn transition-colors cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleMobileCreateTask} className="space-y-3">
<div>
<label className="text-[11px] font-semibold text-muted block mb-1">Aufgabentext</label>
<div className="relative">
<input
ref={mobileCreateInputRef}
type="text"
autoFocus
value={mobileCreateTitle}
onChange={(e) => setMobileCreateTitle(e.target.value)}
placeholder={mobileVoiceError || 'Was ist zu tun?…'}
className="w-full input-field pl-3 pr-11 py-2 text-sm"
/>
<VoiceInputButton
value={mobileCreateTitle}
onChange={setMobileCreateTitle}
focusRef={mobileCreateInputRef}
onError={(message) => {
setMobileVoiceError(message);
window.setTimeout(() => setMobileVoiceError(null), 4000);
}}
className="absolute right-1 top-1/2 -translate-y-1/2"
/>
</div>
</div>
{/* List Selector (if lists exist) */}
{safeTaskLists.length > 0 && (
<div>
<label className="text-[11px] font-semibold text-muted block mb-1">Aufgabenliste</label>
<select
value={mobileCreateListId}
onChange={(e) => setMobileCreateListId(e.target.value)}
className="w-full input-field px-2.5 py-2 text-xs bg-panel"
>
<option value="">Standard (Alle Aufgaben)</option>
{safeTaskLists.map((tl) => (
<option key={tl.id} value={tl.id}>
{tl.icon ? `${tl.icon} ${tl.title}` : tl.title}
</option>
))}
</select>
</div>
)}
{/* Priority - 4 Buttons */}
<div className="space-y-1.5 pt-1">
<label className="text-[11px] font-semibold text-muted block">Priorität</label>
<div className="grid grid-cols-4 gap-1.5">
{(['low', 'normal', 'high', 'urgent'] as PriorityLevel[]).map((p) => {
const config: Record<PriorityLevel, { label: string; icon?: React.ReactNode; activeClass: string }> = {
low: { label: 'Niedrig', activeClass: 'bg-canvas border-strong text-main font-semibold shadow-xs' },
normal: { label: 'Normal', activeClass: 'bg-accent/15 border-accent text-accent font-semibold shadow-xs' },
high: { label: 'Wichtig', icon: <AlertTriangle className="w-3 h-3 text-status-warn" />, activeClass: 'bg-status-warn/15 border-status-warn text-status-warn font-semibold shadow-xs' },
urgent: { label: 'Dringend', icon: <Flame className="w-3 h-3 text-status-err" />, activeClass: 'bg-status-err/15 border-status-err text-status-err font-semibold shadow-xs' },
};
const active = mobileCreatePriority === p;
const item = config[p];
return (
<button
key={p}
type="button"
onClick={() => {
triggerHaptic(10);
setMobileCreatePriority(p);
}}
className={`h-9 px-1 text-xs rounded-btn font-medium transition-colors border flex items-center justify-center gap-1 cursor-pointer ${
active
? item.activeClass
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
{item.icon}
<span className="truncate">{item.label}</span>
</button>
);
})}
</div>
</div>
{/* Due Date */}
<div className="space-y-1 pt-1">
<label className="text-[11px] font-semibold text-muted flex items-center gap-1">
<Calendar className="w-3.5 h-3.5 text-accent" />
<span>Fällig am</span>
</label>
<input
type="date"
value={mobileCreateDueDate}
onChange={(e) => setMobileCreateDueDate(e.target.value)}
className="w-full input-field px-3 py-2 text-xs"
/>
</div>
<div className="pt-2 flex justify-end gap-2">
<button
type="button"
onClick={() => setShowMobileCreateModal(false)}
className="btn-ghost h-10 px-4 text-xs cursor-pointer"
>
Abbrechen
</button>
<button
type="submit"
disabled={!mobileCreateTitle.trim()}
className="btn-primary h-10 px-5 text-xs font-semibold disabled:opacity-40 cursor-pointer"
>
Aufgabe erstellen
</button>
</div>
</form>
</div>
</div>
)}
{/* MOBILE FILTER & SORT MODAL */}
{showFilterModal && (
<div
className="sm:hidden fixed inset-0 !m-0 z-50 flex items-end justify-center bg-black/60 backdrop-blur-xs animate-in fade-in duration-150"
onClick={() => setShowFilterModal(false)}
>
<div
className="w-full max-h-[85vh] overflow-y-auto bg-panel border-t border-border rounded-t-2xl shadow-panel p-4 pb-[calc(1.5rem+env(safe-area-inset-bottom))] animate-in slide-in-from-bottom duration-200 space-y-4"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between pb-3 border-b border-border">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-btn bg-accent/15 text-accent flex items-center justify-center">
<Filter className="w-4 h-4" />
</div>
<h3 className="text-sm font-semibold text-main">Filter & Sortierung</h3>
</div>
<button
type="button"
onClick={() => setShowFilterModal(false)}
className="p-1.5 text-faint hover:text-main rounded-btn transition-colors cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Hashtag Filters */}
<div className="space-y-2">
<span className="text-[11px] font-bold uppercase tracking-wider text-muted flex items-center gap-1">
<Hash className="w-3 h-3 text-accent" />
<span>Nach Hashtags filtern</span>
</span>
<div className="flex flex-wrap gap-1.5">
<button
type="button"
onClick={() => setSelectedTag(null)}
className={`min-h-[32px] px-3 text-xs rounded-full border transition-colors cursor-pointer ${
selectedTag === null
? 'bg-accent text-on-accent border-accent font-semibold'
: 'bg-canvas border-border text-muted'
}`}
>
Alle ({safeTasks.length})
</button>
{allAvailableTags.map((tag) => {
const count = safeTasks.filter((t) => t.tags?.includes(tag)).length;
const isSelected = selectedTag === tag;
return (
<button
key={tag}
type="button"
onClick={() => setSelectedTag(isSelected ? null : tag)}
className={`min-h-[32px] px-3 text-xs rounded-full border transition-colors flex items-center gap-1 cursor-pointer ${
isSelected
? 'bg-accent text-on-accent border-accent font-semibold'
: 'bg-canvas border-border text-muted'
}`}
>
<span>#{tag}</span>
<span className="text-[10px] opacity-75">({count})</span>
</button>
);
})}
</div>
</div>
{/* Grouping */}
{viewMode !== 'kanban' && (
<div className="space-y-2 pt-2 border-t border-border">
<span className="text-[11px] font-bold uppercase tracking-wider text-muted block">
Gruppierung
</span>
<div className="grid grid-cols-2 gap-1.5">
{[
{ id: 'none', label: 'Keine' },
{ id: 'tag', label: 'Nach Hashtag' },
{ id: 'status', label: 'Nach Status' },
{ id: 'priority', label: 'Nach Priorität' },
{ id: 'due', label: 'Nach Fälligkeit' },
].map((g) => (
<button
key={g.id}
type="button"
onClick={() => setGroupBy(g.id as any)}
className={`min-h-[36px] px-3 text-xs rounded-btn border text-left transition-colors cursor-pointer ${
groupBy === g.id
? 'bg-accent-dim border-accent text-accent font-semibold'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
{g.label}
</button>
))}
</div>
</div>
)}
{/* Sorting */}
{viewMode !== 'kanban' && (
<div className="space-y-2 pt-2 border-t border-border">
<span className="text-[11px] font-bold uppercase tracking-wider text-muted block">
Sortierung
</span>
<div className="grid grid-cols-2 gap-1.5">
{[
{ id: 'custom', label: 'Manuell (Drag & Drop)' },
{ id: 'created', label: 'Erstelldatum' },
{ id: 'due', label: 'Fälligkeitsdatum' },
{ id: 'priority', label: 'Priorität' },
{ id: 'alpha', label: 'Alphabetisch' },
].map((s) => (
<button
key={s.id}
type="button"
onClick={() => setSortBy(s.id as any)}
className={`min-h-[36px] px-3 text-xs rounded-btn border text-left transition-colors cursor-pointer ${
sortBy === s.id
? 'bg-accent-dim border-accent text-accent font-semibold'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
{s.label}
</button>
))}
</div>
</div>
)}
<div className="pt-3 border-t border-border flex items-center justify-between gap-2">
<button
type="button"
onClick={() => {
setSelectedTag(null);
setGroupBy('none');
setSortBy('custom');
}}
className="btn-ghost h-9 px-3 text-xs text-muted hover:text-main cursor-pointer"
>
Filter zurücksetzen
</button>
<button
type="button"
onClick={() => setShowFilterModal(false)}
className="btn-primary h-9 px-5 text-xs font-semibold cursor-pointer"
>
Fertig
</button>
</div>
</div>
</div>
)}
{/* NEW LIST MODAL */}
{showNewListModal && (
<FormSheet
title="Neue Aufgabenliste erstellen"
icon={<Layers className="w-4 h-4 text-accent" />}
onClose={() => setShowNewListModal(false)}
onSubmit={handleCreateTaskList}
>
<div>
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
<div className="flex items-center gap-1.5">
<EmojiIconButton value={newListIcon} onChange={setNewListIcon} />
<input
type="text"
value={newListTitle}
onChange={(e) => setNewListTitle(e.target.value)}
placeholder="z.B. Homelab, Einkäufe, Steuern"
autoFocus
required
className="flex-1 min-w-0 h-11 sm:h-8 input-field px-2.5 text-base sm:text-xs"
/>
</div>
</div>
</FormSheet>
)}
{/* Short notice after deleting or undoing (same look as in the workspace) */}
{toast && (
<div
role="status"
className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] sm:bottom-6 z-50 max-w-[calc(100vw-1.5rem)] px-3 py-2 rounded-card text-xs text-main bg-panel-elevated border border-border shadow-panel flex items-center gap-3"
>
<span>{toast.text}</span>
{toast.undo && (
<button
type="button"
onClick={() => void undoLast()}
className="font-semibold text-accent hover:underline min-h-[44px] sm:min-h-0 shrink-0"
title="Rückgängig (Strg+Z)"
>
Rückgängig
</button>
)}
</div>
)}
{/* UNIFIED SHARE LIST MODAL */}
{showShareListModal && sharingTaskList && (
<UnifiedShareModal
isOpen={showShareListModal}
onClose={() => {
setShowShareListModal(false);
setSharingTaskList(null);
}}
title={`Aufgabenliste teilen: ${sharingTaskList.title}`}
resourceName={sharingTaskList.title}
resourceIcon={sharingTaskList.icon || undefined}
isOwner={sharingTaskList.isOwner !== false}
currentUserId={currentUser?.id}
shares={(sharingTaskList.shares || []).map((sh) => ({
id: sh.id,
targetType: sh.targetType,
targetId: sh.targetId,
targetName: sh.targetName || (sh.targetType === 'team' ? 'Gruppe' : sh.email || 'Benutzer'),
targetEmail: sh.email,
avatarUrl: sh.avatarUrl,
permission: sh.permission === 'viewer' ? 'viewer' as const : 'editor' as const,
role: sh.permission === 'viewer' ? 'viewer' : 'editor',
createdAt: sh.createdAt,
}))}
teams={safeTeams}
collaborators={safeCollaborators}
onAddShare={async (targetType, targetId, permission) => {
await api.addTaskListShare(sharingTaskList.id, {
targetType,
targetId,
permission,
});
const [freshLists, freshShares] = await Promise.all([
api.getTaskLists(),
api.getTaskListShares(sharingTaskList.id).catch(() => []),
]);
setTaskLists(freshLists);
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
if (updated) {
setSharingTaskList({ ...updated, shares: freshShares });
}
await loadData();
}}
onRemoveShare={async (shareId) => {
await api.removeTaskListShare(sharingTaskList.id, shareId);
const [freshLists, freshShares] = await Promise.all([
api.getTaskLists(),
api.getTaskListShares(sharingTaskList.id).catch(() => []),
]);
setTaskLists(freshLists);
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
if (updated) {
setSharingTaskList({ ...updated, shares: freshShares });
}
await loadData();
}}
/>
)}
{/* MANAGE COLUMNS MODAL */}
<ManageColumnsModal
isOpen={showManageColumnsModal}
onClose={() => setShowManageColumnsModal(false)}
initialColumns={activeColumns}
onSave={handleSaveColumns}
listTitle={currentSelectedList?.title || 'Alle Aufgaben'}
/>
{/* TASK DETAIL MODAL */}
<TaskDetailModal
task={detailTask}
isOpen={showDetailModal}
onClose={() => {
setShowDetailModal(false);
setDetailTaskId(null);
}}
activeColumns={detailTask ? columnsFor(detailTask) : activeColumns}
taskLists={safeTaskLists}
onOpenDocument={onOpenDocument}
onUpdateTitle={handleUpdateTaskTitle}
onUpdateStatus={handleSetKanbanStatus}
onUpdatePriority={handleSetPriority}
onUpdateDueDate={handleUpdateTaskDueDate}
onUpdateRecurrence={handleUpdateTaskRecurrence}
onUpdateTaskList={handleUpdateTaskTaskList}
onUpdateNotes={handleUpdateTaskNotes}
onUpdateTags={handleUpdateTaskTags}
assignableMembers={detailTask ? assignableMembers(detailTask) : []}
onUpdateAssignees={handleUpdateTaskAssignees}
onToggleSubtask={handleToggleSubtask}
onAddSubtask={handleAddSubtask}
onDeleteSubtask={handleDeleteSubtask}
onConvertToDoc={handleConvertToDoc}
onDeleteTask={handleDeletePermanent}
readOnly={detailTask ? isTaskReadOnly(detailTask) : false}
/>
{/* VIEW MODES: LIST OR KANBAN */}
{viewMode === 'kanban' ? (
/* DYNAMIC KANBAN BOARD */
<div className="space-y-2">
{kanbanTouch.ghost}
<div
ref={kanbanBoardRef}
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, boardColumns.length)}
>
{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);
const slotHere = kanbanSlot && kanbanSlot.col === col.id ? kanbanSlot.index : -1;
return (
<div
key={col.id}
onDragOver={(e) => handleKanbanColDragOver(e, col.id)}
onDragLeave={(e) => handleKanbanColDragLeave(e, col.id)}
onDrop={(e) => handleKanbanDrop(e, col.id, colTasks)}
data-kanban-col={col.id}
style={kanbanColHeight ? { height: kanbanColHeight } : undefined}
className={`bg-canvas border border-border flex flex-col overflow-hidden transition-all ${
kanbanColHeight ? '' : 'min-h-[420px]'
} ${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">
<span className="text-xs font-semibold text-main flex items-center gap-1.5">
<span className={`w-2 h-2 rounded-full ${columnDotClass(col)}`} />
<span>{col.name}</span>
</span>
<span className="badge-neutral font-mono">{colTasks.length}</span>
</div>
{/* Tasks List in Column */}
<div className={`p-2 space-y-2 flex-1 min-h-0 overflow-y-auto ${kanbanColHeight ? '' : 'max-h-[70vh]'}`}>
{colTasks.map((task) => {
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}>
<AnimatePresence initial={false} custom={justDroppedId !== null}>
{slotHere >= 0 && slotHere === visibleIdx && renderDropSlot(col.id, visibleIdx)}
</AnimatePresence>
<div
draggable={canDragTasks && !readOnly}
onDragStart={(e) => {
handleTaskDragStart(e, task);
setJustDroppedId(null);
dragHeightRef.current = e.currentTarget.getBoundingClientRect().height;
// Hidden a tick later: hiding the source right away would cancel the drag
window.setTimeout(() => setKanbanHiddenId(task.id), 0);
}}
onDragEnd={resetKanbanDrag}
data-kanban-task={task.id}
{...kanbanTouch.cardProps(task.id, task.content, !readOnly)}
onClick={() => {
if (kanbanTouch.consumeClick()) return;
setDetailTaskId(task.id);
setShowDetailModal(true);
}}
className={`panel-surface p-2.5 space-y-2 hover:border-strong transition-colors shadow-sm group relative select-none ${
readOnly ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing'
} ${
kanbanHiddenId === task.id ? 'hidden' : isDragging ? 'opacity-30 border border-dashed border-accent/40' : ''
} ${justDroppedId === task.id ? 'ring-2 ring-accent/60' : ''}`}
>
<div className="flex items-start justify-between gap-2">
<p
className={`text-xs font-medium break-words flex-1 leading-snug ${
task.isCompleted ? 'line-through text-faint' : 'text-main'
}`}
>
{task.content}
</p>
{!readOnly && (
<div className="hidden sm:flex items-center gap-1 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => handleConvertToDoc(task)}
title={task.docId ? 'Verknüpfte Notiz öffnen' : 'Als Notiz vertiefen'}
className={`${task.docId ? 'text-accent' : 'text-faint'} hover:text-accent p-0.5`}
>
<FileText className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleDeletePermanent(task.id)}
title="Aufgabe löschen"
className="text-faint hover:text-status-err p-0.5"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
)}
</div>
{/* Subtasks progress */}
{renderTaskProgressBar(task)}
{/* Notes preview badge */}
{task.notes && (
<div className="flex items-center gap-1 text-[10px] text-muted bg-canvas p-1 rounded border border-border truncate">
<FileText className="w-2.5 h-2.5 text-accent flex-shrink-0" />
<span className="truncate">{task.notes}</span>
</div>
)}
{/* Badges: In Notiz, Priority, Status, Tags */}
<div className="flex flex-wrap items-center gap-1 pt-0.5" onClick={stopOnControl}>
{(task.convertedType === 'document_block' || task.docId) && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
const docId = task.docId || (task.convertedRefId ? task.convertedRefId.split(':')[0] : '');
const blockId = task.blockId || (task.convertedRefId ? task.convertedRefId.split(':')[1] : '');
if (docId) onOpenDocument(docId, blockId);
}}
className="h-4 px-1.5 text-[9px] rounded-btn border border-accent/40 bg-accent/10 text-accent hover:bg-accent/20 cursor-pointer flex items-center gap-1 font-medium shrink-0"
title="In Notiz an der passenden Stelle öffnen"
>
<FileText className="w-2.5 h-2.5" />
<span>In Notiz</span>
</button>
)}
{task.priority === 'urgent' && (
<button
type="button"
disabled={readOnly}
onClick={() => handleCyclePriority(task)}
className="badge-accent text-[9px] cursor-pointer hover:opacity-85 disabled:cursor-default"
title="Klicken zum Ändern der Priorität (Dringend -> Normal)"
>
🔥 Dringend
</button>
)}
{task.priority === 'high' && (
<button
type="button"
disabled={readOnly}
onClick={() => handleCyclePriority(task)}
className="px-1.5 py-0.2 border border-status-warn text-status-warn rounded-btn text-[9px] font-medium cursor-pointer hover:opacity-85 disabled:cursor-default"
title="Klicken zum Ändern der Priorität (Wichtig -> Dringend)"
>
⚠️ Wichtig
</button>
)}
<OriginBadge origin={task.origin} createdAt={task.createdAt} size="xs" />
{task.dueDate && (
<span
className={`inline-flex items-center gap-0.5 text-[9px] ${!task.isCompleted && isOverdue(task.dueDate) ? 'text-status-err font-semibold' : 'text-muted'}`}
title={`Fällig: ${task.dueDate.replace('T', ' ')}`}
>
<Calendar className="w-2.5 h-2.5" />
<span>{formatDue(task.dueDate)}</span>
</span>
)}
{task.recurrence && (
<span className="inline-flex items-center text-muted" title={`Wiederholt sich ${RECURRENCE_LABELS[task.recurrence]}`}>
<Repeat className="w-2.5 h-2.5" />
</span>
)}
{task.tags?.map((tag) => (
<button
key={tag}
type="button"
onClick={() => setSelectedTag(selectedTag === tag ? null : tag)}
className={`text-[9px] px-1.5 py-0.2 rounded-pill border transition-colors ${
selectedTag === tag
? 'bg-accent/15 border-accent text-accent font-semibold'
: 'badge-neutral hover:border-accent'
}`}
>
#{tag}
</button>
))}
{getAssigneePeople(task).length > 0 && (
<span className="ml-auto">{renderAssignees(task, 'xxs')}</span>
)}
</div>
{/* Column Navigation controls: Move Left / Move Right */}
{!readOnly && (
<div
className="flex items-center justify-between pt-1 border-t border-border text-[10px]"
onClick={stopOnControl}
>
<button
type="button"
disabled={!prevCol}
onClick={() => prevCol && handleSetKanbanStatus(task, prevCol.id)}
className="text-faint hover:text-main disabled:opacity-20 flex items-center gap-0.5"
title={prevCol ? `Nach "${prevCol.name}"` : ''}
>
<ArrowLeft className="w-3 h-3" />
<span className="hidden sm:inline">{prevCol?.name ?? ''}</span>
</button>
<button
type="button"
disabled={!nextCol}
onClick={() => nextCol && handleSetKanbanStatus(task, nextCol.id)}
className="text-faint hover:text-main disabled:opacity-20 flex items-center gap-0.5"
title={nextCol ? `Nach "${nextCol.name}"` : ''}
>
<span className="hidden sm:inline">{nextCol?.name ?? ''}</span>
<ArrowRight className="w-3 h-3" />
</button>
</div>
)}
</div>
</React.Fragment>
);
})}
<AnimatePresence initial={false} custom={justDroppedId !== null}>
{slotHere >= 0 && slotHere === visibleIds.length && renderDropSlot(col.id, visibleIds.length)}
</AnimatePresence>
{colTasks.length === 0 && slotHere < 0 && (
<div className="p-4 text-center text-faint text-xs italic">
Keine Aufgaben in dieser Spalte
</div>
)}
</div>
{/* Quick Add Task to Column */}
{!isSelectedListReadOnly && activeColumns.some((c) => boardIdOf(c) === col.id) && (
<div className="p-2 border-t border-border bg-panel">
<button
type="button"
onClick={() => handleQuickAddTaskToColumn(col.id)}
className="w-full h-7 px-2 text-xs border border-dashed border-border rounded-btn text-muted hover:text-main hover:bg-canvas flex items-center justify-center gap-1 transition-colors"
>
<Plus className="w-3 h-3" />
<span>Aufgabe hinzufügen</span>
</button>
</div>
)}
</div>
);
})}
</div>
</div>
) : (
/* STANDARD LIST VIEW (CLEAN & UNCLUTTERED) */
<div className="space-y-2">
{/* Subfilter tabs for List View (Desktop; on mobile controlled via MindSift Logo Menu) */}
<div className="flex items-center justify-between gap-2">
<div className="hidden sm:flex items-center border border-border rounded-btn bg-canvas overflow-hidden">
<button
onClick={() => setFilter('open')}
className={`h-8 px-3.5 text-xs font-medium border-r border-border transition-colors ${
filter === 'open' ? 'bg-panel text-main font-semibold' : 'text-muted hover:text-main'
}`}
>
Offen ({openTasks.length})
</button>
<button
onClick={() => setFilter('done')}
className={`h-8 px-3.5 text-xs font-medium border-r border-border transition-colors ${
filter === 'done' ? 'bg-panel text-main font-semibold' : 'text-muted hover:text-main'
}`}
>
Erledigt ({doneTasks.length})
</button>
<button
onClick={() => setFilter('discarded')}
className={`h-8 px-3.5 text-xs font-medium transition-colors ${
filter === 'discarded' ? 'bg-panel text-main font-semibold' : 'text-muted hover:text-main'
}`}
>
Papierkorb ({taskTrash.length})
</button>
</div>
{/* Grouping and sorting right above the list, then clearing as a plain trash icon (desktop; phones
use the menu) */}
<div className="hidden sm:flex items-center gap-2 ml-auto">
<div className="flex items-center gap-1 text-xs text-muted">
<span className="text-[11px]">Gruppe:</span>
<select
value={groupBy}
onChange={(e) => setGroupBy(e.target.value as any)}
className="h-8 px-2 input-field text-xs bg-panel"
>
<option value="none">Keine</option>
<option value="tag">Nach Hashtag</option>
<option value="status">Nach Status</option>
<option value="priority">Nach Priorität</option>
<option value="due">Nach Fälligkeit</option>
</select>
</div>
<div className="flex items-center gap-1 text-xs text-muted">
<span className="text-[11px]">Sortierung:</span>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="h-8 px-2 input-field text-xs bg-panel"
>
<option value="custom">Manuell (Drag & Drop)</option>
<option value="created">Erstelldatum</option>
<option value="due">Fälligkeitsdatum</option>
<option value="priority">Priorität</option>
<option value="alpha">Titel (A-Z)</option>
</select>
</div>
</div>
{filter === 'discarded' && taskTrash.some((t) => t.canEdit) && (
<button
onClick={handleEmptyTaskTrash}
className="btn-action h-8 w-8 p-0 justify-center min-h-[38px] min-w-[38px] sm:min-h-0 sm:min-w-0 ml-auto sm:ml-0"
title="Papierkorb leeren"
aria-label="Papierkorb leeren"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
{filter === 'done' && doneTasks.length > 0 && !isSelectedListReadOnly && (
<button
onClick={handleClearDone}
className="btn-action h-8 w-8 p-0 justify-center min-h-[38px] min-w-[38px] sm:min-h-0 sm:min-w-0 ml-auto sm:ml-0"
title="Erledigte in den Papierkorb"
aria-label="Erledigte in den Papierkorb"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
{loading ? (
<div className="p-6 text-center text-faint text-sm">Lade Aufgaben…</div>
) : filter === 'discarded' ? (
<div className="panel-surface divide-y divide-border">
{taskTrash.length === 0 ? (
<div className="px-4 py-6 text-center text-muted text-sm">Der Papierkorb ist leer.</div>
) : (
taskTrash.map((item) => {
const listOfItem = item.taskListId ? safeTaskLists.find((l) => l.id === item.taskListId) : undefined;
return (
<div key={item.id} className="px-3 py-2 flex items-center justify-between gap-3 hover:bg-panel-hover">
<div className="flex items-center gap-2.5 min-w-0 flex-1">
<Trash2 className="w-4 h-4 text-faint flex-shrink-0" />
<div className="min-w-0">
<p className={`text-sm truncate ${item.isCompleted ? 'text-muted line-through' : 'text-main'}`}>{item.content}</p>
<p className="text-[11px] text-faint truncate">
{selectedListId === 'all' && (
<>
{listOfItem ? (listOfItem.icon ? `${listOfItem.icon} ${listOfItem.title}` : listOfItem.title) : 'Persönlich'}
{' · '}
</>
)}
gelöscht {formatDeletedAt(item.trashedAt)}
{item.trashedBy ? ` von ${item.trashedBy}` : ' (automatisch)'}
</p>
</div>
</div>
{item.canEdit && (
<div className="flex items-center gap-2 flex-shrink-0">
<button onClick={() => handleRestoreTrashed(item)} className="btn-action min-h-[38px] sm:min-h-0">
<RotateCcw className="w-3 h-3 text-accent" /> Zurück
</button>
<button
onClick={() => handlePurgeTrashed(item)}
className="btn-icon btn-icon-danger min-h-[38px] min-w-[38px] sm:min-h-0 sm:min-w-0"
title="Endgültig löschen"
aria-label="Endgültig löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
);
})
)}
</div>
) : (
<div className="space-y-3">
{groupedTasks ? (
/* GROUPED LIST */
groupedTasks.map((grp) => (
<div key={grp.key} className="panel-surface overflow-hidden">
<div className="px-3 py-1.5 bg-panel border-b border-border flex items-center justify-between">
<span className="text-xs font-semibold text-main">{grp.title}</span>
<span className="badge-neutral font-mono text-[10px]">{grp.count}</span>
</div>
<div>
{grp.tasks.map((task) => renderTaskRow(task))}
</div>
</div>
))
) : (
/* FLAT LIST */
<div className="panel-surface divide-y divide-border overflow-hidden">
{filteredTasks.length === 0 ? (
<div className="p-8 text-center text-muted text-sm space-y-1">
<p className="font-medium text-main">Keine Aufgaben gefunden</p>
<p className="text-xs text-faint">
{selectedTag
? `Keine Aufgaben mit Hashtag #${selectedTag}.`
: filter === 'done'
? 'Noch keine Aufgaben erledigt.'
: 'Erstelle oben eine neue Aufgabe.'}
</p>
</div>
) : (
filteredTasks.map((task) => renderTaskRow(task))
)}
</div>
)}
</div>
)}
</div>
)}
</div>
);
};