@tuturuuu/ui 0.4.1 → 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 (107) hide show
  1. package/CHANGELOG.md +43 -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 +126 -0
  9. package/src/components/ui/custom/settings/task-sound-settings.test.tsx +146 -0
  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/infinite-transactions-list.tsx +29 -18
  22. package/src/components/ui/finance/transactions/transaction-card.tsx +75 -43
  23. package/src/components/ui/finance/transactions/transfer-merge.test.ts +90 -0
  24. package/src/components/ui/finance/transactions/transfer-merge.ts +52 -0
  25. package/src/components/ui/finance/transactions/wallet-filter.tsx +21 -2
  26. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +219 -0
  27. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-amount.tsx +32 -0
  28. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-delete-dialog.tsx +50 -0
  29. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-dialog.tsx +138 -0
  30. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-history-dialog.tsx +617 -0
  31. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-panel.tsx +197 -0
  32. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-sections.tsx +201 -0
  33. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +541 -0
  34. package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +362 -0
  35. package/src/components/ui/finance/wallets/columns-rendering.test.tsx +125 -0
  36. package/src/components/ui/finance/wallets/columns.test.ts +56 -0
  37. package/src/components/ui/finance/wallets/columns.tsx +196 -43
  38. package/src/components/ui/finance/wallets/form.test.tsx +79 -14
  39. package/src/components/ui/finance/wallets/form.tsx +41 -197
  40. package/src/components/ui/finance/wallets/query-invalidation.ts +3 -0
  41. package/src/components/ui/finance/wallets/wallet-basics-fields.tsx +141 -0
  42. package/src/components/ui/finance/wallets/wallet-credit-fields.tsx +136 -0
  43. package/src/components/ui/finance/wallets/walletId/credit-wallet-summary.tsx +143 -68
  44. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.test.tsx +105 -0
  45. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +120 -16
  46. package/src/components/ui/finance/wallets/walletId/wallet-details-amount.test.tsx +64 -0
  47. package/src/components/ui/finance/wallets/walletId/wallet-details-amount.tsx +226 -6
  48. package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +71 -5
  49. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +52 -35
  50. package/src/components/ui/finance/wallets/wallets-data-table.test.tsx +171 -0
  51. package/src/components/ui/finance/wallets/wallets-data-table.tsx +132 -29
  52. package/src/components/ui/finance/wallets/wallets-page.test.tsx +117 -36
  53. package/src/components/ui/finance/wallets/wallets-page.tsx +40 -64
  54. package/src/components/ui/storefront/accent-button.tsx +33 -0
  55. package/src/components/ui/storefront/cart-summary.tsx +140 -0
  56. package/src/components/ui/storefront/empty-listings.tsx +32 -0
  57. package/src/components/ui/storefront/hero-panel.tsx +70 -0
  58. package/src/components/ui/storefront/image-panel.tsx +40 -0
  59. package/src/components/ui/storefront/index.ts +12 -0
  60. package/src/components/ui/storefront/listing-card.tsx +129 -0
  61. package/src/components/ui/storefront/storefront-surface.test.tsx +85 -0
  62. package/src/components/ui/storefront/storefront-surface.tsx +235 -0
  63. package/src/components/ui/storefront/types.ts +99 -0
  64. package/src/components/ui/storefront/utils.ts +90 -0
  65. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +14 -0
  66. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operations.ts +29 -0
  67. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +134 -0
  68. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +127 -0
  69. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +17 -42
  70. package/src/components/ui/tu-do/boards/boardId/timeline-board-open-task.test.tsx +164 -0
  71. package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +25 -16
  72. package/src/components/ui/tu-do/hooks/useTaskDialog.ts +15 -1
  73. package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +11 -0
  74. package/src/components/ui/tu-do/my-tasks/use-my-tasks-state.ts +2 -0
  75. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +124 -7
  76. package/src/components/ui/tu-do/providers/__tests__/task-dialog-provider.test.tsx +217 -5
  77. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +180 -35
  78. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +222 -26
  79. package/src/components/ui/tu-do/shared/board-client.tsx +1 -3
  80. package/src/components/ui/tu-do/shared/list-view-context-menu.test.tsx +55 -2
  81. package/src/components/ui/tu-do/shared/list-view.tsx +23 -16
  82. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +93 -76
  83. package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +11 -0
  84. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +268 -0
  85. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +243 -0
  86. package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +26 -0
  87. package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.test.tsx +129 -0
  88. package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.tsx +358 -0
  89. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +1 -1
  90. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +6 -2
  91. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-list-selector.tsx +36 -20
  92. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.test.tsx +41 -1
  93. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.tsx +157 -102
  94. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-form-reset.ts +18 -2
  95. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-realtime-sync.ts +1 -2
  96. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.test.ts +84 -1
  97. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.ts +5 -1
  98. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +5 -3
  99. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +300 -172
  100. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +959 -340
  101. package/src/components/ui/tu-do/shared/task-sound-effects.test.ts +189 -0
  102. package/src/components/ui/tu-do/shared/task-sound-effects.tsx +468 -0
  103. package/src/hooks/__tests__/use-task-actions.test.tsx +61 -0
  104. package/src/hooks/use-task-actions.ts +45 -0
  105. package/src/hooks/useBoardRealtime.ts +54 -1
  106. package/src/hooks/useBoardRealtimeEventHandler.ts +169 -4
  107. package/src/hooks/useTaskUserRealtime.ts +338 -0
@@ -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
+ }