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 <noreply@anthropic.com>
This commit is contained in:
1 parent
b04991ee23
commit
2cdfa45375
3 files changed
+110
-36
No files matched your search
@@ -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<SharedListsViewProps> = ({ onRefreshGlobal, currentUser, initialListId }) => {
|
||||
const { setGroups, setPrimaryAction } = useMobileActions();
|
||||
const [lists, setLists] = useState<SharedList[]>([]);
|
||||
// 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<string>(
|
||||
() => 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(() => {
|
||||
|
||||
@@ -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<GroupBy, string> = {
|
||||
none: 'Keine',
|
||||
list: 'Nach Liste',
|
||||
tag: 'Nach Hashtag',
|
||||
status: 'Nach Status',
|
||||
priority: 'Nach Priorität',
|
||||
due: 'Nach Fälligkeit',
|
||||
};
|
||||
const SORT_LABELS: Record<string, string> = {
|
||||
custom: 'Manuell',
|
||||
created: 'Erstelldatum',
|
||||
due: 'Fälligkeitsdatum',
|
||||
priority: 'Priorität',
|
||||
alpha: 'Alphabetisch',
|
||||
};
|
||||
|
||||
export const TasksView: React.FC<TasksViewProps> = ({
|
||||
currentUser,
|
||||
@@ -102,9 +123,18 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
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<TasksSession>('tasks'), []);
|
||||
const [viewMode, setViewMode] = useState<'list' | 'kanban'>(() =>
|
||||
readViewSession<TasksSession>('tasks').viewMode === 'kanban' ? 'kanban' : 'list'
|
||||
remembered.viewMode === 'kanban' ? 'kanban' : 'list'
|
||||
);
|
||||
const [groupAll, setGroupAll] = useState<GroupBy>(() =>
|
||||
remembered.groupAll && remembered.groupAll in GROUP_LABELS ? remembered.groupAll : DEFAULT_GROUP_ALL
|
||||
);
|
||||
const [groupList, setGroupList] = useState<GroupBy>(() =>
|
||||
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<TasksViewProps> = ({
|
||||
const [tasks, setTasks] = useState<Thought[]>([]);
|
||||
const [taskLists, setTaskLists] = useState<TaskList[]>([]);
|
||||
const [selectedListId, setSelectedListId] = useState<string>(
|
||||
() => readViewSession<TasksSession>('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<TasksSession>('tasks', { listId: selectedListId, viewMode });
|
||||
}, [selectedListId, viewMode]);
|
||||
writeViewMemory<TasksSession>('tasks', { listId: selectedListId, viewMode, groupAll, groupList });
|
||||
}, [selectedListId, viewMode, groupAll, groupList]);
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
const [collaborators, setCollaborators] = useState<User[]>([]);
|
||||
// Task trash of the selected list (or all): deleted tasks and cleared or cleaned-up completed ones
|
||||
@@ -216,7 +254,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
const tagRowRef = useRef<HTMLDivElement>(null);
|
||||
const [tagsExpanded, setTagsExpanded] = useState(false);
|
||||
const [tagsOverflow, setTagsOverflow] = useState(false);
|
||||
const [groupBy, setGroupBy] = useState<'none' | 'tag' | 'status' | 'priority' | 'due'>('none');
|
||||
const [sortBy, setSortBy] = useState<'custom' | 'created' | 'due' | 'priority' | 'alpha'>('custom');
|
||||
|
||||
// Reloads happen after almost every action and on realtime events. Only the first load of a list shows
|
||||
@@ -571,7 +608,25 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
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<string, Thought[]>();
|
||||
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<string, Thought[]>();
|
||||
const withoutTag: Thought[] = [];
|
||||
for (const t of filteredTasks) {
|
||||
@@ -616,7 +671,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
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<TasksViewProps> = ({
|
||||
];
|
||||
|
||||
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<TasksViewProps> = ({
|
||||
selectedListId,
|
||||
selectedTag,
|
||||
groupBy,
|
||||
defaultGroupBy,
|
||||
sortBy,
|
||||
openTasks.length,
|
||||
doneTasks.length,
|
||||
@@ -2051,7 +2107,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
)}
|
||||
|
||||
{/* Mobile Active Filter Badge Banner (minimal and clean) */}
|
||||
{(selectedTag || groupBy !== 'none' || sortBy !== 'custom') && (
|
||||
{(selectedTag || groupBy !== defaultGroupBy || sortBy !== 'custom') && (
|
||||
<div className="flex sm:hidden items-center justify-between gap-2 p-2 rounded-btn bg-accent/10 border border-accent/20 text-xs">
|
||||
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
|
||||
<Filter className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
@@ -2067,14 +2123,14 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{groupBy !== 'none' && (
|
||||
{groupBy !== defaultGroupBy && (
|
||||
<span className="text-muted text-[11px]">
|
||||
Gruppe: <strong className="text-main">{groupBy}</strong>
|
||||
Gruppe: <strong className="text-main">{GROUP_LABELS[groupBy]}</strong>
|
||||
</span>
|
||||
)}
|
||||
{sortBy !== 'custom' && (
|
||||
<span className="text-muted text-[11px]">
|
||||
Sortiert: <strong className="text-main">{sortBy}</strong>
|
||||
Sortiert: <strong className="text-main">{SORT_LABELS[sortBy]}</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -2082,7 +2138,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
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<TasksViewProps> = ({
|
||||
Gruppierung
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{[
|
||||
{ id: 'none', label: 'Keine' },
|
||||
{ id: 'tag', label: 'Nach Hashtag' },
|
||||
{ id: 'status', label: 'Nach Status' },
|
||||
{ id: 'priority', label: 'Nach Priorität' },
|
||||
{ id: 'due', label: 'Nach Fälligkeit' },
|
||||
].map((g) => (
|
||||
{groupOptions.map((g) => (
|
||||
<button
|
||||
key={g.id}
|
||||
type="button"
|
||||
onClick={() => setGroupBy(g.id as any)}
|
||||
onClick={() => setGroupBy(g.id)}
|
||||
className={`min-h-[36px] px-3 text-xs rounded-btn border text-left transition-colors cursor-pointer ${
|
||||
groupBy === g.id
|
||||
? 'bg-accent-dim border-accent text-accent font-semibold'
|
||||
@@ -2457,7 +2507,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedTag(null);
|
||||
setGroupBy('none');
|
||||
setGroupBy(defaultGroupBy);
|
||||
setSortBy('custom');
|
||||
}}
|
||||
className="btn-ghost h-9 px-3 text-xs text-muted hover:text-main cursor-pointer"
|
||||
@@ -2918,14 +2968,14 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
<span className="text-[11px]">Gruppe:</span>
|
||||
<select
|
||||
value={groupBy}
|
||||
onChange={(e) => setGroupBy(e.target.value as any)}
|
||||
onChange={(e) => setGroupBy(e.target.value as GroupBy)}
|
||||
className="h-8 px-2 input-field text-xs bg-panel"
|
||||
>
|
||||
<option value="none">Keine</option>
|
||||
<option value="tag">Nach Hashtag</option>
|
||||
<option value="status">Nach Status</option>
|
||||
<option value="priority">Nach Priorität</option>
|
||||
<option value="due">Nach Fälligkeit</option>
|
||||
{groupOptions.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-muted">
|
||||
@@ -3014,7 +3064,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{groupedTasks ? (
|
||||
{groupedTasks && groupedTasks.length > 0 ? (
|
||||
/* GROUPED LIST */
|
||||
groupedTasks.map((grp) => (
|
||||
<div key={grp.key} className="panel-surface overflow-hidden">
|
||||
|
||||
Reference in new issue
Block a user