@tuturuuu/ui 0.9.0 → 0.10.0

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 (94) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/package.json +6 -5
  3. package/src/components/ui/custom/__tests__/settings-dialog-search.test.ts +78 -0
  4. package/src/components/ui/custom/__tests__/settings-dialog-shell-compile-graph.test.ts +76 -0
  5. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +27 -1
  6. package/src/components/ui/custom/nav-link.test.tsx +165 -0
  7. package/src/components/ui/custom/nav-link.tsx +69 -11
  8. package/src/components/ui/custom/navigation.tsx +1 -0
  9. package/src/components/ui/custom/settings/task-settings.tsx +104 -0
  10. package/src/components/ui/custom/settings-dialog-search-loader.d.ts +5 -0
  11. package/src/components/ui/custom/settings-dialog-search-loader.js +3 -0
  12. package/src/components/ui/custom/settings-dialog-search.ts +75 -0
  13. package/src/components/ui/custom/settings-dialog-shell.tsx +63 -27
  14. package/src/components/ui/custom/workspace-select-helpers.ts +23 -0
  15. package/src/components/ui/custom/workspace-select.tsx +17 -16
  16. package/src/components/ui/tu-do/boards/__tests__/board-share-dialog.test.tsx +16 -0
  17. package/src/components/ui/tu-do/boards/__tests__/task-board-form.test.tsx +12 -0
  18. package/src/components/ui/tu-do/boards/board-share-dialog.tsx +4 -328
  19. package/src/components/ui/tu-do/boards/board-share-settings-panel.tsx +351 -0
  20. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +50 -37
  21. package/src/components/ui/tu-do/boards/boardId/enhanced-task-list.tsx +7 -0
  22. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operation-types.ts +3 -3
  23. package/src/components/ui/tu-do/boards/boardId/kanban/data/use-bulk-resources.ts +59 -5
  24. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/drag-preview.tsx +20 -1
  25. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +263 -21
  26. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +133 -14
  27. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-deadline-panels.tsx +112 -54
  28. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-skeleton.tsx +8 -2
  29. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +29 -14
  30. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +24 -1
  31. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +7 -0
  32. package/src/components/ui/tu-do/boards/boardId/task-card/measured-task-card.tsx +7 -1
  33. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +20 -0
  34. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +10 -0
  35. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +80 -8
  36. package/src/components/ui/tu-do/boards/boardId/task-list.tsx +15 -0
  37. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +9 -0
  38. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +9 -0
  39. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-toolbar.tsx +9 -0
  40. package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +35 -3
  41. package/src/components/ui/tu-do/boards/form.tsx +1 -1
  42. package/src/components/ui/tu-do/hooks/__tests__/useTaskLabelManagement.test.tsx +48 -0
  43. package/src/components/ui/tu-do/hooks/__tests__/useTaskProjectManagement.test.tsx +144 -0
  44. package/src/components/ui/tu-do/hooks/useTaskDialog.ts +7 -0
  45. package/src/components/ui/tu-do/hooks/useTaskLabelManagement.ts +115 -106
  46. package/src/components/ui/tu-do/hooks/useTaskProjectManagement.ts +115 -122
  47. package/src/components/ui/tu-do/progress/task-progress-import-panel.tsx +60 -0
  48. package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +156 -0
  49. package/src/components/ui/tu-do/progress/task-progress-page.tsx +348 -0
  50. package/src/components/ui/tu-do/progress/task-progress-panels.tsx +301 -0
  51. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +26 -0
  52. package/src/components/ui/tu-do/shared/__tests__/assignee-select.test.tsx +81 -10
  53. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +116 -1
  54. package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +38 -0
  55. package/src/components/ui/tu-do/shared/__tests__/board-switcher.test.tsx +128 -7
  56. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +222 -9
  57. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +21 -0
  58. package/src/components/ui/tu-do/shared/__tests__/task-cache-patches.test.ts +147 -0
  59. package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +3 -0
  60. package/src/components/ui/tu-do/shared/assignee-select.tsx +77 -26
  61. package/src/components/ui/tu-do/shared/board-client.tsx +14 -4
  62. package/src/components/ui/tu-do/shared/board-header.tsx +8 -1
  63. package/src/components/ui/tu-do/shared/board-switcher.tsx +70 -38
  64. package/src/components/ui/tu-do/shared/board-user-presence-avatars.tsx +18 -12
  65. package/src/components/ui/tu-do/shared/board-views.tsx +49 -69
  66. package/src/components/ui/tu-do/shared/list-view.tsx +21 -3
  67. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +4 -4
  68. package/src/components/ui/tu-do/shared/task-cache-patches.ts +394 -0
  69. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +21 -1
  70. package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +5 -1
  71. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +25 -2
  72. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-list-selector.tsx +7 -1
  73. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-data.ts +79 -10
  74. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +76 -77
  75. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-relationships.test.tsx +63 -0
  76. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-relationships.ts +78 -69
  77. package/src/components/ui/tu-do/shared/task-edit-dialog/personal-overrides-section.tsx +28 -8
  78. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/dependencies-section.tsx +14 -3
  79. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/parent-section.tsx +6 -1
  80. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/related-section.tsx +6 -1
  81. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/subtasks-section.tsx +6 -1
  82. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/types/task-relationships.types.ts +8 -0
  83. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +8 -1
  84. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.test.tsx +150 -0
  85. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +61 -35
  86. package/src/components/ui/tu-do/shared/task-edit-dialog/task-relationships-properties.tsx +44 -2
  87. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +9 -0
  88. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +11 -0
  89. package/src/components/ui/tu-do/shared/use-progressive-board-loader.ts +2 -0
  90. package/src/hooks/__tests__/useBoardPresence.test.tsx +191 -0
  91. package/src/hooks/__tests__/useBoardRealtime.test.tsx +24 -144
  92. package/src/hooks/useBoardPresence.ts +364 -0
  93. package/src/hooks/useBoardRealtimeEventHandler.ts +34 -90
  94. package/src/lib/workspace-actions.ts +2 -6
