perf(renderer): memoize project task groups
This commit is contained in:
parent
ee5554e59d
commit
fd3137237d
1 changed files with 206 additions and 108 deletions
|
|
@ -162,6 +162,8 @@ const dateCategoryLabels: Record<string, string> = {
|
||||||
Older: 'Earlier',
|
Older: 'Earlier',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ProjectTaskGroupData = ReturnType<typeof groupTasksByProject>[number];
|
||||||
|
|
||||||
function applySearch(tasks: GlobalTask[], query: string): GlobalTask[] {
|
function applySearch(tasks: GlobalTask[], query: string): GlobalTask[] {
|
||||||
if (!query.trim()) return tasks;
|
if (!query.trim()) return tasks;
|
||||||
const q = query.toLowerCase();
|
const q = query.toLowerCase();
|
||||||
|
|
@ -198,6 +200,7 @@ type TaskBooleanResolver = (teamName: string, taskId: string) => boolean;
|
||||||
type TeamBooleanResolver = (teamName: string) => boolean;
|
type TeamBooleanResolver = (teamName: string) => boolean;
|
||||||
type TaskOwnerColorResolver = (task: GlobalTask) => string | null | undefined;
|
type TaskOwnerColorResolver = (task: GlobalTask) => string | null | undefined;
|
||||||
type TeamHeaderFormatter = (teamDisplayName: string) => string;
|
type TeamHeaderFormatter = (teamDisplayName: string) => string;
|
||||||
|
type ProjectGroupVisibleCountChange = (projectKey: string, visibleCount: number) => void;
|
||||||
|
|
||||||
interface GlobalTaskRowProps {
|
interface GlobalTaskRowProps {
|
||||||
task: GlobalTask;
|
task: GlobalTask;
|
||||||
|
|
@ -398,6 +401,158 @@ const TaskRows = memo(function TaskRows({
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
interface ProjectTaskGroupProps {
|
||||||
|
group: ProjectTaskGroupData;
|
||||||
|
isCollapsed: boolean;
|
||||||
|
visibleCount: number;
|
||||||
|
noProjectGroupColor: ReturnType<typeof projectColor>;
|
||||||
|
showMoreLabel: string;
|
||||||
|
showLessLabel: string;
|
||||||
|
isPinned: TaskBooleanResolver;
|
||||||
|
isArchived: TaskBooleanResolver;
|
||||||
|
isNewTask: (task: GlobalTask) => boolean;
|
||||||
|
isTeamOffline: TeamBooleanResolver;
|
||||||
|
renamingKey: string | null;
|
||||||
|
formatTeamHeader: TeamHeaderFormatter;
|
||||||
|
onToggleGroup: (projectKey: string) => void;
|
||||||
|
onVisibleCountChange: ProjectGroupVisibleCountChange;
|
||||||
|
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 ProjectTaskGroup = memo(function ProjectTaskGroup({
|
||||||
|
group,
|
||||||
|
isCollapsed,
|
||||||
|
visibleCount,
|
||||||
|
noProjectGroupColor,
|
||||||
|
showMoreLabel,
|
||||||
|
showLessLabel,
|
||||||
|
isPinned,
|
||||||
|
isArchived,
|
||||||
|
isNewTask,
|
||||||
|
isTeamOffline,
|
||||||
|
renamingKey,
|
||||||
|
formatTeamHeader,
|
||||||
|
onToggleGroup,
|
||||||
|
onVisibleCountChange,
|
||||||
|
onTogglePin,
|
||||||
|
onToggleArchive,
|
||||||
|
onMarkUnread,
|
||||||
|
onRename,
|
||||||
|
onDelete,
|
||||||
|
onRenameComplete,
|
||||||
|
onRenameCancel,
|
||||||
|
getDisplaySubject,
|
||||||
|
getOwnerColorName,
|
||||||
|
}: ProjectTaskGroupProps): React.JSX.Element | null {
|
||||||
|
if (group.tasks.length === 0) return null;
|
||||||
|
|
||||||
|
const isNoProjectGroup = group.projectKey === NO_PROJECT_KEY;
|
||||||
|
const groupColor = isNoProjectGroup ? noProjectGroupColor : projectColor(group.projectLabel);
|
||||||
|
const showMoreVisible = canProjectGroupShowMore(visibleCount, group.tasks.length);
|
||||||
|
const showLessVisible = canProjectGroupShowLess(visibleCount, group.tasks.length);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggleGroup(group.projectKey)}
|
||||||
|
className="hover:bg-surface-raised/40 sticky top-0 z-10 flex w-full cursor-pointer items-center gap-1.5 p-2 transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--color-surface-sidebar)',
|
||||||
|
backgroundImage: isNoProjectGroup
|
||||||
|
? undefined
|
||||||
|
: `linear-gradient(90deg, ${groupColor.glow} 0%, transparent 80%)`,
|
||||||
|
boxShadow: `inset 2px 0 0 ${groupColor.border}, inset 0 -1px 0 var(--color-border)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isCollapsed ? (
|
||||||
|
<ChevronRight className="size-3 shrink-0 text-text-muted" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="size-3 shrink-0 text-text-muted" />
|
||||||
|
)}
|
||||||
|
<Folder
|
||||||
|
className="size-3.5 shrink-0"
|
||||||
|
style={{ color: groupColor.icon }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="truncate text-[11px] font-bold leading-none"
|
||||||
|
style={{ color: groupColor.icon }}
|
||||||
|
>
|
||||||
|
{group.projectLabel}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto shrink-0 text-[10px] font-normal text-text-muted">
|
||||||
|
{group.tasks.length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{!isCollapsed && (
|
||||||
|
<TaskRows
|
||||||
|
tasks={group.tasks}
|
||||||
|
visibleCount={visibleCount}
|
||||||
|
isPinned={isPinned}
|
||||||
|
isArchived={isArchived}
|
||||||
|
isNewTask={isNewTask}
|
||||||
|
isTeamOffline={isTeamOffline}
|
||||||
|
hideTeamName
|
||||||
|
hideProjectName
|
||||||
|
showTeamHeader
|
||||||
|
formatTeamHeader={formatTeamHeader}
|
||||||
|
renamingKey={renamingKey}
|
||||||
|
onTogglePin={onTogglePin}
|
||||||
|
onToggleArchive={onToggleArchive}
|
||||||
|
onMarkUnread={onMarkUnread}
|
||||||
|
onRename={onRename}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onRenameComplete={onRenameComplete}
|
||||||
|
onRenameCancel={onRenameCancel}
|
||||||
|
getDisplaySubject={getDisplaySubject}
|
||||||
|
getOwnerColorName={getOwnerColorName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isCollapsed && (showMoreVisible || showLessVisible) && (
|
||||||
|
<div className="flex items-center gap-2 px-3 pb-2 pt-1">
|
||||||
|
{showMoreVisible && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-[11px] font-medium text-text-muted transition-colors hover:text-text"
|
||||||
|
onClick={() =>
|
||||||
|
onVisibleCountChange(
|
||||||
|
group.projectKey,
|
||||||
|
getNextProjectGroupVisibleCount(visibleCount, group.tasks.length)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{showMoreLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showLessVisible && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-[11px] font-medium text-text-muted transition-colors hover:text-text"
|
||||||
|
onClick={() =>
|
||||||
|
onVisibleCountChange(
|
||||||
|
group.projectKey,
|
||||||
|
getPreviousProjectGroupVisibleCount(visibleCount, group.tasks.length)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{showLessLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
export const GlobalTaskList = memo(function GlobalTaskList({
|
export const GlobalTaskList = memo(function GlobalTaskList({
|
||||||
hideHeader = false,
|
hideHeader = false,
|
||||||
filters: externalFilters,
|
filters: externalFilters,
|
||||||
|
|
@ -633,6 +788,15 @@ export const GlobalTaskList = memo(function GlobalTaskList({
|
||||||
(teamDisplayName: string): string => t('tasksPanel.teamLabel', { team: teamDisplayName }),
|
(teamDisplayName: string): string => t('tasksPanel.teamLabel', { team: teamDisplayName }),
|
||||||
[t]
|
[t]
|
||||||
);
|
);
|
||||||
|
const handleProjectGroupVisibleCountChange = useCallback(
|
||||||
|
(projectKey: string, visibleCount: number): void => {
|
||||||
|
setProjectRequestedVisibleCountByKey((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[projectKey]: visibleCount,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const setGroupingMode = (mode: TaskGroupingMode): void => {
|
const setGroupingMode = (mode: TaskGroupingMode): void => {
|
||||||
setGroupingModeState(mode);
|
setGroupingModeState(mode);
|
||||||
|
|
@ -860,8 +1024,20 @@ export const GlobalTaskList = memo(function GlobalTaskList({
|
||||||
[teams]
|
[teams]
|
||||||
);
|
);
|
||||||
|
|
||||||
const projectCollapsed = useCollapsedGroups('project', projectGroupKeys);
|
const { isCollapsed: isProjectGroupCollapsed, toggle: toggleProjectGroup } = useCollapsedGroups(
|
||||||
const timeCollapsed = useCollapsedGroups('time', timeGroupKeys);
|
'project',
|
||||||
|
projectGroupKeys
|
||||||
|
);
|
||||||
|
const { isCollapsed: isTimeGroupCollapsed, toggle: toggleTimeGroup } = useCollapsedGroups(
|
||||||
|
'time',
|
||||||
|
timeGroupKeys
|
||||||
|
);
|
||||||
|
const handleToggleProjectGroup = useCallback(
|
||||||
|
(projectKey: string): void => {
|
||||||
|
toggleProjectGroup(projectKey);
|
||||||
|
},
|
||||||
|
[toggleProjectGroup]
|
||||||
|
);
|
||||||
|
|
||||||
const hasContent =
|
const hasContent =
|
||||||
pinnedTasks.length > 0 ||
|
pinnedTasks.length > 0 ||
|
||||||
|
|
@ -1102,128 +1278,50 @@ export const GlobalTaskList = memo(function GlobalTaskList({
|
||||||
|
|
||||||
{groupingMode === 'project' &&
|
{groupingMode === 'project' &&
|
||||||
projectGroups.map((group) => {
|
projectGroups.map((group) => {
|
||||||
if (group.tasks.length === 0) return null;
|
|
||||||
const isGroupCollapsed = projectCollapsed.isCollapsed(group.projectKey);
|
|
||||||
const isNoProjectGroup = group.projectKey === NO_PROJECT_KEY;
|
|
||||||
const groupColor = isNoProjectGroup
|
|
||||||
? noProjectGroupColor
|
|
||||||
: projectColor(group.projectLabel);
|
|
||||||
const visibleCount = getProjectGroupVisibleCount(
|
const visibleCount = getProjectGroupVisibleCount(
|
||||||
projectVisibleCountByKey[group.projectKey],
|
projectVisibleCountByKey[group.projectKey],
|
||||||
group.tasks.length
|
group.tasks.length
|
||||||
);
|
);
|
||||||
const showMoreVisible = canProjectGroupShowMore(visibleCount, group.tasks.length);
|
|
||||||
const showLessVisible = canProjectGroupShowLess(visibleCount, group.tasks.length);
|
|
||||||
return (
|
return (
|
||||||
<div key={group.projectKey}>
|
<ProjectTaskGroup
|
||||||
<button
|
key={group.projectKey}
|
||||||
type="button"
|
group={group}
|
||||||
onClick={() => projectCollapsed.toggle(group.projectKey)}
|
isCollapsed={isProjectGroupCollapsed(group.projectKey)}
|
||||||
className="hover:bg-surface-raised/40 sticky top-0 z-10 flex w-full cursor-pointer items-center gap-1.5 p-2 transition-colors"
|
visibleCount={visibleCount}
|
||||||
style={{
|
noProjectGroupColor={noProjectGroupColor}
|
||||||
backgroundColor: 'var(--color-surface-sidebar)',
|
showMoreLabel={t('tasksPanel.showMore')}
|
||||||
backgroundImage: isNoProjectGroup
|
showLessLabel={t('tasksPanel.showLess')}
|
||||||
? undefined
|
isPinned={taskLocalState.isPinned}
|
||||||
: `linear-gradient(90deg, ${groupColor.glow} 0%, transparent 80%)`,
|
isArchived={taskLocalState.isArchived}
|
||||||
boxShadow: `inset 2px 0 0 ${groupColor.border}, inset 0 -1px 0 var(--color-border)`,
|
isNewTask={isNewTask}
|
||||||
}}
|
isTeamOffline={isTeamOffline}
|
||||||
>
|
renamingKey={renamingTaskKey}
|
||||||
{isGroupCollapsed ? (
|
formatTeamHeader={formatTeamHeader}
|
||||||
<ChevronRight className="size-3 shrink-0 text-text-muted" />
|
onToggleGroup={handleToggleProjectGroup}
|
||||||
) : (
|
onVisibleCountChange={handleProjectGroupVisibleCountChange}
|
||||||
<ChevronDown className="size-3 shrink-0 text-text-muted" />
|
onTogglePin={handleToggleTaskPin}
|
||||||
)}
|
onToggleArchive={handleToggleTaskArchive}
|
||||||
<Folder
|
onMarkUnread={handleMarkTaskUnread}
|
||||||
className="size-3.5 shrink-0"
|
onRename={handleStartTaskRename}
|
||||||
style={{ color: groupColor.icon }}
|
onDelete={handleDeleteTask}
|
||||||
aria-hidden="true"
|
onRenameComplete={handleRenameComplete}
|
||||||
/>
|
onRenameCancel={handleRenameCancel}
|
||||||
<span
|
getDisplaySubject={getTaskDisplaySubject}
|
||||||
className="truncate text-[11px] font-bold leading-none"
|
getOwnerColorName={getOwnerColorName}
|
||||||
style={{ color: groupColor.icon }}
|
/>
|
||||||
>
|
|
||||||
{group.projectLabel}
|
|
||||||
</span>
|
|
||||||
<span className="ml-auto shrink-0 text-[10px] font-normal text-text-muted">
|
|
||||||
{group.tasks.length}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{!isGroupCollapsed && (
|
|
||||||
<TaskRows
|
|
||||||
tasks={group.tasks}
|
|
||||||
visibleCount={visibleCount}
|
|
||||||
isPinned={taskLocalState.isPinned}
|
|
||||||
isArchived={taskLocalState.isArchived}
|
|
||||||
isNewTask={isNewTask}
|
|
||||||
isTeamOffline={isTeamOffline}
|
|
||||||
hideTeamName
|
|
||||||
hideProjectName
|
|
||||||
showTeamHeader
|
|
||||||
formatTeamHeader={formatTeamHeader}
|
|
||||||
renamingKey={renamingTaskKey}
|
|
||||||
onTogglePin={handleToggleTaskPin}
|
|
||||||
onToggleArchive={handleToggleTaskArchive}
|
|
||||||
onMarkUnread={handleMarkTaskUnread}
|
|
||||||
onRename={handleStartTaskRename}
|
|
||||||
onDelete={handleDeleteTask}
|
|
||||||
onRenameComplete={handleRenameComplete}
|
|
||||||
onRenameCancel={handleRenameCancel}
|
|
||||||
getDisplaySubject={getTaskDisplaySubject}
|
|
||||||
getOwnerColorName={getOwnerColorName}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!isGroupCollapsed && (showMoreVisible || showLessVisible) && (
|
|
||||||
<div className="flex items-center gap-2 px-3 pb-2 pt-1">
|
|
||||||
{showMoreVisible && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-[11px] font-medium text-text-muted transition-colors hover:text-text"
|
|
||||||
onClick={() =>
|
|
||||||
setProjectRequestedVisibleCountByKey((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[group.projectKey]: getNextProjectGroupVisibleCount(
|
|
||||||
projectVisibleCountByKey[group.projectKey],
|
|
||||||
group.tasks.length
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t('tasksPanel.showMore')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{showLessVisible && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-[11px] font-medium text-text-muted transition-colors hover:text-text"
|
|
||||||
onClick={() =>
|
|
||||||
setProjectRequestedVisibleCountByKey((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[group.projectKey]: getPreviousProjectGroupVisibleCount(
|
|
||||||
projectVisibleCountByKey[group.projectKey],
|
|
||||||
group.tasks.length
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t('tasksPanel.showLess')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{groupingMode === 'time' &&
|
{groupingMode === 'time' &&
|
||||||
categories.map((category) => {
|
categories.map((category) => {
|
||||||
const tasks = grouped[category];
|
const tasks = grouped[category];
|
||||||
const isGroupCollapsed = timeCollapsed.isCollapsed(category);
|
const isGroupCollapsed = isTimeGroupCollapsed(category);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={category}>
|
<div key={category}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => timeCollapsed.toggle(category)}
|
onClick={() => toggleTimeGroup(category)}
|
||||||
className="hover:bg-surface-raised/40 sticky top-0 z-10 flex w-full cursor-pointer items-center gap-1 px-2 py-1.5 text-[11px] font-semibold text-text-secondary transition-colors"
|
className="hover:bg-surface-raised/40 sticky top-0 z-10 flex w-full cursor-pointer items-center gap-1 px-2 py-1.5 text-[11px] font-semibold text-text-secondary transition-colors"
|
||||||
style={{ backgroundColor: 'var(--color-surface-sidebar)' }}
|
style={{ backgroundColor: 'var(--color-surface-sidebar)' }}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue