@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.
Files changed (88) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/package.json +41 -34
  3. package/src/components/ui/currency-input.tsx +65 -23
  4. package/src/components/ui/custom/__tests__/sidebar-context.test.tsx +64 -0
  5. package/src/components/ui/custom/__tests__/sidebar-remote-behavior-bridge.test.tsx +109 -0
  6. package/src/components/ui/custom/combobox.test.tsx +141 -0
  7. package/src/components/ui/custom/combobox.tsx +105 -36
  8. package/src/components/ui/custom/settings/task-settings.tsx +50 -0
  9. package/src/components/ui/custom/settings/task-sound-settings.test.tsx +21 -1
  10. package/src/components/ui/custom/sidebar-context.tsx +68 -6
  11. package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +21 -2
  12. package/src/components/ui/finance/finance-layout.tsx +2 -4
  13. package/src/components/ui/finance/shared/balance-mode-toggle.tsx +35 -0
  14. package/src/components/ui/finance/shared/finance-layout-controls.tsx +43 -0
  15. package/src/components/ui/finance/shared/quick-actions.tsx +14 -6
  16. package/src/components/ui/finance/shared/use-finance-balance-mode.ts +72 -0
  17. package/src/components/ui/finance/shared/wallet-balance-mode.test.ts +66 -0
  18. package/src/components/ui/finance/shared/wallet-balance-mode.ts +42 -0
  19. package/src/components/ui/finance/transactions/form-types.ts +23 -0
  20. package/src/components/ui/finance/transactions/form.tsx +81 -22
  21. package/src/components/ui/finance/transactions/wallet-filter.tsx +21 -2
  22. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +73 -26
  23. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-history-dialog.tsx +617 -0
  24. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-panel.tsx +2 -1
  25. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-sections.tsx +4 -4
  26. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +298 -34
  27. package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +219 -46
  28. package/src/components/ui/finance/wallets/columns-rendering.test.tsx +125 -0
  29. package/src/components/ui/finance/wallets/columns.test.ts +56 -0
  30. package/src/components/ui/finance/wallets/columns.tsx +196 -43
  31. package/src/components/ui/finance/wallets/form.test.tsx +79 -14
  32. package/src/components/ui/finance/wallets/form.tsx +41 -197
  33. package/src/components/ui/finance/wallets/query-invalidation.ts +1 -0
  34. package/src/components/ui/finance/wallets/wallet-basics-fields.tsx +141 -0
  35. package/src/components/ui/finance/wallets/wallet-credit-fields.tsx +136 -0
  36. package/src/components/ui/finance/wallets/walletId/credit-wallet-summary.tsx +143 -68
  37. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.test.tsx +105 -0
  38. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +120 -16
  39. package/src/components/ui/finance/wallets/walletId/wallet-details-amount.test.tsx +64 -0
  40. package/src/components/ui/finance/wallets/walletId/wallet-details-amount.tsx +226 -6
  41. package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +64 -2
  42. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +42 -35
  43. package/src/components/ui/finance/wallets/wallets-data-table.test.tsx +171 -0
  44. package/src/components/ui/finance/wallets/wallets-data-table.tsx +132 -29
  45. package/src/components/ui/finance/wallets/wallets-page.test.tsx +111 -37
  46. package/src/components/ui/finance/wallets/wallets-page.tsx +38 -78
  47. package/src/components/ui/storefront/accent-button.tsx +33 -0
  48. package/src/components/ui/storefront/cart-summary.tsx +140 -0
  49. package/src/components/ui/storefront/empty-listings.tsx +32 -0
  50. package/src/components/ui/storefront/hero-panel.tsx +70 -0
  51. package/src/components/ui/storefront/image-panel.tsx +40 -0
  52. package/src/components/ui/storefront/index.ts +12 -0
  53. package/src/components/ui/storefront/listing-card.tsx +129 -0
  54. package/src/components/ui/storefront/storefront-surface.test.tsx +85 -0
  55. package/src/components/ui/storefront/storefront-surface.tsx +235 -0
  56. package/src/components/ui/storefront/types.ts +99 -0
  57. package/src/components/ui/storefront/utils.ts +90 -0
  58. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +134 -0
  59. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +127 -0
  60. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +17 -42
  61. package/src/components/ui/tu-do/boards/boardId/timeline-board-open-task.test.tsx +164 -0
  62. package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +25 -16
  63. package/src/components/ui/tu-do/hooks/useTaskDialog.ts +15 -1
  64. package/src/components/ui/tu-do/my-tasks/use-my-tasks-state.ts +2 -0
  65. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +114 -7
  66. package/src/components/ui/tu-do/providers/__tests__/task-dialog-provider.test.tsx +217 -5
  67. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +180 -35
  68. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +222 -26
  69. package/src/components/ui/tu-do/shared/board-client.tsx +1 -3
  70. package/src/components/ui/tu-do/shared/list-view-context-menu.test.tsx +55 -2
  71. package/src/components/ui/tu-do/shared/list-view.tsx +23 -16
  72. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +93 -76
  73. package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +11 -0
  74. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +128 -1
  75. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +104 -69
  76. package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.test.tsx +129 -0
  77. package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.tsx +358 -0
  78. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +1 -1
  79. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +6 -2
  80. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.test.tsx +17 -1
  81. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.tsx +151 -111
  82. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-form-reset.ts +18 -2
  83. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-realtime-sync.ts +1 -2
  84. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +5 -3
  85. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +584 -53
  86. package/src/hooks/useBoardRealtime.ts +54 -1
  87. package/src/hooks/useBoardRealtimeEventHandler.ts +169 -4
  88. 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
- for (const task of merged.values()) {
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
- if (queryClient.getQueryData<Task[]>(['tasks-full', boardId])) {
82
- queryClient.setQueryData(['tasks-full', boardId], updater);
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
+ }