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 { readViewMemory, writeViewMemory } 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 { ListSwitcher } from '../../components/ListSwitcher'; import { isPhoneWidth } from '../../lib/breakpoints'; import { reportNav, onNavStep, restoringNavPlace } from '../../lib/navHistory'; import { LivePill } from '../../components/LivePill'; 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'; // Desktop Kanban columns never get lower than this: room for the header and about three cards const KANBAN_MIN_COL_HEIGHT = 340; const KANBAN_MIN_PHONE_COL_HEIGHT = 280; // First row of a Kanban board with several rows: the full height minus what is still visible of the bar // above the board (--kb-top at the start, shrinking with the scroll position --kb-scroll) const KANBAN_FIRST_ROW_HEIGHT = `max(${KANBAN_MIN_COL_HEIGHT}px, calc(var(--kb-full) - max(0px, calc(var(--kb-top) - var(--kb-scroll, 0px)))))`; // Below the board: what the first row has not grown into yet, so the page keeps its height while scrolling const KANBAN_BAR_SPACER = 'max(0px, calc(var(--kb-top) - var(--kb-scroll, 0px)))'; // 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 GroupBy = 'none' | 'list' | 'tag' | 'status' | 'priority' | 'due'; type ViewMode = 'list' | 'kanban'; // viewModes: list or Kanban per task list ("all" for Alle Aufgaben); viewMode is the old single value type TasksSession = { listId: string; viewModes: Record; viewMode?: ViewMode; groupAll: GroupBy; groupList: GroupBy; }; // "Alle Aufgaben" is grouped by task list unless the user picks something else; a single list has no // grouping by default (grouping it by list would give one group) const DEFAULT_GROUP_ALL: GroupBy = 'list'; const DEFAULT_GROUP_LIST: GroupBy = 'none'; const GROUP_LABELS: Record = { none: 'Keine', list: 'Nach Liste', tag: 'Nach Hashtag', status: 'Nach Status', priority: 'Nach Priorität', due: 'Nach Fälligkeit', }; const SORT_LABELS: Record = { custom: 'Manuell', created: 'Erstelldatum', due: 'Fälligkeitsdatum', priority: 'Priorität', alpha: 'Alphabetisch', }; export const TasksView: React.FC = ({ currentUser, onRefreshStats, onOpenDocument, refreshTrigger = 0, initialSelectedTaskId, onInitialTaskShown, openTasksCount, }) => { // Open list, view per list and grouping are remembered on this device (new tab, restart of the // installed app) const remembered = useMemo(() => readViewMemory('tasks'), []); const [viewModes, setViewModes] = useState>(() => { const out: Record = {}; if (remembered.viewModes && typeof remembered.viewModes === 'object') { for (const [id, mode] of Object.entries(remembered.viewModes)) { if (mode === 'kanban') out[id] = 'kanban'; } } else if (remembered.viewMode === 'kanban' && remembered.listId) { out[remembered.listId] = 'kanban'; // stored before views were kept per list } return out; }); const [groupAll, setGroupAll] = useState(() => remembered.groupAll && remembered.groupAll in GROUP_LABELS ? remembered.groupAll : DEFAULT_GROUP_ALL ); const [groupList, setGroupList] = useState(() => remembered.groupList && remembered.groupList in GROUP_LABELS && remembered.groupList !== 'list' ? remembered.groupList : DEFAULT_GROUP_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 [filter, setFilter] = useState<'open' | 'done' | 'discarded'>('open'); const [tasks, setTasks] = useState([]); const [taskLists, setTaskLists] = useState([]); const [selectedListId, setSelectedListIdState] = useState( () => { // Coming back here by the browser's back/forward: the list of that step const step = restoringNavPlace(); return (step?.tab === 'tasks' && step.taskList) || remembered.listId || 'all'; } ); const viewMode: ViewMode = viewModes[selectedListId] || 'list'; // Only Kanban is stored; the list view is the default const setViewMode = useCallback( (mode: ViewMode) => setViewModes((prev) => { const next = { ...prev }; if (mode === 'kanban') next[selectedListId] = 'kanban'; else delete next[selectedListId]; return next; }), [selectedListId] ); const isKanbanWide = viewMode === 'kanban' && kanbanWide; const isAllTasks = selectedListId === 'all'; const groupBy: GroupBy = isAllTasks ? groupAll : groupList; const setGroupBy = (g: GroupBy) => (isAllTasks ? setGroupAll(g) : setGroupList(g)); const defaultGroupBy = isAllTasks ? DEFAULT_GROUP_ALL : DEFAULT_GROUP_LIST; // "Nach Liste" only where several lists show up const groupOptions = (Object.keys(GROUP_LABELS) as GroupBy[]) .filter((id) => isAllTasks || id !== 'list') .map((id) => ({ id, label: GROUP_LABELS[id] })); useEffect(() => { writeViewMemory('tasks', { listId: selectedListId, viewModes, groupAll, groupList }); }, [selectedListId, viewModes, groupAll, groupList]); 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 [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; listCacheRef.current.set(listId, { tasks: Array.isArray(allTasks) ? allTasks : [], trash: Array.isArray(trash) ? trash : [], }); 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; // Last known tasks of every list opened in this session. Switching lists shows them in the same render // as the new list's view (list or Kanban), so the board never flashes the previous list's tasks; the // reload then brings them up to date. A list not opened yet takes its share of "Alle Aufgaben". const listCacheRef = useRef(new Map()); useEffect(() => { if (!loading && loadedListRef.current === selectedListId) { listCacheRef.current.set(selectedListId, { tasks, trash: taskTrash }); } }, [tasks, taskTrash, loading, selectedListId]); const setSelectedListId = useCallback((id: string) => { if (id === selectedListIdRef.current) return; const cache = listCacheRef.current; let known = cache.get(id); const all = cache.get('all'); if (!known && id !== 'all' && all) { known = { tasks: all.tasks.filter((t) => t.taskListId === id), trash: all.trash.filter((t) => t.taskListId === id), }; } setTasks(known ? known.tasks : []); setTaskTrash(known ? known.trash : []); setLoading(!known); loadedListRef.current = known ? id : null; setSelectedListIdState(id); }, []); // Switching task lists is a step of the browser history (lib/navHistory.ts) useEffect(() => reportNav({ taskList: selectedListId }), [selectedListId]); useEffect( () => onNavStep((place) => { if (place.tab === 'tasks' && place.taskList) setSelectedListId(place.taskList); }), [setSelectedListId] ); 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: every column, in every row, is as high as the room from the board's top to the bottom of // the scroll area, so the first row fits completely on opening and each further row fills one screen; // more cards scroll inside the column. At least room for about three cards (the page scrolls below that). // The first row grows while the bar above the board scrolls away, so a row always fills the screen (with // one row too: the first scroll step hides the bar); the wheel moves row by row (below). const kanbanBoardRef = useRef(null); const [kanbanColHeight, setKanbanColHeight] = useState(null); const [kanbanMultiRow, setKanbanMultiRow] = useState(false); const [kanbanPerRow, setKanbanPerRow] = useState(0); // Phones: one column per screen (between header and bottom bar) and the page snaps column by column; // kanbanPhoneTop is where a snapped column starts (below the header) const [kanbanPhoneSnap, setKanbanPhoneSnap] = useState(false); const [kanbanPhoneTop, setKanbanPhoneTop] = useState(0); // The first column is lower by the bar above the board, so it shows completely at the page top (its // snap point); a swipe then goes on to the second one const [kanbanPhoneFirstHeight, setKanbanPhoneFirstHeight] = useState(0); const isKanban = viewMode === 'kanban'; useEffect(() => { if (!isKanban) { setKanbanColHeight(null); setKanbanMultiRow(false); setKanbanPhoneSnap(false); return; } const fit = () => { const el = kanbanBoardRef.current; if (!el) { setKanbanColHeight(null); setKanbanMultiRow(false); setKanbanPhoneSnap(false); return; } if (isPhoneWidth()) { const headerBottom = document.querySelector('header')?.getBoundingClientRect().bottom ?? 0; const navTop = document.querySelector('[data-mobile-nav]')?.getBoundingClientRect().top ?? window.innerHeight; setKanbanColHeight(Math.max(KANBAN_MIN_PHONE_COL_HEIGHT, navTop - headerBottom - 24)); setKanbanPhoneTop(Math.round(headerBottom + 12)); const boardTop = el.getBoundingClientRect().top + window.scrollY; setKanbanPhoneFirstHeight(Math.max(KANBAN_MIN_PHONE_COL_HEIGHT, Math.round(navTop - boardTop - 12))); setKanbanMultiRow(false); setKanbanPhoneSnap(true); return; } setKanbanPhoneSnap(false); const area = el.closest('[data-app-scroll]'); const areaTop = area ? area.getBoundingClientRect().top - area.scrollTop : -window.scrollY; const areaHeight = area ? area.clientHeight : window.innerHeight; const top = el.getBoundingClientRect().top - areaTop; setKanbanColHeight(Math.max(KANBAN_MIN_COL_HEIGHT, areaHeight - top - 16)); // A row fills the whole area (--kb-full); the first one grows into the room the bar above the board // frees while scrolling (--kb-top - --kb-scroll, see the scroll effect). With one row as well, so the // first scroll step hides the bar there too. Set on the board's wrapper: the spacer below needs them. const vars = el.parentElement ?? el; vars.style.setProperty('--kb-full', `${Math.max(KANBAN_MIN_COL_HEIGHT, areaHeight - 24)}px`); vars.style.setProperty('--kb-top', `${Math.max(0, top - 12)}px`); setKanbanMultiRow(true); requestAnimationFrame(() => { const colTops = Array.from(el.querySelectorAll('[data-kanban-col]')).map((c) => c.offsetTop); setKanbanPerRow(colTops.filter((t) => t === Math.min(...colTops)).length); }); }; fit(); // The board's width animates when it is stretched to full width or back: measure again at the end const board = kanbanBoardRef.current; const onTransitionEnd = (e: TransitionEvent) => { if (e.target === board) fit(); }; window.addEventListener('resize', fit); board?.addEventListener('transitionend', onTransitionEnd); return () => { window.removeEventListener('resize', fit); board?.removeEventListener('transitionend', onTransitionEnd); }; }, [isKanban, isKanbanWide, boardColumns.length, selectedListId, loading]); // Several rows: one wheel step moves to the next row (or back up to the previous row / the page top), // smoothly. The browser's own snapping goes back to the nearest point, so a wheel step shorter than a row // never got past the first one. A column whose cards can still scroll that way scrolls first; Ctrl + // wheel over the board scrolls freely (and does not zoom there); scrollbar and keys scroll freely. useEffect(() => { const board = kanbanBoardRef.current; const area = board?.closest('[data-app-scroll]'); if (!kanbanMultiRow || !board || !area) return; let pending = 0; // trackpads send many small steps: move only once they add up let lockedUntil = 0; const snapPoints = () => { const areaTop = area.getBoundingClientRect().top - area.scrollTop; const rows = new Set([0]); board.querySelectorAll('[data-kanban-col]').forEach((c) => { rows.add(Math.max(0, Math.round(c.getBoundingClientRect().top - areaTop - 12))); }); return Array.from(rows).sort((a, b) => a - b); }; const onWheel = (e: WheelEvent) => { const dy = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY; if (e.ctrlKey) { if (!board.contains(e.target as Node)) return; // zooming elsewhere stays as it is e.preventDefault(); area.scrollBy({ top: dy }); return; } if (dy === 0 || e.shiftKey) return; const list = (e.target as HTMLElement).closest('[data-kanban-list]'); if (list && (dy > 0 ? list.scrollTop + list.clientHeight < list.scrollHeight - 1 : list.scrollTop > 0)) return; e.preventDefault(); if (Date.now() < lockedUntil) return; pending += dy; if (Math.abs(pending) < 40) return; const down = pending > 0; pending = 0; const cur = area.scrollTop; const points = snapPoints(); const next = down ? points.find((p) => p > cur + 2) : [...points].reverse().find((p) => p < cur - 2); if (next === undefined) { // Past the last row (e.g. a very low window): plain scrolling area.scrollBy({ top: dy }); return; } lockedUntil = Date.now() + 450; area.scrollTo({ top: next, behavior: 'smooth' }); }; // The first row grows while the bar above the board scrolls away const vars = board.parentElement ?? board; const onScroll = () => vars.style.setProperty('--kb-scroll', `${area.scrollTop}px`); onScroll(); area.addEventListener('wheel', onWheel, { passive: false }); area.addEventListener('scroll', onScroll, { passive: true }); return () => { area.removeEventListener('wheel', onWheel); area.removeEventListener('scroll', onScroll); }; }, [kanbanMultiRow]); 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 === 'list') { // Tasks without a list first, then the lists in the order of the tabs const byList = new Map(); for (const t of filteredTasks) { const key = t.taskListId || ''; if (!byList.has(key)) byList.set(key, []); byList.get(key)!.push(t); } const without = byList.get('') || []; if (without.length > 0) groups.push({ key: 'list:none', title: 'Ohne Liste', count: without.length, tasks: without }); for (const tl of safeTaskLists) { const list = byList.get(tl.id); if (!list || list.length === 0) continue; groups.push({ key: `list:${tl.id}`, title: [tl.icon, tl.title].filter(Boolean).join(' '), count: list.length, tasks: list }); byList.delete(tl.id); } byList.delete(''); byList.forEach((list, id) => groups.push({ key: `list:${id}`, title: 'Andere Liste', count: list.length, tasks: list })); } else 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, safeTaskLists]); // --- 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); }; // Page positions of the phone board's snap points: the page top (bar and first column) and the start of // every further column const phoneSnapPoints = useCallback(() => { const points = new Set([0]); // page top: bar and first column kanbanBoardRef.current?.querySelectorAll('[data-kanban-col]').forEach((c, i) => { if (i > 0) points.add(Math.max(0, Math.round(c.getBoundingClientRect().top + window.scrollY - kanbanPhoneTop))); }); return Array.from(points).sort((a, b) => a - b); }, [kanbanPhoneTop]); 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, snapPoints: kanbanPhoneSnap ? phoneSnapPoints : undefined, }); // Phones: the page snaps to its top and to each column; while a card is dragged it does not (the drag // moves the page from column to column itself, see useKanbanTouchDrag) useEffect(() => { if (!kanbanPhoneSnap) return; const root = document.documentElement; root.style.scrollPaddingTop = `${kanbanPhoneTop}px`; root.style.scrollSnapType = kanbanTouch.dragging ? 'none' : 'y mandatory'; return () => { root.style.scrollSnapType = ''; root.style.scrollPaddingTop = ''; }; }, [kanbanPhoneSnap, kanbanPhoneTop, kanbanTouch.dragging]); 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)); // Plain switch: the deleted list's tasks belong to "Alle Aufgaben" anyway (and the menu effect keeps // this handler out of its dependencies) setSelectedListIdState('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 !== defaultGroupBy ? 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, defaultGroupBy, setViewMode, setSelectedListId, 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 ( // Phone Kanban: the page top is the first snap point (bar and first column)
{/* 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 like the notes: status (live, shared) | actions (share, 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 ? ( ) : ( ) )}
); })()} {!currentSelectedList && } {/* 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) */}
({ id: tl.id, title: tl.title, icon: tl.icon, count: tl.tasksCount })), ]} activeId={selectedListId} onSelect={setSelectedListId} /> {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 !== defaultGroupBy || sortBy !== 'custom') && (
{selectedTag && ( #{selectedTag} )} {groupBy !== defaultGroupBy && ( Gruppe: {GROUP_LABELS[groupBy]} )} {sortBy !== 'custom' && ( Sortiert: {SORT_LABELS[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
{groupOptions.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, colIndex) => { 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 ? undefined : kanbanPhoneSnap && colIndex === 0 ? { height: kanbanPhoneFirstHeight } : !kanbanMultiRow ? { height: kanbanColHeight } : colIndex < kanbanPerRow ? { height: KANBAN_FIRST_ROW_HEIGHT } : { height: 'var(--kb-full)' } } className={`bg-canvas border border-border flex flex-col overflow-hidden transition-[opacity,background-color,box-shadow] ${ kanbanPhoneSnap && colIndex > 0 ? 'snap-start snap-always' : '' } ${ 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) && (
)}
); })}
{/* Desktop: room as high as what is still visible of the bar above the board, so the page can scroll the bar away even with one row while the first row grows (the page keeps its height) */} {kanbanMultiRow &&
}
) : ( /* 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 && groupedTasks.length > 0 ? ( /* 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)) )}
)}
)}
)}
); };