@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
@@ -0,0 +1,364 @@
1
+ 'use client';
2
+
3
+ import { getCurrentUserProfile } from '@tuturuuu/internal-api/users';
4
+ import { createClient } from '@tuturuuu/supabase/next/client';
5
+ import type { RealtimePresenceState } from '@tuturuuu/supabase/next/realtime';
6
+ import type { User } from '@tuturuuu/types/primitives/User';
7
+ import { DEV_MODE } from '@tuturuuu/utils/constants';
8
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
9
+ import type {
10
+ PresenceLocation,
11
+ WorkspacePresenceState,
12
+ } from './use-workspace-presence';
13
+ import { getBoardRealtimeChannelName } from './useBoardRealtime.types';
14
+
15
+ type BoardPresenceChannel = ReturnType<
16
+ ReturnType<typeof createClient>['channel']
17
+ >;
18
+
19
+ export type BoardPresenceState = WorkspacePresenceState;
20
+
21
+ export interface UseBoardPresenceConfig {
22
+ enabled?: boolean;
23
+ }
24
+
25
+ export interface UseBoardPresenceResult {
26
+ presenceState: RealtimePresenceState<BoardPresenceState>;
27
+ currentUserId?: string;
28
+ updateLocation: (
29
+ location: PresenceLocation,
30
+ metadata?: Record<string, any>
31
+ ) => void;
32
+ updateMetadata: (metadata: Record<string, any>) => void;
33
+ getBoardViewers: (boardId: string) => BoardPresenceState[];
34
+ getTaskViewers: (taskId: string) => BoardPresenceState[];
35
+ }
36
+
37
+ const SESSION_STORAGE_SESSION_KEY = 'tuturuuu:board-presence:session-id';
38
+ let fallbackBoardPresenceCounter = 0;
39
+
40
+ function createPresenceSessionId(): string {
41
+ if (
42
+ typeof crypto !== 'undefined' &&
43
+ typeof crypto.randomUUID === 'function'
44
+ ) {
45
+ return crypto.randomUUID();
46
+ }
47
+
48
+ fallbackBoardPresenceCounter += 1;
49
+ return `fallback-${Date.now().toString(36)}-${fallbackBoardPresenceCounter}`;
50
+ }
51
+
52
+ function getOrCreatePresenceSessionId(): string {
53
+ if (typeof window === 'undefined' || typeof sessionStorage === 'undefined') {
54
+ return createPresenceSessionId();
55
+ }
56
+
57
+ try {
58
+ const existing = sessionStorage.getItem(SESSION_STORAGE_SESSION_KEY);
59
+ if (existing) return existing;
60
+
61
+ const next = createPresenceSessionId();
62
+ sessionStorage.setItem(SESSION_STORAGE_SESSION_KEY, next);
63
+ return next;
64
+ } catch {
65
+ return createPresenceSessionId();
66
+ }
67
+ }
68
+
69
+ function buildTrackSignature(
70
+ payload: Omit<BoardPresenceState, 'online_at'>
71
+ ): string {
72
+ return JSON.stringify(payload);
73
+ }
74
+
75
+ export function useBoardPresence(
76
+ boardId: string,
77
+ { enabled = true }: UseBoardPresenceConfig = {}
78
+ ): UseBoardPresenceResult {
79
+ const [presenceState, setPresenceState] = useState<
80
+ RealtimePresenceState<BoardPresenceState>
81
+ >({});
82
+ const [currentUserId, setCurrentUserId] = useState<string>();
83
+
84
+ const channelRef = useRef<BoardPresenceChannel | null>(null);
85
+ const isCleanedUpRef = useRef(false);
86
+ const setupPromiseRef = useRef<Promise<boolean> | null>(null);
87
+ const locationRef = useRef<PresenceLocation>({ type: 'other' });
88
+ const metadataRef = useRef<Record<string, any> | undefined>(undefined);
89
+ const awayRef = useRef(false);
90
+ const userDataRef = useRef<User | null>(null);
91
+ const presenceSessionIdRef = useRef<string>(getOrCreatePresenceSessionId());
92
+ const lastTrackSignatureRef = useRef<string | null>(null);
93
+
94
+ const channelName =
95
+ enabled && boardId ? getBoardRealtimeChannelName(boardId) : '';
96
+
97
+ const ensureChannel = useCallback(async (): Promise<boolean> => {
98
+ if (channelRef.current) return true;
99
+ if (!channelName || isCleanedUpRef.current) return false;
100
+ if (setupPromiseRef.current) return setupPromiseRef.current;
101
+
102
+ const promise = (async () => {
103
+ const supabase = createClient();
104
+
105
+ try {
106
+ const {
107
+ data: { user },
108
+ } = await supabase.auth.getUser();
109
+
110
+ if (!user?.id || isCleanedUpRef.current) return false;
111
+
112
+ if (!userDataRef.current) {
113
+ const profile = await getCurrentUserProfile().catch((error) => {
114
+ if (DEV_MODE) {
115
+ console.error('Error fetching board presence user data:', error);
116
+ }
117
+ return null;
118
+ });
119
+
120
+ if (!profile) return false;
121
+
122
+ setCurrentUserId(user.id);
123
+ userDataRef.current = {
124
+ id: user.id,
125
+ display_name: profile.display_name,
126
+ email: profile.email,
127
+ avatar_url: profile.avatar_url,
128
+ };
129
+ }
130
+
131
+ if (isCleanedUpRef.current) return false;
132
+
133
+ const channel = supabase.channel(channelName, {
134
+ config: {
135
+ presence: {
136
+ enabled: true,
137
+ key: user.id,
138
+ },
139
+ private: true,
140
+ },
141
+ });
142
+ channelRef.current = channel;
143
+
144
+ return new Promise<boolean>((resolve) => {
145
+ channel
146
+ .on('presence', { event: 'sync' }, () => {
147
+ if (isCleanedUpRef.current) return;
148
+ const nextState =
149
+ channel.presenceState() as RealtimePresenceState<BoardPresenceState>;
150
+ setPresenceState({ ...nextState });
151
+ })
152
+ .on('presence', { event: 'join' }, ({ key }) => {
153
+ if (DEV_MODE) {
154
+ console.log('Board presence join:', key);
155
+ }
156
+ })
157
+ .on('presence', { event: 'leave' }, ({ key }) => {
158
+ if (DEV_MODE) {
159
+ console.log('Board presence leave:', key);
160
+ }
161
+ })
162
+ .subscribe((status) => {
163
+ if (DEV_MODE) {
164
+ console.log('Board presence status:', status);
165
+ }
166
+
167
+ if (status === 'SUBSCRIBED') {
168
+ resolve(true);
169
+ return;
170
+ }
171
+
172
+ if (
173
+ status === 'CHANNEL_ERROR' ||
174
+ status === 'TIMED_OUT' ||
175
+ status === 'CLOSED'
176
+ ) {
177
+ const deadChannel = channelRef.current;
178
+ channelRef.current = null;
179
+ setupPromiseRef.current = null;
180
+ lastTrackSignatureRef.current = null;
181
+ if (deadChannel) {
182
+ supabase.removeChannel(deadChannel).catch(() => {});
183
+ }
184
+ resolve(false);
185
+ }
186
+ });
187
+ });
188
+ } catch (error) {
189
+ if (DEV_MODE) {
190
+ console.error('Error setting up board presence:', error);
191
+ }
192
+ channelRef.current = null;
193
+ setupPromiseRef.current = null;
194
+ lastTrackSignatureRef.current = null;
195
+ return false;
196
+ }
197
+ })();
198
+
199
+ setupPromiseRef.current = promise;
200
+ return promise;
201
+ }, [channelName]);
202
+
203
+ useEffect(() => {
204
+ if (!channelName) return;
205
+ isCleanedUpRef.current = false;
206
+
207
+ return () => {
208
+ isCleanedUpRef.current = true;
209
+ setupPromiseRef.current = null;
210
+ if (channelRef.current) {
211
+ channelRef.current.untrack?.().catch(() => {});
212
+ createClient().removeChannel(channelRef.current);
213
+ channelRef.current = null;
214
+ lastTrackSignatureRef.current = null;
215
+ }
216
+ };
217
+ }, [channelName]);
218
+
219
+ const trackPresence = useCallback(async () => {
220
+ if (isCleanedUpRef.current) return;
221
+
222
+ const ready = await ensureChannel();
223
+ if (
224
+ !ready ||
225
+ !channelRef.current ||
226
+ !userDataRef.current ||
227
+ isCleanedUpRef.current
228
+ ) {
229
+ return;
230
+ }
231
+
232
+ try {
233
+ const payload: Omit<BoardPresenceState, 'online_at'> = {
234
+ user: userDataRef.current,
235
+ session_id: presenceSessionIdRef.current,
236
+ location: locationRef.current,
237
+ away: awayRef.current,
238
+ metadata: metadataRef.current,
239
+ };
240
+ const nextSignature = buildTrackSignature(payload);
241
+ if (nextSignature === lastTrackSignatureRef.current) return;
242
+
243
+ await channelRef.current.track({
244
+ ...payload,
245
+ online_at: new Date().toISOString(),
246
+ });
247
+ lastTrackSignatureRef.current = nextSignature;
248
+ } catch (error) {
249
+ if (DEV_MODE) {
250
+ console.error('Error tracking board presence:', error);
251
+ }
252
+ }
253
+ }, [ensureChannel]);
254
+
255
+ const updateLocation = useCallback(
256
+ async (location: PresenceLocation, metadata?: Record<string, any>) => {
257
+ locationRef.current = location;
258
+ if (metadata !== undefined) metadataRef.current = metadata;
259
+ await trackPresence();
260
+ },
261
+ [trackPresence]
262
+ );
263
+
264
+ const updateMetadata = useCallback(
265
+ async (metadata: Record<string, any>) => {
266
+ metadataRef.current = metadata;
267
+ await trackPresence();
268
+ },
269
+ [trackPresence]
270
+ );
271
+
272
+ useEffect(() => {
273
+ if (!channelName) return;
274
+
275
+ const handleVisibilityChange = () => {
276
+ const isAway =
277
+ typeof document !== 'undefined' &&
278
+ document.visibilityState === 'hidden';
279
+ if (awayRef.current === isAway) return;
280
+
281
+ awayRef.current = isAway;
282
+ if (channelRef.current) {
283
+ void trackPresence();
284
+ }
285
+ };
286
+
287
+ document.addEventListener('visibilitychange', handleVisibilityChange);
288
+ return () =>
289
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
290
+ }, [channelName, trackPresence]);
291
+
292
+ const allPresences = useMemo(() => {
293
+ const latestBySession = new Map<string, BoardPresenceState>();
294
+
295
+ for (const [presenceKey, presences] of Object.entries(presenceState)) {
296
+ for (const presence of presences) {
297
+ if (!presence) continue;
298
+
299
+ const userId = presence.user?.id || presenceKey;
300
+ if (!userId) continue;
301
+
302
+ const sessionId =
303
+ presence.session_id ||
304
+ (presence as { presence_ref?: string }).presence_ref ||
305
+ `${userId}:${presenceKey}`;
306
+ const dedupeKey = `${userId}:${sessionId}`;
307
+
308
+ const existing = latestBySession.get(dedupeKey);
309
+ if (!existing) {
310
+ latestBySession.set(dedupeKey, presence);
311
+ continue;
312
+ }
313
+
314
+ const existingTimestamp = Date.parse(existing.online_at);
315
+ const nextTimestamp = Date.parse(presence.online_at);
316
+ const shouldReplace = Number.isFinite(nextTimestamp)
317
+ ? !Number.isFinite(existingTimestamp) ||
318
+ nextTimestamp >= existingTimestamp
319
+ : !Number.isFinite(existingTimestamp);
320
+
321
+ if (shouldReplace) {
322
+ latestBySession.set(dedupeKey, presence);
323
+ }
324
+ }
325
+ }
326
+
327
+ return Array.from(latestBySession.values());
328
+ }, [presenceState]);
329
+
330
+ const getBoardViewers = useCallback(
331
+ (viewerBoardId: string) =>
332
+ allPresences.filter(
333
+ (presence) =>
334
+ presence.location?.type === 'board' &&
335
+ presence.location?.boardId === viewerBoardId
336
+ ),
337
+ [allPresences]
338
+ );
339
+
340
+ const getTaskViewers = useCallback(
341
+ (taskId: string) =>
342
+ allPresences.filter((presence) => presence.location?.taskId === taskId),
343
+ [allPresences]
344
+ );
345
+
346
+ return useMemo(
347
+ () => ({
348
+ presenceState,
349
+ currentUserId,
350
+ updateLocation,
351
+ updateMetadata,
352
+ getBoardViewers,
353
+ getTaskViewers,
354
+ }),
355
+ [
356
+ presenceState,
357
+ currentUserId,
358
+ updateLocation,
359
+ updateMetadata,
360
+ getBoardViewers,
361
+ getTaskViewers,
362
+ ]
363
+ );
364
+ }
@@ -1,18 +1,10 @@
1
1
  import type { QueryClient } from '@tanstack/react-query';
