@tuturuuu/ui 0.5.0 → 0.6.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 +41 -34
- package/src/components/ui/currency-input.tsx +65 -23
- package/src/components/ui/custom/__tests__/sidebar-context.test.tsx +64 -0
- package/src/components/ui/custom/__tests__/sidebar-remote-behavior-bridge.test.tsx +109 -0
- package/src/components/ui/custom/combobox.test.tsx +141 -0
- package/src/components/ui/custom/combobox.tsx +105 -36
- package/src/components/ui/custom/settings/task-settings.tsx +50 -0
- package/src/components/ui/custom/settings/task-sound-settings.test.tsx +21 -1
- package/src/components/ui/custom/sidebar-context.tsx +68 -6
- package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +21 -2
- package/src/components/ui/finance/finance-layout.tsx +2 -4
- package/src/components/ui/finance/shared/balance-mode-toggle.tsx +35 -0
- package/src/components/ui/finance/shared/finance-layout-controls.tsx +43 -0
- package/src/components/ui/finance/shared/quick-actions.tsx +14 -6
- package/src/components/ui/finance/shared/use-finance-balance-mode.ts +72 -0
- package/src/components/ui/finance/shared/wallet-balance-mode.test.ts +66 -0
- package/src/components/ui/finance/shared/wallet-balance-mode.ts +42 -0
- package/src/components/ui/finance/transactions/form-types.ts +23 -0
- package/src/components/ui/finance/transactions/form.tsx +81 -22
- package/src/components/ui/finance/transactions/wallet-filter.tsx +21 -2
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +73 -26
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-history-dialog.tsx +617 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-panel.tsx +2 -1
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-sections.tsx +4 -4
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +298 -34
- package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +219 -46
- package/src/components/ui/finance/wallets/columns-rendering.test.tsx +125 -0
- package/src/components/ui/finance/wallets/columns.test.ts +56 -0
- package/src/components/ui/finance/wallets/columns.tsx +196 -43
- package/src/components/ui/finance/wallets/form.test.tsx +79 -14
- package/src/components/ui/finance/wallets/form.tsx +41 -197
- package/src/components/ui/finance/wallets/query-invalidation.ts +1 -0
- package/src/components/ui/finance/wallets/wallet-basics-fields.tsx +141 -0
- package/src/components/ui/finance/wallets/wallet-credit-fields.tsx +136 -0
- package/src/components/ui/finance/wallets/walletId/credit-wallet-summary.tsx +143 -68
- package/src/components/ui/finance/wallets/walletId/wallet-details-actions.test.tsx +105 -0
- package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +120 -16
- package/src/components/ui/finance/wallets/walletId/wallet-details-amount.test.tsx +64 -0
- package/src/components/ui/finance/wallets/walletId/wallet-details-amount.tsx +226 -6
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +64 -2
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +42 -35
- package/src/components/ui/finance/wallets/wallets-data-table.test.tsx +171 -0
- package/src/components/ui/finance/wallets/wallets-data-table.tsx +132 -29
- package/src/components/ui/finance/wallets/wallets-page.test.tsx +111 -37
- package/src/components/ui/finance/wallets/wallets-page.tsx +38 -78
- package/src/components/ui/storefront/accent-button.tsx +33 -0
- package/src/components/ui/storefront/cart-summary.tsx +140 -0
- package/src/components/ui/storefront/empty-listings.tsx +32 -0
- package/src/components/ui/storefront/hero-panel.tsx +70 -0
- package/src/components/ui/storefront/image-panel.tsx +40 -0
- package/src/components/ui/storefront/index.ts +12 -0
- package/src/components/ui/storefront/listing-card.tsx +129 -0
- package/src/components/ui/storefront/storefront-surface.test.tsx +85 -0
- package/src/components/ui/storefront/storefront-surface.tsx +235 -0
- package/src/components/ui/storefront/types.ts +99 -0
- package/src/components/ui/storefront/utils.ts +90 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +134 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +127 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +17 -42
- package/src/components/ui/tu-do/boards/boardId/timeline-board-open-task.test.tsx +164 -0
- package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +25 -16
- package/src/components/ui/tu-do/hooks/useTaskDialog.ts +15 -1
- package/src/components/ui/tu-do/my-tasks/use-my-tasks-state.ts +2 -0
- package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +114 -7
- package/src/components/ui/tu-do/providers/__tests__/task-dialog-provider.test.tsx +217 -5
- package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +180 -35
- package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +222 -26
- package/src/components/ui/tu-do/shared/board-client.tsx +1 -3
- package/src/components/ui/tu-do/shared/list-view-context-menu.test.tsx +55 -2
- package/src/components/ui/tu-do/shared/list-view.tsx +23 -16
- package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +93 -76
- package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +11 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +128 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +104 -69
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.test.tsx +129 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.tsx +358 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +1 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +6 -2
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.test.tsx +17 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.tsx +151 -111
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-form-reset.ts +18 -2
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-realtime-sync.ts +1 -2
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +5 -3
- package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +584 -53
- package/src/hooks/useBoardRealtime.ts +54 -1
- package/src/hooks/useBoardRealtimeEventHandler.ts +169 -4
- package/src/hooks/useTaskUserRealtime.ts +338 -0
|
@@ -223,6 +223,18 @@ export function useBoardRealtime(
|
|
|
223
223
|
payload as BoardRealtimePayload
|
|
224
224
|
);
|
|
225
225
|
})
|
|
226
|
+
.on('broadcast', { event: 'task:upsert:batch' }, ({ payload }) => {
|
|
227
|
+
handleBoardRealtimeEvent(
|
|
228
|
+
'task:upsert:batch',
|
|
229
|
+
payload as BoardRealtimePayload
|
|
230
|
+
);
|
|
231
|
+
})
|
|
232
|
+
.on('broadcast', { event: 'task:delete:batch' }, ({ payload }) => {
|
|
233
|
+
handleBoardRealtimeEvent(
|
|
234
|
+
'task:delete:batch',
|
|
235
|
+
payload as BoardRealtimePayload
|
|
236
|
+
);
|
|
237
|
+
})
|
|
226
238
|
.on('broadcast', { event: 'list:upsert' }, ({ payload }) => {
|
|
227
239
|
handleBoardRealtimeEvent(
|
|
228
240
|
'list:upsert',
|
|
@@ -235,6 +247,18 @@ export function useBoardRealtime(
|
|
|
235
247
|
payload as BoardRealtimePayload
|
|
236
248
|
);
|
|
237
249
|
})
|
|
250
|
+
.on('broadcast', { event: 'list:upsert:batch' }, ({ payload }) => {
|
|
251
|
+
handleBoardRealtimeEvent(
|
|
252
|
+
'list:upsert:batch',
|
|
253
|
+
payload as BoardRealtimePayload
|
|
254
|
+
);
|
|
255
|
+
})
|
|
256
|
+
.on('broadcast', { event: 'list:delete:batch' }, ({ payload }) => {
|
|
257
|
+
handleBoardRealtimeEvent(
|
|
258
|
+
'list:delete:batch',
|
|
259
|
+
payload as BoardRealtimePayload
|
|
260
|
+
);
|
|
261
|
+
})
|
|
238
262
|
.on('broadcast', { event: 'task:relations-changed' }, ({ payload }) => {
|
|
239
263
|
handleBoardRealtimeEvent(
|
|
240
264
|
'task:relations-changed',
|
|
@@ -246,6 +270,22 @@ export function useBoardRealtime(
|
|
|
246
270
|
'task:deps-changed',
|
|
247
271
|
payload as BoardRealtimePayload
|
|
248
272
|
);
|
|
273
|
+
})
|
|
274
|
+
.on(
|
|
275
|
+
'broadcast',
|
|
276
|
+
{ event: 'task:relations-changed:batch' },
|
|
277
|
+
({ payload }) => {
|
|
278
|
+
handleBoardRealtimeEvent(
|
|
279
|
+
'task:relations-changed:batch',
|
|
280
|
+
payload as BoardRealtimePayload
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
)
|
|
284
|
+
.on('broadcast', { event: 'task:deps-changed:batch' }, ({ payload }) => {
|
|
285
|
+
handleBoardRealtimeEvent(
|
|
286
|
+
'task:deps-changed:batch',
|
|
287
|
+
payload as BoardRealtimePayload
|
|
288
|
+
);
|
|
249
289
|
});
|
|
250
290
|
|
|
251
291
|
channel.subscribe((status, err) => {
|
|
@@ -328,11 +368,24 @@ export function useBoardRealtime(
|
|
|
328
368
|
const existing = merged.get(task.id);
|
|
329
369
|
merged.set(task.id, existing ? { ...existing, ...task } : task);
|
|
330
370
|
}
|
|
331
|
-
|
|
371
|
+
const tasks = [...merged.values()];
|
|
372
|
+
if (tasks.length > 1) {
|
|
373
|
+
sendBoardRealtimeEvent(channel, 'task:upsert:batch', {
|
|
374
|
+
payloads: tasks.map((task) => ({ task })),
|
|
375
|
+
});
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
for (const task of tasks) {
|
|
332
379
|
sendBoardRealtimeEvent(channel, event, { task });
|
|
333
380
|
}
|
|
334
381
|
} else {
|
|
335
382
|
// Other events (task:delete, list:upsert, list:delete) — send each
|
|
383
|
+
if (payloads.length > 1) {
|
|
384
|
+
sendBoardRealtimeEvent(channel, `${event}:batch`, {
|
|
385
|
+
payloads,
|
|
386
|
+
});
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
336
389
|
for (const p of payloads) {
|
|
337
390
|
sendBoardRealtimeEvent(channel, event, p);
|
|
338
391
|
}
|
|
@@ -76,11 +76,120 @@ function updateBoardTaskCaches(
|
|
|
76
76
|
boardId: string,
|
|
77
77
|
updater: (old: Task[] | undefined) => Task[] | undefined
|
|
78
78
|
) {
|
|
79
|
-
queryClient.setQueryData(['tasks', boardId], updater);
|
|
79
|
+
queryClient.setQueryData<Task[]>(['tasks', boardId], updater);
|
|
80
|
+
queryClient.setQueryData<Task[]>(['tasks-full', boardId], updater);
|
|
81
|
+
queryClient.setQueriesData<Task[]>({ queryKey: ['tasks', boardId] }, updater);
|
|
82
|
+
queryClient.setQueriesData<Task[]>(
|
|
83
|
+
{ queryKey: ['tasks-full', boardId] },
|
|
84
|
+
updater
|
|
85
|
+
);
|
|
86
|
+
}
|
|
80
87
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
88
|
+
function patchWorkspaceTaskCaches(
|
|
89
|
+
queryClient: QueryClient,
|
|
90
|
+
taskData: Partial<Task> & { id: string }
|
|
91
|
+
) {
|
|
92
|
+
queryClient.setQueriesData<{ task?: Task | null }>(
|
|
93
|
+
{
|
|
94
|
+
predicate: (query) =>
|
|
95
|
+
Array.isArray(query.queryKey) &&
|
|
96
|
+
query.queryKey[0] === 'workspaceTask' &&
|
|
97
|
+
query.queryKey[2] === taskData.id,
|
|
98
|
+
},
|
|
99
|
+
(old) =>
|
|
100
|
+
old?.task
|
|
101
|
+
? {
|
|
102
|
+
...old,
|
|
103
|
+
task: { ...old.task, ...taskData },
|
|
104
|
+
}
|
|
105
|
+
: old
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function patchMyTasksCaches(
|
|
110
|
+
queryClient: QueryClient,
|
|
111
|
+
taskData: Partial<Task> & { id: string }
|
|
112
|
+
) {
|
|
113
|
+
const patchTask = <T extends { id?: string }>(task: T): T =>
|
|
114
|
+
task.id === taskData.id ? ({ ...task, ...taskData } as T) : task;
|
|
115
|
+
|
|
116
|
+
queryClient.setQueriesData<{
|
|
117
|
+
overdue?: Array<{ id?: string }>;
|
|
118
|
+
today?: Array<{ id?: string }>;
|
|
119
|
+
upcoming?: Array<{ id?: string }>;
|
|
120
|
+
completed?: Array<{ id?: string }>;
|
|
121
|
+
}>({ queryKey: ['my-tasks'] }, (old) =>
|
|
122
|
+
old
|
|
123
|
+
? {
|
|
124
|
+
...old,
|
|
125
|
+
overdue: old.overdue?.map(patchTask) ?? old.overdue,
|
|
126
|
+
today: old.today?.map(patchTask) ?? old.today,
|
|
127
|
+
upcoming: old.upcoming?.map(patchTask) ?? old.upcoming,
|
|
128
|
+
completed: old.completed?.map(patchTask) ?? old.completed,
|
|
129
|
+
}
|
|
130
|
+
: old
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
queryClient.setQueriesData<{
|
|
134
|
+
pages?: Array<{ completed?: Array<{ id?: string }> }>;
|
|
135
|
+
}>({ queryKey: ['my-completed-tasks'] }, (old) =>
|
|
136
|
+
old?.pages
|
|
137
|
+
? {
|
|
138
|
+
...old,
|
|
139
|
+
pages: old.pages.map((page) => ({
|
|
140
|
+
...page,
|
|
141
|
+
completed: page.completed?.map(patchTask) ?? page.completed,
|
|
142
|
+
})),
|
|
143
|
+
}
|
|
144
|
+
: old
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function deleteFromMyTasksCaches(queryClient: QueryClient, taskId: string) {
|
|
149
|
+
const removeTask = <T extends { id?: string }>(tasks: T[] | undefined) =>
|
|
150
|
+
tasks?.filter((task) => task.id !== taskId);
|
|
151
|
+
|
|
152
|
+
queryClient.setQueriesData<{
|
|
153
|
+
overdue?: Array<{ id?: string }>;
|
|
154
|
+
today?: Array<{ id?: string }>;
|
|
155
|
+
upcoming?: Array<{ id?: string }>;
|
|
156
|
+
completed?: Array<{ id?: string }>;
|
|
157
|
+
}>({ queryKey: ['my-tasks'] }, (old) =>
|
|
158
|
+
old
|
|
159
|
+
? {
|
|
160
|
+
...old,
|
|
161
|
+
overdue: removeTask(old.overdue) ?? old.overdue,
|
|
162
|
+
today: removeTask(old.today) ?? old.today,
|
|
163
|
+
upcoming: removeTask(old.upcoming) ?? old.upcoming,
|
|
164
|
+
completed: removeTask(old.completed) ?? old.completed,
|
|
165
|
+
}
|
|
166
|
+
: old
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
queryClient.setQueriesData<{
|
|
170
|
+
pages?: Array<{ completed?: Array<{ id?: string }> }>;
|
|
171
|
+
}>({ queryKey: ['my-completed-tasks'] }, (old) =>
|
|
172
|
+
old?.pages
|
|
173
|
+
? {
|
|
174
|
+
...old,
|
|
175
|
+
pages: old.pages.map((page) => ({
|
|
176
|
+
...page,
|
|
177
|
+
completed: removeTask(page.completed) ?? page.completed,
|
|
178
|
+
})),
|
|
179
|
+
}
|
|
180
|
+
: old
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function invalidateTaskMembershipQueries(
|
|
185
|
+
queryClient: QueryClient,
|
|
186
|
+
boardId: string
|
|
187
|
+
) {
|
|
188
|
+
void queryClient.invalidateQueries({
|
|
189
|
+
queryKey: ['task-list-counts', boardId],
|
|
190
|
+
});
|
|
191
|
+
void queryClient.invalidateQueries({ queryKey: ['my-tasks'] });
|
|
192
|
+
void queryClient.invalidateQueries({ queryKey: ['my-completed-tasks'] });
|
|
84
193
|
}
|
|
85
194
|
|
|
86
195
|
export function useBoardRealtimeEventHandler({
|
|
@@ -164,6 +273,10 @@ export function useBoardRealtimeEventHandler({
|
|
|
164
273
|
return relations ? { ...task, ...relations } : task;
|
|
165
274
|
});
|
|
166
275
|
});
|
|
276
|
+
for (const [taskId, relations] of relationsMap.entries()) {
|
|
277
|
+
patchWorkspaceTaskCaches(queryClient, { id: taskId, ...relations });
|
|
278
|
+
patchMyTasksCaches(queryClient, { id: taskId, ...relations });
|
|
279
|
+
}
|
|
167
280
|
} catch (err) {
|
|
168
281
|
if (DEV_MODE) {
|
|
169
282
|
console.error(
|
|
@@ -178,8 +291,40 @@ export function useBoardRealtimeEventHandler({
|
|
|
178
291
|
(event: string, payload: BoardRealtimePayload) => {
|
|
179
292
|
if (rememberEventId(payload)) return;
|
|
180
293
|
|
|
294
|
+
if (event.endsWith(':batch')) {
|
|
295
|
+
const baseEvent = event.slice(0, -':batch'.length);
|
|
296
|
+
const payloads = Array.isArray(payload.payloads)
|
|
297
|
+
? payload.payloads
|
|
298
|
+
: Array.isArray(payload.events)
|
|
299
|
+
? payload.events
|
|
300
|
+
.filter((entry) => {
|
|
301
|
+
return (
|
|
302
|
+
typeof entry === 'object' &&
|
|
303
|
+
entry !== null &&
|
|
304
|
+
'payload' in entry
|
|
305
|
+
);
|
|
306
|
+
})
|
|
307
|
+
.map((entry) => (entry as { payload: unknown }).payload)
|
|
308
|
+
: [];
|
|
309
|
+
|
|
310
|
+
for (const childPayload of payloads) {
|
|
311
|
+
if (
|
|
312
|
+
typeof childPayload === 'object' &&
|
|
313
|
+
childPayload !== null &&
|
|
314
|
+
!Array.isArray(childPayload)
|
|
315
|
+
) {
|
|
316
|
+
handleBoardRealtimeEvent(
|
|
317
|
+
baseEvent,
|
|
318
|
+
childPayload as BoardRealtimePayload
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
181
325
|
if (event === 'task:upsert') {
|
|
182
326
|
const taskData = payload.task as Partial<Task> & { id: string };
|
|
327
|
+
if (!taskData?.id) return;
|
|
183
328
|
if (DEV_MODE) {
|
|
184
329
|
console.log('[useBoardRealtime] task:upsert received', taskData.id);
|
|
185
330
|
}
|
|
@@ -193,6 +338,17 @@ export function useBoardRealtimeEventHandler({
|
|
|
193
338
|
updateBoardTaskCaches(queryClient, boardId, (old) =>
|
|
194
339
|
mergeRealtimeTask(old, taskData)
|
|
195
340
|
);
|
|
341
|
+
patchWorkspaceTaskCaches(queryClient, taskData);
|
|
342
|
+
patchMyTasksCaches(queryClient, taskData);
|
|
343
|
+
if (
|
|
344
|
+
'list_id' in taskData ||
|
|
345
|
+
'completed' in taskData ||
|
|
346
|
+
'completed_at' in taskData ||
|
|
347
|
+
'closed_at' in taskData ||
|
|
348
|
+
'deleted_at' in taskData
|
|
349
|
+
) {
|
|
350
|
+
invalidateTaskMembershipQueries(queryClient, boardId);
|
|
351
|
+
}
|
|
196
352
|
return;
|
|
197
353
|
}
|
|
198
354
|
|
|
@@ -208,6 +364,14 @@ export function useBoardRealtimeEventHandler({
|
|
|
208
364
|
updateBoardTaskCaches(queryClient, boardId, (old) =>
|
|
209
365
|
deleteRealtimeTask(old, taskId)
|
|
210
366
|
);
|
|
367
|
+
deleteFromMyTasksCaches(queryClient, taskId);
|
|
368
|
+
queryClient.removeQueries({
|
|
369
|
+
predicate: (query) =>
|
|
370
|
+
Array.isArray(query.queryKey) &&
|
|
371
|
+
query.queryKey[0] === 'workspaceTask' &&
|
|
372
|
+
query.queryKey[2] === taskId,
|
|
373
|
+
});
|
|
374
|
+
invalidateTaskMembershipQueries(queryClient, boardId);
|
|
211
375
|
|
|
212
376
|
if (deleted) {
|
|
213
377
|
onTaskChangeRef.current?.(deleted, 'DELETE');
|
|
@@ -262,6 +426,7 @@ export function useBoardRealtimeEventHandler({
|
|
|
262
426
|
updateBoardTaskCaches(queryClient, boardId, (old) =>
|
|
263
427
|
old?.filter((task) => task.list_id !== listId)
|
|
264
428
|
);
|
|
429
|
+
invalidateTaskMembershipQueries(queryClient, boardId);
|
|
265
430
|
|
|
266
431
|
if (deleted) {
|
|
267
432
|
onListChangeRef.current?.(deleted, 'DELETE');
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { QueryClient } from '@tanstack/react-query';
|
|
4
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
5
|
+
import { createClient } from '@tuturuuu/supabase/next/client';
|
|
6
|
+
import { DEV_MODE } from '@tuturuuu/utils/constants';
|
|
7
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
8
|
+
import {
|
|
9
|
+
type BoardRealtimePayload,
|
|
10
|
+
createRealtimeClientId,
|
|
11
|
+
type RealtimeChannel,
|
|
12
|
+
SEEN_REALTIME_EVENT_LIMIT,
|
|
13
|
+
} from './useBoardRealtime.types';
|
|
14
|
+
|
|
15
|
+
export const TASK_USER_REALTIME_CHANNEL_PREFIX = 'task-user-realtime';
|
|
16
|
+
|
|
17
|
+
export function getTaskUserRealtimeChannelName(userId: string) {
|
|
18
|
+
return `${TASK_USER_REALTIME_CHANNEL_PREFIX}-${userId}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type TaskLike = { id?: string };
|
|
22
|
+
type TaskUserBroadcastFn = (
|
|
23
|
+
event: string,
|
|
24
|
+
payload: Record<string, unknown>
|
|
25
|
+
) => void;
|
|
26
|
+
|
|
27
|
+
let activeTaskUserBroadcast: TaskUserBroadcastFn | null = null;
|
|
28
|
+
|
|
29
|
+
export function setActiveTaskUserBroadcast(fn: TaskUserBroadcastFn | null) {
|
|
30
|
+
activeTaskUserBroadcast = fn;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getActiveTaskUserBroadcast(): TaskUserBroadcastFn | null {
|
|
34
|
+
return activeTaskUserBroadcast;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function rememberEventId(
|
|
38
|
+
seen: Set<string>,
|
|
39
|
+
payload: BoardRealtimePayload
|
|
40
|
+
): boolean {
|
|
41
|
+
const eventId = payload.__tuturuuuBoardRealtimeEventId;
|
|
42
|
+
if (typeof eventId !== 'string' || eventId.length === 0) return false;
|
|
43
|
+
if (seen.has(eventId)) return true;
|
|
44
|
+
|
|
45
|
+
seen.add(eventId);
|
|
46
|
+
while (seen.size > SEEN_REALTIME_EVENT_LIMIT) {
|
|
47
|
+
const first = seen.values().next().value;
|
|
48
|
+
if (!first) break;
|
|
49
|
+
seen.delete(first);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function patchTaskList<T extends TaskLike>(
|
|
56
|
+
tasks: T[] | undefined,
|
|
57
|
+
taskData: TaskLike
|
|
58
|
+
) {
|
|
59
|
+
if (!tasks || !taskData.id) return tasks;
|
|
60
|
+
return tasks.map((task) =>
|
|
61
|
+
task.id === taskData.id ? ({ ...task, ...taskData } as T) : task
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function removeTaskFromList<T extends TaskLike>(
|
|
66
|
+
tasks: T[] | undefined,
|
|
67
|
+
taskId: string
|
|
68
|
+
) {
|
|
69
|
+
return tasks?.filter((task) => task.id !== taskId);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function patchMyTasksCaches(queryClient: QueryClient, taskData: TaskLike) {
|
|
73
|
+
queryClient.setQueriesData<{
|
|
74
|
+
overdue?: TaskLike[];
|
|
75
|
+
today?: TaskLike[];
|
|
76
|
+
upcoming?: TaskLike[];
|
|
77
|
+
completed?: TaskLike[];
|
|
78
|
+
}>({ queryKey: ['my-tasks'] }, (old) =>
|
|
79
|
+
old
|
|
80
|
+
? {
|
|
81
|
+
...old,
|
|
82
|
+
overdue: patchTaskList(old.overdue, taskData) ?? old.overdue,
|
|
83
|
+
today: patchTaskList(old.today, taskData) ?? old.today,
|
|
84
|
+
upcoming: patchTaskList(old.upcoming, taskData) ?? old.upcoming,
|
|
85
|
+
completed: patchTaskList(old.completed, taskData) ?? old.completed,
|
|
86
|
+
}
|
|
87
|
+
: old
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
queryClient.setQueriesData<{
|
|
91
|
+
pages?: Array<{ completed?: TaskLike[] }>;
|
|
92
|
+
}>({ queryKey: ['my-completed-tasks'] }, (old) =>
|
|
93
|
+
old?.pages
|
|
94
|
+
? {
|
|
95
|
+
...old,
|
|
96
|
+
pages: old.pages.map((page) => ({
|
|
97
|
+
...page,
|
|
98
|
+
completed:
|
|
99
|
+
patchTaskList(page.completed, taskData) ?? page.completed,
|
|
100
|
+
})),
|
|
101
|
+
}
|
|
102
|
+
: old
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function removeFromMyTasksCaches(queryClient: QueryClient, taskId: string) {
|
|
107
|
+
queryClient.setQueriesData<{
|
|
108
|
+
overdue?: TaskLike[];
|
|
109
|
+
today?: TaskLike[];
|
|
110
|
+
upcoming?: TaskLike[];
|
|
111
|
+
completed?: TaskLike[];
|
|
112
|
+
}>({ queryKey: ['my-tasks'] }, (old) =>
|
|
113
|
+
old
|
|
114
|
+
? {
|
|
115
|
+
...old,
|
|
116
|
+
overdue: removeTaskFromList(old.overdue, taskId) ?? old.overdue,
|
|
117
|
+
today: removeTaskFromList(old.today, taskId) ?? old.today,
|
|
118
|
+
upcoming: removeTaskFromList(old.upcoming, taskId) ?? old.upcoming,
|
|
119
|
+
completed: removeTaskFromList(old.completed, taskId) ?? old.completed,
|
|
120
|
+
}
|
|
121
|
+
: old
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
queryClient.setQueriesData<{
|
|
125
|
+
pages?: Array<{ completed?: TaskLike[] }>;
|
|
126
|
+
}>({ queryKey: ['my-completed-tasks'] }, (old) =>
|
|
127
|
+
old?.pages
|
|
128
|
+
? {
|
|
129
|
+
...old,
|
|
130
|
+
pages: old.pages.map((page) => ({
|
|
131
|
+
...page,
|
|
132
|
+
completed:
|
|
133
|
+
removeTaskFromList(page.completed, taskId) ?? page.completed,
|
|
134
|
+
})),
|
|
135
|
+
}
|
|
136
|
+
: old
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function useTaskUserRealtime(userId: string | null | undefined) {
|
|
141
|
+
const queryClient = useQueryClient();
|
|
142
|
+
const channelRef = useRef<RealtimeChannel | null>(null);
|
|
143
|
+
const seenEventIdsRef = useRef<Set<string>>(new Set());
|
|
144
|
+
const invalidateTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
145
|
+
const clientIdRef = useRef<string>(createRealtimeClientId());
|
|
146
|
+
const eventCounterRef = useRef(0);
|
|
147
|
+
|
|
148
|
+
const withEventMetadata = useCallback(
|
|
149
|
+
(payload: Record<string, unknown>): BoardRealtimePayload => {
|
|
150
|
+
eventCounterRef.current += 1;
|
|
151
|
+
return {
|
|
152
|
+
...payload,
|
|
153
|
+
__tuturuuuBoardRealtimeEventId: `${clientIdRef.current}:${Date.now()}:${eventCounterRef.current}`,
|
|
154
|
+
__tuturuuuBoardRealtimeOrigin: clientIdRef.current,
|
|
155
|
+
};
|
|
156
|
+
},
|
|
157
|
+
[]
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const broadcast = useCallback<TaskUserBroadcastFn>(
|
|
161
|
+
(event, payload) => {
|
|
162
|
+
const channel = channelRef.current;
|
|
163
|
+
if (!channel) return;
|
|
164
|
+
|
|
165
|
+
channel.send({
|
|
166
|
+
type: 'broadcast',
|
|
167
|
+
event,
|
|
168
|
+
payload: withEventMetadata(payload),
|
|
169
|
+
});
|
|
170
|
+
},
|
|
171
|
+
[withEventMetadata]
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
if (!userId) return;
|
|
176
|
+
|
|
177
|
+
const scheduleInvalidate = () => {
|
|
178
|
+
if (invalidateTimerRef.current) {
|
|
179
|
+
clearTimeout(invalidateTimerRef.current);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
invalidateTimerRef.current = setTimeout(() => {
|
|
183
|
+
invalidateTimerRef.current = null;
|
|
184
|
+
void queryClient.invalidateQueries({ queryKey: ['my-tasks'] });
|
|
185
|
+
void queryClient.invalidateQueries({
|
|
186
|
+
queryKey: ['my-completed-tasks'],
|
|
187
|
+
});
|
|
188
|
+
}, 150);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const handleEvent = (event: string, payload: BoardRealtimePayload) => {
|
|
192
|
+
if (payload.__tuturuuuBoardRealtimeOrigin === clientIdRef.current) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (rememberEventId(seenEventIdsRef.current, payload)) return;
|
|
196
|
+
|
|
197
|
+
if (event.endsWith(':batch')) {
|
|
198
|
+
const baseEvent = event.slice(0, -':batch'.length);
|
|
199
|
+
const payloads = Array.isArray(payload.payloads)
|
|
200
|
+
? payload.payloads
|
|
201
|
+
: Array.isArray(payload.events)
|
|
202
|
+
? payload.events
|
|
203
|
+
.filter((entry) => {
|
|
204
|
+
return (
|
|
205
|
+
typeof entry === 'object' &&
|
|
206
|
+
entry !== null &&
|
|
207
|
+
'payload' in entry
|
|
208
|
+
);
|
|
209
|
+
})
|
|
210
|
+
.map((entry) => (entry as { payload: unknown }).payload)
|
|
211
|
+
: [];
|
|
212
|
+
|
|
213
|
+
for (const childPayload of payloads) {
|
|
214
|
+
if (
|
|
215
|
+
typeof childPayload === 'object' &&
|
|
216
|
+
childPayload !== null &&
|
|
217
|
+
!Array.isArray(childPayload)
|
|
218
|
+
) {
|
|
219
|
+
handleEvent(baseEvent, childPayload as BoardRealtimePayload);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (event === 'task:upsert') {
|
|
226
|
+
const task = payload.task as TaskLike | undefined;
|
|
227
|
+
if (task?.id) patchMyTasksCaches(queryClient, task);
|
|
228
|
+
scheduleInvalidate();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (event === 'task:delete') {
|
|
233
|
+
const taskId =
|
|
234
|
+
typeof payload.taskId === 'string' ? payload.taskId : undefined;
|
|
235
|
+
if (taskId) removeFromMyTasksCaches(queryClient, taskId);
|
|
236
|
+
scheduleInvalidate();
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (
|
|
241
|
+
event === 'task:relations-changed' ||
|
|
242
|
+
event === 'task:deps-changed' ||
|
|
243
|
+
event === 'list:upsert' ||
|
|
244
|
+
event === 'list:delete'
|
|
245
|
+
) {
|
|
246
|
+
scheduleInvalidate();
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const supabase = createClient();
|
|
251
|
+
if (
|
|
252
|
+
typeof supabase.channel !== 'function' ||
|
|
253
|
+
typeof supabase.removeChannel !== 'function'
|
|
254
|
+
) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const channel = supabase.channel(getTaskUserRealtimeChannelName(userId), {
|
|
259
|
+
config: { broadcast: { self: false } },
|
|
260
|
+
});
|
|
261
|
+
channelRef.current = channel;
|
|
262
|
+
|
|
263
|
+
channel
|
|
264
|
+
.on('broadcast', { event: 'task:upsert' }, ({ payload }) => {
|
|
265
|
+
handleEvent('task:upsert', payload as BoardRealtimePayload);
|
|
266
|
+
})
|
|
267
|
+
.on('broadcast', { event: 'task:delete' }, ({ payload }) => {
|
|
268
|
+
handleEvent('task:delete', payload as BoardRealtimePayload);
|
|
269
|
+
})
|
|
270
|
+
.on('broadcast', { event: 'task:upsert:batch' }, ({ payload }) => {
|
|
271
|
+
handleEvent('task:upsert:batch', payload as BoardRealtimePayload);
|
|
272
|
+
})
|
|
273
|
+
.on('broadcast', { event: 'task:delete:batch' }, ({ payload }) => {
|
|
274
|
+
handleEvent('task:delete:batch', payload as BoardRealtimePayload);
|
|
275
|
+
})
|
|
276
|
+
.on('broadcast', { event: 'task:relations-changed' }, ({ payload }) => {
|
|
277
|
+
handleEvent('task:relations-changed', payload as BoardRealtimePayload);
|
|
278
|
+
})
|
|
279
|
+
.on('broadcast', { event: 'task:deps-changed' }, ({ payload }) => {
|
|
280
|
+
handleEvent('task:deps-changed', payload as BoardRealtimePayload);
|
|
281
|
+
})
|
|
282
|
+
.on(
|
|
283
|
+
'broadcast',
|
|
284
|
+
{ event: 'task:relations-changed:batch' },
|
|
285
|
+
({ payload }) => {
|
|
286
|
+
handleEvent(
|
|
287
|
+
'task:relations-changed:batch',
|
|
288
|
+
payload as BoardRealtimePayload
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
)
|
|
292
|
+
.on('broadcast', { event: 'task:deps-changed:batch' }, ({ payload }) => {
|
|
293
|
+
handleEvent('task:deps-changed:batch', payload as BoardRealtimePayload);
|
|
294
|
+
})
|
|
295
|
+
.on('broadcast', { event: 'list:upsert' }, ({ payload }) => {
|
|
296
|
+
handleEvent('list:upsert', payload as BoardRealtimePayload);
|
|
297
|
+
})
|
|
298
|
+
.on('broadcast', { event: 'list:delete' }, ({ payload }) => {
|
|
299
|
+
handleEvent('list:delete', payload as BoardRealtimePayload);
|
|
300
|
+
})
|
|
301
|
+
.on('broadcast', { event: 'list:upsert:batch' }, ({ payload }) => {
|
|
302
|
+
handleEvent('list:upsert:batch', payload as BoardRealtimePayload);
|
|
303
|
+
})
|
|
304
|
+
.on('broadcast', { event: 'list:delete:batch' }, ({ payload }) => {
|
|
305
|
+
handleEvent('list:delete:batch', payload as BoardRealtimePayload);
|
|
306
|
+
})
|
|
307
|
+
.subscribe((status, error) => {
|
|
308
|
+
if (
|
|
309
|
+
DEV_MODE &&
|
|
310
|
+
(status === 'CHANNEL_ERROR' || status === 'TIMED_OUT')
|
|
311
|
+
) {
|
|
312
|
+
console.warn('[useTaskUserRealtime] channel issue', {
|
|
313
|
+
error,
|
|
314
|
+
status,
|
|
315
|
+
userId,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
setActiveTaskUserBroadcast(broadcast);
|
|
321
|
+
|
|
322
|
+
return () => {
|
|
323
|
+
if (channelRef.current === channel) {
|
|
324
|
+
channelRef.current = null;
|
|
325
|
+
}
|
|
326
|
+
if (activeTaskUserBroadcast === broadcast) {
|
|
327
|
+
setActiveTaskUserBroadcast(null);
|
|
328
|
+
}
|
|
329
|
+
if (invalidateTimerRef.current) {
|
|
330
|
+
clearTimeout(invalidateTimerRef.current);
|
|
331
|
+
invalidateTimerRef.current = null;
|
|
332
|
+
}
|
|
333
|
+
void supabase.removeChannel(channel);
|
|
334
|
+
};
|
|
335
|
+
}, [broadcast, queryClient, userId]);
|
|
336
|
+
|
|
337
|
+
return { broadcast };
|
|
338
|
+
}
|