@tuturuuu/utils 0.24.2 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.25.0](https://github.com/tutur3u/platform/compare/utils-v0.24.2...utils-v0.25.0) (2026-08-14)
4
+
5
+
6
+ ### Features
7
+
8
+ * **workspaces:** assign roles to pending invites ([#5125](https://github.com/tutur3u/platform/issues/5125)) ([d4052fd](https://github.com/tutur3u/platform/commit/d4052fd40de66bda4e9535740bbcfd5a8da19123))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **contacts:** harden satellite workspace access ([b160723](https://github.com/tutur3u/platform/commit/b160723b509062a4f8edf2694c0af27c25c05775))
14
+ * **tasks:** keep dropped cards in destination ([de9353b](https://github.com/tutur3u/platform/commit/de9353b0b3574b240c0caa478ba52935d3ad00bb))
15
+ * **tasks:** render dropped tasks immediately ([1dcdb65](https://github.com/tutur3u/platform/commit/1dcdb655ff960c59f91b93b7f13622d1b65f6f48))
16
+
3
17
  ## [0.24.2](https://github.com/tutur3u/platform/compare/utils-v0.24.1...utils-v0.24.2) (2026-08-11)
4
18
 
5
19
 
package/biome.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "root": false,
3
- "$schema": "https://biomejs.dev/schemas/2.5.7/schema.json",
3
+ "$schema": "https://biomejs.dev/schemas/2.5.8/schema.json",
4
4
  "extends": "//"
5
5
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tuturuuu/utils",
3
3
  "license": "MIT",
4
- "version": "0.24.2",
4
+ "version": "0.25.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/tutur3u/platform",
@@ -17,14 +17,14 @@
17
17
  },
18
18
  "dependencies": {
19
19
  "@dnd-kit/core": "^6.3.1",
20
- "@noble/ciphers": "^2.2.0",
21
- "@noble/hashes": "^2.2.0",
20
+ "@noble/ciphers": "^2.3.0",
21
+ "@noble/hashes": "^2.3.0",
22
22
  "@tanstack/react-query": "^5.101.4",
23
- "@tiptap/core": "3.29.2",
24
- "@tiptap/react": "3.29.2",
23
+ "@tiptap/core": "3.30.0",
24
+ "@tiptap/react": "3.30.0",
25
25
  "@tuturuuu/google": "0.1.0",
26
26
  "@tuturuuu/icons": "0.1.0",
27
- "@tuturuuu/internal-api": "0.29.1",
27
+ "@tuturuuu/internal-api": "0.30.0",
28
28
  "@tuturuuu/supabase": "0.5.0",
29
29
  "@upstash/ratelimit": "^2.0.8",
30
30
  "@upstash/redis": "^1.38.2",
@@ -34,7 +34,7 @@
34
34
  "diff": "^9.0.0",
35
35
  "effect": "^3.22.1",
36
36
  "moment": "^2.30.1",
37
- "next": "^16.3.0",
37
+ "next": "^16.3.1",
38
38
  "prosemirror-model": "^1.25.11",
39
39
  "react": "^19.2.8",
40
40
  "react-dom": "^19.2.8",
@@ -48,11 +48,11 @@
48
48
  "@tuturuuu/types": "0.27.0",
49
49
  "@tuturuuu/typescript-config": "0.1.1",
50
50
  "@types/diff": "^8.0.0",
51
- "@types/node": "^26.1.2",
51
+ "@types/node": "^26.2.0",
52
52
  "@types/react": "^19.2.18",
53
53
  "@vitejs/plugin-react": "^6.0.5",
54
54
  "typescript": "7.0.2",
55
- "vite": "^8.2.0",
55
+ "vite": "^8.2.1",
56
56
  "vitest": "^4.1.10"
57
57
  },
58
58
  "exports": {
@@ -0,0 +1,117 @@
1
+ import { QueryClient } from '@tanstack/react-query';
2
+ import type { Task } from '@tuturuuu/types/primitives/Task';
3
+ import { describe, expect, it } from 'vitest';
4
+ import {
5
+ insertOptimisticTaskIntoBoardCaches,
6
+ reconcileOptimisticTaskInBoardCaches,
7
+ removeOptimisticTaskFromBoardCaches,
8
+ } from '../task-helper/optimistic-task-cache';
9
+
10
+ function task(id: string, listId = 'list-1'): Task {
11
+ return {
12
+ assignees: [],
13
+ created_at: '2026-08-14T00:00:00.000Z',
14
+ id,
15
+ labels: [],
16
+ list_id: listId,
17
+ name: id,
18
+ projects: [],
19
+ } as unknown as Task;
20
+ }
21
+
22
+ describe('optimistic task creation cache lifecycle', () => {
23
+ it('inserts the pending task into the board and every mounted full-task variant', () => {
24
+ const queryClient = new QueryClient();
25
+ const boardId = 'board-1';
26
+ const existingTask = task('existing');
27
+ const optimisticTask = {
28
+ ...task('optimistic-1'),
29
+ _isOptimistic: true,
30
+ } as Task;
31
+
32
+ queryClient.setQueryData(['tasks', boardId], [existingTask]);
33
+ queryClient.setQueryData(['tasks-full', boardId], [existingTask]);
34
+ queryClient.setQueryData(
35
+ ['tasks-full', boardId, 'filtered'],
36
+ [existingTask]
37
+ );
38
+ queryClient.setQueryData(['tasks-full', 'another-board'], [existingTask]);
39
+
40
+ insertOptimisticTaskIntoBoardCaches(queryClient, boardId, optimisticTask);
41
+
42
+ expect(queryClient.getQueryData(['tasks', boardId])).toEqual([
43
+ existingTask,
44
+ optimisticTask,
45
+ ]);
46
+ expect(queryClient.getQueryData(['tasks-full', boardId])).toEqual([
47
+ existingTask,
48
+ optimisticTask,
49
+ ]);
50
+ expect(
51
+ queryClient.getQueryData(['tasks-full', boardId, 'filtered'])
52
+ ).toEqual([existingTask, optimisticTask]);
53
+ expect(queryClient.getQueryData(['tasks-full', 'another-board'])).toEqual([
54
+ existingTask,
55
+ ]);
56
+ });
57
+
58
+ it('replaces the pending task without duplicating a realtime server task', () => {
59
+ const queryClient = new QueryClient();
60
+ const boardId = 'board-1';
61
+ const optimisticTask = {
62
+ ...task('optimistic-1'),
63
+ _isOptimistic: true,
64
+ } as Task;
65
+ const createdTask = task('created-1');
66
+
67
+ queryClient.setQueryData(['tasks', boardId], [optimisticTask]);
68
+ queryClient.setQueryData(
69
+ ['tasks-full', boardId, 'filtered'],
70
+ [optimisticTask, createdTask]
71
+ );
72
+
73
+ reconcileOptimisticTaskInBoardCaches(
74
+ queryClient,
75
+ boardId,
76
+ optimisticTask.id,
77
+ createdTask
78
+ );
79
+
80
+ expect(queryClient.getQueryData(['tasks', boardId])).toEqual([createdTask]);
81
+ expect(
82
+ queryClient.getQueryData(['tasks-full', boardId, 'filtered'])
83
+ ).toEqual([createdTask]);
84
+ });
85
+
86
+ it('rolls back only the failed optimistic task', () => {
87
+ const queryClient = new QueryClient();
88
+ const boardId = 'board-1';
89
+ const optimisticTask = {
90
+ ...task('optimistic-1'),
91
+ _isOptimistic: true,
92
+ } as Task;
93
+ const concurrentTask = task('concurrent');
94
+
95
+ queryClient.setQueryData(
96
+ ['tasks', boardId],
97
+ [optimisticTask, concurrentTask]
98
+ );
99
+ queryClient.setQueryData(
100
+ ['tasks-full', boardId, 'filtered'],
101
+ [optimisticTask, concurrentTask]
102
+ );
103
+
104
+ removeOptimisticTaskFromBoardCaches(
105
+ queryClient,
106
+ boardId,
107
+ optimisticTask.id
108
+ );
109
+
110
+ expect(queryClient.getQueryData(['tasks', boardId])).toEqual([
111
+ concurrentTask,
112
+ ]);
113
+ expect(
114
+ queryClient.getQueryData(['tasks-full', boardId, 'filtered'])
115
+ ).toEqual([concurrentTask]);
116
+ });
117
+ });
@@ -1,7 +1,9 @@
1
+ import { QueryClient } from '@tanstack/react-query';
1
2
  import type { Task } from '@tuturuuu/types/primitives/Task';
2
3
  import { describe, expect, it, vi } from 'vitest';
3
4
 
4
5
  import {
6
+ cancelStaleTaskBoardQueries,
5
7
  mergeOptimisticReorderedTaskIntoCache,
6
8
  mergeServerReorderedTaskIntoCache,
7
9
  } from '../task/reorder';
@@ -19,6 +21,22 @@ function createTask(overrides: Partial<Task> = {}): Task {
19
21
  }
20
22
 
21
23
  describe('reorder task cache helpers', () => {
24
+ it('cancels stale board fetches without reverting the dropped destination', () => {
25
+ const queryClient = new QueryClient();
26
+ const cancelQueries = vi.spyOn(queryClient, 'cancelQueries');
27
+
28
+ cancelStaleTaskBoardQueries(queryClient, 'board-1');
29
+
30
+ expect(cancelQueries).toHaveBeenCalledWith(
31
+ { queryKey: ['tasks', 'board-1'] },
32
+ { revert: false }
33
+ );
34
+ expect(cancelQueries).toHaveBeenCalledWith(
35
+ { queryKey: ['tasks-full', 'board-1'] },
36
+ { revert: false }
37
+ );
38
+ });
39
+
22
40
  it('marks optimistic list moves so stale list loads cannot move the card back', () => {
23
41
  vi.useFakeTimers();
24
42
  vi.setSystemTime(new Date('2026-05-07T01:00:00.000Z'));
@@ -8,6 +8,7 @@ import {
8
8
  isTuturuuuTurbopackRustReactCompilerEnabled,
9
9
  resolveTuturuuuInfrastructureAppUrl,
10
10
  resolveTuturuuuWebAppUrl,
11
+ TUTURUUU_NEXT_IMAGE_MINIMUM_CACHE_TTL_SECONDS,
11
12
  TUTURUUU_NEXT_IMAGE_REMOTE_PATTERNS,
12
13
  TUTURUUU_NEXT_OPTIMIZE_PACKAGE_IMPORTS,
13
14
  trimTrailingSlashes,
@@ -26,6 +27,9 @@ describe('createTuturuuuNextConfig', () => {
26
27
  expect(config.images?.remotePatterns).toEqual(
27
28
  TUTURUUU_NEXT_IMAGE_REMOTE_PATTERNS
28
29
  );
30
+ expect(config.images?.minimumCacheTTL).toBe(
31
+ TUTURUUU_NEXT_IMAGE_MINIMUM_CACHE_TTL_SECONDS
32
+ );
29
33
  expect(config.poweredByHeader).toBe(false);
30
34
  expect(config.reactCompiler).toBe(true);
31
35
  expect(config.reactStrictMode).toBe(true);
@@ -198,6 +202,7 @@ describe('createTuturuuuNextConfig', () => {
198
202
  partialPrefetching: false,
199
203
  reactCompiler: false,
200
204
  transpilePackages: ['@tuturuuu/ui'],
205
+ images: { minimumCacheTTL: 86_400 },
201
206
  experimental: {
202
207
  cpus: 2,
203
208
  turbopackFileSystemCacheForBuild: false,
@@ -213,6 +218,7 @@ describe('createTuturuuuNextConfig', () => {
213
218
  expect(config.partialPrefetching).toBe(false);
214
219
  expect(config.reactCompiler).toBe(true);
215
220
  expect(config.transpilePackages).toEqual(['@tuturuuu/ui']);
221
+ expect(config.images?.minimumCacheTTL).toBe(86_400);
216
222
  expect(config.experimental?.cpus).toBe(2);
217
223
  expect(config.experimental?.turbopackFileSystemCacheForBuild).toBe(false);
218
224
  expect(config.experimental?.turbopackRustReactCompiler).toBe(false);
@@ -259,6 +265,11 @@ describe('createTuturuuuWebWorkspaceApiRewrites', () => {
259
265
  source: '/api/workspaces/invitations',
260
266
  destination: 'https://tuturuuu.com/api/workspaces/invitations',
261
267
  },
268
+ {
269
+ source: '/api/v1/workspaces/:wsId/settings/permissions',
270
+ destination:
271
+ 'https://tuturuuu.com/api/v1/workspaces/:wsId/settings/permissions',
272
+ },
262
273
  ]);
263
274
  });
264
275
  });
@@ -15,6 +15,7 @@ export const TUTURUUU_NEXT_OPTIMIZE_PACKAGE_IMPORTS = [
15
15
 
16
16
  export const TUTURUUU_WEB_WORKSPACE_API_RESERVED_PATHS = [
17
17
  '/api/workspaces/invitations',
18
+ '/api/v1/workspaces/:wsId/settings/permissions',
18
19
  ] as const;
19
20
 
20
21
  type NextImageConfig = NonNullable<NextConfig['images']>;
@@ -37,6 +38,8 @@ export const TUTURUUU_NEXT_IMAGE_REMOTE_PATTERNS = [
37
38
  },
38
39
  ] satisfies NextImageRemotePattern[];
39
40
 
41
+ export const TUTURUUU_NEXT_IMAGE_MINIMUM_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60;
42
+
40
43
  const TUTURUUU_ANTI_FRAMING_SOURCE =
41
44
  '/:path((?!api/v1/workspaces/[^/]+/external-projects/assets/[^/]+/webgl(?:/|$)).*)';
42
45
 
@@ -111,6 +114,9 @@ export function createTuturuuuNextConfig(config: NextConfig = {}): NextConfig {
111
114
  ),
112
115
  images: {
113
116
  ...imageConfig,
117
+ minimumCacheTTL:
118
+ imageConfig.minimumCacheTTL ??
119
+ TUTURUUU_NEXT_IMAGE_MINIMUM_CACHE_TTL_SECONDS,
114
120
  remotePatterns: mergeRemotePatterns(
115
121
  TUTURUUU_NEXT_IMAGE_REMOTE_PATTERNS,
116
122
  imageConfig.remotePatterns
@@ -7,7 +7,7 @@ import {
7
7
 
8
8
  describe('platform release metadata', () => {
9
9
  it('uses the centralized shared browser app version', () => {
10
- expect(TUTURUUU_PLATFORM_VERSION).toBe('0.36.2'); // x-release-please-version
10
+ expect(TUTURUUU_PLATFORM_VERSION).toBe('0.37.0'); // x-release-please-version
11
11
  });
12
12
 
13
13
  it('preserves the source timestamp in builtAt and derives a short hash', () => {
@@ -1,6 +1,6 @@
1
1
  import { PLATFORM_BUILD_METADATA } from './generated/platform-build-metadata';
2
2
 
3
- export const TUTURUUU_PLATFORM_VERSION = '0.36.2'; // x-release-please-version
3
+ export const TUTURUUU_PLATFORM_VERSION = '0.37.0'; // x-release-please-version
4
4
 
5
5
  export type PlatformBuildMetadataInput = {
6
6
  /** ISO timestamp of the source commit used for the build, not deployment time. */
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  type QueryClient,
3
+ type QueryKey,
3
4
  useMutation,
4
5
  useQueryClient,
5
6
  } from '@tanstack/react-query';
@@ -31,6 +32,7 @@ type ReorderTaskMutationInput = {
31
32
  newSortKey: number;
32
33
  optimisticPreviousTasks?: Task[];
33
34
  optimisticPreviousFullTasks?: Task[];
35
+ optimisticPreviousFullTaskEntries?: [QueryKey, Task[] | undefined][];
34
36
  };
35
37
 
36
38
  export function mergeOptimisticReorderedTaskIntoCache(
@@ -93,9 +95,24 @@ function setReorderedTaskCache(
93
95
  ) {
94
96
  queryClient.setQueryData(['tasks', boardId], updater);
95
97
 
96
- if (queryClient.getQueryData<Task[]>(['tasks-full', boardId])) {
97
- queryClient.setQueryData(['tasks-full', boardId], updater);
98
- }
98
+ queryClient.setQueriesData<Task[]>(
99
+ { queryKey: ['tasks-full', boardId] },
100
+ updater
101
+ );
102
+ }
103
+
104
+ export function cancelStaleTaskBoardQueries(
105
+ queryClient: QueryClient,
106
+ boardId: string
107
+ ) {
108
+ void queryClient.cancelQueries(
109
+ { queryKey: ['tasks', boardId] },
110
+ { revert: false }
111
+ );
112
+ void queryClient.cancelQueries(
113
+ { queryKey: ['tasks-full', boardId] },
114
+ { revert: false }
115
+ );
99
116
  }
100
117
 
101
118
  // Reorder task within the same list or move to a different list with specific position
@@ -139,19 +156,21 @@ export function useReorderTask(boardId: string, wsId: string) {
139
156
  newSortKey,
140
157
  optimisticPreviousTasks,
141
158
  optimisticPreviousFullTasks,
159
+ optimisticPreviousFullTaskEntries,
142
160
  }) => {
143
161
  // Snapshot the previous value
144
162
  const previousTasks = queryClient.getQueryData<Task[]>([
145
163
  'tasks',
146
164
  boardId,
147
165
  ]);
148
- const previousFullTasks = queryClient.getQueryData<Task[]>([
149
- 'tasks-full',
150
- boardId,
151
- ]);
166
+ const previousFullTaskEntries = queryClient.getQueriesData<Task[]>({
167
+ queryKey: ['tasks-full', boardId],
168
+ });
152
169
 
153
- // Cancel any outgoing refetches without delaying the optimistic landing.
154
- void queryClient.cancelQueries({ queryKey: ['tasks', boardId] });
170
+ // Keep the drop preview visible while cancelling stale board responses.
171
+ // The default cancellation behavior reverts to the pre-fetch snapshot,
172
+ // which makes a dropped card briefly jump back to its source list.
173
+ cancelStaleTaskBoardQueries(queryClient, boardId);
155
174
 
156
175
  // Check if moving to a done or closed list
157
176
  const targetList = queryClient.getQueryData(['task_lists', boardId]) as
@@ -194,7 +213,16 @@ export function useReorderTask(boardId: string, wsId: string) {
194
213
 
195
214
  return {
196
215
  previousTasks: optimisticPreviousTasks ?? previousTasks,
197
- previousFullTasks: optimisticPreviousFullTasks ?? previousFullTasks,
216
+ previousFullTaskEntries:
217
+ optimisticPreviousFullTaskEntries ??
218
+ (optimisticPreviousFullTasks
219
+ ? [
220
+ [['tasks-full', boardId], optimisticPreviousFullTasks] as [
221
+ QueryKey,
222
+ Task[],
223
+ ],
224
+ ]
225
+ : previousFullTaskEntries),
198
226
  blockedTaskIdsPromise,
199
227
  };
200
228
  },
@@ -202,11 +230,8 @@ export function useReorderTask(boardId: string, wsId: string) {
202
230
  if (context?.previousTasks) {
203
231
  queryClient.setQueryData(['tasks', boardId], context.previousTasks);
204
232
  }
205
- if (context?.previousFullTasks) {
206
- queryClient.setQueryData(
207
- ['tasks-full', boardId],
208
- context.previousFullTasks
209
- );
233
+ for (const [queryKey, tasks] of context?.previousFullTaskEntries ?? []) {
234
+ queryClient.setQueryData(queryKey, tasks);
210
235
  }
211
236
 
212
237
  console.error('Failed to reorder task:', err);
@@ -0,0 +1,105 @@
1
+ import type { QueryClient } from '@tanstack/react-query';
2
+ import type { Task } from '@tuturuuu/types/primitives/Task';
3
+
4
+ export type OptimisticTask = Task & { _isOptimistic: true };
5
+
6
+ function updateOptimisticTaskCaches(
7
+ queryClient: QueryClient,
8
+ boardId: string,
9
+ updater: (tasks: Task[] | undefined) => Task[] | undefined
10
+ ) {
11
+ queryClient.setQueryData<Task[]>(['tasks', boardId], updater);
12
+ queryClient.setQueriesData<Task[]>(
13
+ { queryKey: ['tasks-full', boardId] },
14
+ updater
15
+ );
16
+ }
17
+
18
+ export function createOptimisticTask(
19
+ task: Partial<Task> & Pick<Task, 'list_id' | 'name'>,
20
+ options: { id?: string; now?: Date } = {}
21
+ ): OptimisticTask {
22
+ const now = options.now ?? new Date();
23
+ const id =
24
+ options.id ??
25
+ `optimistic-${
26
+ typeof crypto !== 'undefined' && 'randomUUID' in crypto
27
+ ? crypto.randomUUID()
28
+ : `${now.getTime()}-${Math.random().toString(36).slice(2)}`
29
+ }`;
30
+
31
+ return {
32
+ ...task,
33
+ _isOptimistic: true,
34
+ assignees: task.assignees ?? [],
35
+ created_at: task.created_at ?? now.toISOString(),
36
+ id,
37
+ labels: task.labels ?? [],
38
+ projects: task.projects ?? [],
39
+ } as OptimisticTask;
40
+ }
41
+
42
+ export function insertOptimisticTaskIntoBoardCaches(
43
+ queryClient: QueryClient,
44
+ boardId: string,
45
+ optimisticTask: Task
46
+ ) {
47
+ void queryClient.cancelQueries(
48
+ { queryKey: ['tasks', boardId] },
49
+ { revert: false }
50
+ );
51
+ void queryClient.cancelQueries(
52
+ { queryKey: ['tasks-full', boardId] },
53
+ { revert: false }
54
+ );
55
+
56
+ updateOptimisticTaskCaches(queryClient, boardId, (tasks) => {
57
+ const currentTasks = tasks ?? [];
58
+ if (currentTasks.some((task) => task.id === optimisticTask.id)) {
59
+ return currentTasks;
60
+ }
61
+ return [...currentTasks, optimisticTask];
62
+ });
63
+ }
64
+
65
+ export function reconcileOptimisticTaskInBoardCaches(
66
+ queryClient: QueryClient,
67
+ boardId: string,
68
+ optimisticTaskId: string,
69
+ createdTask: Task
70
+ ) {
71
+ updateOptimisticTaskCaches(queryClient, boardId, (tasks) => {
72
+ if (!tasks) return [createdTask];
73
+
74
+ const optimisticIndex = tasks.findIndex(
75
+ (task) => task.id === optimisticTaskId
76
+ );
77
+ const createdIndex = tasks.findIndex((task) => task.id === createdTask.id);
78
+ const insertionIndex =
79
+ optimisticIndex >= 0
80
+ ? optimisticIndex
81
+ : createdIndex >= 0
82
+ ? createdIndex
83
+ : tasks.length;
84
+ const nextTasks = tasks.filter(
85
+ (task) => task.id !== optimisticTaskId && task.id !== createdTask.id
86
+ );
87
+
88
+ nextTasks.splice(
89
+ Math.min(insertionIndex, nextTasks.length),
90
+ 0,
91
+ createdTask
92
+ );
93
+ return nextTasks;
94
+ });
95
+ }
96
+
97
+ export function removeOptimisticTaskFromBoardCaches(
98
+ queryClient: QueryClient,
99
+ boardId: string,
100
+ optimisticTaskId: string
101
+ ) {
102
+ updateOptimisticTaskCaches(queryClient, boardId, (tasks) =>
103
+ tasks?.filter((task) => task.id !== optimisticTaskId)
104
+ );
105
+ }
@@ -6,6 +6,12 @@ import {
6
6
  } from '@tuturuuu/internal-api/tasks';
7
7
  import type { Task } from '@tuturuuu/types/primitives/Task';
8
8
 
9
+ import {
10
+ createOptimisticTask,
11
+ insertOptimisticTaskIntoBoardCaches,
12
+ reconcileOptimisticTaskInBoardCaches,
13
+ removeOptimisticTaskFromBoardCaches,
14
+ } from './optimistic-task-cache';
9
15
  import { getBrowserApiOptions, toWorkspaceTaskUpdatePayload } from './shared';
10
16
 
11
17
  export function useUpdateTask(boardId: string, wsId?: string) {
@@ -165,69 +171,36 @@ export function useCreateTask(boardId: string, wsId?: string) {
165
171
  return createdTask as Task;
166
172
  },
167
173
  onMutate: async ({ listId, task }) => {
168
- await queryClient.cancelQueries({ queryKey: ['tasks', boardId] });
169
-
170
- const previousTasks = queryClient.getQueryData(['tasks', boardId]);
171
174
  const trimmedName = task.name?.trim() ?? '';
172
-
173
- const optimisticTask: Task = {
175
+ const optimisticTask = createOptimisticTask({
174
176
  ...task,
175
- id: `temp-${Date.now()}`,
176
177
  name: trimmedName,
177
178
  list_id: listId,
178
179
  closed_at: undefined,
179
180
  deleted_at: undefined,
180
- created_at: new Date().toISOString(),
181
- updated_at: new Date().toISOString(),
182
- assignees: [],
183
- } as Task;
181
+ });
184
182
 
185
- queryClient.setQueryData(
186
- ['tasks', boardId],
187
- (old: Task[] | undefined) => {
188
- if (!old) return [optimisticTask];
189
- return [...old, optimisticTask];
190
- }
191
- );
183
+ insertOptimisticTaskIntoBoardCaches(queryClient, boardId, optimisticTask);
192
184
 
193
- return { previousTasks, optimisticTask };
185
+ return { optimisticTask };
194
186
  },
195
187
  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
- }
188
+ if (context?.optimisticTask) {
189
+ removeOptimisticTaskFromBoardCaches(
190
+ queryClient,
191
+ boardId,
192
+ context.optimisticTask.id
208
193
  );
209
194
  }
210
195
 
211
196
  console.error('Failed to create task:', err);
212
197
  },
213
198
  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
- }
199
+ reconcileOptimisticTaskInBoardCaches(
200
+ queryClient,
201
+ boardId,
202
+ context?.optimisticTask.id ?? newTask.id,
203
+ newTask
231
204
  );
232
205
  },
233
206
  });
@@ -2,6 +2,7 @@ export { reorderTask, useReorderTask } from './task/reorder';
2
2
  export { transformAssignees, transformTaskRecord } from './task/transformers';
3
3
  export * from './task-helper/board';
4
4
  export * from './task-helper/bulk-actions';
5
+ export * from './task-helper/optimistic-task-cache';
5
6
  export * from './task-helper/personal-external-staging';
6
7
  export * from './task-helper/recycle-bin';
7
8
  export * from './task-helper/relationships';
@@ -676,7 +676,7 @@ export interface PermissionsResult {
676
676
  withoutPermission(permission: PermissionId): boolean;
677
677
  }
678
678
 
679
- async function resolveWorkspaceIdForPermissions({
679
+ export async function resolveWorkspaceIdForPrincipal({
680
680
  authorizationClient,
681
681
  principal,
682
682
  wsId,
@@ -773,7 +773,7 @@ async function getPermissionsImpl({
773
773
  // Handle "personal" workspace slug by looking up the user's personal workspace
774
774
  let resolvedWorkspaceId: string;
775
775
  try {
776
- resolvedWorkspaceId = await resolveWorkspaceIdForPermissions({
776
+ resolvedWorkspaceId = await resolveWorkspaceIdForPrincipal({
777
777
  authorizationClient: sbAdmin,
778
778
  principal,
779
779
  wsId,