@tuturuuu/ui 0.1.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/package.json +82 -70
  3. package/src/components/ui/__tests__/avatar.test.tsx +8 -5
  4. package/src/components/ui/calendar-app/components/calendar-connections-compact.tsx +414 -0
  5. package/src/components/ui/calendar-app/components/calendar-connections-manager.tsx +5 -1
  6. package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +529 -0
  7. package/src/components/ui/calendar-app/components/calendar-connections-unified.tsx +26 -1429
  8. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +711 -0
  9. package/src/components/ui/chart.test.tsx +29 -0
  10. package/src/components/ui/chart.tsx +12 -3
  11. package/src/components/ui/chat/chat-agent-details-external-thread-panel.test.tsx +43 -13
  12. package/src/components/ui/chat/chat-agent-details-external-thread-panel.tsx +138 -74
  13. package/src/components/ui/chat/chat-agent-details-operations-panel.test.tsx +70 -0
  14. package/src/components/ui/chat/chat-agent-details-operations-panel.tsx +60 -1
  15. package/src/components/ui/chat/chat-agent-details-sidebar.tsx +13 -5
  16. package/src/components/ui/chat/chat-sidebar-panel.test.tsx +110 -0
  17. package/src/components/ui/chat/chat-sidebar-panel.tsx +13 -3
  18. package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +24 -1
  19. package/src/components/ui/custom/__tests__/tuturuuu-logo.test.ts +12 -3
  20. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +39 -0
  21. package/src/components/ui/custom/common-footer.tsx +16 -1
  22. package/src/components/ui/custom/production-indicator.tsx +1 -1
  23. package/src/components/ui/custom/settings/sidebar-settings.tsx +1 -1
  24. package/src/components/ui/custom/settings/task-settings.tsx +18 -0
  25. package/src/components/ui/custom/settings-dialog-shell.tsx +38 -23
  26. package/src/components/ui/custom/sidebar-context-compile-graph.test.ts +60 -0
  27. package/src/components/ui/custom/sidebar-context.tsx +61 -61
  28. package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +123 -0
  29. package/src/components/ui/custom/tuturuuu-logo-urls.ts +6 -0
  30. package/src/components/ui/custom/tuturuuu-logo.tsx +25 -7
  31. package/src/components/ui/custom/workspace-select-helpers.ts +20 -0
  32. package/src/components/ui/custom/workspace-select.tsx +33 -12
  33. package/src/components/ui/finance/invoices/components/invoice-checkout-summary.tsx +7 -1
  34. package/src/components/ui/finance/invoices/components/invoice-payment-settings.tsx +3 -0
  35. package/src/components/ui/finance/invoices/components/invoice-products-permission-warning.tsx +58 -0
  36. package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +12 -20
  37. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +10 -9
  38. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +10 -5
  39. package/src/components/ui/finance/invoices/hooks.ts +75 -20
  40. package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +137 -0
  41. package/src/components/ui/finance/invoices/new-invoice-page.tsx +86 -37
  42. package/src/components/ui/finance/invoices/product-selection.test.tsx +8 -26
  43. package/src/components/ui/finance/invoices/product-selection.tsx +2 -10
  44. package/src/components/ui/finance/invoices/standard-invoice.tsx +88 -26
  45. package/src/components/ui/finance/invoices/subscription-invoice.tsx +154 -46
  46. package/src/components/ui/finance/invoices/utils.test.ts +50 -0
  47. package/src/components/ui/finance/invoices/utils.ts +75 -17
  48. package/src/components/ui/finance/shared/finance-display-amount.tsx +3 -1
  49. package/src/components/ui/finance/shared/finance-permission-warning-dialog.test.tsx +34 -0
  50. package/src/components/ui/finance/shared/finance-permission-warning-dialog.tsx +157 -0
  51. package/src/components/ui/finance/transactions/form-basic-tab.tsx +8 -0
  52. package/src/components/ui/finance/transactions/form-more-tab.tsx +8 -0
  53. package/src/components/ui/finance/transactions/form-types.ts +2 -0
  54. package/src/components/ui/finance/transactions/form.test.tsx +43 -0
  55. package/src/components/ui/finance/transactions/form.tsx +60 -0
  56. package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +27 -0
  57. package/src/components/ui/finance/transactions/transactions-create-summary.tsx +13 -1
  58. package/src/components/ui/finance/transactions/transactions-infinite-page.tsx +4 -0
  59. package/src/components/ui/finance/transactions/transactions-page.tsx +23 -1
  60. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +4 -0
  61. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +5 -0
  62. package/src/components/ui/legacy/calendar/calendar-content.tsx +9 -1
  63. package/src/components/ui/legacy/calendar/event-modal.tsx +146 -2
  64. package/src/components/ui/legacy/calendar/event-preview-popover.tsx +200 -0
  65. package/src/components/ui/legacy/calendar/smart-calendar.test.tsx +76 -0
  66. package/src/components/ui/legacy/calendar/smart-calendar.tsx +13 -1
  67. package/src/components/ui/legacy/meet/page.test.ts +180 -0
  68. package/src/components/ui/legacy/meet/page.tsx +87 -39
  69. package/src/components/ui/legacy/meet/planId/page.tsx +10 -4
  70. package/src/components/ui/text-editor/__tests__/task-mention-chip.test.tsx +203 -6
  71. package/src/components/ui/text-editor/task-mention-chip.tsx +29 -7
  72. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +79 -25
  73. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-external-workspaces.test.tsx +392 -0
  74. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-actions-island.test.tsx +57 -0
  75. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-actions-island.tsx +106 -0
  76. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-clear-delete.ts +106 -161
  77. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-relations-assignees.ts +96 -150
  78. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-relations-labels.ts +63 -79
  79. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-relations-projects.ts +64 -83
  80. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-updates.ts +115 -155
  81. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operation-utils.ts +319 -2
  82. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operations.ts +8 -1
  83. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +63 -37
  84. package/src/components/ui/tu-do/boards/boardId/kanban/kanban-column-collapse.ts +16 -0
  85. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +46 -0
  86. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +5 -3
  87. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +19 -7
  88. package/src/components/ui/tu-do/boards/boardId/menus/__tests__/task-menus.test.tsx +181 -2
  89. package/src/components/ui/tu-do/boards/boardId/menus/index.ts +1 -0
  90. package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-menu.tsx +463 -0
  91. package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-utils.ts +109 -0
  92. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +4 -0
  93. package/src/components/ui/tu-do/boards/boardId/task-card/TaskCardCheckbox.tsx +6 -3
  94. package/src/components/ui/tu-do/boards/boardId/task-card/TaskCardDates.tsx +26 -9
  95. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-checkbox-style.ts +39 -0
  96. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-comparator.test.ts +43 -0
  97. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-comparator.ts +33 -0
  98. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-completion-checkbox-visibility.test.ts +31 -0
  99. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-completion-checkbox-visibility.ts +9 -0
  100. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-identifier-row.test.tsx +124 -0
  101. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-identifier-row.tsx +88 -0
  102. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +151 -76
  103. package/src/components/ui/tu-do/boards/boardId/task-card/task-scheduling-badge.tsx +174 -0
  104. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +34 -13
  105. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +54 -1
  106. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +158 -0
  107. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +5 -2
  108. package/src/components/ui/tu-do/shared/board-client.tsx +12 -2
  109. package/src/components/ui/tu-do/shared/board-views.tsx +195 -328
  110. package/src/components/ui/tu-do/shared/list-view.tsx +18 -8
  111. package/src/components/ui/tu-do/shared/task-due-date-visibility.test.ts +72 -0
  112. package/src/components/ui/tu-do/shared/task-due-date-visibility.ts +38 -0
  113. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/__tests__/use-task-realtime-sync.test.tsx +37 -9
  114. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +6 -3
  115. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-realtime-sync.ts +89 -70
  116. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.ts +2 -2
  117. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +33 -0
  118. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +74 -3
  119. package/src/hooks/__tests__/use-task-actions.test.tsx +118 -0
  120. package/src/hooks/__tests__/use-user-config.test.tsx +65 -0
  121. package/src/hooks/__tests__/use-workspace-presence.test.tsx +1 -1
  122. package/src/hooks/use-calendar-sync.tsx +22 -277
  123. package/src/hooks/use-calendar.tsx +95 -525
  124. package/src/hooks/use-task-actions.ts +43 -117
  125. package/src/hooks/use-user-config.ts +1 -1
  126. package/src/hooks/use-workspace-config.ts +6 -2
  127. package/src/hooks/use-workspace-presence.ts +1 -1
  128. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-actions-bar.tsx +0 -94
