@tuturuuu/utils 0.0.3 → 0.6.1

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 (186) hide show
  1. package/CHANGELOG.md +313 -0
  2. package/biome.json +5 -0
  3. package/jsr.json +8 -8
  4. package/package.json +63 -32
  5. package/src/__tests__/ai-temp-auth.test.ts +309 -0
  6. package/src/__tests__/api-proxy-guard.test.ts +1451 -0
  7. package/src/__tests__/app-url.test.ts +270 -0
  8. package/src/__tests__/avatar-url.test.ts +97 -0
  9. package/src/__tests__/color-helper.test.ts +179 -0
  10. package/src/__tests__/constants.test.ts +351 -0
  11. package/src/__tests__/crypto.test.ts +107 -0
  12. package/src/__tests__/date-helper.test.ts +408 -0
  13. package/src/__tests__/fixtures/task-description-full-featured.json +456 -0
  14. package/src/__tests__/format.test.ts +317 -0
  15. package/src/__tests__/html-sanitizer.test.ts +360 -0
  16. package/src/__tests__/interest-calculator.test.ts +336 -0
  17. package/src/__tests__/interest-detector.test.ts +222 -0
  18. package/src/__tests__/label-colors.test.ts +241 -0
  19. package/src/__tests__/name-helper.test.ts +158 -0
  20. package/src/__tests__/node-diff.test.ts +576 -0
  21. package/src/__tests__/notification-service.test.ts +210 -0
  22. package/src/__tests__/onboarding-helper.test.ts +331 -0
  23. package/src/__tests__/path-helper.test.ts +152 -0
  24. package/src/__tests__/permissions.test.tsx +81 -0
  25. package/src/__tests__/request-emoji-limit.test.ts +172 -0
  26. package/src/__tests__/search-helper.test.ts +51 -0
  27. package/src/__tests__/storage-display-name.test.ts +37 -0
  28. package/src/__tests__/storage-path.test.ts +238 -0
  29. package/src/__tests__/tag-utils.test.ts +205 -0
  30. package/src/__tests__/task-description-yjs-state.test.ts +581 -0
  31. package/src/__tests__/task-helper-board-api-routing.test.ts +94 -0
  32. package/src/__tests__/task-helper-create-task.test.ts +129 -0
  33. package/src/__tests__/task-helpers.test.ts +464 -0
  34. package/src/__tests__/task-overrides.test.ts +305 -0
  35. package/src/__tests__/task-reorder-cache.test.ts +74 -0
  36. package/src/__tests__/task-sort-keys.test.ts +36 -0
  37. package/src/__tests__/task-transformers.test.ts +62 -0
  38. package/src/__tests__/text-helper.test.ts +776 -0
  39. package/src/__tests__/time-helper.test.ts +70 -0
  40. package/src/__tests__/time-tracker-period.test.ts +55 -0
  41. package/src/__tests__/timezone.test.ts +117 -0
  42. package/src/__tests__/upstash-rest.test.ts +77 -0
  43. package/src/__tests__/uuid-helper.test.ts +133 -0
  44. package/src/__tests__/workspace-helper.test.ts +859 -0
  45. package/src/__tests__/workspace-limits.test.ts +255 -0
  46. package/src/__tests__/yjs-helper.test.ts +581 -0
  47. package/src/abuse-protection/__tests__/backend-rate-limit.test.ts +113 -0
  48. package/src/abuse-protection/__tests__/edge.test.ts +136 -0
  49. package/src/abuse-protection/__tests__/index.test.ts +562 -0
  50. package/src/abuse-protection/__tests__/reputation.test.ts +192 -0
  51. package/src/abuse-protection/backend-rate-limit.ts +44 -0
  52. package/src/abuse-protection/constants.ts +117 -0
  53. package/src/abuse-protection/edge.ts +223 -0
  54. package/src/abuse-protection/index.ts +1545 -0
  55. package/src/abuse-protection/reputation.ts +587 -0
  56. package/src/abuse-protection/types.ts +97 -0
  57. package/src/abuse-protection/user-agent.ts +124 -0
  58. package/src/abuse-protection/user-suspension.ts +231 -0
  59. package/src/ai-temp-auth.ts +315 -0
  60. package/src/api-proxy-guard.ts +965 -0
  61. package/src/app-url.ts +96 -0
  62. package/src/avatar-url.ts +64 -0
  63. package/src/break-duration.ts +84 -0
  64. package/src/calendar-auth-token.test.ts +37 -0
  65. package/src/calendar-auth-token.ts +19 -0
  66. package/src/calendar-sync-coordination.md +197 -0
  67. package/src/calendar-utils.test.ts +169 -0
  68. package/src/calendar-utils.ts +91 -0
  69. package/src/color-helper.ts +110 -0
  70. package/src/common/nextjs.tsx +99 -0
  71. package/src/common/scan.tsx +15 -0
  72. package/src/configs/reports.ts +160 -0
  73. package/src/constants.ts +85 -0
  74. package/src/crypto.ts +21 -0
  75. package/src/currencies.ts +97 -0
  76. package/src/date-helper.ts +313 -0
  77. package/src/editor/convert-to-task.ts +264 -0
  78. package/src/editor/index.ts +5 -0
  79. package/src/email/__tests__/client.test.ts +141 -0
  80. package/src/email/__tests__/validation.test.ts +46 -0
  81. package/src/email/client.ts +92 -0
  82. package/src/email/server.ts +128 -0
  83. package/src/email/validation.ts +11 -0
  84. package/src/encryption/__tests__/calendar-events.test.ts +411 -0
  85. package/src/encryption/__tests__/configuration.test.ts +114 -0
  86. package/src/encryption/__tests__/field-encryption.test.ts +232 -0
  87. package/src/encryption/__tests__/key-generation.test.ts +30 -0
  88. package/src/encryption/__tests__/performance-edge-cases.test.ts +187 -0
  89. package/src/encryption/__tests__/test-helpers.ts +22 -0
  90. package/src/encryption/__tests__/workspace-key-encryption.test.ts +129 -0
  91. package/src/encryption/encryption-service.ts +343 -0
  92. package/src/encryption/index.ts +25 -0
  93. package/src/encryption/types.ts +57 -0
  94. package/src/exchange-rates.ts +49 -0
  95. package/src/feature-flags/__tests__/feature-flags.test.ts +302 -0
  96. package/src/feature-flags/core.ts +322 -0
  97. package/src/feature-flags/data.ts +16 -0
  98. package/src/feature-flags/default.ts +18 -0
  99. package/src/feature-flags/index.ts +7 -0
  100. package/src/feature-flags/requestable-features.ts +79 -0
  101. package/src/feature-flags/types.ts +4 -0
  102. package/src/fetcher.ts +2 -0
  103. package/src/finance/index.ts +4 -0
  104. package/src/finance/interest-calculator.ts +456 -0
  105. package/src/finance/interest-detector.ts +141 -0
  106. package/src/finance/transform-invoice-results.ts +219 -0
  107. package/src/finance/wallet-permissions.test.ts +169 -0
  108. package/src/finance/wallet-permissions.ts +82 -0
  109. package/src/format.ts +120 -1
  110. package/src/generated/platform-build-metadata.ts +11 -0
  111. package/src/hooks/use-platform.ts +64 -0
  112. package/src/html-sanitizer.ts +155 -0
  113. package/src/internal-domains.ts +497 -0
  114. package/src/keyboard-preset.ts +109 -0
  115. package/src/label-colors.ts +213 -0
  116. package/src/launchable-apps.test.ts +126 -0
  117. package/src/launchable-apps.ts +490 -0
  118. package/src/name-helper.ts +269 -0
  119. package/src/next-config.test.ts +234 -0
  120. package/src/next-config.ts +203 -0
  121. package/src/node-diff.ts +375 -0
  122. package/src/notification-service.ts +379 -0
  123. package/src/nova/scores/__tests__/calculate.test.ts +254 -0
  124. package/src/nova/scores/calculate.ts +132 -0
  125. package/src/nova/submissions/check-permission.ts +132 -0
  126. package/src/onboarding-helper.ts +213 -0
  127. package/src/path-helper.ts +93 -0
  128. package/src/permissions.tsx +1170 -0
  129. package/src/plan-helpers.test.ts +188 -0
  130. package/src/plan-helpers.ts +80 -0
  131. package/src/platform-release.test.ts +74 -0
  132. package/src/platform-release.ts +155 -0
  133. package/src/portless.ts +124 -0
  134. package/src/priority-styles.ts +42 -0
  135. package/src/request-emoji-limit.ts +335 -0
  136. package/src/search-helper.ts +18 -0
  137. package/src/search.test.ts +89 -0
  138. package/src/search.ts +355 -0
  139. package/src/storage-display-name.ts +30 -0
  140. package/src/storage-path.ts +147 -0
  141. package/src/tag-utils.ts +159 -0
  142. package/src/task/reorder.ts +245 -0
  143. package/src/task/transformers.ts +149 -0
  144. package/src/task-date-timezone.ts +133 -0
  145. package/src/task-description-content.ts +240 -0
  146. package/src/task-helper/board.ts +193 -0
  147. package/src/task-helper/bulk-actions.ts +564 -0
  148. package/src/task-helper/personal-external-staging.ts +21 -0
  149. package/src/task-helper/recycle-bin.ts +202 -0
  150. package/src/task-helper/relationships.ts +346 -0
  151. package/src/task-helper/shared.ts +109 -0
  152. package/src/task-helper/sort-keys.ts +337 -0
  153. package/src/task-helper/task-hooks-basic.ts +342 -0
  154. package/src/task-helper/task-hooks-move.ts +264 -0
  155. package/src/task-helper/task-operations.ts +278 -0
  156. package/src/task-helper.ts +12 -0
  157. package/src/task-helpers.ts +241 -0
  158. package/src/task-list-status.ts +62 -0
  159. package/src/task-overrides.ts +82 -0
  160. package/src/task-snapshot.ts +374 -0
  161. package/src/text-diff.ts +81 -0
  162. package/src/text-helper.ts +537 -0
  163. package/src/time-helper.ts +63 -0
  164. package/src/time-tracker-period.ts +73 -0
  165. package/src/timeblock-helper.ts +418 -0
  166. package/src/timezone.ts +190 -0
  167. package/src/timezones.json +1271 -0
  168. package/src/upstash-rest.ts +56 -0
  169. package/src/user-helper.ts +296 -0
  170. package/src/uuid-helper.ts +11 -0
  171. package/src/workspace-handle.ts +10 -0
  172. package/src/workspace-helper.ts +1408 -0
  173. package/src/workspace-limits.ts +68 -0
  174. package/src/yjs-helper.ts +217 -0
  175. package/src/yjs-task-description.ts +81 -0
  176. package/tsconfig.json +3 -5
  177. package/tsconfig.typecheck.json +33 -0
  178. package/vitest.config.ts +36 -0
  179. package/dist/index.d.ts +0 -8
  180. package/dist/index.js +0 -2
  181. package/dist/index.js.map +0 -1
  182. package/dist/index.mjs +0 -2
  183. package/dist/index.mjs.map +0 -1
  184. package/eslint.config.mjs +0 -20
  185. package/rollup.config.js +0 -41
  186. package/src/index.ts +0 -1
