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 = ({ 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('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([]); const [taskLists, setTaskLists] = useState([]); const [selectedListId, setSelectedListId] = useState( () => readViewSession('tasks').listId || 'all' ); useEffect(() => { writeViewSession('tasks', { listId: selectedListId, viewMode }); }, [selectedListId, viewMode]); const [teams, setTeams] = useState([]); const [collaborators, setCollaborators] = useState([]); // Task trash of the selected list (or all): deleted tasks and cleared or cleaned-up completed ones const [taskTrash, setTaskTrash] = useState([]); const [loading, setLoading] = useState(true); // Drag and Drop state const [draggingTaskId, setDraggingTaskId] = useState(null); const [dragOverTaskTarget, setDragOverTaskTarget] = useState<{ taskId: string; position: 'before' | 'after' } | null>(null); const [dragOverKanbanCol, setDragOverKanbanCol] = useState(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(null); const [justDroppedId, setJustDroppedId] = useState(null); const dragHeightRef = useRef(64); // List tab a dragged task hovers over (dropping moves it into that list) const [dragOverListTab, setDragOverListTab] = useState(null); // Quick Task Add state const [quickTitle, setQuickTitle] = useState(''); const [quickVoiceError, setQuickVoiceError] = useState(null); const [quickDueDate, setQuickDueDate] = useState(''); const quickInputRef = useRef(null); const { setGroups, setPrimaryAction } = useMobileActions(); // Mobile task creation & filter modal state const [showMobileCreateModal, setShowMobileCreateModal] = useState(false); const [mobileCreateTitle, setMobileCreateTitle] = useState(''); const [mobileVoiceError, setMobileVoiceError] = useState(null); const mobileCreateInputRef = useRef(null); const [mobileCreateDueDate, setMobileCreateDueDate] = useState(''); const [mobileCreatePriority, setMobileCreatePriority] = useState('normal'); const [mobileCreateListId, setMobileCreateListId] = useState(''); 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(null); // Detail modal state const [detailTaskId, setDetailTaskId] = useState(null); const [showDetailModal, setShowDetailModal] = useState(false); const [extraTask, setExtraTask] = useState(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(() => { 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(null); // Hashtag row on desktop: whether it is cut off, and whether it is opened up into several lines const tagRowRef = useRef(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(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(); 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 ( 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(null); const [kanbanColHeight, setKanbanColHeight] = useState(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(); 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(); 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 = { 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(); 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([]); 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(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) => ( ({ 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, 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('[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, 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: , 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: , badge: filterSortActiveCount > 0 ? `${filterSortActiveCount} aktiv` : undefined, active: filterSortActiveCount > 0, onClick: () => setShowFilterModal(true), }, ]; const listManagementItems: any[] = [ { id: 'new-task-list', label: 'Neue Aufgabenliste anlegen', icon: , onClick: () => setShowNewListModal(true), }, ]; if (currentSelectedList) { listManagementItems.push({ id: 'share-task-list', label: `Liste „${currentSelectedList.title}“ teilen`, icon: , onClick: () => handleOpenShareModal(currentSelectedList), }); listManagementItems.push({ id: 'delete-task-list', label: `Liste „${currentSelectedList.title}“ löschen`, icon: , variant: 'danger', onClick: () => handleDeleteTaskList(currentSelectedList.id), }); } const viewItems: any[] = [ { id: 'filter-open', label: 'Offene Aufgaben (Liste)', icon: , badge: openTasks.length, active: viewMode === 'list' && filter === 'open', onClick: () => { setViewMode('list'); setFilter('open'); }, }, { id: 'filter-done', label: 'Erledigte Aufgaben', icon: , badge: doneTasks.length, active: viewMode === 'list' && filter === 'done', onClick: () => { setViewMode('list'); setFilter('done'); }, }, { id: 'filter-discarded', label: 'Papierkorb', icon: , badge: taskTrash.length, active: viewMode === 'list' && filter === 'discarded', onClick: () => { setViewMode('list'); setFilter('discarded'); }, }, { id: 'view-kanban', label: 'Kanban-Board Ansicht', icon: , badge: kanbanTasks.length, active: viewMode === 'kanban', onClick: () => setViewMode('kanban'), }, ]; if (viewMode === 'kanban' && !isSelectedListReadOnly) { viewItems.push({ id: 'manage-columns', label: 'Kanban-Spalten verwalten', icon: , onClick: () => setShowManageColumnsModal(true), }); } const listItems: any[] = [ { id: 'list-all', label: 'Alle Aufgaben', icon: , 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 (
{done}/{total} Unteraufgaben {percent}%
); }; // 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 (
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' && (
)} {isTarget && dragOverTaskTarget.position === 'after' && (
)}
{/* Checkbox (larger touch target on mobile) */} {/* Clickable Title & Metadata */}

{task.content}

{/* Subtasks Progress Bar */} {renderTaskProgressBar(task)} {/* Interactive Badges Bar */}
{(task.convertedType === 'document_block' || task.docId) && ( )} {/* 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 =
{renderAssignees(task, 'xs')} {/* Action Controls (Desktop only - on mobile, tapping the task opens Detail Modal) */}
e.stopPropagation()} onClick={(e) => e.stopPropagation()}> {!readOnly && ( <> )}
); }; return (
{/* Top Header: Task Lists Tabs & View Mode Switcher (Desktop) */}
{/* Task Lists selector tabs */}
{safeTaskLists.map((tl) => (
{ 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' : ''}`} > {selectedListId === tl.id && ( )}
))}
{/* Right Header Section: Sharing Badge, Icon-Only Button & View Switcher */}
{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 (
{isShared && ( isOwner ? ( ) : ( ) )}
); })()} {/* View Switcher: List vs Kanban */}
{/* 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) */}
{currentSelectedList ? [currentSelectedList.icon, currentSelectedList.title].filter(Boolean).join(' ') : 'đź“‹ Alle Aufgaben'} {viewMode === 'kanban' ? 'Kanban' : filter === 'open' ? `Offen (${openTasks.length})` : filter === 'done' ? `Erledigt (${doneTasks.length})` : `Papierkorb (${taskTrash.length})`}
{currentSelectedList && ( )}
{isSelectedListReadOnly && (
Diese Liste wurde von {currentSelectedList?.ownerName || 'einem anderen Benutzer'} nur zum Lesen freigegeben.
)} {/* Quick Add Task Input (Desktop only - on mobile, tapping the bottom '+' FAB opens mobile create sheet) */} {!isSelectedListReadOnly && (
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" />
{ setQuickVoiceError(message); window.setTimeout(() => setQuickVoiceError(null), 4000); }} /> setQuickDueDate(e.target.value)} className="hidden sm:block h-7 px-2 input-field text-xs" title="Fälligkeitsdatum" />
)} {/* Mobile Active Filter Badge Banner (minimal and clean) */} {(selectedTag || groupBy !== 'none' || sortBy !== 'custom') && (
{selectedTag && ( #{selectedTag} )} {groupBy !== 'none' && ( Gruppe: {groupBy} )} {sortBy !== 'custom' && ( Sortiert: {sortBy} )}
)} {/* Hashtag Filter & Grouping / Sorting Toolbar (Desktop only - on mobile accessible via Logo Menu Filter & Sort) */}
{/* Hashtag filter chips: one line, cut off with "…" when too many; "…" opens them all in lines */}
Hashtags: {allAvailableTags.map((tag) => { const count = safeTasks.filter((t) => t.tags?.includes(tag)).length; const isSelected = selectedTag === tag; return ( ); })} {tagsExpanded && ( )}
{!tagsExpanded && tagsOverflow && ( )} {/* Kanban: manage columns and width (the list view has grouping and sorting above the list) */}
{viewMode === 'kanban' && ( <> {!isSelectedListReadOnly && ( )} )}
{/* MOBILE CREATE TASK BOTTOM SHEET MODAL */} {showMobileCreateModal && (
setShowMobileCreateModal(false)} >
e.stopPropagation()} >

Neue Aufgabe anlegen

setMobileCreateTitle(e.target.value)} placeholder={mobileVoiceError || 'Was ist zu tun?…'} className="w-full input-field pl-3 pr-11 py-2 text-sm" /> { setMobileVoiceError(message); window.setTimeout(() => setMobileVoiceError(null), 4000); }} className="absolute right-1 top-1/2 -translate-y-1/2" />
{/* List Selector (if lists exist) */} {safeTaskLists.length > 0 && (
)} {/* Priority - 4 Buttons */}
{(['low', 'normal', 'high', 'urgent'] as PriorityLevel[]).map((p) => { const config: Record = { 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: , activeClass: 'bg-status-warn/15 border-status-warn text-status-warn font-semibold shadow-xs' }, urgent: { label: 'Dringend', icon: , activeClass: 'bg-status-err/15 border-status-err text-status-err font-semibold shadow-xs' }, }; const active = mobileCreatePriority === p; const item = config[p]; return ( ); })}
{/* Due Date */}
setMobileCreateDueDate(e.target.value)} className="w-full input-field px-3 py-2 text-xs" />
)} {/* MOBILE FILTER & SORT MODAL */} {showFilterModal && (
setShowFilterModal(false)} >
e.stopPropagation()} >

