@tuturuuu/utils 0.25.2 → 0.25.4
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 +16 -0
- package/biome.json +1 -1
- package/package.json +13 -13
- package/src/__tests__/optimistic-task-cache.test.ts +67 -5
- package/src/platform-release.test.ts +1 -1
- package/src/platform-release.ts +1 -1
- package/src/task-helper/board.test.ts +54 -0
- package/src/task-helper/board.ts +42 -11
- package/src/task-helper/optimistic-task-cache.ts +58 -1
- package/src/task-helper/task-sort.test.ts +78 -0
- package/src/task-helper/task-sort.ts +162 -0
- package/src/task-helper.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.25.4](https://github.com/tutur3u/platform/compare/utils-v0.25.3...utils-v0.25.4) (2026-08-21)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **ci:** complete dependency and Rust validation ([61e4c12](https://github.com/tutur3u/platform/commit/61e4c12ec3c90707dde63ed7469854519b3688c8))
|
|
9
|
+
* **tasks:** repair resource CRUD and sorting ([de345a4](https://github.com/tutur3u/platform/commit/de345a40e2948415eaec6f2965dfd83154d83935))
|
|
10
|
+
|
|
11
|
+
## [0.25.3](https://github.com/tutur3u/platform/compare/utils-v0.25.2...utils-v0.25.3) (2026-08-20)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### Bug Fixes
|
|
15
|
+
|
|
16
|
+
* **tasks:** keep optimistic creates at top ([fed3be6](https://github.com/tutur3u/platform/commit/fed3be688b954ebae8153647609cf8710b99171a))
|
|
17
|
+
* **tasks:** preserve cached ticket prefixes ([59a4b31](https://github.com/tutur3u/platform/commit/59a4b3109046f910bd586aee8af3200afffcd65f))
|
|
18
|
+
|
|
3
19
|
## [0.25.2](https://github.com/tutur3u/platform/compare/utils-v0.25.1...utils-v0.25.2) (2026-08-16)
|
|
4
20
|
|
|
5
21
|
|
package/biome.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuturuuu/utils",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "0.25.
|
|
4
|
+
"version": "0.25.4",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "https://github.com/tutur3u/platform",
|
|
@@ -20,40 +20,40 @@
|
|
|
20
20
|
"@noble/ciphers": "^2.3.0",
|
|
21
21
|
"@noble/hashes": "^2.3.0",
|
|
22
22
|
"@tanstack/react-query": "^5.101.4",
|
|
23
|
-
"@tiptap/core": "3.30.
|
|
24
|
-
"@tiptap/react": "3.30.
|
|
23
|
+
"@tiptap/core": "3.30.2",
|
|
24
|
+
"@tiptap/react": "3.30.2",
|
|
25
25
|
"@tuturuuu/google": "0.1.0",
|
|
26
26
|
"@tuturuuu/icons": "0.1.0",
|
|
27
|
-
"@tuturuuu/internal-api": "0.31.
|
|
28
|
-
"@tuturuuu/supabase": "0.5.
|
|
27
|
+
"@tuturuuu/internal-api": "0.31.1",
|
|
28
|
+
"@tuturuuu/supabase": "0.5.1",
|
|
29
29
|
"@upstash/ratelimit": "^2.0.8",
|
|
30
30
|
"@upstash/redis": "^1.38.2",
|
|
31
31
|
"clsx": "^2.1.1",
|
|
32
32
|
"date-fns": "^4.4.0",
|
|
33
|
-
"dayjs": "^1.11.
|
|
33
|
+
"dayjs": "^1.11.23",
|
|
34
34
|
"diff": "^9.0.0",
|
|
35
35
|
"effect": "^3.22.1",
|
|
36
36
|
"moment": "^2.30.1",
|
|
37
|
-
"next": "^16.3.
|
|
37
|
+
"next": "^16.3.2",
|
|
38
38
|
"prosemirror-model": "^1.25.11",
|
|
39
39
|
"react": "^19.2.8",
|
|
40
40
|
"react-dom": "^19.2.8",
|
|
41
41
|
"tailwind-merge": "^3.6.0",
|
|
42
|
-
"uuid": "^14.0.
|
|
42
|
+
"uuid": "^14.0.2",
|
|
43
43
|
"y-prosemirror": "^1.3.7",
|
|
44
44
|
"yjs": "^13.6.32",
|
|
45
45
|
"zod": "^4.4.3"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@tuturuuu/types": "0.27.0",
|
|
49
|
-
"@tuturuuu/typescript-config": "0.1.
|
|
49
|
+
"@tuturuuu/typescript-config": "0.1.2",
|
|
50
50
|
"@types/diff": "^8.0.0",
|
|
51
51
|
"@types/node": "^26.2.0",
|
|
52
52
|
"@types/react": "^19.2.18",
|
|
53
|
-
"@vitejs/plugin-react": "^6.0
|
|
53
|
+
"@vitejs/plugin-react": "^6.1.0",
|
|
54
54
|
"typescript": "7.0.2",
|
|
55
|
-
"vite": "^8.2.
|
|
56
|
-
"vitest": "^4.1.
|
|
55
|
+
"vite": "^8.2.2",
|
|
56
|
+
"vitest": "^4.1.11"
|
|
57
57
|
},
|
|
58
58
|
"exports": {
|
|
59
59
|
"./timezones": "./src/timezones.json",
|
|
@@ -77,5 +77,5 @@
|
|
|
77
77
|
"./encryption": "./src/encryption/index.ts",
|
|
78
78
|
"./finance": "./src/finance/index.ts"
|
|
79
79
|
},
|
|
80
|
-
"packageManager": "bun@1.
|
|
80
|
+
"packageManager": "bun@1.4.0"
|
|
81
81
|
}
|
|
@@ -7,7 +7,11 @@ import {
|
|
|
7
7
|
removeOptimisticTaskFromBoardCaches,
|
|
8
8
|
} from '../task-helper/optimistic-task-cache';
|
|
9
9
|
|
|
10
|
-
function task(
|
|
10
|
+
function task(
|
|
11
|
+
id: string,
|
|
12
|
+
listId = 'list-1',
|
|
13
|
+
overrides: Partial<Task> = {}
|
|
14
|
+
): Task {
|
|
11
15
|
return {
|
|
12
16
|
assignees: [],
|
|
13
17
|
created_at: '2026-08-14T00:00:00.000Z',
|
|
@@ -16,6 +20,7 @@ function task(id: string, listId = 'list-1'): Task {
|
|
|
16
20
|
list_id: listId,
|
|
17
21
|
name: id,
|
|
18
22
|
projects: [],
|
|
23
|
+
...overrides,
|
|
19
24
|
} as unknown as Task;
|
|
20
25
|
}
|
|
21
26
|
|
|
@@ -23,7 +28,7 @@ describe('optimistic task creation cache lifecycle', () => {
|
|
|
23
28
|
it('inserts the pending task into the board and every mounted full-task variant', () => {
|
|
24
29
|
const queryClient = new QueryClient();
|
|
25
30
|
const boardId = 'board-1';
|
|
26
|
-
const existingTask = task('existing');
|
|
31
|
+
const existingTask = task('existing', 'list-1', { sort_key: 1_000_000 });
|
|
27
32
|
const optimisticTask = {
|
|
28
33
|
...task('optimistic-1'),
|
|
29
34
|
_isOptimistic: true,
|
|
@@ -40,21 +45,78 @@ describe('optimistic task creation cache lifecycle', () => {
|
|
|
40
45
|
insertOptimisticTaskIntoBoardCaches(queryClient, boardId, optimisticTask);
|
|
41
46
|
|
|
42
47
|
expect(queryClient.getQueryData(['tasks', boardId])).toEqual([
|
|
48
|
+
expect.objectContaining({
|
|
49
|
+
id: optimisticTask.id,
|
|
50
|
+
sort_key: expect.any(Number),
|
|
51
|
+
}),
|
|
43
52
|
existingTask,
|
|
44
|
-
optimisticTask,
|
|
45
53
|
]);
|
|
46
54
|
expect(queryClient.getQueryData(['tasks-full', boardId])).toEqual([
|
|
55
|
+
expect.objectContaining({
|
|
56
|
+
id: optimisticTask.id,
|
|
57
|
+
sort_key: expect.any(Number),
|
|
58
|
+
}),
|
|
47
59
|
existingTask,
|
|
48
|
-
optimisticTask,
|
|
49
60
|
]);
|
|
50
61
|
expect(
|
|
51
62
|
queryClient.getQueryData(['tasks-full', boardId, 'filtered'])
|
|
52
|
-
).toEqual([
|
|
63
|
+
).toEqual([
|
|
64
|
+
expect.objectContaining({
|
|
65
|
+
id: optimisticTask.id,
|
|
66
|
+
sort_key: expect.any(Number),
|
|
67
|
+
}),
|
|
68
|
+
existingTask,
|
|
69
|
+
]);
|
|
53
70
|
expect(queryClient.getQueryData(['tasks-full', 'another-board'])).toEqual([
|
|
54
71
|
existingTask,
|
|
55
72
|
]);
|
|
56
73
|
});
|
|
57
74
|
|
|
75
|
+
it('orders consecutive optimistic creates above mixed local and placed external tasks', () => {
|
|
76
|
+
const queryClient = new QueryClient();
|
|
77
|
+
const boardId = 'board-1';
|
|
78
|
+
const localTask = task('local', 'list-1', { sort_key: 1_000_000 });
|
|
79
|
+
const externalTask = task('external', 'list-1', {
|
|
80
|
+
is_personal_external: true,
|
|
81
|
+
is_personal_external_default: false,
|
|
82
|
+
personal_sort_key: 500_000,
|
|
83
|
+
sort_key: 9_000_000,
|
|
84
|
+
});
|
|
85
|
+
const firstOptimisticTask = {
|
|
86
|
+
...task('optimistic-1'),
|
|
87
|
+
_isOptimistic: true,
|
|
88
|
+
} as Task;
|
|
89
|
+
const secondOptimisticTask = {
|
|
90
|
+
...task('optimistic-2'),
|
|
91
|
+
_isOptimistic: true,
|
|
92
|
+
} as Task;
|
|
93
|
+
|
|
94
|
+
queryClient.setQueryData(['tasks', boardId], [externalTask, localTask]);
|
|
95
|
+
|
|
96
|
+
insertOptimisticTaskIntoBoardCaches(
|
|
97
|
+
queryClient,
|
|
98
|
+
boardId,
|
|
99
|
+
firstOptimisticTask
|
|
100
|
+
);
|
|
101
|
+
insertOptimisticTaskIntoBoardCaches(
|
|
102
|
+
queryClient,
|
|
103
|
+
boardId,
|
|
104
|
+
secondOptimisticTask
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const tasks = queryClient.getQueryData<Task[]>(['tasks', boardId]);
|
|
108
|
+
expect(tasks?.map(({ id }) => id)).toEqual([
|
|
109
|
+
'optimistic-2',
|
|
110
|
+
'optimistic-1',
|
|
111
|
+
'external',
|
|
112
|
+
'local',
|
|
113
|
+
]);
|
|
114
|
+
expect(tasks?.[0]?.sort_key).toBeLessThan(tasks?.[1]?.sort_key ?? 0);
|
|
115
|
+
expect(tasks?.[1]?.sort_key).toBeLessThan(
|
|
116
|
+
externalTask.personal_sort_key ?? 0
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
58
120
|
it('replaces the pending task without duplicating a realtime server task', () => {
|
|
59
121
|
const queryClient = new QueryClient();
|
|
60
122
|
const boardId = 'board-1';
|
|
@@ -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.
|
|
10
|
+
expect(TUTURUUU_PLATFORM_VERSION).toBe('0.40.2'); // x-release-please-version
|
|
11
11
|
});
|
|
12
12
|
|
|
13
13
|
it('preserves the source timestamp in builtAt and derives a short hash', () => {
|
package/src/platform-release.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { PLATFORM_BUILD_METADATA } from './generated/platform-build-metadata';
|
|
2
2
|
|
|
3
|
-
export const TUTURUUU_PLATFORM_VERSION = '0.
|
|
3
|
+
export const TUTURUUU_PLATFORM_VERSION = '0.40.2'; // 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. */
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { QueryClient } from '@tanstack/react-query';
|
|
2
|
+
import type { WorkspaceTaskBoard } from '@tuturuuu/types';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { getCachedBoardConfig, toBoardConfig } from './board';
|
|
5
|
+
|
|
6
|
+
function board(overrides: Partial<WorkspaceTaskBoard> = {}) {
|
|
7
|
+
return {
|
|
8
|
+
id: 'board-1',
|
|
9
|
+
name: 'Roadmap',
|
|
10
|
+
ws_id: 'workspace-1',
|
|
11
|
+
ticket_prefix: 'VHP',
|
|
12
|
+
...overrides,
|
|
13
|
+
} as WorkspaceTaskBoard;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('board config cache', () => {
|
|
17
|
+
it('derives the ticket prefix and estimation defaults from cached board metadata', () => {
|
|
18
|
+
expect(toBoardConfig(board())).toEqual({
|
|
19
|
+
id: 'board-1',
|
|
20
|
+
name: 'Roadmap',
|
|
21
|
+
ws_id: 'workspace-1',
|
|
22
|
+
ticket_prefix: 'VHP',
|
|
23
|
+
estimation_type: null,
|
|
24
|
+
extended_estimation: false,
|
|
25
|
+
allow_zero_estimates: false,
|
|
26
|
+
count_unestimated_issues: false,
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('finds a cached personal board by board id when its query scope uses an alias', () => {
|
|
31
|
+
const queryClient = new QueryClient();
|
|
32
|
+
queryClient.setQueryData(
|
|
33
|
+
['task-board', 'personal', 'board-1'],
|
|
34
|
+
board({ ws_id: 'user-workspace-id' })
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
expect(
|
|
38
|
+
getCachedBoardConfig(queryClient, 'board-1', 'user-workspace-id')
|
|
39
|
+
?.ticket_prefix
|
|
40
|
+
).toBe('VHP');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('does not reuse metadata from another board', () => {
|
|
44
|
+
const queryClient = new QueryClient();
|
|
45
|
+
queryClient.setQueryData(
|
|
46
|
+
['task-board', 'workspace-1', 'board-2'],
|
|
47
|
+
board({ id: 'board-2' })
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
expect(
|
|
51
|
+
getCachedBoardConfig(queryClient, 'board-1', 'workspace-1')
|
|
52
|
+
).toBeUndefined();
|
|
53
|
+
});
|
|
54
|
+
});
|
package/src/task-helper/board.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type QueryClient,
|
|
3
|
+
useMutation,
|
|
4
|
+
useQuery,
|
|
5
|
+
useQueryClient,
|
|
6
|
+
} from '@tanstack/react-query';
|
|
2
7
|
import type { InternalApiClientOptions } from '@tuturuuu/internal-api/client';
|
|
3
8
|
import {
|
|
4
9
|
createWorkspaceTaskBoard,
|
|
@@ -162,10 +167,41 @@ export interface BoardConfig {
|
|
|
162
167
|
ticket_prefix: string | null;
|
|
163
168
|
}
|
|
164
169
|
|
|
170
|
+
export function toBoardConfig(board: WorkspaceTaskBoard): BoardConfig {
|
|
171
|
+
return {
|
|
172
|
+
id: board.id,
|
|
173
|
+
name: board.name ?? null,
|
|
174
|
+
estimation_type: board.estimation_type ?? null,
|
|
175
|
+
extended_estimation: board.extended_estimation ?? false,
|
|
176
|
+
allow_zero_estimates: board.allow_zero_estimates ?? false,
|
|
177
|
+
count_unestimated_issues: board.count_unestimated_issues ?? false,
|
|
178
|
+
ws_id: board.ws_id,
|
|
179
|
+
ticket_prefix: board.ticket_prefix ?? null,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function getCachedBoardConfig(
|
|
184
|
+
queryClient: Pick<QueryClient, 'getQueriesData'>,
|
|
185
|
+
boardId: string,
|
|
186
|
+
wsId: string
|
|
187
|
+
): BoardConfig | undefined {
|
|
188
|
+
const boards = queryClient
|
|
189
|
+
.getQueriesData<WorkspaceTaskBoard>({ queryKey: ['task-board'] })
|
|
190
|
+
.map(([, cachedBoard]) => cachedBoard);
|
|
191
|
+
const board =
|
|
192
|
+
boards.find(
|
|
193
|
+
(cachedBoard) => cachedBoard?.id === boardId && cachedBoard.ws_id === wsId
|
|
194
|
+
) ?? boards.find((cachedBoard) => cachedBoard?.id === boardId);
|
|
195
|
+
|
|
196
|
+
return board ? toBoardConfig(board) : undefined;
|
|
197
|
+
}
|
|
198
|
+
|
|
165
199
|
export function useBoardConfig(
|
|
166
200
|
boardId: string | null | undefined,
|
|
167
201
|
wsId: string | null | undefined
|
|
168
202
|
) {
|
|
203
|
+
const queryClient = useQueryClient();
|
|
204
|
+
|
|
169
205
|
return useQuery({
|
|
170
206
|
queryKey: ['board-config', wsId, boardId],
|
|
171
207
|
queryFn: async () => {
|
|
@@ -179,18 +215,13 @@ export function useBoardConfig(
|
|
|
179
215
|
return null;
|
|
180
216
|
}
|
|
181
217
|
|
|
182
|
-
return
|
|
183
|
-
id: board.id,
|
|
184
|
-
name: board.name ?? null,
|
|
185
|
-
estimation_type: board.estimation_type ?? null,
|
|
186
|
-
extended_estimation: board.extended_estimation ?? false,
|
|
187
|
-
allow_zero_estimates: board.allow_zero_estimates ?? false,
|
|
188
|
-
count_unestimated_issues: board.count_unestimated_issues ?? false,
|
|
189
|
-
ws_id: board.ws_id,
|
|
190
|
-
ticket_prefix: board.ticket_prefix ?? null,
|
|
191
|
-
} as BoardConfig;
|
|
218
|
+
return toBoardConfig(board);
|
|
192
219
|
},
|
|
193
220
|
enabled: Boolean(boardId && wsId),
|
|
221
|
+
placeholderData: () =>
|
|
222
|
+
boardId && wsId
|
|
223
|
+
? getCachedBoardConfig(queryClient, boardId, wsId)
|
|
224
|
+
: undefined,
|
|
194
225
|
staleTime: 10 * 60 * 1000,
|
|
195
226
|
refetchOnWindowFocus: false,
|
|
196
227
|
});
|
|
@@ -3,6 +3,58 @@ import type { Task } from '@tuturuuu/types/primitives/Task';
|
|
|
3
3
|
|
|
4
4
|
export type OptimisticTask = Task & { _isOptimistic: true };
|
|
5
5
|
|
|
6
|
+
const EMPTY_LIST_OPTIMISTIC_SORT_KEY = 1_000_000_000;
|
|
7
|
+
|
|
8
|
+
function getEffectiveTaskSortKey(task: Task) {
|
|
9
|
+
if (
|
|
10
|
+
task.is_personal_external === true &&
|
|
11
|
+
task.is_personal_external_default !== true &&
|
|
12
|
+
typeof task.personal_sort_key === 'number' &&
|
|
13
|
+
Number.isFinite(task.personal_sort_key)
|
|
14
|
+
) {
|
|
15
|
+
return task.personal_sort_key;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return typeof task.sort_key === 'number' && Number.isFinite(task.sort_key)
|
|
19
|
+
? task.sort_key
|
|
20
|
+
: null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getOptimisticTopSortKey(
|
|
24
|
+
queryClient: QueryClient,
|
|
25
|
+
boardId: string,
|
|
26
|
+
optimisticTask: Task
|
|
27
|
+
) {
|
|
28
|
+
const cachedTaskSets = queryClient.getQueriesData<Task[]>({
|
|
29
|
+
predicate: ({ queryKey }) =>
|
|
30
|
+
(queryKey[0] === 'tasks' || queryKey[0] === 'tasks-full') &&
|
|
31
|
+
queryKey[1] === boardId,
|
|
32
|
+
});
|
|
33
|
+
let minimumSortKey: number | null = null;
|
|
34
|
+
|
|
35
|
+
for (const [, tasks] of cachedTaskSets) {
|
|
36
|
+
for (const task of tasks ?? []) {
|
|
37
|
+
if (
|
|
38
|
+
task.id === optimisticTask.id ||
|
|
39
|
+
task.list_id !== optimisticTask.list_id
|
|
40
|
+
) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const sortKey = getEffectiveTaskSortKey(task);
|
|
45
|
+
if (
|
|
46
|
+
sortKey !== null &&
|
|
47
|
+
(minimumSortKey === null || sortKey < minimumSortKey)
|
|
48
|
+
) {
|
|
49
|
+
minimumSortKey = sortKey;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (minimumSortKey === null) return EMPTY_LIST_OPTIMISTIC_SORT_KEY;
|
|
55
|
+
return Math.max(Number.MIN_SAFE_INTEGER, minimumSortKey - 1);
|
|
56
|
+
}
|
|
57
|
+
|
|
6
58
|
function updateOptimisticTaskCaches(
|
|
7
59
|
queryClient: QueryClient,
|
|
8
60
|
boardId: string,
|
|
@@ -53,12 +105,17 @@ export function insertOptimisticTaskIntoBoardCaches(
|
|
|
53
105
|
{ revert: false }
|
|
54
106
|
);
|
|
55
107
|
|
|
108
|
+
const taskAtTop = {
|
|
109
|
+
...optimisticTask,
|
|
110
|
+
sort_key: getOptimisticTopSortKey(queryClient, boardId, optimisticTask),
|
|
111
|
+
};
|
|
112
|
+
|
|
56
113
|
updateOptimisticTaskCaches(queryClient, boardId, (tasks) => {
|
|
57
114
|
const currentTasks = tasks ?? [];
|
|
58
115
|
if (currentTasks.some((task) => task.id === optimisticTask.id)) {
|
|
59
116
|
return currentTasks;
|
|
60
117
|
}
|
|
61
|
-
return [...currentTasks
|
|
118
|
+
return [taskAtTop, ...currentTasks];
|
|
62
119
|
});
|
|
63
120
|
}
|
|
64
121
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
parseTaskSortBy,
|
|
4
|
+
type SortableTask,
|
|
5
|
+
sortTasksByCriterion,
|
|
6
|
+
} from './task-sort';
|
|
7
|
+
|
|
8
|
+
const task = (
|
|
9
|
+
id: string,
|
|
10
|
+
values: Partial<SortableTask> = {}
|
|
11
|
+
): SortableTask => ({
|
|
12
|
+
id,
|
|
13
|
+
name: id,
|
|
14
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
15
|
+
...values,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('sortTasksByCriterion', () => {
|
|
19
|
+
it('accepts only supported server and client sort criteria', () => {
|
|
20
|
+
expect(parseTaskSortBy('priority-high')).toBe('priority-high');
|
|
21
|
+
expect(parseTaskSortBy('unknown')).toBeUndefined();
|
|
22
|
+
expect(parseTaskSortBy(null)).toBeUndefined();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('sorts every priority direction with unset priorities last', () => {
|
|
26
|
+
const tasks = [
|
|
27
|
+
task('none'),
|
|
28
|
+
task('high', { priority: 'high' }),
|
|
29
|
+
task('low', { priority: 'low' }),
|
|
30
|
+
task('critical', { priority: 'critical' }),
|
|
31
|
+
task('normal', { priority: 'normal' }),
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
expect(
|
|
35
|
+
sortTasksByCriterion(tasks, 'priority-high').map(({ id }) => id)
|
|
36
|
+
).toEqual(['critical', 'high', 'normal', 'low', 'none']);
|
|
37
|
+
expect(
|
|
38
|
+
sortTasksByCriterion(tasks, 'priority-low').map(({ id }) => id)
|
|
39
|
+
).toEqual(['low', 'normal', 'high', 'critical', 'none']);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('keeps missing due dates and estimations last in both directions', () => {
|
|
43
|
+
const tasks = [
|
|
44
|
+
task('none'),
|
|
45
|
+
task('invalid', { end_date: 'not-a-date' }),
|
|
46
|
+
task('early', {
|
|
47
|
+
end_date: '2026-01-02T00:00:00.000Z',
|
|
48
|
+
estimation_points: 1,
|
|
49
|
+
}),
|
|
50
|
+
task('late', {
|
|
51
|
+
end_date: '2026-01-03T00:00:00.000Z',
|
|
52
|
+
estimation_points: 8,
|
|
53
|
+
}),
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
expect(
|
|
57
|
+
sortTasksByCriterion(tasks, 'due-date-desc').map(({ id }) => id)
|
|
58
|
+
).toEqual(['late', 'early', 'invalid', 'none']);
|
|
59
|
+
expect(
|
|
60
|
+
sortTasksByCriterion(tasks, 'estimation-low').map(({ id }) => id)
|
|
61
|
+
).toEqual(['early', 'late', 'invalid', 'none']);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('uses newest creation time and then id as stable tie-breakers', () => {
|
|
65
|
+
const tasks = [
|
|
66
|
+
task('b', { priority: 'high' }),
|
|
67
|
+
task('c', {
|
|
68
|
+
priority: 'high',
|
|
69
|
+
created_at: '2026-01-02T00:00:00.000Z',
|
|
70
|
+
}),
|
|
71
|
+
task('a', { priority: 'high' }),
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
expect(
|
|
75
|
+
sortTasksByCriterion(tasks, 'priority-high').map(({ id }) => id)
|
|
76
|
+
).toEqual(['c', 'a', 'b']);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { TaskPriority } from '@tuturuuu/types/primitives/Priority';
|
|
2
|
+
|
|
3
|
+
export type TaskSortBy =
|
|
4
|
+
| 'name-asc'
|
|
5
|
+
| 'name-desc'
|
|
6
|
+
| 'priority-high'
|
|
7
|
+
| 'priority-low'
|
|
8
|
+
| 'due-date-asc'
|
|
9
|
+
| 'due-date-desc'
|
|
10
|
+
| 'created-date-desc'
|
|
11
|
+
| 'created-date-asc'
|
|
12
|
+
| 'estimation-high'
|
|
13
|
+
| 'estimation-low';
|
|
14
|
+
|
|
15
|
+
export type SortableTask = {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
priority?: TaskPriority | null;
|
|
19
|
+
end_date?: string | null;
|
|
20
|
+
created_at?: string | null;
|
|
21
|
+
estimation_points?: number | null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function parseTaskSortBy(value: string | null) {
|
|
25
|
+
switch (value) {
|
|
26
|
+
case 'name-asc':
|
|
27
|
+
case 'name-desc':
|
|
28
|
+
case 'priority-high':
|
|
29
|
+
case 'priority-low':
|
|
30
|
+
case 'due-date-asc':
|
|
31
|
+
case 'due-date-desc':
|
|
32
|
+
case 'created-date-desc':
|
|
33
|
+
case 'created-date-asc':
|
|
34
|
+
case 'estimation-high':
|
|
35
|
+
case 'estimation-low':
|
|
36
|
+
return value satisfies TaskSortBy;
|
|
37
|
+
default:
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const PRIORITY_RANK: Record<TaskPriority, number> = {
|
|
43
|
+
critical: 0,
|
|
44
|
+
high: 1,
|
|
45
|
+
normal: 2,
|
|
46
|
+
low: 3,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function compareNullable<T>(
|
|
50
|
+
a: T | null | undefined,
|
|
51
|
+
b: T | null | undefined,
|
|
52
|
+
compare: (left: T, right: T) => number
|
|
53
|
+
) {
|
|
54
|
+
if (a == null && b == null) return 0;
|
|
55
|
+
if (a == null) return 1;
|
|
56
|
+
if (b == null) return -1;
|
|
57
|
+
return compare(a, b);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function compareText(a: string, b: string) {
|
|
61
|
+
const left = a.toLowerCase();
|
|
62
|
+
const right = b.toLowerCase();
|
|
63
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getTimestamp(value: string | null | undefined) {
|
|
67
|
+
if (!value) return null;
|
|
68
|
+
const timestamp = new Date(value).getTime();
|
|
69
|
+
return Number.isFinite(timestamp) ? timestamp : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function compareCreatedFallback(a: SortableTask, b: SortableTask) {
|
|
73
|
+
const created = compareNullable(
|
|
74
|
+
getTimestamp(a.created_at),
|
|
75
|
+
getTimestamp(b.created_at),
|
|
76
|
+
(left, right) => right - left
|
|
77
|
+
);
|
|
78
|
+
return created || a.id.localeCompare(b.id);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function compareTasksByCriterion(
|
|
82
|
+
a: SortableTask,
|
|
83
|
+
b: SortableTask,
|
|
84
|
+
sortBy: TaskSortBy
|
|
85
|
+
) {
|
|
86
|
+
let result = 0;
|
|
87
|
+
|
|
88
|
+
switch (sortBy) {
|
|
89
|
+
case 'name-asc':
|
|
90
|
+
result = compareText(a.name, b.name);
|
|
91
|
+
break;
|
|
92
|
+
case 'name-desc':
|
|
93
|
+
result = compareText(b.name, a.name);
|
|
94
|
+
break;
|
|
95
|
+
case 'priority-high':
|
|
96
|
+
result = compareNullable(
|
|
97
|
+
a.priority,
|
|
98
|
+
b.priority,
|
|
99
|
+
(left, right) => PRIORITY_RANK[left] - PRIORITY_RANK[right]
|
|
100
|
+
);
|
|
101
|
+
break;
|
|
102
|
+
case 'priority-low':
|
|
103
|
+
result = compareNullable(
|
|
104
|
+
a.priority,
|
|
105
|
+
b.priority,
|
|
106
|
+
(left, right) => PRIORITY_RANK[right] - PRIORITY_RANK[left]
|
|
107
|
+
);
|
|
108
|
+
break;
|
|
109
|
+
case 'due-date-asc':
|
|
110
|
+
result = compareNullable(
|
|
111
|
+
getTimestamp(a.end_date),
|
|
112
|
+
getTimestamp(b.end_date),
|
|
113
|
+
(left, right) => left - right
|
|
114
|
+
);
|
|
115
|
+
break;
|
|
116
|
+
case 'due-date-desc':
|
|
117
|
+
result = compareNullable(
|
|
118
|
+
getTimestamp(a.end_date),
|
|
119
|
+
getTimestamp(b.end_date),
|
|
120
|
+
(left, right) => right - left
|
|
121
|
+
);
|
|
122
|
+
break;
|
|
123
|
+
case 'created-date-asc':
|
|
124
|
+
result = compareNullable(
|
|
125
|
+
getTimestamp(a.created_at),
|
|
126
|
+
getTimestamp(b.created_at),
|
|
127
|
+
(left, right) => left - right
|
|
128
|
+
);
|
|
129
|
+
break;
|
|
130
|
+
case 'created-date-desc':
|
|
131
|
+
result = compareNullable(
|
|
132
|
+
getTimestamp(a.created_at),
|
|
133
|
+
getTimestamp(b.created_at),
|
|
134
|
+
(left, right) => right - left
|
|
135
|
+
);
|
|
136
|
+
break;
|
|
137
|
+
case 'estimation-high':
|
|
138
|
+
result = compareNullable(
|
|
139
|
+
a.estimation_points,
|
|
140
|
+
b.estimation_points,
|
|
141
|
+
(left, right) => right - left
|
|
142
|
+
);
|
|
143
|
+
break;
|
|
144
|
+
case 'estimation-low':
|
|
145
|
+
result = compareNullable(
|
|
146
|
+
a.estimation_points,
|
|
147
|
+
b.estimation_points,
|
|
148
|
+
(left, right) => left - right
|
|
149
|
+
);
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return result || compareCreatedFallback(a, b);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function sortTasksByCriterion<T extends SortableTask>(
|
|
157
|
+
tasks: readonly T[],
|
|
158
|
+
sortBy: TaskSortBy | null | undefined
|
|
159
|
+
) {
|
|
160
|
+
if (!sortBy) return [...tasks];
|
|
161
|
+
return [...tasks].sort((a, b) => compareTasksByCriterion(a, b, sortBy));
|
|
162
|
+
}
|
package/src/task-helper.ts
CHANGED