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 + } +}