@@ -6,7 +6,10 @@ import {
6
6
  useHotkeySequence,
7
7
  } from '@tanstack/react-hotkeys';
8
8
  import { useQuery, useQueryClient } from '@tanstack/react-query';
9
- import { listWorkspaceTasks } from '@tuturuuu/internal-api/tasks';
9
+ import {
10
+ type ListWorkspaceTasksOptions,
11
+ listWorkspaceTasks,
12
+ } from '@tuturuuu/internal-api/tasks';
10
13
  import type {
11
14
  Workspace,
12
15
  WorkspaceProductTier,
@@ -14,18 +17,17 @@ import type {
14
17
  } from '@tuturuuu/types';
15
18
  import type { Task } from '@tuturuuu/types/primitives/Task';
16
19
  import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
17
- import { useSemanticTaskSearch } from '@tuturuuu/ui/hooks/use-semantic-task-search';
18
20
  import {
19
21
  getPersonalExternalStagingListId,
20
22
  type WorkspaceLabel,
21
23
  } from '@tuturuuu/utils/task-helper';
22
24
  import { useTranslations } from 'next-intl';
23
25
  import {
26
+ type ReactNode,
24
27
  useCallback,
25
28
  useEffect,
26
29
  useLayoutEffect,
27
30
  useMemo,
28
- useRef,
29
31
  useState,
30
32
  } from 'react';
31
33
  import { KanbanBoard } from '../boards/boardId/kanban';
@@ -34,7 +36,6 @@ import { TimelineBoard } from '../boards/boardId/timeline-board';
34
36
  import { useTaskDialog } from '../hooks/useTaskDialog';
35
37
  import { BoardHeader, type ListStatusFilter } from '../shared/board-header';
36
38
  import { ListView } from '../shared/list-view';
37
- import { useProgressiveLoader } from '../shared/progressive-loader-context';
38
39
  import { RecycleBinPanel } from '../shared/recycle-bin-panel';
39
40
  import { loadBoardConfig } from './board-config-storage';
40
41
 
@@ -46,6 +47,8 @@ const HOTKEY_GO_TO_LIST: ['G', 'L'] = ['G', 'L'];
46
47
  const HOTKEY_GO_TO_TIMELINE: ['G', 'T'] = ['G', 'T'];
47
48
  const EXTERNAL_TASKS_COLLAPSED_STORAGE_PREFIX =
48
49
  'personal-board-external-tasks-collapsed';
50
+ const CLOSED_TASK_LIST_COLLAPSED_STORAGE_PREFIX =
51
+ 'task-board-closed-list-collapsed';
49
52
  const DEFAULT_TASK_FILTERS: TaskFilters = {
50
53
  labels: [],
51
54
  assignees: [],
@@ -71,6 +74,10 @@ function hasAssignedExternalTasks(tasks: Task[], boardId: string) {
71
74
  );
72
75
  }
73
76
 
77
+ function getClosedTaskListCollapsedStorageKey(boardId: string, listId: string) {
78
+ return `${CLOSED_TASK_LIST_COLLAPSED_STORAGE_PREFIX}:${boardId}:${listId}`;
79
+ }
80
+
74
81
  interface Props {
75
82
  workspace: Workspace;
76
83
  workspaceTier?: WorkspaceProductTier | null;
@@ -80,6 +87,7 @@ interface Props {
80
87
  workspaceLabels: WorkspaceLabel[];
81
88
  canManageBoard?: boolean;
82
89
  currentUserId?: string;
90
+ idleBottomIsland?: ReactNode;
83
91
  }
84
92
 
85
93
  export function BoardViews({
@@ -90,6 +98,7 @@ export function BoardViews({
90
98
  lists,
91
99
  canManageBoard = true,
92
100
  currentUserId,
101
+ idleBottomIsland,
93
102
  }: Props) {
94
103
  const t = useTranslations('common');
95
104
  const tTasks = useTranslations('ws-tasks');
@@ -98,6 +107,9 @@ export function BoardViews({
98
107
  const effectiveWorkspaceId = board.ws_id ?? workspace.id;
99
108
  const [currentView, setCurrentView] = useState<ViewType>('kanban');
100
109
  const [externalTasksCollapsed, setExternalTasksCollapsed] = useState(false);
110
+ const [closedTaskListsCollapsed, setClosedTaskListsCollapsed] = useState<
111
+ Record<string, boolean>
112
+ >({});
101
113
  const [filters, setFilters] = useState<TaskFilters>(DEFAULT_TASK_FILTERS);
102
114
  const [listStatusFilter, setListStatusFilter] =
103
115
  useState<ListStatusFilter>('all');
@@ -107,8 +119,9 @@ export function BoardViews({
107
119
  >({});
108
120
  const [recycleBinOpen, setRecycleBinOpen] = useState(false);
109
121
  const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
122
+ const [kanbanBulkSelectionActive, setKanbanBulkSelectionActive] =
123
+ useState(false);
110
124
  const { createTask } = useTaskDialog();
111
- const { pagination, loadListPage } = useProgressiveLoader();
112
125
  const sourceScope = filters.sourceScope ?? 'all_visible';
113
126
  const sourceWorkspaceIds = filters.sourceWorkspaceIds ?? [];
114
127
  const sourceBoardIds = filters.sourceBoardIds ?? [];
@@ -119,15 +132,79 @@ export function BoardViews({
119
132
  () => (listStatusFilter === 'all' ? undefined : [listStatusFilter]),
120
133
  [listStatusFilter]
121
134
  );
122
- const sourceFilterKey = useMemo(
135
+ const hasTaskFilters = useMemo(
136
+ () =>
137
+ filters.labels.length > 0 ||
138
+ filters.assignees.length > 0 ||
139
+ filters.projects.length > 0 ||
140
+ filters.priorities.length > 0 ||
141
+ !!filters.dueDateRange?.from ||
142
+ !!filters.dueDateRange?.to ||
143
+ typeof filters.estimationRange?.min === 'number' ||
144
+ typeof filters.estimationRange?.max === 'number' ||
145
+ !!filters.searchQuery?.trim() ||
146
+ filters.includeMyTasks ||
147
+ filters.includeUnassigned ||
148
+ sourceScope !== 'all_visible' ||
149
+ sourceWorkspaceIds.length > 0 ||
150
+ sourceBoardIds.length > 0,
151
+ [filters, sourceBoardIds.length, sourceScope, sourceWorkspaceIds.length]
152
+ );
153
+ const hasServerTaskQuery = hasTaskFilters || !!filters.sortBy;
154
+ const taskQueryOptions = useMemo<ListWorkspaceTasksOptions>(
155
+ () => ({
156
+ assignedToMe: filters.includeMyTasks || undefined,
157
+ assigneeIds: filters.includeMyTasks
158
+ ? undefined
159
+ : filters.assignees.map((assignee) => assignee.id),
160
+ dueDateFrom: filters.dueDateRange?.from?.toISOString(),
161
+ dueDateTo: filters.dueDateRange?.to?.toISOString(),
162
+ estimationMax: filters.estimationRange?.max,
163
+ estimationMin: filters.estimationRange?.min,
164
+ includeUnassigned: filters.includeUnassigned || undefined,
165
+ labelIds: filters.labels.map((label) => label.id),
166
+ priorities: filters.priorities,
167
+ projectIds: filters.projects.map((project) => project.id),
168
+ q: filters.searchQuery?.trim() || undefined,
169
+ sortBy: filters.sortBy,
170
+ sourceBoardIds,
171
+ sourceScope,
172
+ sourceWorkspaceIds,
173
+ }),
174
+ [
175
+ filters.assignees,
176
+ filters.dueDateRange?.from,
177
+ filters.dueDateRange?.to,
178
+ filters.estimationRange?.max,
179
+ filters.estimationRange?.min,
180
+ filters.includeMyTasks,
181
+ filters.includeUnassigned,
182
+ filters.labels,
183
+ filters.priorities,
184
+ filters.projects,
185
+ filters.searchQuery,
186
+ filters.sortBy,
187
+ sourceBoardIds,
188
+ sourceScope,
189
+ sourceWorkspaceIds,
190
+ ]
191
+ );
192
+ const taskFilterKey = useMemo(
123
193
  () =>
124
194
  JSON.stringify({
125
195
  listStatusFilter,
196
+ query: taskQueryOptions,
126
197
  scope: sourceScope,
127
198
  sourceBoardIds: [...sourceBoardIds].sort(),
128
199
  sourceWorkspaceIds: [...sourceWorkspaceIds].sort(),
129
200
  }),
130
- [listStatusFilter, sourceBoardIds, sourceScope, sourceWorkspaceIds]
201
+ [
202
+ listStatusFilter,
203
+ sourceBoardIds,
204
+ sourceScope,
205
+ sourceWorkspaceIds,
206
+ taskQueryOptions,
207
+ ]
131
208
  );
132
209
  const viewHotkeyLabels = useMemo(
133
210
  () => ({
@@ -138,38 +215,28 @@ export function BoardViews({
138
215
  []
139
216
  );
140
217
  const shouldEagerLoadTasks =
141
- currentView === 'list' ||
142
- currentView === 'timeline' ||
143
- sourceScope !== 'all_visible';
218
+ currentView === 'list' || currentView === 'timeline' || hasServerTaskQuery;
144
219
  const fetchBoardTasks = useCallback(async () => {
145
220
  const result = await listWorkspaceTasks(effectiveWorkspaceId, {
221
+ ...taskQueryOptions,
146
222
  boardId: board.id,
147
223
  listStatuses: listStatusesForQuery,
148
- sourceBoardIds,
149
- sourceScope: sourceScope === 'all_visible' ? undefined : sourceScope,
150
- sourceWorkspaceIds,
224
+ limit: 200,
151
225
  });
152
226
  return result.tasks;
153
- }, [
154
- board.id,
155
- effectiveWorkspaceId,
156
- listStatusesForQuery,
157
- sourceBoardIds,
158
- sourceScope,
159
- sourceWorkspaceIds,
160
- ]);
227
+ }, [board.id, effectiveWorkspaceId, listStatusesForQuery, taskQueryOptions]);
161
228
 
162
229
  const primeFullTaskCache = useCallback(
163
230
  (nextView: ViewType) => {
164
231
  if (nextView !== 'list' && nextView !== 'timeline') return;
165
232
 
166
233
  void queryClient.prefetchQuery({
167
- queryKey: ['tasks-full', board.id, sourceFilterKey],
234
+ queryKey: ['tasks-full', board.id, taskFilterKey],
168
235
  queryFn: fetchBoardTasks,
169
236
  staleTime: 0,
170
237
  });
171
238
  },
172
- [board.id, fetchBoardTasks, queryClient, sourceFilterKey]
239
+ [board.id, fetchBoardTasks, queryClient, taskFilterKey]
173
240
  );
174
241
 
175
242
  const handleViewChange = useCallback(
@@ -180,8 +247,8 @@ export function BoardViews({
180
247
  [primeFullTaskCache]
181
248
  );
182
249
 
183
- const { data: fullTasks = [] } = useQuery({
184
- queryKey: ['tasks-full', board.id, sourceFilterKey],
250
+ const { data: fullTasks = [], isFetching: isFullTasksFetching } = useQuery({
251
+ queryKey: ['tasks-full', board.id, taskFilterKey],
185
252
  enabled: shouldEagerLoadTasks,
186
253
  queryFn: fetchBoardTasks,
187
254
  refetchOnMount: 'always',
@@ -253,22 +320,52 @@ export function BoardViews({
253
320
  [board.id, workspace.personal]
254
321
  );
255
322
 
256
- // Detect whether any filter is active (requires all data to be loaded)
257
- const hasActiveFilters = useMemo(
258
- () =>
259
- filters.labels.length > 0 ||
260
- filters.assignees.length > 0 ||
261
- filters.projects.length > 0 ||
262
- filters.priorities.length > 0 ||
263
- !!filters.dueDateRange?.from ||
264
- !!filters.searchQuery?.trim() ||
265
- filters.includeMyTasks ||
266
- filters.includeUnassigned ||
267
- sourceScope !== 'all_visible' ||
268
- sourceWorkspaceIds.length > 0 ||
269
- sourceBoardIds.length > 0 ||
270
- !!filters.sortBy,
271
- [filters, sourceBoardIds.length, sourceScope, sourceWorkspaceIds.length]
323
+ useEffect(() => {
324
+ const closedLists = boardLists.filter(
325
+ (list) => !list.deleted && list.status === 'closed'
326
+ );
327
+
328
+ if (closedLists.length === 0) {
329
+ setClosedTaskListsCollapsed({});
330
+ return;
331
+ }
332
+
333
+ setClosedTaskListsCollapsed((previous) => {
334
+ const next: Record<string, boolean> = {};
335
+
336
+ for (const list of closedLists) {
337
+ const storedValue =
338
+ typeof window === 'undefined'
339
+ ? null
340
+ : window.localStorage.getItem(
341
+ getClosedTaskListCollapsedStorageKey(board.id, list.id)
342
+ );
343
+
344
+ next[list.id] =
345
+ storedValue === null
346
+ ? (previous[list.id] ?? true)
347
+ : storedValue === 'true';
348
+ }
349
+
350
+ return next;
351
+ });
352
+ }, [board.id, boardLists]);
353
+
354
+ const handleTaskListCollapsedChange = useCallback(
355
+ (listId: string, collapsed: boolean) => {
356
+ setClosedTaskListsCollapsed((previous) => ({
357
+ ...previous,
358
+ [listId]: collapsed,
359
+ }));
360
+
361
+ if (typeof window === 'undefined') return;
362
+
363
+ window.localStorage.setItem(
364
+ getClosedTaskListCollapsedStorageKey(board.id, listId),
365
+ String(collapsed)
366
+ );
367
+ },
368
+ [board.id]
272
369
  );
273
370
 
274
371
  const externalStagingList = useMemo<TaskList | null>(() => {
@@ -297,63 +394,43 @@ export function BoardViews({
297
394
  ]);
298
395
 
299
396
  const activeLists = useMemo(() => {
300
- const realLists = boardLists.filter((list) => !list.deleted);
397
+ const realLists = boardLists
398
+ .filter((list) => !list.deleted)
399
+ .map((list) =>
400
+ list.status === 'closed'
401
+ ? {
402
+ ...list,
403
+ is_collapsed: closedTaskListsCollapsed[list.id] ?? true,
404
+ }
405
+ : list
406
+ );
301
407
  return externalStagingList
302
408
  ? [externalStagingList, ...realLists]
303
409
  : realLists;
304
- }, [boardLists, externalStagingList]);
305
-
306
- // When filters or sorting are active, auto-load all remaining pages so
307
- // client-side filtering/sorting operates on complete data.
308
- const autoLoadingRef = useRef(false);
309
- useEffect(() => {
310
- if (
311
- currentView !== 'kanban' ||
312
- !hasActiveFilters ||
313
- sourceScope !== 'all_visible' ||
314
- autoLoadingRef.current
315
- ) {
316
- return;
317
- }
318
-
319
- // Find the first list that still has more pages and isn't loading
320
- for (const list of activeLists) {
321
- const state = pagination[list.id];
322
- if (state?.hasMore && !state.isLoading) {
323
- autoLoadingRef.current = true;
324
- loadListPage(list.id, state.page + 1).finally(() => {
325
- autoLoadingRef.current = false;
410
+ }, [boardLists, closedTaskListsCollapsed, externalStagingList]);
411
+
412
+ const { data: filteredListCounts, isFetching: isFilteredListCountsFetching } =
413
+ useQuery({
414
+ queryKey: ['task-list-counts', board.id, taskFilterKey],
415
+ enabled: hasTaskFilters,
416
+ queryFn: async () => {
417
+ const result = await listWorkspaceTasks(effectiveWorkspaceId, {
418
+ ...taskQueryOptions,
419
+ boardId: board.id,
420
+ includeListCounts: true,
421
+ includeRelationshipSummary: false,
422
+ limit: 0,
423
+ listStatuses: listStatusesForQuery,
326
424
  });
327
- return; // Process one at a time; the next run picks up the next list
328
- }
329
- }
330
- }, [
331
- currentView,
332
- hasActiveFilters,
333
- pagination,
334
- activeLists,
335
- loadListPage,
336
- sourceScope,
337
- ]);
338
-
339
- // Semantic search hook
340
- const {
341
- data: semanticSearchResults = [],
342
- isLoading: isSearchLoading,
343
- isFetching: isSearchFetching,
344
- } = useSemanticTaskSearch({
345
- wsId: effectiveWorkspaceId,
346
- query: filters.searchQuery || '',
347
- matchThreshold: 0.3,
348
- matchCount: 50,
349
- enabled: !!filters.searchQuery && filters.searchQuery.trim().length > 0,
350
- });
425
+ return result.listCounts ?? [];
426
+ },
427
+ staleTime: 30_000,
428
+ });
351
429
 
352
430
  // Filter lists based on selected status filter
353
- const filteredLists = useMemo(() => {
354
- if (listStatusFilter === 'all') {
355
- return activeLists;
356
- }
431
+ const statusFilteredLists = useMemo(() => {
432
+ if (listStatusFilter === 'all') return activeLists;
433
+
357
434
  const stagingLists = activeLists.filter((list) => list.is_external_staging);
358
435
  const realLists = activeLists.filter(
359
436
  (list) => !list.is_external_staging && list.status === listStatusFilter
@@ -361,83 +438,26 @@ export function BoardViews({
361
438
  return [...stagingLists, ...realLists];
362
439
  }, [activeLists, listStatusFilter]);
363
440
 
364
- // Helper function to apply non-search filters
365
- const applyNonSearchFilters = useCallback(
366
- (tasksToFilter: Task[]) => {
367
- let result = tasksToFilter;
368
-
369
- // Filter by labels
370
- if (filters.labels.length > 0) {
371
- result = result.filter((task) => {
372
- if (!task.labels || task.labels.length === 0) return false;
373
- return filters.labels.some((selectedLabel) =>
374
- task.labels?.some((taskLabel) => taskLabel.id === selectedLabel.id)
375
- );
376
- });
377
- }
378
-
379
- // Filter by assignees or "my tasks"
380
- if (filters.includeMyTasks && currentUserId) {
381
- result = result.filter((task) =>
382
- task.assignees?.some((a) => a.id === currentUserId)
383
- );
384
- } else if (filters.assignees.length > 0) {
385
- result = result.filter((task) =>
386
- task.assignees?.some((a) =>
387
- filters.assignees.some((fa) => fa.id === a.id)
388
- )
389
- );
390
- }
391
-
392
- // Filter by unassigned
393
- if (filters.includeUnassigned) {
394
- result = result.filter(
395
- (task) => !task.assignees || task.assignees.length === 0
396
- );
397
- }
398
-
399
- // Filter by projects
400
- if (filters.projects.length > 0) {
401
- result = result.filter((task) => {
402
- // Check if task has projects relationship
403
- if (!task.projects || task.projects.length === 0) return false;
404
- return task.projects.some((pt: any) =>
405
- filters.projects.some((p) => p.id === pt.id)
406
- );
407
- });
408
- }
409
-
410
- // Filter by priorities
411
- if (filters.priorities.length > 0) {
412
- result = result.filter((task) =>
413
- task.priority ? filters.priorities.includes(task.priority) : false
414
- );
415
- }
441
+ const filteredLists = useMemo(() => {
442
+ if (!hasTaskFilters || !filteredListCounts) return statusFilteredLists;
416
443
 
417
- // Filter by due date range
418
- if (filters.dueDateRange?.from) {
419
- result = result.filter((task) => {
420
- if (!task.end_date) return false;
421
- const taskDate = new Date(task.end_date);
422
- const fromDate = filters.dueDateRange!.from!;
423
- const toDate = filters.dueDateRange!.to;
424
- return taskDate >= fromDate && (!toDate || taskDate <= toDate);
425
- });
426
- }
444
+ const countByListId = new Map(
445
+ filteredListCounts.map((entry) => [entry.list_id, entry.count] as const)
446
+ );
427
447
 
428
- return result;
429
- },
430
- [filters, currentUserId]
431
- );
448
+ return statusFilteredLists.filter(
449
+ (list) => (countByListId.get(list.id) ?? 0) > 0
450
+ );
451
+ }, [filteredListCounts, hasTaskFilters, statusFilteredLists]);
432
452
 
433
453
  const sourceTasks = useMemo(() => {
434
454
  if (!shouldEagerLoadTasks) return tasks;
435
455
 
436
456
  if (fullTasks.length === 0) {
437
- return sourceScope === 'all_visible' ? tasks : [];
457
+ return hasServerTaskQuery || sourceScope !== 'all_visible' ? [] : tasks;
438
458
  }
439
459
 
440
- if (sourceScope !== 'all_visible') {
460
+ if (hasServerTaskQuery || sourceScope !== 'all_visible') {
441
461
  return fullTasks;
442
462
  }
443
463
 
@@ -455,44 +475,15 @@ export function BoardViews({
455
475
  }
456
476
 
457
477
  return merged;
458
- }, [fullTasks, shouldEagerLoadTasks, sourceScope, tasks]);
478
+ }, [fullTasks, hasServerTaskQuery, shouldEagerLoadTasks, sourceScope, tasks]);
459
479
 
460
- // Filter tasks based on filters AND filtered lists
480
+ // Keep only tasks that belong to the server-visible lists/status scope.
461
481
  const filteredTasks = useMemo(() => {
462
- // First, filter by list status
463
482
  const listIds = new Set(filteredLists.map((list) => list.id));
464
- let result = isExternalSourceScope
483
+ return isExternalSourceScope
465
484
  ? sourceTasks
466
485
  : sourceTasks.filter((task) => listIds.has(task.list_id));
467
-
468
- // If there's a search query, use semantic search results
469
- if (filters.searchQuery && filters.searchQuery.trim().length > 0) {
470
- // Create a map of task IDs to their search ranking
471
- const searchRankMap = new Map(
472
- semanticSearchResults.map((result, index) => [result.id, index])
473
- );
474
-
475
- // Filter to only include semantic search results
476
- result = result.filter((task) => searchRankMap.has(task.id));
477
-
478
- // Sort by search relevance (lower index = higher relevance)
479
- result.sort((a, b) => {
480
- const rankA = searchRankMap.get(a.id) ?? Number.MAX_SAFE_INTEGER;
481
- const rankB = searchRankMap.get(b.id) ?? Number.MAX_SAFE_INTEGER;
482
- return rankA - rankB;
483
- });
484
- }
485
-
486
- // Apply other filters (labels, assignees, projects, priorities, due date)
487
- return applyNonSearchFilters(result);
488
- }, [
489
- sourceTasks,
490
- filters,
491
- filteredLists,
492
- isExternalSourceScope,
493
- semanticSearchResults,
494
- applyNonSearchFilters,
495
- ]);
486
+ }, [filteredLists, isExternalSourceScope, sourceTasks]);
496
487
 
497
488
  // Apply optimistic overrides so views receive up-to-date edits (durations, name, dates) even before refetch.
498
489
  const effectiveTasks = useMemo(() => {
@@ -508,141 +499,8 @@ export function BoardViews({
508
499
 
509
500
  tasks = tasks.filter((task) => !task.deleted_at);
510
501
 
511
- // Apply sorting - but NEVER sort done/closed tasks (they always sort by timestamps)
512
- if (filters.sortBy) {
513
- // Create a map of list_id to status
514
- const listStatusMap = new Map(
515
- activeLists.map((list) => [list.id, list.status])
516
- );
517
-
518
- // Separate tasks into sortable and completion (done/closed) tasks
519
- const sortableTasks = tasks.filter((task) => {
520
- const status = listStatusMap.get(task.list_id);
521
- return status !== 'done' && status !== 'closed';
522
- });
523
- const completionTasks = tasks.filter((task) => {
524
- const status = listStatusMap.get(task.list_id);
525
- return status === 'done' || status === 'closed';
526
- });
527
-
528
- // Sort only the sortable tasks
529
- const sorted = [...sortableTasks];
530
- switch (filters.sortBy) {
531
- case 'name-asc':
532
- sorted.sort((a, b) => a.name.localeCompare(b.name));
533
- break;
534
- case 'name-desc':
535
- sorted.sort((a, b) => b.name.localeCompare(a.name));
536
- break;
537
- case 'priority-high': {
538
- const priorityOrder = { critical: 0, high: 1, normal: 2, low: 3 };
539
- sorted.sort((a, b) => {
540
- const priorityA = a.priority
541
- ? priorityOrder[a.priority]
542
- : Number.MAX_SAFE_INTEGER;
543
- const priorityB = b.priority
544
- ? priorityOrder[b.priority]
545
- : Number.MAX_SAFE_INTEGER;
546
- return priorityA - priorityB;
547
- });
548
- break;
549
- }
550
- case 'priority-low': {
551
- const priorityOrder = { critical: 0, high: 1, normal: 2, low: 3 };
552
- sorted.sort((a, b) => {
553
- const priorityA = a.priority
554
- ? priorityOrder[a.priority]
555
- : Number.MAX_SAFE_INTEGER;
556
- const priorityB = b.priority
557
- ? priorityOrder[b.priority]
558
- : Number.MAX_SAFE_INTEGER;
559
- return priorityB - priorityA;
560
- });
561
- break;
562
- }
563
- case 'due-date-asc':
564
- sorted.sort((a, b) => {
565
- if (!a.end_date && !b.end_date) return 0;
566
- if (!a.end_date) return 1;
567
- if (!b.end_date) return -1;
568
- return (
569
- new Date(a.end_date).getTime() - new Date(b.end_date).getTime()
570
- );
571
- });
572
- break;
573
- case 'due-date-desc':
574
- sorted.sort((a, b) => {
575
- if (!a.end_date && !b.end_date) return 0;
576
- if (!a.end_date) return -1;
577
- if (!b.end_date) return 1;
578
- return (
579
- new Date(b.end_date).getTime() - new Date(a.end_date).getTime()
580
- );
581
- });
582
- break;
583
- case 'created-date-desc':
584
- sorted.sort(
585
- (a, b) =>
586
- new Date(b.created_at).getTime() -
587
- new Date(a.created_at).getTime()
588
- );
589
- break;
590
- case 'created-date-asc':
591
- sorted.sort(
592
- (a, b) =>
593
- new Date(a.created_at).getTime() -
594
- new Date(b.created_at).getTime()
595
- );
596
- break;
597
- case 'estimation-high':
598
- sorted.sort((a, b) => {
599
- const estA = a.estimation_points ?? Number.MIN_SAFE_INTEGER;
600
- const estB = b.estimation_points ?? Number.MIN_SAFE_INTEGER;
601
- return estB - estA;
602
- });
603
- break;
604
- case 'estimation-low':
605
- sorted.sort((a, b) => {
606
- const estA = a.estimation_points ?? Number.MAX_SAFE_INTEGER;
607
- const estB = b.estimation_points ?? Number.MAX_SAFE_INTEGER;
608
- return estA - estB;
609
- });
610
- break;
611
- }
612
-
613
- // Sort completion tasks by their respective timestamps
614
- const sortedCompletionTasks = [...completionTasks].sort((a, b) => {
615
- const statusA = listStatusMap.get(a.list_id);
616
- const statusB = listStatusMap.get(b.list_id);
617
-
618
- // For done tasks, sort by completed_at
619
- if (statusA === 'done' && statusB === 'done') {
620
- const completionA = a.completed_at
621
- ? new Date(a.completed_at).getTime()
622
- : 0;
623
- const completionB = b.completed_at
624
- ? new Date(b.completed_at).getTime()
625
- : 0;
626
- return completionB - completionA; // Most recent first
627
- }
628
-
629
- // For closed tasks, sort by closed_at
630
- if (statusA === 'closed' && statusB === 'closed') {
631
- const closedA = a.closed_at ? new Date(a.closed_at).getTime() : 0;
632
- const closedB = b.closed_at ? new Date(b.closed_at).getTime() : 0;
633
- return closedB - closedA; // Most recent first
634
- }
635
-
636
- // Mixed statuses - maintain original order
637
- return 0;
638
- });
639
-
640
- // Combine sorted tasks with completion tasks (unsorted)
641
- return [...sorted, ...sortedCompletionTasks];
642
- }
643
-
644
502
  return tasks;
645
- }, [filteredTasks, taskOverrides, filters.sortBy, activeLists]);
503
+ }, [filteredTasks, taskOverrides]);
646
504
 
647
505
  const handleTaskPartialUpdate = (taskId: string, partial: Partial<Task>) => {
648
506
  setTaskOverrides((prev) => ({
@@ -728,6 +586,8 @@ export function BoardViews({
728
586
  isMultiSelectMode={isMultiSelectMode}
729
587
  setIsMultiSelectMode={setIsMultiSelectMode}
730
588
  onExternalTasksCollapsedChange={handleExternalTasksCollapsedChange}
589
+ onTaskListCollapsedChange={handleTaskListCollapsedChange}
590
+ onBulkSelectionActiveChange={setKanbanBulkSelectionActive}
731
591
  />
732
592
  );
733
593
  case 'list':
@@ -768,11 +628,17 @@ export function BoardViews({
768
628
  isMultiSelectMode={isMultiSelectMode}
769
629
  setIsMultiSelectMode={setIsMultiSelectMode}
770
630
  onExternalTasksCollapsedChange={handleExternalTasksCollapsedChange}
631
+ onTaskListCollapsedChange={handleTaskListCollapsedChange}
632
+ onBulkSelectionActiveChange={setKanbanBulkSelectionActive}
771
633
  />
772
634
  );
773
635
  }
774
636
  };
775
637
 
638
+ const showIdleBottomIsland =
639
+ !!idleBottomIsland &&
640
+ (currentView !== 'kanban' || !kanbanBulkSelectionActive);
641
+
776
642
  return (
777
643
  <div className="-m-2 -mb-4 flex h-[calc(100vh-0.5rem)] flex-1 flex-col md:-mx-4">
778
644
  <BoardHeader
@@ -787,7 +653,7 @@ export function BoardViews({
787
653
  listStatusFilter={listStatusFilter}
788
654
  onListStatusFilterChange={setListStatusFilter}
789
655
  isPersonalWorkspace={workspace.personal}
790
- isSearching={isSearchLoading || isSearchFetching}
656
+ isSearching={isFullTasksFetching || isFilteredListCountsFetching}
791
657
  lists={boardLists}
792
658
  onUpdate={handleUpdate}
793
659
  onRecycleBinOpen={() => setRecycleBinOpen(true)}
@@ -796,6 +662,7 @@ export function BoardViews({
796
662
  hideActions={!canManageBoard}
797
663
  />
798
664
  <div className="h-full overflow-hidden">{renderView()}</div>
665
+ {showIdleBottomIsland ? idleBottomIsland : null}
799
666
 
800
667
  <RecycleBinPanel
801
668
  open={recycleBinOpen}