perf(team): limit rendered kanban cards
This commit is contained in:
parent
67826eb275
commit
ab50c43383
2 changed files with 139 additions and 13 deletions
|
|
@ -1635,11 +1635,12 @@ export const TeamDetailView = memo(function TeamDetailView({
|
||||||
|
|
||||||
const activeMembers = useStableActiveMembers(membersWithLiveBranches);
|
const activeMembers = useStableActiveMembers(membersWithLiveBranches);
|
||||||
|
|
||||||
|
const kanbanSearchQuery = kanbanSearch.trim();
|
||||||
|
const isKanbanSearchActive = kanbanSearchQuery.length > 0;
|
||||||
const kanbanDisplayTasks = useMemo(() => {
|
const kanbanDisplayTasks = useMemo(() => {
|
||||||
const query = kanbanSearch.trim();
|
if (!kanbanSearchQuery) return filteredTasks;
|
||||||
if (!query) return filteredTasks;
|
return filterKanbanTasks(filteredTasks, kanbanSearchQuery);
|
||||||
return filterKanbanTasks(filteredTasks, query);
|
}, [filteredTasks, kanbanSearchQuery]);
|
||||||
}, [filteredTasks, kanbanSearch]);
|
|
||||||
|
|
||||||
const activeTeammateCount = useMemo(
|
const activeTeammateCount = useMemo(
|
||||||
() => activeMembers.filter((m) => !isLeadMember(m)).length,
|
() => activeMembers.filter((m) => !isLeadMember(m)).length,
|
||||||
|
|
@ -2498,7 +2499,7 @@ export const TeamDetailView = memo(function TeamDetailView({
|
||||||
icon={<Columns3 size={14} />}
|
icon={<Columns3 size={14} />}
|
||||||
badge={filteredTasks.length}
|
badge={filteredTasks.length}
|
||||||
defaultOpen
|
defaultOpen
|
||||||
forceOpen={kanbanSearch.trim().length > 0}
|
forceOpen={isKanbanSearchActive}
|
||||||
action={
|
action={
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -2523,6 +2524,7 @@ export const TeamDetailView = memo(function TeamDetailView({
|
||||||
sessions={teamSessions}
|
sessions={teamSessions}
|
||||||
leadSessionId={data.config.leadSessionId}
|
leadSessionId={data.config.leadSessionId}
|
||||||
members={activeMembers}
|
members={activeMembers}
|
||||||
|
forceShowAllTasks={isKanbanSearchActive}
|
||||||
onFilterChange={setKanbanFilter}
|
onFilterChange={setKanbanFilter}
|
||||||
onSortChange={setKanbanSort}
|
onSortChange={setKanbanSort}
|
||||||
toolbarLeft={
|
toolbarLeft={
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { cn } from '@renderer/lib/utils';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
ChevronDown,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
Columns3,
|
Columns3,
|
||||||
Eye,
|
Eye,
|
||||||
|
|
@ -77,6 +78,8 @@ interface KanbanBoardProps {
|
||||||
sessions: Session[];
|
sessions: Session[];
|
||||||
leadSessionId?: string;
|
leadSessionId?: string;
|
||||||
members: ResolvedTeamMember[];
|
members: ResolvedTeamMember[];
|
||||||
|
/** Shows all cards when another UI flow, such as search, must not hide matches. */
|
||||||
|
forceShowAllTasks?: boolean;
|
||||||
onFilterChange: (filter: KanbanFilterState) => void;
|
onFilterChange: (filter: KanbanFilterState) => void;
|
||||||
onSortChange: (sort: KanbanSortState) => void;
|
onSortChange: (sort: KanbanSortState) => void;
|
||||||
onRequestReview: (taskId: string) => void;
|
onRequestReview: (taskId: string) => void;
|
||||||
|
|
@ -107,6 +110,8 @@ interface KanbanBoardProps {
|
||||||
type KanbanViewMode = 'grid' | 'columns';
|
type KanbanViewMode = 'grid' | 'columns';
|
||||||
|
|
||||||
const SCROLLABLE_OVERFLOW_VALUES = new Set(['auto', 'scroll', 'overlay']);
|
const SCROLLABLE_OVERFLOW_VALUES = new Set(['auto', 'scroll', 'overlay']);
|
||||||
|
const INITIAL_VISIBLE_TASKS_PER_COLUMN = 20;
|
||||||
|
const LOAD_MORE_TASKS_PER_COLUMN = 20;
|
||||||
|
|
||||||
const COLUMNS: { id: KanbanColumnId; title: string }[] = [
|
const COLUMNS: { id: KanbanColumnId; title: string }[] = [
|
||||||
{ id: 'todo', title: 'TODO' },
|
{ id: 'todo', title: 'TODO' },
|
||||||
|
|
@ -320,6 +325,7 @@ export const KanbanBoard = memo(function KanbanBoard({
|
||||||
sessions,
|
sessions,
|
||||||
leadSessionId,
|
leadSessionId,
|
||||||
members,
|
members,
|
||||||
|
forceShowAllTasks = false,
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
onRequestReview,
|
onRequestReview,
|
||||||
|
|
@ -344,9 +350,13 @@ export const KanbanBoard = memo(function KanbanBoard({
|
||||||
const [viewMode, setViewMode] = useState<KanbanViewMode>('grid');
|
const [viewMode, setViewMode] = useState<KanbanViewMode>('grid');
|
||||||
const [gridPrimaryColumnWidth, setGridPrimaryColumnWidth] = useState<number | null>(null);
|
const [gridPrimaryColumnWidth, setGridPrimaryColumnWidth] = useState<number | null>(null);
|
||||||
const [gridSkeletonDelayMs, setGridSkeletonDelayMs] = useState(SKELETON_HIDE_DELAY_MS);
|
const [gridSkeletonDelayMs, setGridSkeletonDelayMs] = useState(SKELETON_HIDE_DELAY_MS);
|
||||||
|
const [visibleTaskLimitsByColumn, setVisibleTaskLimitsByColumn] = useState<
|
||||||
|
Partial<Record<KanbanColumnId, number>>
|
||||||
|
>({});
|
||||||
const hasReviewers = kanbanState.reviewers.length > 0;
|
const hasReviewers = kanbanState.reviewers.length > 0;
|
||||||
const enableTaskSorting =
|
const enableTaskSorting =
|
||||||
viewMode === 'columns' && !!onColumnOrderChange && sort.field === 'manual';
|
viewMode === 'columns' && !!onColumnOrderChange && sort.field === 'manual';
|
||||||
|
const shouldLimitTaskCards = !forceShowAllTasks && !enableTaskSorting;
|
||||||
|
|
||||||
const stableTaskMapRef = useRef<{
|
const stableTaskMapRef = useRef<{
|
||||||
signatures: string[];
|
signatures: string[];
|
||||||
|
|
@ -393,6 +403,90 @@ export const KanbanBoard = memo(function KanbanBoard({
|
||||||
return result;
|
return result;
|
||||||
}, [grouped, kanbanState.columnOrder, sort.field]);
|
}, [grouped, kanbanState.columnOrder, sort.field]);
|
||||||
|
|
||||||
|
const filterOwnerKey = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from(filter.selectedOwners)
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
.join('\0'),
|
||||||
|
[filter.selectedOwners]
|
||||||
|
);
|
||||||
|
const filterColumnKey = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from(filter.columns)
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
.join('\0'),
|
||||||
|
[filter.columns]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setVisibleTaskLimitsByColumn({});
|
||||||
|
}, [teamName, viewMode, sort.field, filter.sessionId, filterOwnerKey, filterColumnKey]);
|
||||||
|
|
||||||
|
const getVisibleTaskLimit = useCallback(
|
||||||
|
(columnId: KanbanColumnId) =>
|
||||||
|
visibleTaskLimitsByColumn[columnId] ?? INITIAL_VISIBLE_TASKS_PER_COLUMN,
|
||||||
|
[visibleTaskLimitsByColumn]
|
||||||
|
);
|
||||||
|
|
||||||
|
const revealNextTasks = useCallback((columnId: KanbanColumnId, totalTasks: number) => {
|
||||||
|
setVisibleTaskLimitsByColumn((prev) => {
|
||||||
|
const current = prev[columnId] ?? INITIAL_VISIBLE_TASKS_PER_COLUMN;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[columnId]: Math.min(current + LOAD_MORE_TASKS_PER_COLUMN, totalTasks),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderableColumnTasks = useCallback(
|
||||||
|
(columnId: KanbanColumnId, columnTasks: TeamTask[]) => {
|
||||||
|
if (!shouldLimitTaskCards) {
|
||||||
|
return columnTasks;
|
||||||
|
}
|
||||||
|
return columnTasks.slice(0, getVisibleTaskLimit(columnId));
|
||||||
|
},
|
||||||
|
[getVisibleTaskLimit, shouldLimitTaskCards]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleScrollToTask = useCallback(
|
||||||
|
(taskId: string) => {
|
||||||
|
if (!onScrollToTask) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldLimitTaskCards) {
|
||||||
|
onScrollToTask(taskId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const column of COLUMNS) {
|
||||||
|
const columnTasks = groupedOrdered.get(column.id) ?? [];
|
||||||
|
const taskIndex = columnTasks.findIndex((task) => task.id === taskId);
|
||||||
|
if (taskIndex === -1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentLimit = getVisibleTaskLimit(column.id);
|
||||||
|
if (taskIndex < currentLimit) {
|
||||||
|
onScrollToTask(taskId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVisibleTaskLimitsByColumn((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[column.id]: Math.max(prev[column.id] ?? INITIAL_VISIBLE_TASKS_PER_COLUMN, taskIndex + 1),
|
||||||
|
}));
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
window.requestAnimationFrame(() => onScrollToTask(taskId));
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onScrollToTask(taskId);
|
||||||
|
},
|
||||||
|
[getVisibleTaskLimit, groupedOrdered, onScrollToTask, shouldLimitTaskCards]
|
||||||
|
);
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
activationConstraint: { distance: 8 },
|
activationConstraint: { distance: 8 },
|
||||||
|
|
@ -451,12 +545,30 @@ export const KanbanBoard = memo(function KanbanBoard({
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const visibleTasks = renderableColumnTasks(columnId, columnTasks);
|
||||||
|
const hiddenTaskCount = Math.max(0, columnTasks.length - visibleTasks.length);
|
||||||
|
const nextRevealCount = Math.min(LOAD_MORE_TASKS_PER_COLUMN, hiddenTaskCount);
|
||||||
|
const showMoreButton =
|
||||||
|
hiddenTaskCount > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => revealNextTasks(columnId, columnTasks.length)}
|
||||||
|
className="flex w-full items-center justify-center gap-1.5 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2.5 text-xs text-[var(--color-text-muted)] transition-colors hover:border-[var(--color-border-emphasis)] hover:text-[var(--color-text-secondary)]"
|
||||||
|
>
|
||||||
|
<ChevronDown size={13} />
|
||||||
|
Show {nextRevealCount} more
|
||||||
|
<span className="text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
{hiddenTaskCount} hidden
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
) : null;
|
||||||
|
|
||||||
if (enableTaskSorting) {
|
if (enableTaskSorting) {
|
||||||
const itemIds = columnTasks.map((t) => t.id);
|
const itemIds = visibleTasks.map((t) => t.id);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
|
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
|
||||||
{columnTasks.map((task) => (
|
{visibleTasks.map((task) => (
|
||||||
<SortableKanbanTaskCard
|
<SortableKanbanTaskCard
|
||||||
key={task.id}
|
key={task.id}
|
||||||
task={task}
|
task={task}
|
||||||
|
|
@ -473,20 +585,21 @@ export const KanbanBoard = memo(function KanbanBoard({
|
||||||
onStartTask={onStartTask}
|
onStartTask={onStartTask}
|
||||||
onCompleteTask={onCompleteTask}
|
onCompleteTask={onCompleteTask}
|
||||||
onCancelTask={onCancelTask}
|
onCancelTask={onCancelTask}
|
||||||
onScrollToTask={onScrollToTask}
|
onScrollToTask={handleScrollToTask}
|
||||||
onTaskClick={onTaskClick}
|
onTaskClick={onTaskClick}
|
||||||
onViewChanges={onViewChanges}
|
onViewChanges={onViewChanges}
|
||||||
onDeleteTask={onDeleteTask}
|
onDeleteTask={onDeleteTask}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
|
{showMoreButton}
|
||||||
{addButton}
|
{addButton}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{columnTasks.map((task) => (
|
{visibleTasks.map((task) => (
|
||||||
<KanbanTaskCard
|
<KanbanTaskCard
|
||||||
key={task.id}
|
key={task.id}
|
||||||
task={task}
|
task={task}
|
||||||
|
|
@ -504,18 +617,20 @@ export const KanbanBoard = memo(function KanbanBoard({
|
||||||
onStartTask={onStartTask}
|
onStartTask={onStartTask}
|
||||||
onCompleteTask={onCompleteTask}
|
onCompleteTask={onCompleteTask}
|
||||||
onCancelTask={onCancelTask}
|
onCancelTask={onCancelTask}
|
||||||
onScrollToTask={onScrollToTask}
|
onScrollToTask={handleScrollToTask}
|
||||||
onTaskClick={onTaskClick}
|
onTaskClick={onTaskClick}
|
||||||
onViewChanges={onViewChanges}
|
onViewChanges={onViewChanges}
|
||||||
onDeleteTask={onDeleteTask}
|
onDeleteTask={onDeleteTask}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{showMoreButton}
|
||||||
{addButton}
|
{addButton}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
enableTaskSorting,
|
enableTaskSorting,
|
||||||
|
handleScrollToTask,
|
||||||
hasReviewers,
|
hasReviewers,
|
||||||
kanbanState,
|
kanbanState,
|
||||||
memberColorMap,
|
memberColorMap,
|
||||||
|
|
@ -527,10 +642,11 @@ export const KanbanBoard = memo(function KanbanBoard({
|
||||||
onMoveBackToDone,
|
onMoveBackToDone,
|
||||||
onRequestChanges,
|
onRequestChanges,
|
||||||
onRequestReview,
|
onRequestReview,
|
||||||
onScrollToTask,
|
|
||||||
onStartTask,
|
onStartTask,
|
||||||
onTaskClick,
|
onTaskClick,
|
||||||
onViewChanges,
|
onViewChanges,
|
||||||
|
renderableColumnTasks,
|
||||||
|
revealNextTasks,
|
||||||
taskMap,
|
taskMap,
|
||||||
teamName,
|
teamName,
|
||||||
]
|
]
|
||||||
|
|
@ -623,13 +739,21 @@ export const KanbanBoard = memo(function KanbanBoard({
|
||||||
bodyBg: accent.bodyBg,
|
bodyBg: accent.bodyBg,
|
||||||
content: renderCards(column.id, columnTasks),
|
content: renderCards(column.id, columnTasks),
|
||||||
showAddButton: columnSupportsAddButton(column.id, onAddTask),
|
showAddButton: columnSupportsAddButton(column.id, onAddTask),
|
||||||
skeletonCards: columnTasks.map((task) => ({
|
skeletonCards: renderableColumnTasks(column.id, columnTasks).map((task) => ({
|
||||||
key: task.id,
|
key: task.id,
|
||||||
height: estimateGridSkeletonCardHeight(task, column.id, kanbanState, hasReviewers),
|
height: estimateGridSkeletonCardHeight(task, column.id, kanbanState, hasReviewers),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
[visibleColumns, groupedOrdered, renderCards, onAddTask, kanbanState, hasReviewers]
|
[
|
||||||
|
visibleColumns,
|
||||||
|
groupedOrdered,
|
||||||
|
renderCards,
|
||||||
|
onAddTask,
|
||||||
|
renderableColumnTasks,
|
||||||
|
kanbanState,
|
||||||
|
hasReviewers,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const boardContent = (
|
const boardContent = (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue