@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.
- package/CHANGELOG.md +313 -0
- package/biome.json +5 -0
- package/jsr.json +8 -8
- package/package.json +63 -32
- package/src/__tests__/ai-temp-auth.test.ts +309 -0
- package/src/__tests__/api-proxy-guard.test.ts +1451 -0
- package/src/__tests__/app-url.test.ts +270 -0
- package/src/__tests__/avatar-url.test.ts +97 -0
- package/src/__tests__/color-helper.test.ts +179 -0
- package/src/__tests__/constants.test.ts +351 -0
- package/src/__tests__/crypto.test.ts +107 -0
- package/src/__tests__/date-helper.test.ts +408 -0
- package/src/__tests__/fixtures/task-description-full-featured.json +456 -0
- package/src/__tests__/format.test.ts +317 -0
- package/src/__tests__/html-sanitizer.test.ts +360 -0
- package/src/__tests__/interest-calculator.test.ts +336 -0
- package/src/__tests__/interest-detector.test.ts +222 -0
- package/src/__tests__/label-colors.test.ts +241 -0
- package/src/__tests__/name-helper.test.ts +158 -0
- package/src/__tests__/node-diff.test.ts +576 -0
- package/src/__tests__/notification-service.test.ts +210 -0
- package/src/__tests__/onboarding-helper.test.ts +331 -0
- package/src/__tests__/path-helper.test.ts +152 -0
- package/src/__tests__/permissions.test.tsx +81 -0
- package/src/__tests__/request-emoji-limit.test.ts +172 -0
- package/src/__tests__/search-helper.test.ts +51 -0
- package/src/__tests__/storage-display-name.test.ts +37 -0
- package/src/__tests__/storage-path.test.ts +238 -0
- package/src/__tests__/tag-utils.test.ts +205 -0
- package/src/__tests__/task-description-yjs-state.test.ts +581 -0
- package/src/__tests__/task-helper-board-api-routing.test.ts +94 -0
- package/src/__tests__/task-helper-create-task.test.ts +129 -0
- package/src/__tests__/task-helpers.test.ts +464 -0
- package/src/__tests__/task-overrides.test.ts +305 -0
- package/src/__tests__/task-reorder-cache.test.ts +74 -0
- package/src/__tests__/task-sort-keys.test.ts +36 -0
- package/src/__tests__/task-transformers.test.ts +62 -0
- package/src/__tests__/text-helper.test.ts +776 -0
- package/src/__tests__/time-helper.test.ts +70 -0
- package/src/__tests__/time-tracker-period.test.ts +55 -0
- package/src/__tests__/timezone.test.ts +117 -0
- package/src/__tests__/upstash-rest.test.ts +77 -0
- package/src/__tests__/uuid-helper.test.ts +133 -0
- package/src/__tests__/workspace-helper.test.ts +859 -0
- package/src/__tests__/workspace-limits.test.ts +255 -0
- package/src/__tests__/yjs-helper.test.ts +581 -0
- package/src/abuse-protection/__tests__/backend-rate-limit.test.ts +113 -0
- package/src/abuse-protection/__tests__/edge.test.ts +136 -0
- package/src/abuse-protection/__tests__/index.test.ts +562 -0
- package/src/abuse-protection/__tests__/reputation.test.ts +192 -0
- package/src/abuse-protection/backend-rate-limit.ts +44 -0
- package/src/abuse-protection/constants.ts +117 -0
- package/src/abuse-protection/edge.ts +223 -0
- package/src/abuse-protection/index.ts +1545 -0
- package/src/abuse-protection/reputation.ts +587 -0
- package/src/abuse-protection/types.ts +97 -0
- package/src/abuse-protection/user-agent.ts +124 -0
- package/src/abuse-protection/user-suspension.ts +231 -0
- package/src/ai-temp-auth.ts +315 -0
- package/src/api-proxy-guard.ts +965 -0
- package/src/app-url.ts +96 -0
- package/src/avatar-url.ts +64 -0
- package/src/break-duration.ts +84 -0
- package/src/calendar-auth-token.test.ts +37 -0
- package/src/calendar-auth-token.ts +19 -0
- package/src/calendar-sync-coordination.md +197 -0
- package/src/calendar-utils.test.ts +169 -0
- package/src/calendar-utils.ts +91 -0
- package/src/color-helper.ts +110 -0
- package/src/common/nextjs.tsx +99 -0
- package/src/common/scan.tsx +15 -0
- package/src/configs/reports.ts +160 -0
- package/src/constants.ts +85 -0
- package/src/crypto.ts +21 -0
- package/src/currencies.ts +97 -0
- package/src/date-helper.ts +313 -0
- package/src/editor/convert-to-task.ts +264 -0
- package/src/editor/index.ts +5 -0
- package/src/email/__tests__/client.test.ts +141 -0
- package/src/email/__tests__/validation.test.ts +46 -0
- package/src/email/client.ts +92 -0
- package/src/email/server.ts +128 -0
- package/src/email/validation.ts +11 -0
- package/src/encryption/__tests__/calendar-events.test.ts +411 -0
- package/src/encryption/__tests__/configuration.test.ts +114 -0
- package/src/encryption/__tests__/field-encryption.test.ts +232 -0
- package/src/encryption/__tests__/key-generation.test.ts +30 -0
- package/src/encryption/__tests__/performance-edge-cases.test.ts +187 -0
- package/src/encryption/__tests__/test-helpers.ts +22 -0
- package/src/encryption/__tests__/workspace-key-encryption.test.ts +129 -0
- package/src/encryption/encryption-service.ts +343 -0
- package/src/encryption/index.ts +25 -0
- package/src/encryption/types.ts +57 -0
- package/src/exchange-rates.ts +49 -0
- package/src/feature-flags/__tests__/feature-flags.test.ts +302 -0
- package/src/feature-flags/core.ts +322 -0
- package/src/feature-flags/data.ts +16 -0
- package/src/feature-flags/default.ts +18 -0
- package/src/feature-flags/index.ts +7 -0
- package/src/feature-flags/requestable-features.ts +79 -0
- package/src/feature-flags/types.ts +4 -0
- package/src/fetcher.ts +2 -0
- package/src/finance/index.ts +4 -0
- package/src/finance/interest-calculator.ts +456 -0
- package/src/finance/interest-detector.ts +141 -0
- package/src/finance/transform-invoice-results.ts +219 -0
- package/src/finance/wallet-permissions.test.ts +169 -0
- package/src/finance/wallet-permissions.ts +82 -0
- package/src/format.ts +120 -1
- package/src/generated/platform-build-metadata.ts +11 -0
- package/src/hooks/use-platform.ts +64 -0
- package/src/html-sanitizer.ts +155 -0
- package/src/internal-domains.ts +497 -0
- package/src/keyboard-preset.ts +109 -0
- package/src/label-colors.ts +213 -0
- package/src/launchable-apps.test.ts +126 -0
- package/src/launchable-apps.ts +490 -0
- package/src/name-helper.ts +269 -0
- package/src/next-config.test.ts +234 -0
- package/src/next-config.ts +203 -0
- package/src/node-diff.ts +375 -0
- package/src/notification-service.ts +379 -0
- package/src/nova/scores/__tests__/calculate.test.ts +254 -0
- package/src/nova/scores/calculate.ts +132 -0
- package/src/nova/submissions/check-permission.ts +132 -0
- package/src/onboarding-helper.ts +213 -0
- package/src/path-helper.ts +93 -0
- package/src/permissions.tsx +1170 -0
- package/src/plan-helpers.test.ts +188 -0
- package/src/plan-helpers.ts +80 -0
- package/src/platform-release.test.ts +74 -0
- package/src/platform-release.ts +155 -0
- package/src/portless.ts +124 -0
- package/src/priority-styles.ts +42 -0
- package/src/request-emoji-limit.ts +335 -0
- package/src/search-helper.ts +18 -0
- package/src/search.test.ts +89 -0
- package/src/search.ts +355 -0
- package/src/storage-display-name.ts +30 -0
- package/src/storage-path.ts +147 -0
- package/src/tag-utils.ts +159 -0
- package/src/task/reorder.ts +245 -0
- package/src/task/transformers.ts +149 -0
- package/src/task-date-timezone.ts +133 -0
- package/src/task-description-content.ts +240 -0
- package/src/task-helper/board.ts +193 -0
- package/src/task-helper/bulk-actions.ts +564 -0
- package/src/task-helper/personal-external-staging.ts +21 -0
- package/src/task-helper/recycle-bin.ts +202 -0
- package/src/task-helper/relationships.ts +346 -0
- package/src/task-helper/shared.ts +109 -0
- package/src/task-helper/sort-keys.ts +337 -0
- package/src/task-helper/task-hooks-basic.ts +342 -0
- package/src/task-helper/task-hooks-move.ts +264 -0
- package/src/task-helper/task-operations.ts +278 -0
- package/src/task-helper.ts +12 -0
- package/src/task-helpers.ts +241 -0
- package/src/task-list-status.ts +62 -0
- package/src/task-overrides.ts +82 -0
- package/src/task-snapshot.ts +374 -0
- package/src/text-diff.ts +81 -0
- package/src/text-helper.ts +537 -0
- package/src/time-helper.ts +63 -0
- package/src/time-tracker-period.ts +73 -0
- package/src/timeblock-helper.ts +418 -0
- package/src/timezone.ts +190 -0
- package/src/timezones.json +1271 -0
- package/src/upstash-rest.ts +56 -0
- package/src/user-helper.ts +296 -0
- package/src/uuid-helper.ts +11 -0
- package/src/workspace-handle.ts +10 -0
- package/src/workspace-helper.ts +1408 -0
- package/src/workspace-limits.ts +68 -0
- package/src/yjs-helper.ts +217 -0
- package/src/yjs-task-description.ts +81 -0
- package/tsconfig.json +3 -5
- package/tsconfig.typecheck.json +33 -0
- package/vitest.config.ts +36 -0
- package/dist/index.d.ts +0 -8
- package/dist/index.js +0 -2
- package/dist/index.js.map +0 -1
- package/dist/index.mjs +0 -2
- package/dist/index.mjs.map +0 -1
- package/eslint.config.mjs +0 -20
- package/rollup.config.js +0 -41
- 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
|
+
}
|