2
- import { createClient } from '@tuturuuu/supabase/next/client';
3
2
  import type { Task } from '@tuturuuu/types/primitives/Task';
4
3
  import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
5
4
  import { DEV_MODE } from '@tuturuuu/utils/constants';
6
5
  import { useCallback, useEffect, useRef } from 'react';
7
6
  import type { BoardRealtimePayload } from './useBoardRealtime.types';
8
7
 
9
- type TaskRelationRow = {
10
- id: string;
11
- assignees?: Array<{ user: NonNullable<Task['assignees']>[number] | null }>;
12
- labels?: Array<{ label: NonNullable<Task['labels']>[number] | null }>;
13
- projects?: Array<{ project: NonNullable<Task['projects']>[number] | null }>;
14
- };
15
-
16
8
  type CallbackRef<T> = {
17
9
  current: T | undefined;
18
10
  };
@@ -30,9 +22,6 @@ type UseBoardRealtimeEventHandlerOptions = {
30
22
  onTaskRelationsChangeRef?: CallbackRef<(taskIds: string[]) => void>;
31
23
  };
32
24
 
33
- const isDefined = <T>(value: T | null | undefined): value is T =>
34
- value !== null && value !== undefined;
35
-
36
25
  function mergeRealtimeTask(
37
26
  old: Task[] | undefined,
38
27
  taskData: Partial<Task> & { id: string }
@@ -202,6 +191,21 @@ function invalidateTaskMembershipQueries(
202
191
  void queryClient.invalidateQueries({ queryKey: ['my-completed-tasks'] });
203
192
  }
204
193
 
194
+ function invalidateTaskRelationQueries(
195
+ queryClient: QueryClient,
196
+ boardId: string,
197
+ taskIds: string[]
198
+ ) {
199
+ void queryClient.invalidateQueries({
200
+ predicate: (query) =>
201
+ Array.isArray(query.queryKey) &&
202
+ query.queryKey[0] === 'workspaceTask' &&
203
+ typeof query.queryKey[2] === 'string' &&
204
+ taskIds.includes(query.queryKey[2]),
205
+ });
206
+ invalidateTaskMembershipQueries(queryClient, boardId);
207
+ }
208
+
205
209
  export function useBoardRealtimeEventHandler({
206
210
  boardId,
207
211
  queryClient,
@@ -211,90 +215,27 @@ export function useBoardRealtimeEventHandler({
211
215
  onTaskRelationsChangeRef,
212
216
  }: UseBoardRealtimeEventHandlerOptions) {
213
217
  // Collects task IDs from task:relations-changed events over 150ms
214
- // and batch-fetches all relations in one query.
218
+ // and reconciles relation-bearing queries together. Visible board task arrays
219
+ // stay patched in place by the sender and by board background revalidation.
215
220
  const pendingRelationIdsRef = useRef<Set<string>>(new Set());
216
- const relationFetchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
217
- null
218
- );
221
+ const relationInvalidationTimerRef = useRef<ReturnType<
222
+ typeof setTimeout
223
+ > | null>(null);
219
224
 
220
- const fetchBatchedRelations = useCallback(async () => {
221
- relationFetchTimerRef.current = null;
225
+ const invalidateBatchedRelations = useCallback(() => {
226
+ relationInvalidationTimerRef.current = null;
222
227
  const taskIds = [...pendingRelationIdsRef.current];
223
228
  pendingRelationIdsRef.current.clear();
224
229
  if (taskIds.length === 0) return;
225
230
 
226
231
  if (DEV_MODE) {
227
232
  console.log(
228
- `[useBoardRealtime] Batch-fetching relations for ${taskIds.length} task(s):`,
233
+ `[useBoardRealtime] Reconciling relations for ${taskIds.length} task(s):`,
229
234
  taskIds
230
235
  );
231
236
  }
232
237
 
233
- try {
234
- const supabase = createClient();
235
- const { data, error } = await supabase
236
- .from('tasks')
237
- .select(
238
- `
239
- id,
240
- assignees:task_assignees(
241
- user:users(id, display_name, avatar_url)
242
- ),
243
- labels:task_labels(
244
- label:workspace_task_labels(id, name, color, created_at)
245
- ),
246
- projects:task_project_tasks(
247
- project:task_projects(id, name, status)
248
- )
249
- `
250
- )
251
- .in('id', taskIds);
252
-
253
- if (error || !data) {
254
- if (DEV_MODE) {
255
- console.error(
256
- '[useBoardRealtime] Failed to batch-fetch relations:',
257
- error
258
- );
259
- }
260
- return;
261
- }
262
-
263
- const relationsMap = new Map<
264
- string,
265
- {
266
- assignees: NonNullable<Task['assignees']>;
267
- labels: NonNullable<Task['labels']>;
268
- projects: NonNullable<Task['projects']>;
269
- }
270
- >();
271
- for (const d of data as TaskRelationRow[]) {
272
- relationsMap.set(d.id, {
273
- assignees: d.assignees?.map((a) => a.user).filter(isDefined) || [],
274
- labels: d.labels?.map((l) => l.label).filter(isDefined) || [],
275
- projects: d.projects?.map((p) => p.project).filter(isDefined) || [],
276
- });
277
- }
278
-
279
- updateBoardTaskCaches(queryClient, boardId, (old) => {
280
- if (!old) return old;
281
- return old.map((task) => {
282
- const relations = relationsMap.get(task.id);
283
- return relations ? { ...task, ...relations } : task;
284
- });
285
- });
286
- for (const [taskId, relations] of relationsMap.entries()) {
287
- patchWorkspaceTaskCaches(queryClient, { id: taskId, ...relations });
288
- patchMyTasksCaches(queryClient, { id: taskId, ...relations });
289
- }
290
- } catch (err) {
291
- if (DEV_MODE) {
292
- console.error(
293
- '[useBoardRealtime] Error batch-fetching relations:',
294
- err
295
- );
296
- }
297
- }
238
+ invalidateTaskRelationQueries(queryClient, boardId, taskIds);
298
239
  }, [boardId, queryClient]);
299
240
 
300
241
  const handleBoardRealtimeEvent = useCallback(
@@ -464,10 +405,13 @@ export function useBoardRealtimeEventHandler({
464
405
  onTaskRelationsChangeRef?.current?.(ids);
465
406
  }
466
407
 
467
- if (relationFetchTimerRef.current) {
468
- clearTimeout(relationFetchTimerRef.current);
408
+ if (relationInvalidationTimerRef.current) {
409
+ clearTimeout(relationInvalidationTimerRef.current);
469
410
  }
470
- relationFetchTimerRef.current = setTimeout(fetchBatchedRelations, 150);
411
+ relationInvalidationTimerRef.current = setTimeout(
412
+ invalidateBatchedRelations,
413
+ 150
414
+ );
471
415
  return;
472
416
  }
473
417
 
@@ -485,7 +429,7 @@ export function useBoardRealtimeEventHandler({
485
429
  },
486
430
  [
487
431
  boardId,
488
- fetchBatchedRelations,
432
+ invalidateBatchedRelations,
489
433
  onListChangeRef,
490
434
  onTaskRelationsChangeRef,
491
435
  onTaskChangeRef,
@@ -496,9 +440,9 @@ export function useBoardRealtimeEventHandler({
496
440
 
497
441
  useEffect(() => {
498
442
  return () => {
499
- if (relationFetchTimerRef.current) {
500
- clearTimeout(relationFetchTimerRef.current);
501
- relationFetchTimerRef.current = null;
443
+ if (relationInvalidationTimerRef.current) {
444
+ clearTimeout(relationInvalidationTimerRef.current);
445
+ relationInvalidationTimerRef.current = null;
502
446
  }
503
447
  pendingRelationIdsRef.current.clear();
504
448
  };
@@ -403,13 +403,9 @@ export async function fetchWorkspaceSummaries({
403
403
 
404
404
  guestWorkspaceById.set(workspaceId, {
405
405
  id: workspaceId,
406
- name: workspace?.personal
407
- ? displayLabel || workspace?.name || 'Personal'
408
- : workspace?.name || 'Untitled',
406
+ name: workspace?.name || (workspace?.personal ? 'Personal' : 'Untitled'),
409
407
  personal: workspace?.personal ?? false,
410
- avatar_url: workspace?.personal
411
- ? userAvatarUrl || workspace?.avatar_url || null
412
- : workspace?.avatar_url || null,
408
+ avatar_url: workspace?.avatar_url || null,
413
409
  logo_url: workspace?.logo_url || null,
414
410
  created_by_me: workspace?.creator_id === userId,
415
411
  tier: resolveWorkspaceTier(workspace ?? {}, productTiersById),