@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.
- package/CHANGELOG.md +29 -0
- package/package.json +6 -5
- package/src/components/ui/custom/__tests__/settings-dialog-search.test.ts +78 -0
- package/src/components/ui/custom/__tests__/settings-dialog-shell-compile-graph.test.ts +76 -0
- package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +27 -1
- package/src/components/ui/custom/nav-link.test.tsx +165 -0
- package/src/components/ui/custom/nav-link.tsx +69 -11
- package/src/components/ui/custom/navigation.tsx +1 -0
- package/src/components/ui/custom/settings/task-settings.tsx +104 -0
- package/src/components/ui/custom/settings-dialog-search-loader.d.ts +5 -0
- package/src/components/ui/custom/settings-dialog-search-loader.js +3 -0
- package/src/components/ui/custom/settings-dialog-search.ts +75 -0
- package/src/components/ui/custom/settings-dialog-shell.tsx +63 -27
- package/src/components/ui/custom/workspace-select-helpers.ts +23 -0
- package/src/components/ui/custom/workspace-select.tsx +17 -16
- package/src/components/ui/tu-do/boards/__tests__/board-share-dialog.test.tsx +16 -0
- package/src/components/ui/tu-do/boards/__tests__/task-board-form.test.tsx +12 -0
- package/src/components/ui/tu-do/boards/board-share-dialog.tsx +4 -328
- package/src/components/ui/tu-do/boards/board-share-settings-panel.tsx +351 -0
- package/src/components/ui/tu-do/boards/boardId/board-column.tsx +50 -37
- package/src/components/ui/tu-do/boards/boardId/enhanced-task-list.tsx +7 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operation-types.ts +3 -3
- package/src/components/ui/tu-do/boards/boardId/kanban/data/use-bulk-resources.ts +59 -5
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/drag-preview.tsx +20 -1
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +263 -21
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +133 -14
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-deadline-panels.tsx +112 -54
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-skeleton.tsx +8 -2
- package/src/components/ui/tu-do/boards/boardId/kanban.tsx +29 -14
- package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +24 -1
- package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +7 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/measured-task-card.tsx +7 -1
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +20 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +10 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +80 -8
- package/src/components/ui/tu-do/boards/boardId/task-list.tsx +15 -0
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +9 -0
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +9 -0
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-toolbar.tsx +9 -0
- package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +35 -3
- package/src/components/ui/tu-do/boards/form.tsx +1 -1
- package/src/components/ui/tu-do/hooks/__tests__/useTaskLabelManagement.test.tsx +48 -0
- package/src/components/ui/tu-do/hooks/__tests__/useTaskProjectManagement.test.tsx +144 -0
- package/src/components/ui/tu-do/hooks/useTaskDialog.ts +7 -0
- package/src/components/ui/tu-do/hooks/useTaskLabelManagement.ts +115 -106
- package/src/components/ui/tu-do/hooks/useTaskProjectManagement.ts +115 -122
- package/src/components/ui/tu-do/progress/task-progress-import-panel.tsx +60 -0
- package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +156 -0
- package/src/components/ui/tu-do/progress/task-progress-page.tsx +348 -0
- package/src/components/ui/tu-do/progress/task-progress-panels.tsx +301 -0
- package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +26 -0
- package/src/components/ui/tu-do/shared/__tests__/assignee-select.test.tsx +81 -10
- package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +116 -1
- package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +38 -0
- package/src/components/ui/tu-do/shared/__tests__/board-switcher.test.tsx +128 -7
- package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +222 -9
- package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +21 -0
- package/src/components/ui/tu-do/shared/__tests__/task-cache-patches.test.ts +147 -0
- package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +3 -0
- package/src/components/ui/tu-do/shared/assignee-select.tsx +77 -26
- package/src/components/ui/tu-do/shared/board-client.tsx +14 -4
- package/src/components/ui/tu-do/shared/board-header.tsx +8 -1
- package/src/components/ui/tu-do/shared/board-switcher.tsx +70 -38
- package/src/components/ui/tu-do/shared/board-user-presence-avatars.tsx +18 -12
- package/src/components/ui/tu-do/shared/board-views.tsx +49 -69
- package/src/components/ui/tu-do/shared/list-view.tsx +21 -3
- package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +4 -4
- package/src/components/ui/tu-do/shared/task-cache-patches.ts +394 -0
- package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +21 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +5 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +25 -2
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-list-selector.tsx +7 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-data.ts +79 -10
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +76 -77
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-relationships.test.tsx +63 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-relationships.ts +78 -69
- package/src/components/ui/tu-do/shared/task-edit-dialog/personal-overrides-section.tsx +28 -8
- package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/dependencies-section.tsx +14 -3
- package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/parent-section.tsx +6 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/related-section.tsx +6 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/subtasks-section.tsx +6 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/types/task-relationships.types.ts +8 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +8 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.test.tsx +150 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +61 -35
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-relationships-properties.tsx +44 -2
- package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +9 -0
- package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +11 -0
- package/src/components/ui/tu-do/shared/use-progressive-board-loader.ts +2 -0
- package/src/hooks/__tests__/useBoardPresence.test.tsx +191 -0
- package/src/hooks/__tests__/useBoardRealtime.test.tsx +24 -144
- package/src/hooks/useBoardPresence.ts +364 -0
- package/src/hooks/useBoardRealtimeEventHandler.ts +34 -90
- 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
|
|
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
|
|
217
|
-
|
|
218
|
-
);
|
|
221
|
+
const relationInvalidationTimerRef = useRef<ReturnType<
|
|
222
|
+
typeof setTimeout
|
|
223
|
+
> | null>(null);
|
|
219
224
|
|
|
220
|
-
const
|
|
221
|
-
|
|
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]
|
|
233
|
+
`[useBoardRealtime] Reconciling relations for ${taskIds.length} task(s):`,
|
|
229
234
|
taskIds
|
|
230
235
|
);
|
|
231
236
|
}
|
|
232
237
|
|
|
233
|
-
|
|
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 (
|
|
468
|
-
clearTimeout(
|
|
408
|
+
if (relationInvalidationTimerRef.current) {
|
|
409
|
+
clearTimeout(relationInvalidationTimerRef.current);
|
|
469
410
|
}
|
|
470
|
-
|
|
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
|
-
|
|
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 (
|
|
500
|
-
clearTimeout(
|
|
501
|
-
|
|
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?.
|
|
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),
|