@tuturuuu/utils 0.0.2 → 0.6.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 +305 -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 +122 -3
- 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,245 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type QueryClient,
|
|
3
|
+
useMutation,
|
|
4
|
+
useQueryClient,
|
|
5
|
+
} from '@tanstack/react-query';
|
|
6
|
+
import {
|
|
7
|
+
getWorkspaceTaskRelationships,
|
|
8
|
+
updateWorkspaceTask,
|
|
9
|
+
} from '@tuturuuu/internal-api/tasks';
|
|
10
|
+
import type { Task } from '@tuturuuu/types/primitives/Task';
|
|
11
|
+
import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
|
|
12
|
+
import {
|
|
13
|
+
isTaskBoardCompletedStatus,
|
|
14
|
+
isTaskBoardResolvedStatus,
|
|
15
|
+
isTaskBoardTerminalStatus,
|
|
16
|
+
} from '../task-list-status';
|
|
17
|
+
import { transformTaskRecord } from './transformers';
|
|
18
|
+
|
|
19
|
+
type ReorderTaskCacheInput = {
|
|
20
|
+
taskId: string;
|
|
21
|
+
newListId: string;
|
|
22
|
+
newSortKey: number;
|
|
23
|
+
targetListStatus?: TaskList['status'] | null;
|
|
24
|
+
localMutationAt?: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type LocallyMutatedTask = Task & { _localMutationAt: number };
|
|
28
|
+
type ReorderTaskMutationInput = {
|
|
29
|
+
taskId: string;
|
|
30
|
+
newListId: string;
|
|
31
|
+
newSortKey: number;
|
|
32
|
+
optimisticPreviousTasks?: Task[];
|
|
33
|
+
optimisticPreviousFullTasks?: Task[];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function mergeOptimisticReorderedTaskIntoCache(
|
|
37
|
+
tasks: Task[] | undefined,
|
|
38
|
+
{
|
|
39
|
+
taskId,
|
|
40
|
+
newListId,
|
|
41
|
+
newSortKey,
|
|
42
|
+
targetListStatus,
|
|
43
|
+
localMutationAt = Date.now(),
|
|
44
|
+
}: ReorderTaskCacheInput
|
|
45
|
+
) {
|
|
46
|
+
if (!tasks) return tasks;
|
|
47
|
+
|
|
48
|
+
const targetIsCompleted = isTaskBoardCompletedStatus(targetListStatus);
|
|
49
|
+
const targetIsTerminal = isTaskBoardTerminalStatus(targetListStatus);
|
|
50
|
+
const mutationTimestamp = new Date(localMutationAt).toISOString();
|
|
51
|
+
|
|
52
|
+
return tasks.map((task) => {
|
|
53
|
+
if (task.id !== taskId) return task;
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
...task,
|
|
57
|
+
list_id: newListId,
|
|
58
|
+
sort_key: newSortKey,
|
|
59
|
+
completed: targetIsCompleted,
|
|
60
|
+
completed_at: targetIsCompleted
|
|
61
|
+
? (task.completed_at ?? mutationTimestamp)
|
|
62
|
+
: null,
|
|
63
|
+
closed_at: targetIsTerminal
|
|
64
|
+
? (task.closed_at ?? mutationTimestamp)
|
|
65
|
+
: null,
|
|
66
|
+
_localMutationAt: localMutationAt,
|
|
67
|
+
} as LocallyMutatedTask;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function mergeServerReorderedTaskIntoCache(
|
|
72
|
+
tasks: Task[] | undefined,
|
|
73
|
+
updatedTask: Task,
|
|
74
|
+
localMutationAt = Date.now()
|
|
75
|
+
) {
|
|
76
|
+
if (!tasks) return tasks;
|
|
77
|
+
|
|
78
|
+
return tasks.map((task) => {
|
|
79
|
+
if (task.id !== updatedTask.id) return task;
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
...task,
|
|
83
|
+
...updatedTask,
|
|
84
|
+
_localMutationAt: localMutationAt,
|
|
85
|
+
} as LocallyMutatedTask;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function setReorderedTaskCache(
|
|
90
|
+
queryClient: QueryClient,
|
|
91
|
+
boardId: string,
|
|
92
|
+
updater: (tasks: Task[] | undefined) => Task[] | undefined
|
|
93
|
+
) {
|
|
94
|
+
queryClient.setQueryData(['tasks', boardId], updater);
|
|
95
|
+
|
|
96
|
+
if (queryClient.getQueryData<Task[]>(['tasks-full', boardId])) {
|
|
97
|
+
queryClient.setQueryData(['tasks-full', boardId], updater);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Reorder task within the same list or move to a different list with specific position
|
|
102
|
+
export async function reorderTask(
|
|
103
|
+
wsId: string,
|
|
104
|
+
taskId: string,
|
|
105
|
+
newListId: string,
|
|
106
|
+
newSortKey: number
|
|
107
|
+
): Promise<Task> {
|
|
108
|
+
const baseUrl =
|
|
109
|
+
typeof window !== 'undefined' ? window.location.origin : undefined;
|
|
110
|
+
|
|
111
|
+
const { task } = await updateWorkspaceTask(
|
|
112
|
+
wsId,
|
|
113
|
+
taskId,
|
|
114
|
+
{
|
|
115
|
+
list_id: newListId,
|
|
116
|
+
sort_key: newSortKey,
|
|
117
|
+
},
|
|
118
|
+
baseUrl ? { baseUrl } : undefined
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
return transformTaskRecord(task) as Task;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// React Query hook for reordering tasks
|
|
125
|
+
export function useReorderTask(boardId: string, wsId: string) {
|
|
126
|
+
const queryClient = useQueryClient();
|
|
127
|
+
|
|
128
|
+
return useMutation({
|
|
129
|
+
mutationFn: async ({
|
|
130
|
+
taskId,
|
|
131
|
+
newListId,
|
|
132
|
+
newSortKey,
|
|
133
|
+
}: ReorderTaskMutationInput) => {
|
|
134
|
+
return reorderTask(wsId, taskId, newListId, newSortKey);
|
|
135
|
+
},
|
|
136
|
+
onMutate: ({
|
|
137
|
+
taskId,
|
|
138
|
+
newListId,
|
|
139
|
+
newSortKey,
|
|
140
|
+
optimisticPreviousTasks,
|
|
141
|
+
optimisticPreviousFullTasks,
|
|
142
|
+
}) => {
|
|
143
|
+
// Snapshot the previous value
|
|
144
|
+
const previousTasks = queryClient.getQueryData<Task[]>([
|
|
145
|
+
'tasks',
|
|
146
|
+
boardId,
|
|
147
|
+
]);
|
|
148
|
+
const previousFullTasks = queryClient.getQueryData<Task[]>([
|
|
149
|
+
'tasks-full',
|
|
150
|
+
boardId,
|
|
151
|
+
]);
|
|
152
|
+
|
|
153
|
+
// Cancel any outgoing refetches without delaying the optimistic landing.
|
|
154
|
+
void queryClient.cancelQueries({ queryKey: ['tasks', boardId] });
|
|
155
|
+
|
|
156
|
+
// Check if moving to a done or closed list
|
|
157
|
+
const targetList = queryClient.getQueryData(['task_lists', boardId]) as
|
|
158
|
+
| TaskList[]
|
|
159
|
+
| undefined;
|
|
160
|
+
const list = targetList?.find((l) => l.id === newListId);
|
|
161
|
+
const isCompletionList = isTaskBoardResolvedStatus(list?.status);
|
|
162
|
+
|
|
163
|
+
// If moving to completion list, start fetching blocked task IDs asynchronously
|
|
164
|
+
// Don't await here to avoid blocking the optimistic update
|
|
165
|
+
let blockedTaskIdsPromise: Promise<string[]> | null = null;
|
|
166
|
+
if (isCompletionList) {
|
|
167
|
+
const baseUrl =
|
|
168
|
+
typeof window !== 'undefined' ? window.location.origin : undefined;
|
|
169
|
+
blockedTaskIdsPromise = Promise.resolve(
|
|
170
|
+
getWorkspaceTaskRelationships(
|
|
171
|
+
wsId,
|
|
172
|
+
taskId,
|
|
173
|
+
baseUrl ? { baseUrl } : undefined
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
.then((relationships) =>
|
|
177
|
+
(relationships?.blocking ?? []).map((task) => task.id)
|
|
178
|
+
)
|
|
179
|
+
.catch((err: unknown) => {
|
|
180
|
+
console.error('Failed to fetch blocked task IDs:', err);
|
|
181
|
+
return []; // Return empty array on error to prevent breaking the flow
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Optimistically update the task immediately (not blocked by the fetch above)
|
|
186
|
+
setReorderedTaskCache(queryClient, boardId, (old) =>
|
|
187
|
+
mergeOptimisticReorderedTaskIntoCache(old, {
|
|
188
|
+
taskId,
|
|
189
|
+
newListId,
|
|
190
|
+
newSortKey,
|
|
191
|
+
targetListStatus: list?.status,
|
|
192
|
+
})
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
previousTasks: optimisticPreviousTasks ?? previousTasks,
|
|
197
|
+
previousFullTasks: optimisticPreviousFullTasks ?? previousFullTasks,
|
|
198
|
+
blockedTaskIdsPromise,
|
|
199
|
+
};
|
|
200
|
+
},
|
|
201
|
+
onError: (err, _, context) => {
|
|
202
|
+
if (context?.previousTasks) {
|
|
203
|
+
queryClient.setQueryData(['tasks', boardId], context.previousTasks);
|
|
204
|
+
}
|
|
205
|
+
if (context?.previousFullTasks) {
|
|
206
|
+
queryClient.setQueryData(
|
|
207
|
+
['tasks-full', boardId],
|
|
208
|
+
context.previousFullTasks
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
console.error('Failed to reorder task:', err);
|
|
213
|
+
},
|
|
214
|
+
onSuccess: async (updatedTask, variables, context) => {
|
|
215
|
+
// Update the cache with the server response
|
|
216
|
+
setReorderedTaskCache(queryClient, boardId, (old) =>
|
|
217
|
+
mergeServerReorderedTaskIntoCache(old, updatedTask)
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
// If task was moved to done/closed list (has completed_at or closed_at set),
|
|
221
|
+
// invalidate task relationships to reflect removed blocking relationships
|
|
222
|
+
if (updatedTask.completed_at || updatedTask.closed_at) {
|
|
223
|
+
// Invalidate the completed/closed task's relationships
|
|
224
|
+
await queryClient.invalidateQueries({
|
|
225
|
+
queryKey: ['task-relationships', variables.taskId],
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// Await the blockedTaskIdsPromise to get the list of blocked tasks
|
|
229
|
+
// Then invalidate all blocked tasks' relationships (they're now unblocked)
|
|
230
|
+
if (context?.blockedTaskIdsPromise) {
|
|
231
|
+
const blockedTaskIds = await context.blockedTaskIdsPromise;
|
|
232
|
+
if (blockedTaskIds.length > 0) {
|
|
233
|
+
await Promise.all(
|
|
234
|
+
blockedTaskIds.map((blockedTaskId) =>
|
|
235
|
+
queryClient.invalidateQueries({
|
|
236
|
+
queryKey: ['task-relationships', blockedTaskId],
|
|
237
|
+
})
|
|
238
|
+
)
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { Task, TaskAssignee } from '@tuturuuu/types/primitives/Task';
|
|
2
|
+
import type { User } from '@tuturuuu/types/primitives/User';
|
|
3
|
+
|
|
4
|
+
type TaskUser = NonNullable<Task['assignees']>[number];
|
|
5
|
+
type TaskLabel = NonNullable<Task['labels']>[number];
|
|
6
|
+
type TaskProject = NonNullable<Task['projects']>[number];
|
|
7
|
+
|
|
8
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
9
|
+
return typeof value === 'object' && value !== null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function normalizeTaskUser(
|
|
13
|
+
assignee: (TaskAssignee & { user: User }) | TaskUser | null | undefined
|
|
14
|
+
): (TaskUser & { user_id: string }) | null {
|
|
15
|
+
if (!assignee) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const expandedUser =
|
|
20
|
+
'user' in assignee && assignee.user && typeof assignee.user === 'object'
|
|
21
|
+
? assignee.user
|
|
22
|
+
: null;
|
|
23
|
+
|
|
24
|
+
const effectiveUserId =
|
|
25
|
+
expandedUser?.id ??
|
|
26
|
+
('user_id' in assignee && typeof assignee.user_id === 'string'
|
|
27
|
+
? assignee.user_id
|
|
28
|
+
: undefined) ??
|
|
29
|
+
('id' in assignee && typeof assignee.id === 'string'
|
|
30
|
+
? assignee.id
|
|
31
|
+
: undefined);
|
|
32
|
+
|
|
33
|
+
if (!effectiveUserId) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const displayName =
|
|
38
|
+
expandedUser?.display_name ??
|
|
39
|
+
('display_name' in assignee && typeof assignee.display_name === 'string'
|
|
40
|
+
? assignee.display_name
|
|
41
|
+
: undefined);
|
|
42
|
+
const email =
|
|
43
|
+
expandedUser?.email ??
|
|
44
|
+
('email' in assignee && typeof assignee.email === 'string'
|
|
45
|
+
? assignee.email
|
|
46
|
+
: undefined);
|
|
47
|
+
const avatarUrl =
|
|
48
|
+
expandedUser?.avatar_url ??
|
|
49
|
+
('avatar_url' in assignee && typeof assignee.avatar_url === 'string'
|
|
50
|
+
? assignee.avatar_url
|
|
51
|
+
: undefined);
|
|
52
|
+
const handle =
|
|
53
|
+
expandedUser?.handle ??
|
|
54
|
+
('handle' in assignee && typeof assignee.handle === 'string'
|
|
55
|
+
? assignee.handle
|
|
56
|
+
: undefined);
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
id: effectiveUserId,
|
|
60
|
+
...(displayName ? { display_name: displayName } : {}),
|
|
61
|
+
...(email ? { email } : {}),
|
|
62
|
+
...(avatarUrl ? { avatar_url: avatarUrl } : {}),
|
|
63
|
+
...(handle ? { handle } : {}),
|
|
64
|
+
user_id: effectiveUserId,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function transformAssignees(
|
|
69
|
+
assignees:
|
|
70
|
+
| Array<(TaskAssignee & { user: User }) | TaskUser>
|
|
71
|
+
| null
|
|
72
|
+
| undefined
|
|
73
|
+
): (TaskUser & { user_id: string })[] {
|
|
74
|
+
return (
|
|
75
|
+
assignees
|
|
76
|
+
?.map((assignee) => normalizeTaskUser(assignee))
|
|
77
|
+
.filter((user): user is TaskUser & { user_id: string } =>
|
|
78
|
+
Boolean(user?.id)
|
|
79
|
+
)
|
|
80
|
+
.filter(
|
|
81
|
+
(user, index: number, self) =>
|
|
82
|
+
self.findIndex((u) => u.id === user.id) === index
|
|
83
|
+
) || []
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function normalizeLabels(labels: unknown): TaskLabel[] {
|
|
88
|
+
if (!Array.isArray(labels)) {
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return labels
|
|
93
|
+
.map((entry): TaskLabel | null => {
|
|
94
|
+
if (!isObject(entry)) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if ('label' in entry && isObject(entry.label) && 'id' in entry.label) {
|
|
99
|
+
return entry.label as TaskLabel;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if ('id' in entry) {
|
|
103
|
+
return entry as TaskLabel;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return null;
|
|
107
|
+
})
|
|
108
|
+
.filter((label): label is TaskLabel => Boolean(label?.id));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function normalizeProjects(projects: unknown): TaskProject[] {
|
|
112
|
+
if (!Array.isArray(projects)) {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return projects
|
|
117
|
+
.map((entry): TaskProject | null => {
|
|
118
|
+
if (!isObject(entry)) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (
|
|
123
|
+
'project' in entry &&
|
|
124
|
+
isObject(entry.project) &&
|
|
125
|
+
'id' in entry.project
|
|
126
|
+
) {
|
|
127
|
+
return entry.project as TaskProject;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if ('id' in entry) {
|
|
131
|
+
return entry as TaskProject;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return null;
|
|
135
|
+
})
|
|
136
|
+
.filter((project): project is TaskProject => Boolean(project?.id));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function transformTaskRecord(task: any): Task {
|
|
140
|
+
const normalizedLabels = normalizeLabels(task.labels);
|
|
141
|
+
const normalizedProjects = normalizeProjects(task.projects);
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
...task,
|
|
145
|
+
assignees: transformAssignees(task.assignees),
|
|
146
|
+
labels: normalizedLabels,
|
|
147
|
+
projects: normalizedProjects,
|
|
148
|
+
} as Task;
|
|
149
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task date/time helpers that respect a user-configured timezone.
|
|
3
|
+
* Used when creating or updating task start_date/end_date so stored UTC
|
|
4
|
+
* corresponds to the intended local date and time in the user's timezone.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import dayjs from 'dayjs';
|
|
8
|
+
import timezone from 'dayjs/plugin/timezone';
|
|
9
|
+
import utc from 'dayjs/plugin/utc';
|
|
10
|
+
import { resolveAutoTimezone } from './timezone';
|
|
11
|
+
|
|
12
|
+
dayjs.extend(utc);
|
|
13
|
+
dayjs.extend(timezone);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolves timezone for task dates: 'auto' → browser, otherwise validated IANA or UTC.
|
|
17
|
+
*/
|
|
18
|
+
export function resolveTaskTimezone(timezoneSetting?: string | null): string {
|
|
19
|
+
return resolveAutoTimezone(timezoneSetting);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Builds a Date representing the given local date/time in the specified timezone.
|
|
24
|
+
* Use this when the user selects a date and time in the UI (e.g. picker) so the
|
|
25
|
+
* resulting instant is correct for that timezone.
|
|
26
|
+
*
|
|
27
|
+
* @param year - Full year
|
|
28
|
+
* @param month - Month 1–12 (calendar month)
|
|
29
|
+
* @param day - Day of month
|
|
30
|
+
* @param hour - Hour 0–23
|
|
31
|
+
* @param minute - Minute 0–59
|
|
32
|
+
* @param tz - IANA timezone (e.g. 'America/New_York') or 'auto' for browser
|
|
33
|
+
*/
|
|
34
|
+
export function buildDateInTimezone(
|
|
35
|
+
year: number,
|
|
36
|
+
month: number,
|
|
37
|
+
day: number,
|
|
38
|
+
hour: number,
|
|
39
|
+
minute: number,
|
|
40
|
+
tz: string
|
|
41
|
+
): Date {
|
|
42
|
+
const resolved = resolveTaskTimezone(tz);
|
|
43
|
+
const s = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')} ${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:00`;
|
|
44
|
+
return dayjs.tz(s, resolved).toDate();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Returns date parts (year, month, day, hour, minute) of the given instant
|
|
49
|
+
* in the specified timezone. Useful for displaying a UTC date in user TZ
|
|
50
|
+
* or for re-building the same instant from picker date parts.
|
|
51
|
+
*/
|
|
52
|
+
export function getDatePartsInTimezone(
|
|
53
|
+
date: Date,
|
|
54
|
+
tz: string
|
|
55
|
+
): { year: number; month: number; day: number; hour: number; minute: number } {
|
|
56
|
+
const resolved = resolveTaskTimezone(tz);
|
|
57
|
+
const d = dayjs(date).tz(resolved);
|
|
58
|
+
return {
|
|
59
|
+
year: d.year(),
|
|
60
|
+
month: d.month() + 1,
|
|
61
|
+
day: d.date(),
|
|
62
|
+
hour: d.hour(),
|
|
63
|
+
minute: d.minute(),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Converts a Date to UTC ISO string, treating the date's local calendar/time
|
|
69
|
+
* as being in the given timezone. Use when you have a Date built from local
|
|
70
|
+
* input (e.g. picker in browser local) and want to store "this date/time in
|
|
71
|
+
* user's configured timezone" as UTC.
|
|
72
|
+
*
|
|
73
|
+
* @param date - Date (typically from a picker; its getFullYear/getMonth/etc. are in browser local)
|
|
74
|
+
* @param tz - User/workspace timezone
|
|
75
|
+
* @returns ISO string in UTC for storage
|
|
76
|
+
*/
|
|
77
|
+
export function dateInTimezoneToUTCISO(date: Date, tz: string): string {
|
|
78
|
+
const resolved = resolveTaskTimezone(tz);
|
|
79
|
+
const y = date.getFullYear();
|
|
80
|
+
const m = date.getMonth() + 1;
|
|
81
|
+
const d = date.getDate();
|
|
82
|
+
const h = date.getHours();
|
|
83
|
+
const min = date.getMinutes();
|
|
84
|
+
const s = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')} ${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}:00`;
|
|
85
|
+
return dayjs.tz(s, resolved).toISOString();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Formats a UTC instant (Date or ISO string) in the given timezone for display.
|
|
90
|
+
*/
|
|
91
|
+
export function formatInTimezone(
|
|
92
|
+
date: Date | string,
|
|
93
|
+
tz: string,
|
|
94
|
+
formatStr: string
|
|
95
|
+
): string {
|
|
96
|
+
const resolved = resolveTaskTimezone(tz);
|
|
97
|
+
const d = dayjs(date).tz(resolved);
|
|
98
|
+
return d.format(formatStr);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/;
|
|
102
|
+
const HAS_UTC_OFFSET = /[Zz+-]\d{2}:?\d{2}$|[Zz]$/;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Parses a date string from the AI (e.g. "2025-02-23" or "2025-02-23T15:00:00")
|
|
106
|
+
* and returns UTC ISO string. When the string has no timezone (date-only or
|
|
107
|
+
* naive datetime), interprets it in the user's timezone.
|
|
108
|
+
*
|
|
109
|
+
* @param dateStr - ISO date or date-only YYYY-MM-DD
|
|
110
|
+
* @param tz - User/workspace IANA timezone
|
|
111
|
+
* @param endOfDay - If true and dateStr is date-only, use 23:59:59.999 in tz; else 00:00:00
|
|
112
|
+
*/
|
|
113
|
+
export function parseTaskDateToUTCISO(
|
|
114
|
+
dateStr: string,
|
|
115
|
+
tz: string,
|
|
116
|
+
endOfDay: boolean
|
|
117
|
+
): string {
|
|
118
|
+
const trimmed = dateStr.trim();
|
|
119
|
+
if (HAS_UTC_OFFSET.test(trimmed)) {
|
|
120
|
+
return dayjs.utc(trimmed).toISOString();
|
|
121
|
+
}
|
|
122
|
+
const resolved = resolveTaskTimezone(tz);
|
|
123
|
+
if (DATE_ONLY_REGEX.test(trimmed)) {
|
|
124
|
+
const d = dayjs.tz(trimmed, resolved);
|
|
125
|
+
const anchored = endOfDay ? d.endOf('day') : d.startOf('day');
|
|
126
|
+
return anchored.toISOString();
|
|
127
|
+
}
|
|
128
|
+
const d = dayjs.tz(trimmed, resolved);
|
|
129
|
+
if (!d.isValid()) {
|
|
130
|
+
return dayjs.utc(trimmed).toISOString();
|
|
131
|
+
}
|
|
132
|
+
return d.toISOString();
|
|
133
|
+
}
|