@@ -0,0 +1,337 @@
1
+ import { updateWorkspaceTask } from '@tuturuuu/internal-api/tasks';
2
+ import type { TaskPriority } from '@tuturuuu/types/primitives/Priority';
3
+ import type { Task } from '@tuturuuu/types/primitives/Task';
4
+
5
+ import { getMutationApiOptions, listAllActiveTasksForList } from './shared';
6
+
7
+ export function priorityCompare(
8
+ priorityA: TaskPriority | null | undefined,
9
+ priorityB: TaskPriority | null | undefined
10
+ ) {
11
+ const priorityOrder = {
12
+ critical: 4,
13
+ high: 3,
14
+ normal: 2,
15
+ low: 1,
16
+ };
17
+
18
+ const getOrderValue = (priority: TaskPriority | null | undefined): number => {
19
+ return priority ? priorityOrder[priority] : 0;
20
+ };
21
+
22
+ const valueA = getOrderValue(priorityA);
23
+ const valueB = getOrderValue(priorityB);
24
+
25
+ return valueB - valueA;
26
+ }
27
+
28
+ const SORT_KEY_BASE_UNIT = 1000000;
29
+ const SORT_KEY_DEFAULT = SORT_KEY_BASE_UNIT * 1000;
30
+ const SORT_KEY_MIN_GAP = 1000;
31
+
32
+ let sortKeySequence = 0;
33
+
34
+ export class SortKeyGapExhaustedError extends Error {
35
+ constructor(
36
+ public readonly prevSortKey: number | null | undefined,
37
+ public readonly nextSortKey: number | null | undefined,
38
+ message: string
39
+ ) {
40
+ super(message);
41
+ this.name = 'SortKeyGapExhaustedError';
42
+ }
43
+ }
44
+
45
+ export function calculateSortKey(
46
+ prevSortKey: number | null | undefined,
47
+ nextSortKey: number | null | undefined
48
+ ): number {
49
+ sortKeySequence = (sortKeySequence % 999) + 1;
50
+
51
+ if (prevSortKey === null || prevSortKey === undefined) {
52
+ if (nextSortKey === null || nextSortKey === undefined) {
53
+ return SORT_KEY_DEFAULT + sortKeySequence;
54
+ }
55
+
56
+ if (nextSortKey <= 1) {
57
+ throw new SortKeyGapExhaustedError(
58
+ null,
59
+ nextSortKey,
60
+ `Cannot insert before sort key ${nextSortKey}. No positive integer exists strictly less than it. Normalization required.`
61
+ );
62
+ }
63
+
64
+ const halfNext = Math.floor(nextSortKey / 2);
65
+
66
+ if (nextSortKey <= SORT_KEY_MIN_GAP) {
67
+ const result = Math.max(1, Math.min(halfNext, nextSortKey - 1));
68
+ return result;
69
+ } else {
70
+ const baseKey = Math.max(
71
+ halfNext,
72
+ Math.min(SORT_KEY_BASE_UNIT, nextSortKey - SORT_KEY_MIN_GAP)
73
+ );
74
+ const maxSequence = nextSortKey - baseKey - 1;
75
+ const safeSequence = Math.min(sortKeySequence, Math.max(0, maxSequence));
76
+ return baseKey + safeSequence;
77
+ }
78
+ }
79
+
80
+ if (nextSortKey === null || nextSortKey === undefined) {
81
+ return prevSortKey + SORT_KEY_BASE_UNIT + sortKeySequence;
82
+ }
83
+
84
+ const gap = nextSortKey - prevSortKey;
85
+
86
+ if (gap <= 0) {
87
+ throw new SortKeyGapExhaustedError(
88
+ prevSortKey,
89
+ nextSortKey,
90
+ `Cannot insert between inverted sort keys ${prevSortKey} and ${nextSortKey}. Gap (${gap}) is inverted or zero. Normalization required.`
91
+ );
92
+ }
93
+
94
+ if (gap <= 1) {
95
+ throw new SortKeyGapExhaustedError(
96
+ prevSortKey,
97
+ nextSortKey,
98
+ `Cannot insert between sort keys ${prevSortKey} and ${nextSortKey}. Gap (${gap}) is too small. Normalization required.`
99
+ );
100
+ }
101
+
102
+ const midpoint = Math.floor((prevSortKey + nextSortKey) / 2);
103
+
104
+ if (midpoint <= prevSortKey || midpoint >= nextSortKey) {
105
+ throw new SortKeyGapExhaustedError(
106
+ prevSortKey,
107
+ nextSortKey,
108
+ `Calculated midpoint ${midpoint} is not strictly between ${prevSortKey} and ${nextSortKey}. Gap exhausted. Normalization required.`
109
+ );
110
+ }
111
+
112
+ if (gap <= sortKeySequence) {
113
+ console.warn(
114
+ '⚠️ Gap too small for sequence offset, using midpoint - normalization recommended',
115
+ { prevSortKey, nextSortKey, gap, sortKeySequence, midpoint }
116
+ );
117
+ return midpoint;
118
+ }
119
+
120
+ if (gap <= SORT_KEY_MIN_GAP) {
121
+ console.warn(
122
+ '⚠️ Sort key gap small, task ordering may need renormalization',
123
+ { prevSortKey, nextSortKey, gap, threshold: SORT_KEY_MIN_GAP }
124
+ );
125
+
126
+ const maxOffsetUp = nextSortKey - 1 - midpoint;
127
+ const maxOffsetDown = midpoint - prevSortKey - 1;
128
+ const maxSafeOffset = Math.min(maxOffsetUp, maxOffsetDown);
129
+ const safeOffset = Math.min(sortKeySequence, Math.max(0, maxSafeOffset));
130
+
131
+ const result = midpoint + safeOffset;
132
+
133
+ if (result <= prevSortKey || result >= nextSortKey) {
134
+ throw new SortKeyGapExhaustedError(
135
+ prevSortKey,
136
+ nextSortKey,
137
+ `Calculated result ${result} with offset is not strictly between ${prevSortKey} and ${nextSortKey}. Normalization required.`
138
+ );
139
+ }
140
+
141
+ return result;
142
+ }
143
+
144
+ const halfGap = Math.floor(gap / 2);
145
+ const offset = Math.min(sortKeySequence, halfGap - 1);
146
+
147
+ const result = midpoint + offset;
148
+
149
+ if (result <= prevSortKey || result >= nextSortKey) {
150
+ throw new SortKeyGapExhaustedError(
151
+ prevSortKey,
152
+ nextSortKey,
153
+ `Calculated result ${result} with offset is not strictly between ${prevSortKey} and ${nextSortKey}. Normalization required.`
154
+ );
155
+ }
156
+
157
+ return result;
158
+ }
159
+
160
+ export function calculateTopSortKey(
161
+ nextSortKey: number | null | undefined
162
+ ): number {
163
+ return calculateSortKey(null, nextSortKey);
164
+ }
165
+
166
+ export function calculateBottomSortKey(
167
+ prevSortKey: number | null | undefined
168
+ ): number {
169
+ return calculateSortKey(prevSortKey, null);
170
+ }
171
+
172
+ export function resetSortKeySequence(): void {
173
+ sortKeySequence = 0;
174
+ }
175
+
176
+ export function getSortKeyConfig(): {
177
+ BASE_UNIT: number;
178
+ DEFAULT: number;
179
+ MIN_GAP: number;
180
+ } {
181
+ return {
182
+ BASE_UNIT: SORT_KEY_BASE_UNIT,
183
+ DEFAULT: SORT_KEY_DEFAULT,
184
+ MIN_GAP: SORT_KEY_MIN_GAP,
185
+ };
186
+ }
187
+
188
+ type SortKeyLike = { sort_key?: number | null | undefined };
189
+
190
+ export function hasSortKeyCollisions(tasks: SortKeyLike[]): boolean {
191
+ const sortKeys = tasks
192
+ .map((t) => t.sort_key)
193
+ .filter((key): key is number => key !== null && key !== undefined);
194
+
195
+ if (sortKeys.length === 0) return false;
196
+
197
+ const sorted = [...sortKeys].sort((a, b) => a - b);
198
+
199
+ for (let i = 1; i < sorted.length; i++) {
200
+ const prevKey = sorted[i - 1];
201
+ const currKey = sorted[i];
202
+ if (prevKey !== undefined && currKey !== undefined) {
203
+ const gap = currKey - prevKey;
204
+ if (gap < SORT_KEY_MIN_GAP) {
205
+ return true;
206
+ }
207
+ }
208
+ }
209
+
210
+ return false;
211
+ }
212
+
213
+ export function normalizeSortKeys(tasks: Task[]): Task[] {
214
+ const sorted = [...tasks].sort((a, b) => {
215
+ const sortA = a.sort_key ?? Number.MAX_SAFE_INTEGER;
216
+ const sortB = b.sort_key ?? Number.MAX_SAFE_INTEGER;
217
+ if (sortA !== sortB) return sortA - sortB;
218
+ return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
219
+ });
220
+
221
+ return sorted.map((task, index) => ({
222
+ ...task,
223
+ sort_key: (index + 1) * SORT_KEY_BASE_UNIT,
224
+ }));
225
+ }
226
+
227
+ function sortTasksByPersistedOrder(
228
+ tasks: Pick<Task, 'id' | 'sort_key' | 'created_at'>[]
229
+ ) {
230
+ return [...tasks].sort((a, b) => {
231
+ const sortA = a.sort_key ?? Number.MAX_SAFE_INTEGER;
232
+ const sortB = b.sort_key ?? Number.MAX_SAFE_INTEGER;
233
+ if (sortA !== sortB) return sortA - sortB;
234
+ return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
235
+ });
236
+ }
237
+
238
+ function reorderCanonicalTasksFromVisualOrder(
239
+ canonicalTasks: Pick<Task, 'id' | 'sort_key' | 'created_at'>[],
240
+ visualOrderTasks: Pick<Task, 'id' | 'sort_key' | 'created_at'>[]
241
+ ) {
242
+ const visualOrderById = new Map(
243
+ visualOrderTasks.map((task, index) => [task.id, index] as const)
244
+ );
245
+
246
+ const coversCanonicalList =
247
+ visualOrderById.size === canonicalTasks.length &&
248
+ canonicalTasks.every((task) => visualOrderById.has(task.id));
249
+
250
+ if (!coversCanonicalList) {
251
+ return canonicalTasks;
252
+ }
253
+
254
+ return [...canonicalTasks].sort(
255
+ (a, b) => visualOrderById.get(a.id)! - visualOrderById.get(b.id)!
256
+ );
257
+ }
258
+
259
+ export async function normalizeListSortKeys(
260
+ wsId: string,
261
+ listId: string,
262
+ visualOrderTasks?: Pick<Task, 'id' | 'sort_key' | 'created_at'>[]
263
+ ): Promise<void> {
264
+ if (visualOrderTasks !== undefined) {
265
+ if (visualOrderTasks.length === 0) {
266
+ return;
267
+ }
268
+ }
269
+
270
+ const fetchedTasks = await listAllActiveTasksForList(wsId, listId);
271
+
272
+ if (!fetchedTasks.length) {
273
+ return;
274
+ }
275
+
276
+ const canonicalTasks = sortTasksByPersistedOrder(
277
+ fetchedTasks.map((task) => ({
278
+ id: task.id,
279
+ sort_key: task.sort_key ?? null,
280
+ created_at: task.created_at,
281
+ }))
282
+ );
283
+
284
+ const tasks =
285
+ visualOrderTasks !== undefined
286
+ ? reorderCanonicalTasksFromVisualOrder(canonicalTasks, visualOrderTasks)
287
+ : canonicalTasks;
288
+
289
+ if (!visualOrderTasks) {
290
+ const needsNormalization = hasSortKeyCollisions(tasks);
291
+
292
+ if (!needsNormalization) {
293
+ return;
294
+ }
295
+ }
296
+
297
+ const updates = tasks.map((task, index) => ({
298
+ id: task.id,
299
+ sort_key: (index + 1) * SORT_KEY_BASE_UNIT,
300
+ }));
301
+
302
+ const options = await getMutationApiOptions();
303
+
304
+ const concurrency = 5;
305
+ const errors: Error[] = [];
306
+
307
+ for (let index = 0; index < updates.length; index += concurrency) {
308
+ const chunk = updates.slice(index, index + concurrency);
309
+ const results = await Promise.allSettled(
310
+ chunk.map((update) =>
311
+ updateWorkspaceTask(
312
+ wsId,
313
+ update.id,
314
+ { sort_key: update.sort_key },
315
+ options
316
+ )
317
+ )
318
+ );
319
+
320
+ for (const result of results) {
321
+ if (result.status === 'rejected') {
322
+ errors.push(
323
+ result.reason instanceof Error
324
+ ? result.reason
325
+ : new Error(String(result.reason))
326
+ );
327
+ }
328
+ }
329
+ }
330
+
331
+ if (errors.length > 0) {
332
+ throw new AggregateError(
333
+ errors,
334
+ `Failed to update ${errors.length} task(s) during normalization`
335
+ );
336
+ }
337
+ }
@@ -0,0 +1,342 @@
1
+ import { useMutation, useQueryClient } from '@tanstack/react-query';
2
+ import {
3
+ createWorkspaceTask,
4
+ getWorkspaceTaskRelationships,
5
+ updateWorkspaceTask,
6
+ } from '@tuturuuu/internal-api/tasks';
7
+ import type { Task } from '@tuturuuu/types/primitives/Task';
8
+
9
+ import { getBrowserApiOptions, toWorkspaceTaskUpdatePayload } from './shared';
10
+
11
+ export function useUpdateTask(boardId: string, wsId?: string) {
12
+ const queryClient = useQueryClient();
13
+
14
+ return useMutation({
15
+ mutationFn: async ({
16
+ taskId,
17
+ updates,
18
+ }: {
19
+ taskId: string;
20
+ updates: Partial<Task>;
21
+ }) => {
22
+ if (!wsId) {
23
+ throw new Error('Workspace ID is required to update tasks');
24
+ }
25
+ const { task } = await updateWorkspaceTask(
26
+ wsId,
27
+ taskId,
28
+ toWorkspaceTaskUpdatePayload(updates),
29
+ getBrowserApiOptions()
30
+ );
31
+ return task as Task;
32
+ },
33
+ onMutate: async ({ taskId, updates }) => {
34
+ await queryClient.cancelQueries({ queryKey: ['tasks', boardId] });
35
+
36
+ const previousTasks = queryClient.getQueryData(['tasks', boardId]);
37
+
38
+ let blockedTaskIdsPromise: Promise<string[]> | null = null;
39
+ if (
40
+ updates.completed_at !== undefined ||
41
+ updates.closed_at !== undefined
42
+ ) {
43
+ if (wsId) {
44
+ blockedTaskIdsPromise = Promise.resolve(
45
+ getWorkspaceTaskRelationships(wsId, taskId, getBrowserApiOptions())
46
+ )
47
+ .then((relationships) =>
48
+ (relationships.blocking ?? []).map((task) => task.id)
49
+ )
50
+ .catch((err: unknown) => {
51
+ console.error('Failed to fetch blocked task IDs:', err);
52
+ return [];
53
+ });
54
+ }
55
+ }
56
+
57
+ queryClient.setQueryData(
58
+ ['tasks', boardId],
59
+ (old: Task[] | undefined) => {
60
+ if (!old) return old;
61
+ return old.map((task) =>
62
+ task.id === taskId ? { ...task, ...updates } : task
63
+ );
64
+ }
65
+ );
66
+
67
+ return { previousTasks, blockedTaskIdsPromise };
68
+ },
69
+ onError: (err, _, context) => {
70
+ if (context?.previousTasks) {
71
+ queryClient.setQueryData(['tasks', boardId], context.previousTasks);
72
+ }
73
+
74
+ console.error('Failed to update task:', err);
75
+ },
76
+ onSuccess: async (updatedTask, variables, context) => {
77
+ queryClient.setQueryData(
78
+ ['tasks', boardId],
79
+ (old: Task[] | undefined) => {
80
+ if (!old) return old;
81
+ return old.map((task) => {
82
+ if (task.id === updatedTask.id) {
83
+ return {
84
+ ...task,
85
+ ...updatedTask,
86
+ };
87
+ }
88
+ return task;
89
+ });
90
+ }
91
+ );
92
+
93
+ if (
94
+ variables.updates.completed_at !== undefined ||
95
+ variables.updates.closed_at !== undefined
96
+ ) {
97
+ await queryClient.invalidateQueries({
98
+ queryKey: ['task-relationships', variables.taskId],
99
+ });
100
+
101
+ if (context?.blockedTaskIdsPromise) {
102
+ const blockedTaskIds = await context.blockedTaskIdsPromise;
103
+ if (blockedTaskIds.length > 0) {
104
+ await Promise.all(
105
+ blockedTaskIds.map((blockedTaskId) =>
106
+ queryClient.invalidateQueries({
107
+ queryKey: ['task-relationships', blockedTaskId],
108
+ })
109
+ )
110
+ );
111
+ }
112
+ }
113
+ }
114
+ },
115
+ });
116
+ }
117
+
118
+ export function useCreateTask(boardId: string, wsId?: string) {
119
+ const queryClient = useQueryClient();
120
+
121
+ return useMutation({
122
+ mutationFn: async ({
123
+ listId,
124
+ task,
125
+ }: {
126
+ listId: string;
127
+ task: Partial<Task> & {
128
+ description_yjs_state?: number[];
129
+ label_ids?: string[];
130
+ assignee_ids?: string[];
131
+ project_ids?: string[];
132
+ };
133
+ }) => {
134
+ if (!wsId) {
135
+ throw new Error('Workspace ID is required to create tasks');
136
+ }
137
+ if (!task.name?.trim()) {
138
+ throw new Error('Task name is required');
139
+ }
140
+
141
+ const { task: createdTask } = await createWorkspaceTask(
142
+ wsId,
143
+ {
144
+ name: task.name.trim(),
145
+ description: task.description || null,
146
+ description_yjs_state: task.description_yjs_state ?? null,
147
+ listId,
148
+ priority: task.priority || null,
149
+ start_date: task.start_date || null,
150
+ end_date: task.end_date || null,
151
+ estimation_points: task.estimation_points ?? null,
152
+ label_ids: task.label_ids ?? [],
153
+ assignee_ids: task.assignee_ids ?? [],
154
+ project_ids: task.project_ids ?? [],
155
+ total_duration: task.total_duration ?? null,
156
+ is_splittable: task.is_splittable ?? null,
157
+ min_split_duration_minutes: task.min_split_duration_minutes ?? null,
158
+ max_split_duration_minutes: task.max_split_duration_minutes ?? null,
159
+ calendar_hours: task.calendar_hours ?? null,
160
+ auto_schedule: task.auto_schedule ?? null,
161
+ },
162
+ getBrowserApiOptions()
163
+ );
164
+
165
+ return createdTask as Task;
166
+ },
167
+ onMutate: async ({ listId, task }) => {
168
+ await queryClient.cancelQueries({ queryKey: ['tasks', boardId] });
169
+
170
+ const previousTasks = queryClient.getQueryData(['tasks', boardId]);
171
+ const trimmedName = task.name?.trim() ?? '';
172
+
173
+ const optimisticTask: Task = {
174
+ ...task,
175
+ id: `temp-${Date.now()}`,
176
+ name: trimmedName,
177
+ list_id: listId,
178
+ closed_at: undefined,
179
+ deleted_at: undefined,
180
+ created_at: new Date().toISOString(),
181
+ updated_at: new Date().toISOString(),
182
+ assignees: [],
183
+ } as Task;
184
+
185
+ queryClient.setQueryData(
186
+ ['tasks', boardId],
187
+ (old: Task[] | undefined) => {
188
+ if (!old) return [optimisticTask];
189
+ return [...old, optimisticTask];
190
+ }
191
+ );
192
+
193
+ return { previousTasks, optimisticTask };
194
+ },
195
+ onError: (err, _, context) => {
196
+ if (context?.previousTasks) {
197
+ queryClient.setQueryData(['tasks', boardId], context.previousTasks);
198
+ } else if (context?.optimisticTask) {
199
+ queryClient.setQueryData(
200
+ ['tasks', boardId],
201
+ (old: Task[] | undefined) => {
202
+ if (!old) return old;
203
+ const nextTasks = old.filter(
204
+ (task) => task.id !== context.optimisticTask.id
205
+ );
206
+ return nextTasks.length > 0 ? nextTasks : undefined;
207
+ }
208
+ );
209
+ }
210
+
211
+ console.error('Failed to create task:', err);
212
+ },
213
+ onSuccess: (newTask, _, context) => {
214
+ queryClient.setQueryData(
215
+ ['tasks', boardId],
216
+ (old: Task[] | undefined) => {
217
+ const optimisticTaskId = context?.optimisticTask.id;
218
+
219
+ if (!old) return [newTask];
220
+
221
+ const nextTasks = optimisticTaskId
222
+ ? old.map((task) => (task.id === optimisticTaskId ? newTask : task))
223
+ : [...old];
224
+
225
+ if (nextTasks.some((task) => task.id === newTask.id)) {
226
+ return nextTasks;
227
+ }
228
+
229
+ return [...nextTasks, newTask];
230
+ }
231
+ );
232
+ },
233
+ });
234
+ }
235
+
236
+ export function useDeleteTask(boardId: string, wsId?: string) {
237
+ const queryClient = useQueryClient();
238
+
239
+ return useMutation({
240
+ mutationFn: async (taskId: string) => {
241
+ if (!wsId) {
242
+ throw new Error('Workspace ID is required to delete tasks');
243
+ }
244
+ const { task } = await updateWorkspaceTask(
245
+ wsId,
246
+ taskId,
247
+ { deleted: true },
248
+ getBrowserApiOptions()
249
+ );
250
+ return task as Task;
251
+ },
252
+ onMutate: async (taskId) => {
253
+ await queryClient.cancelQueries({ queryKey: ['tasks', boardId] });
254
+ await queryClient.cancelQueries({ queryKey: ['deleted-tasks', boardId] });
255
+
256
+ const previousTasks = queryClient.getQueryData(['tasks', boardId]) as
257
+ | Task[]
258
+ | undefined;
259
+ const previousDeletedTasks = queryClient.getQueryData([
260
+ 'deleted-tasks',
261
+ boardId,
262
+ ]) as Task[] | undefined;
263
+
264
+ const deletedTask = previousTasks?.find((task) => task.id === taskId);
265
+
266
+ queryClient.setQueryData(
267
+ ['tasks', boardId],
268
+ (old: Task[] | undefined) => {
269
+ if (!old) return old;
270
+ return old.filter((task) => task.id !== taskId);
271
+ }
272
+ );
273
+
274
+ if (deletedTask) {
275
+ queryClient.setQueryData(
276
+ ['deleted-tasks', boardId],
277
+ (old: Task[] | undefined) => {
278
+ const taskWithDeletedAt = {
279
+ ...deletedTask,
280
+ deleted_at: new Date().toISOString(),
281
+ };
282
+ if (!old) return [taskWithDeletedAt];
283
+ return [taskWithDeletedAt, ...old];
284
+ }
285
+ );
286
+ }
287
+
288
+ return { previousTasks, previousDeletedTasks, deletedTask };
289
+ },
290
+ onError: (err, _, context) => {
291
+ if (context?.previousTasks) {
292
+ queryClient.setQueryData(['tasks', boardId], context.previousTasks);
293
+ }
294
+ if (context?.previousDeletedTasks) {
295
+ queryClient.setQueryData(
296
+ ['deleted-tasks', boardId],
297
+ context.previousDeletedTasks
298
+ );
299
+ } else if (context?.deletedTask) {
300
+ const deletedTaskId = context.deletedTask.id;
301
+ queryClient.setQueryData(
302
+ ['deleted-tasks', boardId],
303
+ (old: Task[] | undefined) => {
304
+ if (!old) return old;
305
+ const nextTasks = old.filter((task) => task.id !== deletedTaskId);
306
+ return nextTasks.length > 0 ? nextTasks : undefined;
307
+ }
308
+ );
309
+ }
310
+
311
+ console.error('Failed to delete task:', err);
312
+ },
313
+ onSuccess: (deletedTask) => {
314
+ queryClient.setQueryData(
315
+ ['tasks', boardId],
316
+ (old: Task[] | undefined) => {
317
+ if (!old) return old;
318
+ const nextTasks = old.filter((task) => task.id !== deletedTask.id);
319
+ return nextTasks.length > 0 ? nextTasks : undefined;
320
+ }
321
+ );
322
+
323
+ queryClient.setQueryData(
324
+ ['deleted-tasks', boardId],
325
+ (old: Task[] | undefined) => {
326
+ if (!old) return [deletedTask];
327
+
328
+ const existingIndex = old.findIndex(
329
+ (task) => task.id === deletedTask.id
330
+ );
331
+ if (existingIndex === -1) {
332
+ return [deletedTask, ...old];
333
+ }
334
+
335
+ return old.map((task) =>
336
+ task.id === deletedTask.id ? deletedTask : task
337
+ );
338
+ }
339
+ );
340
+ },
341
+ });
342
+ }