@@ -9,7 +9,17 @@ import type { TaskLabel as DbTaskLabel } from '@tuturuuu/types/db';
9
9
  import type { Task } from '@tuturuuu/types/primitives/Task';
10
10
  import { toast } from '@tuturuuu/ui/sonner';
11
11
  import { useState } from 'react';
12
- import { useBoardBroadcast } from '../shared/board-broadcast-context';
12
+ import {
13
+ getActiveBoardRefresh,
14
+ useBoardBroadcast,
15
+ } from '../shared/board-broadcast-context';
16
+ import {
17
+ getTaskFromVisibleCaches,
18
+ patchTaskInVisibleCaches,
19
+ restoreTasksFromVisibleCacheSnapshot,
20
+ restoreVisibleTaskCaches,
21
+ snapshotVisibleTaskCaches,
22
+ } from '../shared/task-cache-patches';
13
23
  import { getRandomNewLabelColor } from '../utils/taskConstants';
14
24
 
15
25
  type WorkspaceTaskLabel = Pick<
@@ -68,10 +78,14 @@ export function useTaskLabelManagement({
68
78
 
69
79
  // CRITICAL: Get current task state from cache instead of stale prop
70
80
  // This ensures we read the most up-to-date state after optimistic updates
71
- const currentTask = taskId
72
- ? ((queryClient.getQueryData(['task', taskId]) as Task | undefined) ??
73
- task)
74
- : task;
81
+ const canonicalTaskId = taskId ?? task.id;
82
+ const currentTask =
83
+ getTaskFromVisibleCaches({
84
+ queryClient,
85
+ boardId,
86
+ taskId: canonicalTaskId,
87
+ fallback: task,
88
+ }) ?? task;
75
89
 
76
90
  // Check if we're in multi-select mode with multiple tasks selected
77
91
  const shouldBulkUpdate =
@@ -86,11 +100,17 @@ export function useTaskLabelManagement({
86
100
 
87
101
  // Cancel any outgoing refetches
88
102
  await queryClient.cancelQueries({ queryKey: ['tasks', boardId] });
103
+ await queryClient.cancelQueries({ queryKey: ['tasks-full', boardId] });
89
104
 
90
105
  // Snapshot the previous value BEFORE optimistic update
91
106
  const previousTasks = queryClient.getQueryData(['tasks', boardId]) as
92
107
  | Task[]
93
108
  | undefined;
109
+ const cacheSnapshot = snapshotVisibleTaskCaches(
110
+ queryClient,
111
+ boardId,
112
+ tasksToUpdate
113
+ );
94
114
 
95
115
  // Determine action: remove if ALL selected tasks have the label, add otherwise
96
116
  // Use currentTask from cache, not stale task prop
@@ -112,6 +132,13 @@ export function useTaskLabelManagement({
112
132
  const fromBoardCache = previousTasks?.find((ct) => ct.id === taskId);
113
133
  if (fromBoardCache) return fromBoardCache;
114
134
 
135
+ const fromVisibleCaches = getTaskFromVisibleCaches({
136
+ queryClient,
137
+ boardId,
138
+ taskId,
139
+ });
140
+ if (fromVisibleCaches) return fromVisibleCaches;
141
+
115
142
  // Fallback to individual task cache (for tasks not in board view)
116
143
  if (taskId === currentTask.id) return currentTask;
117
144
 
@@ -135,63 +162,36 @@ export function useTaskLabelManagement({
135
162
 
136
163
  // Get label details from workspace labels for optimistic update
137
164
  const label = workspaceLabels.find((l) => l.id === labelId);
165
+ const fallbackLabel = label || {
166
+ id: labelId,
167
+ name: 'Unknown',
168
+ color: '#3b82f6',
169
+ created_at: new Date().toISOString(),
170
+ };
138
171
 
139
172
  // Optimistically update the cache - only update tasks that actually change
140
- queryClient.setQueryData(['tasks', boardId], (old: Task[] | undefined) => {
141
- if (!old) return old;
142
- return old.map((t) => {
143
- if (active && tasksToRemoveFrom.includes(t.id)) {
144
- // Remove the label
145
- return {
146
- ...t,
147
- labels: t.labels?.filter((l) => l.id !== labelId) || [],
148
- };
149
- } else if (!active && tasksNeedingLabel.includes(t.id)) {
150
- // Add the label
151
- return {
152
- ...t,
153
- labels: [
154
- ...(t.labels || []),
155
- label || {
156
- id: labelId,
157
- name: 'Unknown',
158
- color: '#3b82f6',
159
- created_at: new Date().toISOString(),
160
- },
161
- ],
162
- };
163
- }
164
- return t;
165
- });
166
- });
167
-
168
- // CRITICAL: Also update the individual task cache if taskId is provided
169
- // This ensures the chip menu's task cache stays in sync with the board cache
170
- if (taskId) {
171
- queryClient.setQueryData(['task', taskId], (old: Task | undefined) => {
172
- if (!old) return old;
173
- if (active && tasksToRemoveFrom.includes(taskId)) {
174
- // Remove the label
175
- return {
176
- ...old,
177
- labels: old.labels?.filter((l) => l.id !== labelId) || [],
178
- };
179
- } else if (!active && tasksNeedingLabel.includes(taskId)) {
180
- // Add the label
173
+ for (const tid of active ? tasksToRemoveFrom : tasksNeedingLabel) {
174
+ patchTaskInVisibleCaches({
175
+ queryClient,
176
+ boardId,
177
+ taskId: tid,
178
+ updater: (cachedTask) => {
179
+ if (active) {
180
+ return {
181
+ ...cachedTask,
182
+ labels: cachedTask.labels?.filter((l) => l.id !== labelId) || [],
183
+ };
184
+ }
185
+
186
+ if (cachedTask.labels?.some((l) => l.id === labelId)) {
187
+ return cachedTask;
188
+ }
189
+
181
190
  return {
182
- ...old,
183
- labels: [
184
- ...(old.labels || []),
185
- label || {
186
- id: labelId,
187
- name: 'Unknown',
188
- color: '#3b82f6',
189
- created_at: new Date().toISOString(),
190
- },
191
- ],
191
+ ...cachedTask,
192
+ labels: [...(cachedTask.labels || []), fallbackLabel],
192
193
  };
193
- }
194
- return old;
194
+ },
195
195
  });
196
196
  }
197
197
 
@@ -201,33 +201,36 @@ export function useTaskLabelManagement({
201
201
  ? { baseUrl: window.location.origin }
202
202
  : undefined;
203
203
  let successCount = 0;
204
+ const succeededTaskIds: string[] = [];
204
205
 
205
206
  if (active) {
206
- for (const taskId of tasksToRemoveFrom) {
207
+ for (const tid of tasksToRemoveFrom) {
207
208
  try {
208
209
  await removeWorkspaceTaskLabel(
209
210
  workspaceId,
210
- taskId,
211
+ tid,
211
212
  labelId,
212
213
  internalApiOptions
213
214
  );
214
215
  successCount++;
216
+ succeededTaskIds.push(tid);
215
217
  } catch (error) {
216
- console.error(`Failed to remove label from task ${taskId}:`, error);
218
+ console.error(`Failed to remove label from task ${tid}:`, error);
217
219
  }
218
220
  }
219
221
  } else {
220
- for (const taskId of tasksNeedingLabel) {
222
+ for (const tid of tasksNeedingLabel) {
221
223
  try {
222
224
  await addWorkspaceTaskLabel(
223
225
  workspaceId,
224
- taskId,
226
+ tid,
225
227
  labelId,
226
228
  internalApiOptions
227
229
  );
228
230
  successCount++;
231
+ succeededTaskIds.push(tid);
229
232
  } catch (error) {
230
- console.error(`Failed to add label to task ${taskId}:`, error);
233
+ console.error(`Failed to add label to task ${tid}:`, error);
231
234
  }
232
235
  }
233
236
  }
@@ -240,10 +243,22 @@ export function useTaskLabelManagement({
240
243
  throw new Error('Failed to update any tasks');
241
244
  }
242
245
 
246
+ const failedTaskIds = (
247
+ active ? tasksToRemoveFrom : tasksNeedingLabel
248
+ ).filter((tid) => !succeededTaskIds.includes(tid));
249
+ restoreTasksFromVisibleCacheSnapshot({
250
+ queryClient,
251
+ snapshot: cacheSnapshot,
252
+ taskIds: failedTaskIds,
253
+ });
254
+
243
255
  // Broadcast relation changes for all affected tasks
244
- for (const tid of active ? tasksToRemoveFrom : tasksNeedingLabel) {
256
+ for (const tid of succeededTaskIds) {
245
257
  broadcast?.('task:relations-changed', { taskId: tid });
246
258
  }
259
+ if (succeededTaskIds.length > 0) {
260
+ getActiveBoardRefresh()?.({ invalidateTasks: false });
261
+ }
247
262
 
248
263
  toast.success(active ? 'Label removed' : 'Label added', {
249
264
  description:
@@ -253,9 +268,7 @@ export function useTaskLabelManagement({
253
268
  // Don't auto-clear selection - let user manually clear with "Clear" button
254
269
  } catch (e: any) {
255
270
  // Rollback on error
256
- if (previousTasks) {
257
- queryClient.setQueryData(['tasks', boardId], previousTasks);
258
- }
271
+ restoreVisibleTaskCaches(queryClient, cacheSnapshot);
259
272
  console.error('Failed to toggle label:', e);
260
273
  toast.error('Error', {
261
274
  description: 'Failed to update label. Please try again.',
@@ -306,48 +319,44 @@ export function useTaskLabelManagement({
306
319
 
307
320
  // Auto-apply the newly created label to this task
308
321
  let linkSucceeded = false;
309
- let previousTasks: unknown;
322
+ const canonicalTaskId = taskId ?? task.id;
323
+ let cacheSnapshot:
324
+ | ReturnType<typeof snapshotVisibleTaskCaches>
325
+ | undefined;
310
326
  try {
311
327
  // Cancel any outgoing refetches
312
328
  await queryClient.cancelQueries({ queryKey: ['tasks', boardId] });
329
+ await queryClient.cancelQueries({ queryKey: ['tasks-full', boardId] });
313
330
 
314
331
  // Snapshot the previous value
315
- previousTasks = queryClient.getQueryData(['tasks', boardId]);
332
+ cacheSnapshot = snapshotVisibleTaskCaches(queryClient, boardId, [
333
+ canonicalTaskId,
334
+ ]);
316
335
 
317
336
  // Optimistically update the cache
318
- queryClient.setQueryData(
319
- ['tasks', boardId],
320
- (old: any[] | undefined) => {
321
- if (!old) return old;
322
- return old.map((t) => {
323
- if (t.id === task.id) {
324
- return {
325
- ...t,
326
- labels: [...(t.labels || []), newLabel],
327
- };
328
- }
329
- return t;
330
- });
331
- }
332
- );
333
-
334
- // CRITICAL: Also update individual task cache if taskId is provided
335
- if (taskId) {
336
- queryClient.setQueryData(
337
- ['task', taskId],
338
- (old: Task | undefined) => {
339
- if (!old) return old;
340
- return {
341
- ...old,
342
- labels: [...(old.labels || []), newLabel],
343
- };
337
+ patchTaskInVisibleCaches({
338
+ queryClient,
339
+ boardId,
340
+ taskId: canonicalTaskId,
341
+ updater: (cachedTask) => {
342
+ if (cachedTask.labels?.some((label) => label.id === newLabel.id)) {
343
+ return cachedTask;
344
344
  }
345
- );
346
- }
345
+
346
+ return {
347
+ ...cachedTask,
348
+ labels: [...(cachedTask.labels || []), newLabel],
349
+ };
350
+ },
351
+ });
347
352
 
348
353
  const taskState =
349
- (queryClient.getQueryData(['task', task.id]) as Task | undefined) ??
350
- task;
354
+ getTaskFromVisibleCaches({
355
+ queryClient,
356
+ boardId,
357
+ taskId: canonicalTaskId,
358
+ fallback: task,
359
+ }) ?? task;
351
360
  const nextLabelIds = [
352
361
  ...new Set([
353
362
  ...(taskState.labels ?? []).map((entry) => entry.id),
@@ -357,7 +366,7 @@ export function useTaskLabelManagement({
357
366
 
358
367
  await updateWorkspaceTask(
359
368
  workspaceId,
360
- task.id,
369
+ canonicalTaskId,
361
370
  {
362
371
  label_ids: nextLabelIds,
363
372
  },
@@ -368,19 +377,19 @@ export function useTaskLabelManagement({
368
377
  linkSucceeded = true;
369
378
  } catch (linkErr: any) {
370
379
  // Rollback on error
371
- queryClient.setQueryData(['tasks', boardId], previousTasks);
380
+ if (cacheSnapshot) {
381
+ restoreVisibleTaskCaches(queryClient, cacheSnapshot);
382
+ }
372
383
  toast.error(
373
384
  'The label was created but could not be attached to the task. Refresh and try manually.'
374
385
  );
375
- if (taskId) {
376
- queryClient.invalidateQueries({ queryKey: ['task', taskId] });
377
- }
378
386
  console.error('Failed to auto-apply new label', linkErr);
379
387
  }
380
388
 
381
389
  // Only show success toast and reset form if link succeeded
382
390
  if (linkSucceeded) {
383
- broadcast?.('task:relations-changed', { taskId: task.id });
391
+ broadcast?.('task:relations-changed', { taskId: canonicalTaskId });
392
+ getActiveBoardRefresh()?.({ invalidateTasks: false });
384
393
 
385
394
  // Reset form and close dialog
386
395
  setNewLabelName('');
@@ -6,7 +6,17 @@ import {
6
6
  import type { Task } from '@tuturuuu/types/primitives/Task';
7
7
  import { toast } from '@tuturuuu/ui/sonner';
8
8
  import { useState } from 'react';
9
- import { useBoardBroadcast } from '../shared/board-broadcast-context';
9
+ import {
10
+ getActiveBoardRefresh,
11
+ useBoardBroadcast,
12
+ } from '../shared/board-broadcast-context';
13
+ import {
14
+ getTaskFromVisibleCaches,
15
+ patchTaskInVisibleCaches,
16
+ restoreTasksFromVisibleCacheSnapshot,
17
+ restoreVisibleTaskCaches,
18
+ snapshotVisibleTaskCaches,
19
+ } from '../shared/task-cache-patches';
10
20
 
11
21
  function getInternalApiOptions() {
12
22
  if (typeof window === 'undefined') {
@@ -89,10 +99,14 @@ export function useTaskProjectManagement({
89
99
 
90
100
  // CRITICAL: Get current task state from cache instead of stale prop
91
101
  // This ensures we read the most up-to-date state after optimistic updates
92
- const currentTask = taskId
93
- ? ((queryClient.getQueryData(['task', taskId]) as Task | undefined) ??
94
- task)
95
- : task;
102
+ const canonicalTaskId = taskId ?? task.id;
103
+ const currentTask =
104
+ getTaskFromVisibleCaches({
105
+ queryClient,
106
+ boardId,
107
+ taskId: canonicalTaskId,
108
+ fallback: task,
109
+ }) ?? task;
96
110
 
97
111
  // Check if we're in multi-select mode with multiple tasks selected
98
112
  const shouldBulkUpdate =
@@ -107,11 +121,17 @@ export function useTaskProjectManagement({
107
121
 
108
122
  // Cancel any outgoing refetches
109
123
  await queryClient.cancelQueries({ queryKey: ['tasks', boardId] });
124
+ await queryClient.cancelQueries({ queryKey: ['tasks-full', boardId] });
110
125
 
111
126
  // Snapshot the previous value BEFORE optimistic update
112
127
  const previousTasks = queryClient.getQueryData(['tasks', boardId]) as
113
128
  | Task[]
114
129
  | undefined;
130
+ const cacheSnapshot = snapshotVisibleTaskCaches(
131
+ queryClient,
132
+ boardId,
133
+ tasksToUpdate
134
+ );
115
135
 
116
136
  // Determine action: remove if ALL selected tasks have the project, add otherwise
117
137
  // Use currentTask from cache, not stale task prop
@@ -133,6 +153,13 @@ export function useTaskProjectManagement({
133
153
  const fromBoardCache = previousTasks?.find((ct) => ct.id === taskId);
134
154
  if (fromBoardCache) return fromBoardCache;
135
155
 
156
+ const fromVisibleCaches = getTaskFromVisibleCaches({
157
+ queryClient,
158
+ boardId,
159
+ taskId,
160
+ });
161
+ if (fromVisibleCaches) return fromVisibleCaches;
162
+
136
163
  // Fallback to individual task cache (for tasks not in board view)
137
164
  if (taskId === currentTask.id) return currentTask;
138
165
 
@@ -156,54 +183,44 @@ export function useTaskProjectManagement({
156
183
 
157
184
  // Get project details from workspace projects for optimistic update
158
185
  const project = workspaceProjects.find((p) => p.id === projectId);
186
+ const fallbackProject = project
187
+ ? {
188
+ id: project.id,
189
+ name: project.name,
190
+ status: project.status ?? 'unknown',
191
+ }
192
+ : {
193
+ id: projectId,
194
+ name: 'Unknown',
195
+ status: 'unknown',
196
+ };
159
197
 
160
198
  // Optimistically update the cache - only update tasks that actually change
161
- queryClient.setQueryData(['tasks', boardId], (old: Task[] | undefined) => {
162
- if (!old) return old;
163
- return old.map((t) => {
164
- if (active && tasksToRemoveFrom.includes(t.id)) {
165
- // Remove the project
166
- return {
167
- ...t,
168
- projects: t.projects?.filter((p: any) => p.id !== projectId) || [],
169
- };
170
- } else if (!active && tasksNeedingProject.includes(t.id)) {
171
- // Add the project
172
- return {
173
- ...t,
174
- projects: [
175
- ...(t.projects || []),
176
- project || { id: projectId, name: 'Unknown', status: 'unknown' },
177
- ],
178
- };
179
- }
180
- return t;
181
- });
182
- });
183
-
184
- // CRITICAL: Also update the individual task cache if taskId is provided
185
- // This ensures the chip menu's task cache stays in sync with the board cache
186
- if (taskId) {
187
- queryClient.setQueryData(['task', taskId], (old: Task | undefined) => {
188
- if (!old) return old;
189
- if (active && tasksToRemoveFrom.includes(taskId)) {
190
- // Remove the project
191
- return {
192
- ...old,
193
- projects:
194
- old.projects?.filter((p: any) => p.id !== projectId) || [],
195
- };
196
- } else if (!active && tasksNeedingProject.includes(taskId)) {
197
- // Add the project
199
+ for (const tid of active ? tasksToRemoveFrom : tasksNeedingProject) {
200
+ patchTaskInVisibleCaches({
201
+ queryClient,
202
+ boardId,
203
+ taskId: tid,
204
+ updater: (cachedTask) => {
205
+ if (active) {
206
+ return {
207
+ ...cachedTask,
208
+ projects:
209
+ cachedTask.projects?.filter(
210
+ (entry) => entry.id !== projectId
211
+ ) || [],
212
+ };
213
+ }
214
+
215
+ if (cachedTask.projects?.some((entry) => entry.id === projectId)) {
216
+ return cachedTask;
217
+ }
218
+
198
219
  return {
199
- ...old,
200
- projects: [
201
- ...(old.projects || []),
202
- project || { id: projectId, name: 'Unknown', status: 'unknown' },
203
- ],
220
+ ...cachedTask,
221
+ projects: [...(cachedTask.projects || []), fallbackProject],
204
222
  };
205
- }
206
- return old;
223
+ },
207
224
  });
208
225
  }
209
226
 
@@ -281,34 +298,19 @@ export function useTaskProjectManagement({
281
298
  (taskId) => !succeededTaskIds.includes(taskId)
282
299
  );
283
300
 
284
- if (failedTaskIds.length > 0 && previousTasks) {
285
- const previousTaskMap = new Map(previousTasks.map((t) => [t.id, t]));
286
- queryClient.setQueryData(
287
- ['tasks', boardId],
288
- (current: Task[] | undefined) => {
289
- if (!current) return current;
290
- return current.map((task) => {
291
- if (!failedTaskIds.includes(task.id)) {
292
- return task;
293
- }
294
-
295
- return previousTaskMap.get(task.id) || task;
296
- });
297
- }
298
- );
299
-
300
- if (taskId && failedTaskIds.includes(taskId)) {
301
- const previousTask = previousTaskMap.get(taskId);
302
- if (previousTask) {
303
- queryClient.setQueryData(['task', taskId], previousTask);
304
- }
305
- }
306
- }
301
+ restoreTasksFromVisibleCacheSnapshot({
302
+ queryClient,
303
+ snapshot: cacheSnapshot,
304
+ taskIds: failedTaskIds,
305
+ });
307
306
 
308
307
  // Broadcast relation changes for all affected tasks
309
308
  for (const tid of succeededTaskIds) {
310
309
  broadcast?.('task:relations-changed', { taskId: tid });
311
310
  }
311
+ if (succeededTaskIds.length > 0) {
312
+ getActiveBoardRefresh()?.({ invalidateTasks: false });
313
+ }
312
314
 
313
315
  if (failedTaskIds.length > 0) {
314
316
  toast.warning(
@@ -329,17 +331,7 @@ export function useTaskProjectManagement({
329
331
  // Don't auto-clear selection - let user manually clear with "Clear" button
330
332
  } catch (e: any) {
331
333
  // Rollback on error
332
- if (previousTasks) {
333
- queryClient.setQueryData(['tasks', boardId], previousTasks);
334
- if (taskId) {
335
- const previousTask = previousTasks.find(
336
- (entry) => entry.id === taskId
337
- );
338
- if (previousTask) {
339
- queryClient.setQueryData(['task', taskId], previousTask);
340
- }
341
- }
342
- }
334
+ restoreVisibleTaskCaches(queryClient, cacheSnapshot);
343
335
  console.error('Failed to toggle project:', e);
344
336
  toast.error('Error', {
345
337
  description: 'Failed to update project. Please try again.',
@@ -355,52 +347,55 @@ export function useTaskProjectManagement({
355
347
  const newProject =
356
348
  await createProjectMutation.mutateAsync(newProjectName);
357
349
  const canonicalTaskId = taskId ?? task.id;
350
+ const projectForCache = {
351
+ id: newProject.id,
352
+ name: newProject.name,
353
+ status: newProject.status ?? 'unknown',
354
+ };
358
355
 
359
356
  // Auto-apply the newly created project to this task
360
357
  let linkSucceeded = false;
361
- let previousTasks: Task[] | undefined;
358
+ let cacheSnapshot:
359
+ | ReturnType<typeof snapshotVisibleTaskCaches>
360
+ | undefined;
362
361
  try {
363
362
  // Cancel any outgoing refetches
364
363
  await queryClient.cancelQueries({ queryKey: ['tasks', boardId] });
364
+ await queryClient.cancelQueries({ queryKey: ['tasks-full', boardId] });
365
365
 
366
366
  // Snapshot the previous value
367
- previousTasks = queryClient.getQueryData(['tasks', boardId]);
367
+ cacheSnapshot = snapshotVisibleTaskCaches(queryClient, boardId, [
368
+ canonicalTaskId,
369
+ ]);
368
370
 
369
371
  // Optimistically update the cache
370
- queryClient.setQueryData(
371
- ['tasks', boardId],
372
- (old: Task[] | undefined) => {
373
- if (!old) return old;
374
- return old.map((t) => {
375
- if (t.id === canonicalTaskId) {
376
- return {
377
- ...t,
378
- projects: [...(t.projects || []), newProject],
379
- };
380
- }
381
- return t;
382
- });
383
- }
384
- );
385
-
386
- // CRITICAL: Also update individual task cache if taskId is provided
387
- if (canonicalTaskId) {
388
- queryClient.setQueryData(
389
- ['task', canonicalTaskId],
390
- (old: Task | undefined) => {
391
- if (!old) return old;
392
- return {
393
- ...old,
394
- projects: [...(old.projects || []), newProject],
395
- };
372
+ patchTaskInVisibleCaches({
373
+ queryClient,
374
+ boardId,
375
+ taskId: canonicalTaskId,
376
+ updater: (cachedTask) => {
377
+ if (
378
+ cachedTask.projects?.some(
379
+ (project) => project.id === newProject.id
380
+ )
381
+ ) {
382
+ return cachedTask;
396
383
  }
397
- );
398
- }
384
+
385
+ return {
386
+ ...cachedTask,
387
+ projects: [...(cachedTask.projects || []), projectForCache],
388
+ };
389
+ },
390
+ });
399
391
 
400
392
  const taskState =
401
- (queryClient.getQueryData(['task', canonicalTaskId]) as
402
- | Task
403
- | undefined) ?? task;
393
+ getTaskFromVisibleCaches({
394
+ queryClient,
395
+ boardId,
396
+ taskId: canonicalTaskId,
397
+ fallback: task,
398
+ }) ?? task;
404
399
  const nextProjectIds = [
405
400
  ...new Set([
406
401
  ...(taskState.projects ?? []).map((entry) => entry.id),
@@ -418,21 +413,19 @@ export function useTaskProjectManagement({
418
413
  );
419
414
  linkSucceeded = true;
420
415
  } catch (applyErr: any) {
421
- queryClient.setQueryData(['tasks', boardId], previousTasks);
416
+ if (cacheSnapshot) {
417
+ restoreVisibleTaskCaches(queryClient, cacheSnapshot);
418
+ }
422
419
  toast.error(
423
420
  'The project was created but could not be attached to the task. Refresh and try manually.'
424
421
  );
425
- if (canonicalTaskId) {
426
- queryClient.invalidateQueries({
427
- queryKey: ['task', canonicalTaskId],
428
- });
429
- }
430
422
  console.error('Failed to auto-apply new project', applyErr);
431
423
  }
432
424
 
433
425
  // Only show success toast and reset form if link succeeded
434
426
  if (linkSucceeded) {
435
427
  broadcast?.('task:relations-changed', { taskId: canonicalTaskId });
428
+ getActiveBoardRefresh()?.({ invalidateTasks: false });
436
429
 
437
430
  // Reset form and close dialog
438
431
  setNewProjectName('');