perf(renderer): memoize global task rows

This commit is contained in:
777genius 2026-05-31 02:22:43 +03:00
parent 64d2bae98d
commit 9376416b50

View file

@ -194,6 +194,10 @@ function buildTaskTeamSummary(task: GlobalTask): TeamSummary {
type TaskRowAction = (teamName: string, taskId: string) => void; type TaskRowAction = (teamName: string, taskId: string) => void;
type TaskRowDeleteAction = (teamName: string, taskId: string) => void | Promise<void>; type TaskRowDeleteAction = (teamName: string, taskId: string) => void | Promise<void>;
type TaskDisplaySubjectResolver = (task: GlobalTask) => string | undefined; type TaskDisplaySubjectResolver = (task: GlobalTask) => string | undefined;
type TaskBooleanResolver = (teamName: string, taskId: string) => boolean;
type TeamBooleanResolver = (teamName: string) => boolean;
type TaskOwnerColorResolver = (task: GlobalTask) => string | null | undefined;
type TeamHeaderFormatter = (teamDisplayName: string) => string;
interface GlobalTaskRowProps { interface GlobalTaskRowProps {
task: GlobalTask; task: GlobalTask;
@ -288,6 +292,112 @@ const GlobalTaskRow = memo(function GlobalTaskRow({
); );
}); });
interface TaskRowsProps {
tasks: GlobalTask[];
visibleCount?: number;
keyPrefix?: string;
isPinned: TaskBooleanResolver;
isArchived: TaskBooleanResolver;
isNewTask: (task: GlobalTask) => boolean;
isTeamOffline: TeamBooleanResolver;
renamingKey: string | null;
hideTeamName?: boolean;
hideProjectName?: boolean;
showTeamName?: boolean;
showTeamHeader?: boolean;
pinnedOverride?: boolean;
archivedOverride?: boolean;
formatTeamHeader?: TeamHeaderFormatter;
onTogglePin: TaskRowAction;
onToggleArchive: TaskRowAction;
onMarkUnread: TaskRowAction;
onRename: TaskRowAction;
onDelete: TaskRowDeleteAction;
onRenameComplete: (teamName: string, taskId: string, newSubject: string) => void;
onRenameCancel: () => void;
getDisplaySubject: TaskDisplaySubjectResolver;
getOwnerColorName: TaskOwnerColorResolver;
}
const TaskRows = memo(function TaskRows({
tasks,
visibleCount,
keyPrefix = '',
isPinned,
isArchived,
isNewTask,
isTeamOffline,
renamingKey,
hideTeamName,
hideProjectName,
showTeamName,
showTeamHeader,
pinnedOverride,
archivedOverride,
formatTeamHeader,
onTogglePin,
onToggleArchive,
onMarkUnread,
onRename,
onDelete,
onRenameComplete,
onRenameCancel,
getDisplaySubject,
getOwnerColorName,
}: TaskRowsProps): React.JSX.Element {
let lastTeam: string | null = null;
const visibleTasks = typeof visibleCount === 'number' ? tasks.slice(0, visibleCount) : tasks;
return (
<>
{visibleTasks.map((task) => {
const taskKey = `${keyPrefix}${task.teamName}-${task.id}`;
const row = (
<GlobalTaskRow
key={taskKey}
task={task}
isPinned={pinnedOverride ?? isPinned(task.teamName, task.id)}
isArchived={archivedOverride ?? isArchived(task.teamName, task.id)}
isNew={isNewTask(task)}
hideTeamName={hideTeamName}
hideProjectName={hideProjectName}
showTeamName={showTeamName}
teamOffline={isTeamOffline(task.teamName)}
ownerColorName={getOwnerColorName(task)}
renamingKey={renamingKey}
onTogglePin={onTogglePin}
onToggleArchive={onToggleArchive}
onMarkUnread={onMarkUnread}
onRename={onRename}
onDelete={onDelete}
onRenameComplete={onRenameComplete}
onRenameCancel={onRenameCancel}
getDisplaySubject={getDisplaySubject}
/>
);
if (!showTeamHeader || !formatTeamHeader) {
return row;
}
const shouldShowTeamHeader = task.teamName !== lastTeam;
lastTeam = task.teamName;
return (
<div key={taskKey}>
{shouldShowTeamHeader && (
<div className="px-3 pb-0.5 pt-1.5 text-[10px] font-medium text-text-muted">
{formatTeamHeader(task.teamDisplayName)}
</div>
)}
{row}
</div>
);
})}
</>
);
});
export const GlobalTaskList = memo(function GlobalTaskList({ export const GlobalTaskList = memo(function GlobalTaskList({
hideHeader = false, hideHeader = false,
filters: externalFilters, filters: externalFilters,
@ -515,6 +625,14 @@ export const GlobalTaskList = memo(function GlobalTaskList({
}, },
[memberColorByTeam] [memberColorByTeam]
); );
const isTeamOffline = useCallback(
(teamName: string): boolean => offlineTeamNames.has(teamName),
[offlineTeamNames]
);
const formatTeamHeader = useCallback(
(teamDisplayName: string): string => t('tasksPanel.teamLabel', { team: teamDisplayName }),
[t]
);
const setGroupingMode = (mode: TaskGroupingMode): void => { const setGroupingMode = (mode: TaskGroupingMode): void => {
setGroupingModeState(mode); setGroupingModeState(mode);
@ -708,6 +826,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
() => filtered.filter((t) => !taskLocalState.isPinned(t.teamName, t.id)), () => filtered.filter((t) => !taskLocalState.isPinned(t.teamName, t.id)),
[filtered, taskLocalState] [filtered, taskLocalState]
); );
const sortedPinnedTasks = useMemo(() => sortTasksByFreshness(pinnedTasks), [pinnedTasks]);
const sortedFlat = useMemo( const sortedFlat = useMemo(
() => applySortMode(normalTasks, sortMode, readState), () => applySortMode(normalTasks, sortMode, readState),
@ -736,6 +855,10 @@ export const GlobalTaskList = memo(function GlobalTaskList({
syncProjectGroupVisibleCountByKey(projectRequestedVisibleCountByKey, projectGroupVisibility), syncProjectGroupVisibleCountByKey(projectRequestedVisibleCountByKey, projectGroupVisibility),
[projectRequestedVisibleCountByKey, projectGroupVisibility] [projectRequestedVisibleCountByKey, projectGroupVisibility]
); );
const taskFilterTeams = useMemo(
() => teams.map((team) => ({ teamName: team.teamName, displayName: team.displayName })),
[teams]
);
const projectCollapsed = useCollapsedGroups('project', projectGroupKeys); const projectCollapsed = useCollapsedGroups('project', projectGroupKeys);
const timeCollapsed = useCollapsedGroups('time', timeGroupKeys); const timeCollapsed = useCollapsedGroups('time', timeGroupKeys);
@ -838,7 +961,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
<TaskFiltersPopover <TaskFiltersPopover
open={filtersPopoverOpen} open={filtersPopoverOpen}
onOpenChange={setFiltersPopoverOpen} onOpenChange={setFiltersPopoverOpen}
teams={teams.map((t) => ({ teamName: t.teamName, displayName: t.displayName }))} teams={taskFilterTeams}
projectOptions={projectFilterOptions} projectOptions={projectFilterOptions}
filters={filters} filters={filters}
onFiltersChange={setFilters} onFiltersChange={setFilters}
@ -853,27 +976,27 @@ export const GlobalTaskList = memo(function GlobalTaskList({
<Pin className="size-3 text-text-muted" /> <Pin className="size-3 text-text-muted" />
<span className="text-[11px] text-text-muted">{t('tasksPanel.pinned')}</span> <span className="text-[11px] text-text-muted">{t('tasksPanel.pinned')}</span>
</div> </div>
{sortTasksByFreshness(pinnedTasks).map((task) => ( <TaskRows
<GlobalTaskRow tasks={sortedPinnedTasks}
key={`pinned-${task.teamName}-${task.id}`} keyPrefix="pinned-"
task={task} isPinned={taskLocalState.isPinned}
isPinned={true} isArchived={taskLocalState.isArchived}
isArchived={false} isNewTask={isNewTask}
isNew={isNewTask(task)} isTeamOffline={isTeamOffline}
showTeamName pinnedOverride={true}
teamOffline={offlineTeamNames.has(task.teamName)} archivedOverride={false}
ownerColorName={getOwnerColorName(task)} showTeamName
renamingKey={renamingTaskKey} renamingKey={renamingTaskKey}
onTogglePin={handleToggleTaskPin} onTogglePin={handleToggleTaskPin}
onToggleArchive={handleToggleTaskArchive} onToggleArchive={handleToggleTaskArchive}
onMarkUnread={handleMarkTaskUnread} onMarkUnread={handleMarkTaskUnread}
onRename={handleStartTaskRename} onRename={handleStartTaskRename}
onDelete={handleDeleteTask} onDelete={handleDeleteTask}
onRenameComplete={handleRenameComplete} onRenameComplete={handleRenameComplete}
onRenameCancel={handleRenameCancel} onRenameCancel={handleRenameCancel}
getDisplaySubject={getTaskDisplaySubject} getDisplaySubject={getTaskDisplaySubject}
/> getOwnerColorName={getOwnerColorName}
))} />
</div> </div>
)} )}
@ -956,28 +1079,26 @@ export const GlobalTaskList = memo(function GlobalTaskList({
</div> </div>
)} )}
{groupingMode === 'none' && {groupingMode === 'none' && (
sortedFlat.map((task) => ( <TaskRows
<GlobalTaskRow tasks={sortedFlat}
key={`${task.teamName}-${task.id}`} isPinned={taskLocalState.isPinned}
task={task} isArchived={taskLocalState.isArchived}
isPinned={taskLocalState.isPinned(task.teamName, task.id)} isNewTask={isNewTask}
isArchived={taskLocalState.isArchived(task.teamName, task.id)} isTeamOffline={isTeamOffline}
isNew={isNewTask(task)} showTeamName
showTeamName renamingKey={renamingTaskKey}
teamOffline={offlineTeamNames.has(task.teamName)} onTogglePin={handleToggleTaskPin}
ownerColorName={getOwnerColorName(task)} onToggleArchive={handleToggleTaskArchive}
renamingKey={renamingTaskKey} onMarkUnread={handleMarkTaskUnread}
onTogglePin={handleToggleTaskPin} onRename={handleStartTaskRename}
onToggleArchive={handleToggleTaskArchive} onDelete={handleDeleteTask}
onMarkUnread={handleMarkTaskUnread} onRenameComplete={handleRenameComplete}
onRename={handleStartTaskRename} onRenameCancel={handleRenameCancel}
onDelete={handleDeleteTask} getDisplaySubject={getTaskDisplaySubject}
onRenameComplete={handleRenameComplete} getOwnerColorName={getOwnerColorName}
onRenameCancel={handleRenameCancel} />
getDisplaySubject={getTaskDisplaySubject} )}
/>
))}
{groupingMode === 'project' && {groupingMode === 'project' &&
projectGroups.map((group) => { projectGroups.map((group) => {
@ -991,10 +1112,8 @@ export const GlobalTaskList = memo(function GlobalTaskList({
projectVisibleCountByKey[group.projectKey], projectVisibleCountByKey[group.projectKey],
group.tasks.length group.tasks.length
); );
const visibleTasks = group.tasks.slice(0, visibleCount);
const showMoreVisible = canProjectGroupShowMore(visibleCount, group.tasks.length); const showMoreVisible = canProjectGroupShowMore(visibleCount, group.tasks.length);
const showLessVisible = canProjectGroupShowLess(visibleCount, group.tasks.length); const showLessVisible = canProjectGroupShowLess(visibleCount, group.tasks.length);
let lastTeam: string | null = null;
return ( return (
<div key={group.projectKey}> <div key={group.projectKey}>
<button <button
@ -1029,39 +1148,30 @@ export const GlobalTaskList = memo(function GlobalTaskList({
{group.tasks.length} {group.tasks.length}
</span> </span>
</button> </button>
{!isGroupCollapsed && {!isGroupCollapsed && (
visibleTasks.map((task) => { <TaskRows
const showTeamHeader = task.teamName !== lastTeam; tasks={group.tasks}
lastTeam = task.teamName; visibleCount={visibleCount}
return ( isPinned={taskLocalState.isPinned}
<div key={`${task.teamName}-${task.id}`}> isArchived={taskLocalState.isArchived}
{showTeamHeader && ( isNewTask={isNewTask}
<div className="px-3 pb-0.5 pt-1.5 text-[10px] font-medium text-text-muted"> isTeamOffline={isTeamOffline}
{t('tasksPanel.teamLabel', { team: task.teamDisplayName })} hideTeamName
</div> hideProjectName
)} showTeamHeader
<GlobalTaskRow formatTeamHeader={formatTeamHeader}
task={task} renamingKey={renamingTaskKey}
isPinned={taskLocalState.isPinned(task.teamName, task.id)} onTogglePin={handleToggleTaskPin}
isArchived={taskLocalState.isArchived(task.teamName, task.id)} onToggleArchive={handleToggleTaskArchive}
isNew={isNewTask(task)} onMarkUnread={handleMarkTaskUnread}
hideTeamName onRename={handleStartTaskRename}
hideProjectName onDelete={handleDeleteTask}
teamOffline={offlineTeamNames.has(task.teamName)} onRenameComplete={handleRenameComplete}
ownerColorName={getOwnerColorName(task)} onRenameCancel={handleRenameCancel}
renamingKey={renamingTaskKey} getDisplaySubject={getTaskDisplaySubject}
onTogglePin={handleToggleTaskPin} getOwnerColorName={getOwnerColorName}
onToggleArchive={handleToggleTaskArchive} />
onMarkUnread={handleMarkTaskUnread} )}
onRename={handleStartTaskRename}
onDelete={handleDeleteTask}
onRenameComplete={handleRenameComplete}
onRenameCancel={handleRenameCancel}
getDisplaySubject={getTaskDisplaySubject}
/>
</div>
);
})}
{!isGroupCollapsed && (showMoreVisible || showLessVisible) && ( {!isGroupCollapsed && (showMoreVisible || showLessVisible) && (
<div className="flex items-center gap-2 px-3 pb-2 pt-1"> <div className="flex items-center gap-2 px-3 pb-2 pt-1">
{showMoreVisible && ( {showMoreVisible && (
@ -1108,7 +1218,6 @@ export const GlobalTaskList = memo(function GlobalTaskList({
categories.map((category) => { categories.map((category) => {
const tasks = grouped[category]; const tasks = grouped[category];
const isGroupCollapsed = timeCollapsed.isCollapsed(category); const isGroupCollapsed = timeCollapsed.isCollapsed(category);
let lastTeam: string | null = null;
return ( return (
<div key={category}> <div key={category}>
@ -1129,38 +1238,27 @@ export const GlobalTaskList = memo(function GlobalTaskList({
</span> </span>
</button> </button>
{!isGroupCollapsed && {!isGroupCollapsed && (
tasks.map((task) => { <TaskRows
const showTeamHeader = task.teamName !== lastTeam; tasks={tasks}
lastTeam = task.teamName; isPinned={taskLocalState.isPinned}
isArchived={taskLocalState.isArchived}
return ( isNewTask={isNewTask}
<div key={`${task.teamName}-${task.id}`}> isTeamOffline={isTeamOffline}
{showTeamHeader && ( showTeamHeader
<div className="px-3 pb-0.5 pt-1.5 text-[10px] font-medium text-text-muted"> formatTeamHeader={formatTeamHeader}
{t('tasksPanel.teamLabel', { team: task.teamDisplayName })} renamingKey={renamingTaskKey}
</div> onTogglePin={handleToggleTaskPin}
)} onToggleArchive={handleToggleTaskArchive}
<GlobalTaskRow onMarkUnread={handleMarkTaskUnread}
task={task} onRename={handleStartTaskRename}
isPinned={taskLocalState.isPinned(task.teamName, task.id)} onDelete={handleDeleteTask}
isArchived={taskLocalState.isArchived(task.teamName, task.id)} onRenameComplete={handleRenameComplete}
isNew={isNewTask(task)} onRenameCancel={handleRenameCancel}
teamOffline={offlineTeamNames.has(task.teamName)} getDisplaySubject={getTaskDisplaySubject}
ownerColorName={getOwnerColorName(task)} getOwnerColorName={getOwnerColorName}
renamingKey={renamingTaskKey} />
onTogglePin={handleToggleTaskPin} )}
onToggleArchive={handleToggleTaskArchive}
onMarkUnread={handleMarkTaskUnread}
onRename={handleStartTaskRename}
onDelete={handleDeleteTask}
onRenameComplete={handleRenameComplete}
onRenameCancel={handleRenameCancel}
getDisplaySubject={getTaskDisplaySubject}
/>
</div>
);
})}
</div> </div>
); );
})} })}