From 2cdfa453751652223afd23b8404a7d21e31c7c82 Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 2 Nov 2026 13:13:14 +0100 Subject: [PATCH] feat(tasks): group "Alle Aufgaben" by task list by default Tasks without a list come first, then the lists in tab order. Single lists keep no grouping by default and do not offer it. The open task list, view and grouping, like the open shopping list, are now remembered per device instead of per tab. Co-Authored-By: Claude Opus 5.5 --- .../src/features/lists/SharedListsView.tsx | 8 +- frontend/src/features/triage/TasksView.tsx | 114 +++++++++++++----- frontend/src/lib/viewSession.ts | 24 ++++ 3 files changed, 110 insertions(+), 36 deletions(-) diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index d09e1dc..0bd02cb 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -11,7 +11,7 @@ import { } from 'lucide-react'; import { ListItem, SharedList, Team, User, WSMessage } from '../../lib/types'; import { api, triggerHaptic, createRealtimeConnection } from '../../lib/api'; -import { readViewSession, writeViewSession } from '../../lib/viewSession'; +import { readViewMemory, writeViewMemory } from '../../lib/viewSession'; import { OFFLINE_ITEM_PREFIX, pendingListItems } from '../../lib/offline'; import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { AvatarStack, AvatarPerson } from '../../components/AvatarStack'; @@ -91,12 +91,12 @@ export function deduplicateItems(items: ListItem[]): ListItem[] { export const SharedListsView: React.FC = ({ onRefreshGlobal, currentUser, initialListId }) => { const { setGroups, setPrimaryAction } = useMobileActions(); const [lists, setLists] = useState([]); - // The open list survives switching to other tabs (like the workspace's open note) + // The open list is remembered on this device (other tabs of the app, new tab, restart) const [activeListId, setActiveListId] = useState( - () => initialListId || readViewSession<{ listId: string }>('lists').listId || '' + () => initialListId || readViewMemory<{ listId: string }>('lists').listId || '' ); useEffect(() => { - if (activeListId) writeViewSession('lists', { listId: activeListId }); + if (activeListId) writeViewMemory('lists', { listId: activeListId }); }, [activeListId]); useEffect(() => { diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index ac3df51..a908465 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -30,7 +30,7 @@ import { } 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 { readViewMemory, writeViewMemory } from '../../lib/viewSession'; import { useKanbanTouchDrag } from './useKanbanTouchDrag'; import { api, triggerHaptic } from '../../lib/api'; import { UnifiedShareModal } from '../../components/UnifiedShareModal'; @@ -91,7 +91,28 @@ const kanbanBoardStyle = (wide: boolean, columns: number): React.CSSProperties = } as React.CSSProperties; }; -type TasksSession = { listId: string; viewMode: 'list' | 'kanban' }; +type GroupBy = 'none' | 'list' | 'tag' | 'status' | 'priority' | 'due'; +type TasksSession = { listId: string; viewMode: 'list' | 'kanban'; 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, @@ -102,9 +123,18 @@ export const TasksView: React.FC = ({ onInitialTaskShown, openTasksCount, }) => { - // Open list and view survive switching to other tabs (like the workspace's open note) + // Open list, view and grouping are remembered on this device (new tab, restart of the installed app) + const remembered = useMemo(() => readViewMemory('tasks'), []); const [viewMode, setViewMode] = useState<'list' | 'kanban'>(() => - readViewSession('tasks').viewMode === 'kanban' ? 'kanban' : 'list' + remembered.viewMode === 'kanban' ? 'kanban' : 'list' + ); + 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(() => { @@ -128,11 +158,19 @@ export const TasksView: React.FC = ({ const [tasks, setTasks] = useState([]); const [taskLists, setTaskLists] = useState([]); const [selectedListId, setSelectedListId] = useState( - () => readViewSession('tasks').listId || 'all' + () => remembered.listId || 'all' ); + 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(() => { - writeViewSession('tasks', { listId: selectedListId, viewMode }); - }, [selectedListId, viewMode]); + writeViewMemory('tasks', { listId: selectedListId, viewMode, groupAll, groupList }); + }, [selectedListId, viewMode, 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 @@ -216,7 +254,6 @@ export const TasksView: React.FC = ({ 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 @@ -571,7 +608,25 @@ export const TasksView: React.FC = ({ if (groupBy === 'none') return null; const groups: { key: string; title: string; count: number; tasks: Thought[] }[] = []; - if (groupBy === 'tag') { + 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) { @@ -616,7 +671,7 @@ export const TasksView: React.FC = ({ if (noDue.length > 0) groups.push({ key: 'due:none', title: 'Ohne Fälligkeitsdatum', count: noDue.length, tasks: noDue }); } return groups; - }, [filteredTasks, groupBy, boardColumns, inBoardColumn]); + }, [filteredTasks, groupBy, boardColumns, inBoardColumn, safeTaskLists]); // --- TASK ACTIONS --- @@ -1341,7 +1396,7 @@ export const TasksView: React.FC = ({ ]; const filterSortActiveCount = - (selectedTag ? 1 : 0) + (groupBy !== 'none' ? 1 : 0) + (sortBy !== 'custom' ? 1 : 0); + (selectedTag ? 1 : 0) + (groupBy !== defaultGroupBy ? 1 : 0) + (sortBy !== 'custom' ? 1 : 0); const filterItems: any[] = [ { @@ -1482,6 +1537,7 @@ export const TasksView: React.FC = ({ selectedListId, selectedTag, groupBy, + defaultGroupBy, sortBy, openTasks.length, doneTasks.length, @@ -2051,7 +2107,7 @@ export const TasksView: React.FC = ({ )} {/* Mobile Active Filter Badge Banner (minimal and clean) */} - {(selectedTag || groupBy !== 'none' || sortBy !== 'custom') && ( + {(selectedTag || groupBy !== defaultGroupBy || sortBy !== 'custom') && (
@@ -2067,14 +2123,14 @@ export const TasksView: React.FC = ({ )} - {groupBy !== 'none' && ( + {groupBy !== defaultGroupBy && ( - Gruppe: {groupBy} + Gruppe: {GROUP_LABELS[groupBy]} )} {sortBy !== 'custom' && ( - Sortiert: {sortBy} + Sortiert: {SORT_LABELS[sortBy]} )}
@@ -2082,7 +2138,7 @@ export const TasksView: React.FC = ({ type="button" onClick={() => { setSelectedTag(null); - setGroupBy('none'); + setGroupBy(defaultGroupBy); setSortBy('custom'); }} className="text-accent text-[11px] font-semibold flex-shrink-0 hover:underline cursor-pointer" @@ -2397,17 +2453,11 @@ export const TasksView: React.FC = ({ 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) => ( + {groupOptions.map((g) => (
@@ -3014,7 +3064,7 @@ export const TasksView: React.FC = ({
) : (
- {groupedTasks ? ( + {groupedTasks && groupedTasks.length > 0 ? ( /* GROUPED LIST */ groupedTasks.map((grp) => (
diff --git a/frontend/src/lib/viewSession.ts b/frontend/src/lib/viewSession.ts index 51f0f4e..85f0b8c 100644 --- a/frontend/src/lib/viewSession.ts +++ b/frontend/src/lib/viewSession.ts @@ -18,3 +18,27 @@ export function writeViewSession(view: string, state: T) { // storage blocked: the view simply starts fresh } } + +// Remembered per device as well (localStorage): a new tab or a restart of the installed app opens the +// same list again. This tab's own state wins, so two tabs can show different lists. +const DEVICE_PREFIX = 'mindsift_view_device:'; + +export function readViewMemory(view: string): Partial { + let device: Partial = {}; + try { + const raw = JSON.parse(localStorage.getItem(DEVICE_PREFIX + view) || '{}'); + if (raw && typeof raw === 'object') device = raw; + } catch { + // storage blocked or broken: the tab state alone + } + return { ...device, ...readViewSession(view) }; +} + +export function writeViewMemory(view: string, state: T) { + writeViewSession(view, state); + try { + localStorage.setItem(DEVICE_PREFIX + view, JSON.stringify(state)); + } catch { + // only a convenience + } +}