@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
@@ -123,6 +123,8 @@ import { TaskShareDialog } from './task-share-dialog';
123
123
  import type { TaskFilters } from './types';
124
124
  import { UnsavedChangesWarningDialog } from './unsaved-changes-warning-dialog';
125
125
 
126
+ type AssigneeMemberSource = 'workspace' | 'board' | 'workspace-and-board';
127
+
126
128
  export {
127
129
  type DialogHeaderInfo,
128
130
  getTaskDialogHeaderInfo,
@@ -149,6 +151,8 @@ export interface TaskEditDialogProps {
149
151
  taskLoadError?: boolean;
150
152
  taskHydrationVersion?: number;
151
153
  isPersonalWorkspace?: boolean;
154
+ canUseBoardAssignees?: boolean;
155
+ assigneeMemberSource?: AssigneeMemberSource;
152
156
  parentTaskId?: string;
153
157
  parentTaskName?: string;
154
158
  pendingRelationship?: PendingRelationship;
@@ -189,6 +193,8 @@ export function TaskEditDialog({
189
193
  taskLoadError = false,
190
194
  taskHydrationVersion = 0,
191
195
  isPersonalWorkspace = false,
196
+ canUseBoardAssignees,
197
+ assigneeMemberSource,
192
198
  parentTaskId,
193
199
  parentTaskName,
194
200
  pendingRelationship,
@@ -494,6 +500,8 @@ export function TaskEditDialog({
494
500
  isOpen,
495
501
  propAvailableLists,
496
502
  taskSearchQuery,
503
+ canUseBoardAssignees: canUseBoardAssignees ?? !isPersonalWorkspace,
504
+ assigneeMemberSource,
497
505
  sharedContext,
498
506
  });
499
507
  const currentList = availableLists?.find(
@@ -2166,6 +2174,7 @@ export function TaskEditDialog({
2166
2174
  selectedAssignees={formState.selectedAssignees}
2167
2175
  isLoading={isLoading}
2168
2176
  isPersonalWorkspace={isPersonalWorkspace}
2177
+ canUseBoardAssignees={canUseBoardAssignees ?? !isPersonalWorkspace}
2169
2178
  totalDuration={formState.totalDuration}
2170
2179
  isSplittable={formState.isSplittable}
2171
2180
  minSplitDurationMinutes={formState.minSplitDurationMinutes}
@@ -41,6 +41,7 @@ import {
41
41
  TaskSchedulingMenu,
42
42
  } from '../boards/boardId/menus';
43
43
  import { useTaskDialog } from '../hooks/useTaskDialog';
44
+ import type { TaskAssigneeMemberSource } from '../providers/task-dialog-provider';
44
45
  import { useTasksHref } from '../tasks-route-context';
45
46
 
46
47
  interface TaskRowActionsMenuProps {
@@ -49,6 +50,8 @@ interface TaskRowActionsMenuProps {
49
50
  workspaceId: string;
50
51
  lists: TaskList[];
51
52
  isPersonalWorkspace?: boolean;
53
+ canUseBoardAssignees?: boolean;
54
+ assigneeMemberSource?: TaskAssigneeMemberSource;
52
55
  onUpdate: () => void;
53
56
  open?: boolean;
54
57
  onOpenChange?: (open: boolean) => void;
@@ -78,6 +81,8 @@ export function TaskRowActionsMenu({
78
81
  workspaceId,
79
82
  lists,
80
83
  isPersonalWorkspace = false,
84
+ canUseBoardAssignees,
85
+ assigneeMemberSource,
81
86
  onUpdate,
82
87
  open,
83
88
  onOpenChange,
@@ -171,6 +176,12 @@ export function TaskRowActionsMenu({
171
176
  taskWorkspacePersonal: task.source_workspace_id
172
177
  ? false
173
178
  : isPersonalWorkspace,
179
+ canUseBoardAssignees:
180
+ canUseBoardAssignees ??
181
+ (task.source_workspace_id ? true : !isPersonalWorkspace),
182
+ assigneeMemberSource: task.source_workspace_id
183
+ ? 'workspace'
184
+ : assigneeMemberSource,
174
185
  }
175
186
  );
176
187
  setMenuOpen(false);
@@ -93,6 +93,7 @@ export function useProgressiveBoardLoader(
93
93
  offset: page * PAGE_SIZE,
94
94
  includeCount: true,
95
95
  ...options,
96
+ includeRelationshipSummary: false,
96
97
  });
97
98
  const tasks = payload.tasks ?? [];
98
99
  const loadedThrough = page * PAGE_SIZE + tasks.length;
@@ -206,6 +207,7 @@ export function useProgressiveBoardLoader(
206
207
  offset: page * PAGE_SIZE,
207
208
  includeCount: true,
208
209
  ...listOptionsRef.current[listId],
210
+ includeRelationshipSummary: false,
209
211
  })
210
212
  )
211
213
  );
@@ -0,0 +1,191 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+
5
+ import { act, renderHook } from '@testing-library/react';
6
+ import { getCurrentUserProfile } from '@tuturuuu/internal-api/users';
7
+ import { createClient } from '@tuturuuu/supabase/next/client';
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
9
+ import { useBoardPresence } from '../useBoardPresence';
10
+
11
+ type PresenceListener = () => void;
12
+
13
+ type MockChannel = {
14
+ on: ReturnType<typeof vi.fn>;
15
+ presenceState: ReturnType<typeof vi.fn>;
16
+ subscribe: ReturnType<typeof vi.fn>;
17
+ track: ReturnType<typeof vi.fn>;
18
+ untrack: ReturnType<typeof vi.fn>;
19
+ };
20
+
21
+ type MockSupabaseClient = {
22
+ auth: {
23
+ getUser: ReturnType<typeof vi.fn>;
24
+ };
25
+ channel: ReturnType<typeof vi.fn>;
26
+ removeChannel: ReturnType<typeof vi.fn>;
27
+ };
28
+
29
+ type MockCreateClientFn = {
30
+ (): MockSupabaseClient;
31
+ mockReturnValue: (value: MockSupabaseClient) => void;
32
+ };
33
+
34
+ vi.mock('@tuturuuu/supabase/next/client', () => ({
35
+ createClient: vi.fn(),
36
+ }));
37
+
38
+ vi.mock('@tuturuuu/internal-api/users', () => ({
39
+ getCurrentUserProfile: vi.fn(),
40
+ }));
41
+
42
+ vi.mock('@tuturuuu/utils/constants', () => ({
43
+ DEV_MODE: false,
44
+ }));
45
+
46
+ describe('useBoardPresence', () => {
47
+ let mockChannel: MockChannel;
48
+ let mockClient: MockSupabaseClient;
49
+ let presenceListeners: Map<string, PresenceListener>;
50
+
51
+ beforeEach(() => {
52
+ presenceListeners = new Map();
53
+
54
+ mockChannel = {
55
+ on: vi.fn(
56
+ (
57
+ type: string,
58
+ config: { event?: string },
59
+ callback: PresenceListener
60
+ ) => {
61
+ if (type === 'presence' && config.event) {
62
+ presenceListeners.set(config.event, callback);
63
+ }
64
+ return mockChannel;
65
+ }
66
+ ),
67
+ presenceState: vi.fn(() => ({})),
68
+ subscribe: vi.fn((callback?: (status: string) => void) => {
69
+ callback?.('SUBSCRIBED');
70
+ return mockChannel;
71
+ }),
72
+ track: vi.fn(() => Promise.resolve('ok')),
73
+ untrack: vi.fn(() => Promise.resolve('ok')),
74
+ };
75
+
76
+ mockClient = {
77
+ auth: {
78
+ getUser: vi.fn(() =>
79
+ Promise.resolve({
80
+ data: {
81
+ user: {
82
+ email: 'ada.auth@example.com',
83
+ id: 'user-1',
84
+ },
85
+ },
86
+ })
87
+ ),
88
+ },
89
+ channel: vi.fn(() => mockChannel),
90
+ removeChannel: vi.fn(() => Promise.resolve()),
91
+ };
92
+
93
+ (createClient as unknown as MockCreateClientFn).mockReturnValue(mockClient);
94
+ vi.mocked(getCurrentUserProfile).mockResolvedValue({
95
+ avatar_url: 'https://example.com/ada.png',
96
+ created_at: '2026-01-01T00:00:00.000Z',
97
+ default_workspace_id: 'user-1',
98
+ display_name: 'Ada Lovelace',
99
+ email: 'ada@example.com',
100
+ full_name: 'Ada Lovelace',
101
+ id: 'user-1',
102
+ new_email: null,
103
+ });
104
+ });
105
+
106
+ afterEach(() => {
107
+ vi.clearAllMocks();
108
+ });
109
+
110
+ it('tracks sanitized profile data on a private board realtime presence channel', async () => {
111
+ const { result } = renderHook(() => useBoardPresence('board-1'));
112
+
113
+ await act(async () => {
114
+ await (result.current.updateLocation(
115
+ { boardId: 'board-1', type: 'board' },
116
+ { listStatusFilter: 'active' }
117
+ ) as unknown as Promise<void>);
118
+ });
119
+
120
+ expect(mockClient.channel).toHaveBeenCalledWith('board-realtime-board-1', {
121
+ config: {
122
+ presence: {
123
+ enabled: true,
124
+ key: 'user-1',
125
+ },
126
+ private: true,
127
+ },
128
+ });
129
+ expect(mockChannel.track).toHaveBeenCalledWith(
130
+ expect.objectContaining({
131
+ away: false,
132
+ location: { boardId: 'board-1', type: 'board' },
133
+ metadata: { listStatusFilter: 'active' },
134
+ session_id: expect.any(String),
135
+ user: {
136
+ avatar_url: 'https://example.com/ada.png',
137
+ display_name: 'Ada Lovelace',
138
+ email: 'ada@example.com',
139
+ id: 'user-1',
140
+ },
141
+ })
142
+ );
143
+ });
144
+
145
+ it('does not create a channel while disabled', async () => {
146
+ const { result } = renderHook(() =>
147
+ useBoardPresence('board-1', { enabled: false })
148
+ );
149
+
150
+ await act(async () => {
151
+ await (result.current.updateLocation({
152
+ boardId: 'board-1',
153
+ type: 'board',
154
+ }) as unknown as Promise<void>);
155
+ });
156
+
157
+ expect(mockClient.channel).not.toHaveBeenCalled();
158
+ expect(mockChannel.track).not.toHaveBeenCalled();
159
+ });
160
+
161
+ it('exposes board viewers from presence sync events', async () => {
162
+ const viewerPresence = {
163
+ location: { boardId: 'board-1', type: 'board' as const },
164
+ online_at: '2026-01-01T00:00:00.000Z',
165
+ session_id: 'session-2',
166
+ user: {
167
+ avatar_url: null,
168
+ display_name: 'Guest Reviewer',
169
+ email: 'guest@example.com',
170
+ id: 'user-2',
171
+ },
172
+ };
173
+ mockChannel.presenceState.mockReturnValue({
174
+ 'user-2': [viewerPresence],
175
+ });
176
+ const { result } = renderHook(() => useBoardPresence('board-1'));
177
+
178
+ await act(async () => {
179
+ await (result.current.updateLocation({
180
+ boardId: 'board-1',
181
+ type: 'board',
182
+ }) as unknown as Promise<void>);
183
+ });
184
+
185
+ act(() => {
186
+ presenceListeners.get('sync')?.();
187
+ });
188
+
189
+ expect(result.current.getBoardViewers('board-1')).toEqual([viewerPresence]);
190
+ });
191
+ });
@@ -78,6 +78,7 @@ describe('useBoardRealtime', () => {
78
78
  subscribe: ReturnType<typeof vi.fn>;
79
79
  send: ReturnType<typeof vi.fn>;
80
80
  };
81
+ let mockFrom: ReturnType<typeof vi.fn>;
81
82
  let mockRemoveChannel: ReturnType<typeof vi.fn>;
82
83
  let broadcastListeners: Map<string, BroadcastListener>;
83
84
  let subscribeCallback: ((status: string, err?: unknown) => void) | undefined;
@@ -142,92 +143,14 @@ describe('useBoardRealtime', () => {
142
143
  };
143
144
 
144
145
  mockRemoveChannel = vi.fn();
146
+ mockFrom = vi.fn();
145
147
 
146
148
  const mockCreateClient = createClient as unknown as MockCreateClientFn;
147
149
 
148
150
  mockCreateClient.mockReturnValue({
149
151
  channel: vi.fn(() => mockChannel),
150
152
  removeChannel: mockRemoveChannel,
151
- from: vi.fn(() => ({
152
- select: vi.fn(() => ({
153
- in: vi.fn(() =>
154
- Promise.resolve({
155
- data: [
156
- {
157
- id: 'task-1',
158
- assignees: [
159
- {
160
- user: {
161
- id: 'u-1',
162
- display_name: 'User 1',
163
- avatar_url: null,
164
- },
165
- },
166
- ],
167
- labels: [
168
- {
169
- label: {
170
- id: 'l-1',
171
- name: 'Bug',
172
- color: 'red',
173
- created_at: '2025-01-01',
174
- },
175
- },
176
- ],
177
- projects: [
178
- {
179
- project: {
180
- id: 'p-1',
181
- name: 'Project 1',
182
- status: 'active',
183
- },
184
- },
185
- ],
186
- },
187
- ],
188
- error: null,
189
- })
190
- ),
191
- eq: vi.fn(() => ({
192
- single: vi.fn(() =>
193
- Promise.resolve({
194
- data: {
195
- id: 'task-1',
196
- assignees: [
197
- {
198
- user: {
199
- id: 'u-1',
200
- display_name: 'User 1',
201
- avatar_url: null,
202
- },
203
- },
204
- ],
205
- labels: [
206
- {
207
- label: {
208
- id: 'l-1',
209
- name: 'Bug',
210
- color: 'red',
211
- created_at: '2025-01-01',
212
- },
213
- },
214
- ],
215
- projects: [
216
- {
217
- project: {
218
- id: 'p-1',
219
- name: 'Project 1',
220
- status: 'active',
221
- },
222
- },
223
- ],
224
- },
225
- error: null,
226
- })
227
- ),
228
- })),
229
- })),
230
- })),
153
+ from: mockFrom,
231
154
  });
232
155
 
233
156
  vi.clearAllMocks();
@@ -835,11 +758,12 @@ describe('useBoardRealtime', () => {
835
758
  });
836
759
 
837
760
  describe('task:relations-changed event', () => {
838
- it('should fetch relations from DB and merge into cache', async () => {
761
+ it('reconciles relation-bearing queries without refetching visible board caches', async () => {
839
762
  queryClient.setQueryData(
840
763
  ['tasks', 'board-1'],
841
764
  [{ ...mockTaskWithRelations, id: 'task-1' }]
842
765
  );
766
+ const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
843
767
 
844
768
  renderHook(() => useBoardRealtime('board-1', { enabled: true }), {
845
769
  wrapper,
@@ -847,31 +771,29 @@ describe('useBoardRealtime', () => {
847
771
 
848
772
  const listener = broadcastListeners.get('task:relations-changed')!;
849
773
 
850
- // Fire the listener — it debounces for 150ms before fetching
774
+ // Fire the listener — it debounces for 150ms before invalidating caches.
851
775
  await act(async () => {
852
776
  listener({ payload: { taskId: 'task-1' } });
853
777
  });
854
778
 
855
- // Advance past the 150ms receiver debounce and flush microtasks
779
+ // Advance past the 150ms receiver debounce.
856
780
  await act(async () => {
857
781
  vi.advanceTimersByTime(200);
858
- // Allow the async fetch to resolve
859
- await Promise.resolve();
860
- await Promise.resolve();
861
782
  });
862
783
 
863
- const cachedTasks = queryClient.getQueryData<Task[]>([
864
- 'tasks',
865
- 'board-1',
866
- ]);
867
- const task = cachedTasks?.[0];
868
- // The mock returns one assignee, one label, one project
869
- expect(task?.assignees).toHaveLength(1);
870
- expect(task?.labels).toHaveLength(1);
871
- expect(task?.projects).toHaveLength(1);
784
+ expect(mockFrom).not.toHaveBeenCalled();
785
+ expect(invalidateQueriesSpy).not.toHaveBeenCalledWith({
786
+ queryKey: ['tasks', 'board-1'],
787
+ });
788
+ expect(invalidateQueriesSpy).not.toHaveBeenCalledWith({
789
+ queryKey: ['tasks-full', 'board-1'],
790
+ });
791
+ expect(invalidateQueriesSpy).toHaveBeenCalledWith({
792
+ queryKey: ['task-list-counts', 'board-1'],
793
+ });
872
794
  });
873
795
 
874
- it('should merge fetched relations into the full task cache when it exists', async () => {
796
+ it('keeps the full task cache warm when relations change', async () => {
875
797
  queryClient.setQueryData(
876
798
  ['tasks', 'board-1'],
877
799
  [{ ...mockTaskWithRelations, id: 'task-1' }]
@@ -880,6 +802,7 @@ describe('useBoardRealtime', () => {
880
802
  ['tasks-full', 'board-1'],
881
803
  [{ ...mockTaskWithRelations, id: 'task-1' }]
882
804
  );
805
+ const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
883
806
 
884
807
  renderHook(() => useBoardRealtime('board-1', { enabled: true }), {
885
808
  wrapper,
@@ -893,57 +816,14 @@ describe('useBoardRealtime', () => {
893
816
 
894
817
  await act(async () => {
895
818
  vi.advanceTimersByTime(200);
896
- await Promise.resolve();
897
- await Promise.resolve();
898
819
  });
899
820
 
900
- const task = queryClient.getQueryData<Task[]>([
901
- 'tasks-full',
902
- 'board-1',
903
- ])?.[0];
904
- expect(task?.assignees).toHaveLength(1);
905
- expect(task?.labels).toHaveLength(1);
906
- expect(task?.projects).toHaveLength(1);
907
- });
908
-
909
- it('should handle DB fetch errors gracefully', async () => {
910
- queryClient.setQueryData(['tasks', 'board-1'], [mockTaskWithRelations]);
911
-
912
- // Mock a batched fetch error from the .in(...) branch.
913
- (createClient as unknown as MockCreateClientFn).mockReturnValue({
914
- channel: vi.fn(() => mockChannel),
915
- removeChannel: mockRemoveChannel,
916
- from: vi.fn(() => ({
917
- select: vi.fn(() => ({
918
- in: vi.fn(() =>
919
- Promise.resolve({ data: null, error: { message: 'Not found' } })
920
- ),
921
- })),
922
- })),
923
- });
924
-
925
- renderHook(() => useBoardRealtime('board-1', { enabled: true }), {
926
- wrapper,
821
+ expect(invalidateQueriesSpy).not.toHaveBeenCalledWith({
822
+ queryKey: ['tasks-full', 'board-1'],
927
823
  });
928
-
929
- const listener = broadcastListeners.get('task:relations-changed')!;
930
-
931
- await act(async () => {
932
- listener({ payload: { taskId: 'task-1' } });
933
- });
934
-
935
- await act(async () => {
936
- vi.advanceTimersByTime(150);
937
- await Promise.resolve();
938
- await Promise.resolve();
939
- });
940
-
941
- // Cache should remain unchanged
942
- const cachedTasks = queryClient.getQueryData<Task[]>([
943
- 'tasks',
944
- 'board-1',
945
- ]);
946
- expect(cachedTasks?.[0]?.assignees).toEqual([]);
824
+ expect(
825
+ queryClient.getQueryData<Task[]>(['tasks-full', 'board-1'])?.[0]?.id
826
+ ).toBe('task-1');
947
827
  });
948
828
 
949
829
  it('calls onTaskRelationsChange for Supabase relation broadcasts', async () => {