@tuturuuu/utils 0.25.2 → 0.25.3

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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.25.3](https://github.com/tutur3u/platform/compare/utils-v0.25.2...utils-v0.25.3) (2026-08-20)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **tasks:** keep optimistic creates at top ([fed3be6](https://github.com/tutur3u/platform/commit/fed3be688b954ebae8153647609cf8710b99171a))
9
+ * **tasks:** preserve cached ticket prefixes ([59a4b31](https://github.com/tutur3u/platform/commit/59a4b3109046f910bd586aee8af3200afffcd65f))
10
+
3
11
  ## [0.25.2](https://github.com/tutur3u/platform/compare/utils-v0.25.1...utils-v0.25.2) (2026-08-16)
4
12
 
5
13
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tuturuuu/utils",
3
3
  "license": "MIT",
4
- "version": "0.25.2",
4
+ "version": "0.25.3",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/tutur3u/platform",
@@ -24,7 +24,7 @@
24
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.31.0",
27
+ "@tuturuuu/internal-api": "0.31.1",
28
28
  "@tuturuuu/supabase": "0.5.0",
29
29
  "@upstash/ratelimit": "^2.0.8",
30
30
  "@upstash/redis": "^1.38.2",
@@ -7,7 +7,11 @@ import {
7
7
  removeOptimisticTaskFromBoardCaches,
8
8
  } from '../task-helper/optimistic-task-cache';
9
9
 
10
- function task(id: string, listId = 'list-1'): 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([existingTask, optimisticTask]);
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.39.0'); // x-release-please-version
10
+ expect(TUTURUUU_PLATFORM_VERSION).toBe('0.40.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.39.0'; // x-release-please-version
3
+ export const TUTURUUU_PLATFORM_VERSION = '0.40.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. */
@@ -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
+ });
@@ -1,4 +1,9 @@
1
- import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
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, optimisticTask];
118
+ return [taskAtTop, ...currentTasks];
62
119
  });
63
120
  }
64
121