Filter & Sortierung

{/* Hashtag Filters */}
Nach Hashtags filtern
{allAvailableTags.map((tag) => { const count = safeTasks.filter((t) => t.tags?.includes(tag)).length; const isSelected = selectedTag === tag; return ( ); })}
{/* Grouping */} {viewMode !== 'kanban' && (
Gruppierung
{[ { 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) => ( ))}
)} {/* Sorting */} {viewMode !== 'kanban' && (
Sortierung
{[ { 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) => ( ))}
)}
)} {/* NEW LIST MODAL */} {showNewListModal && ( } onClose={() => setShowNewListModal(false)} onSubmit={handleCreateTaskList} >
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" />
)} {/* Short notice after deleting or undoing (same look as in the workspace) */} {toast && (
{toast.text} {toast.undo && ( )}
)} {/* UNIFIED SHARE LIST MODAL */} {showShareListModal && sharingTaskList && ( { 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 */} setShowManageColumnsModal(false)} initialColumns={activeColumns} onSave={handleSaveColumns} listTitle={currentSelectedList?.title || 'Alle Aufgaben'} /> {/* TASK DETAIL MODAL */} { 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 */
{kanbanTouch.ghost}
{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 (
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 */}
{col.name} {colTasks.length}
{/* Tasks List in Column */}
{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 ( {slotHere >= 0 && slotHere === visibleIdx && renderDropSlot(col.id, visibleIdx)}
{ 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' : ''}`} >

{task.content}

{!readOnly && (
e.stopPropagation()}>
)}
{/* Subtasks progress */} {renderTaskProgressBar(task)} {/* Notes preview badge */} {task.notes && (
{task.notes}
)} {/* Badges: In Notiz, Priority, Status, Tags */}
{(task.convertedType === 'document_block' || task.docId) && ( )} {task.priority === 'urgent' && ( )} {task.priority === 'high' && ( )} {task.dueDate && ( {formatDue(task.dueDate)} )} {task.recurrence && ( )} {task.tags?.map((tag) => ( ))} {getAssigneePeople(task).length > 0 && ( {renderAssignees(task, 'xxs')} )}
{/* Column Navigation controls: Move Left / Move Right */} {!readOnly && (
)}
); })} {slotHere >= 0 && slotHere === visibleIds.length && renderDropSlot(col.id, visibleIds.length)} {colTasks.length === 0 && slotHere < 0 && (
Keine Aufgaben in dieser Spalte
)}
{/* Quick Add Task to Column */} {!isSelectedListReadOnly && activeColumns.some((c) => boardIdOf(c) === col.id) && (
)}
); })}
) : ( /* STANDARD LIST VIEW (CLEAN & UNCLUTTERED) */
{/* Subfilter tabs for List View (Desktop; on mobile controlled via MindSift Logo Menu) */}
{/* Grouping and sorting right above the list, then clearing as a plain trash icon (desktop; phones use the menu) */}
Gruppe:
Sortierung:
{filter === 'discarded' && taskTrash.some((t) => t.canEdit) && ( )} {filter === 'done' && doneTasks.length > 0 && !isSelectedListReadOnly && ( )}
{loading ? (
Lade Aufgaben…
) : filter === 'discarded' ? (
{taskTrash.length === 0 ? (
Der Papierkorb ist leer.
) : ( taskTrash.map((item) => { const listOfItem = item.taskListId ? safeTaskLists.find((l) => l.id === item.taskListId) : undefined; return (

{item.content}

{selectedListId === 'all' && ( <> {listOfItem ? (listOfItem.icon ? `${listOfItem.icon} ${listOfItem.title}` : listOfItem.title) : 'Persönlich'} {' · '} )} gelöscht {formatDeletedAt(item.trashedAt)} {item.trashedBy ? ` von ${item.trashedBy}` : ' (automatisch)'}

{item.canEdit && (
)}
); }) )}
) : (
{groupedTasks ? ( /* GROUPED LIST */ groupedTasks.map((grp) => (
{grp.title} {grp.count}
{grp.tasks.map((task) => renderTaskRow(task))}
)) ) : ( /* FLAT LIST */
{filteredTasks.length === 0 ? (

Keine Aufgaben gefunden

{selectedTag ? `Keine Aufgaben mit Hashtag #${selectedTag}.` : filter === 'done' ? 'Noch keine Aufgaben erledigt.' : 'Erstelle oben eine neue Aufgabe.'}

) : ( filteredTasks.map((task) => renderTaskRow(task)) )}
)}
)}
)}
); };